Skip to content

Commit 93f5388

Browse files
authored
Merge pull request #142 from keyxmakerx/claude/foundry-module-setup-ZjHlL
Claude/foundry module setup zj hl l
2 parents 7965c9e + 38cdc07 commit 93f5388

26 files changed

Lines changed: 1027 additions & 51 deletions

.ai/status.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88
<!-- ====================================================================== -->
99

1010
## Last Updated
11-
2026-03-17 -- **Sprint: F-6 Armory/Inventory, F-7 Shop Enhancement, Sync Dashboard, Foundry Config.**
11+
2026-03-17 -- **Sprint: Draw Steel System Module + Foundry Infrastructure Fixes.**
12+
13+
44. **Sprint: Draw Steel System Module + Infrastructure.**
14+
- **Draw Steel System (COMPLETE)** — Full manifest expansion: 24-field character preset with Foundry path annotations, creature preset with EV/role/role_type, kit preset. Reference data: 8 abilities, 6 creatures, 10 ancestries, 6 kits (CC-BY-4.0). Status changed from `coming_soon` to `available`.
15+
- **Dynamic Foundry Actor Types (COMPLETE)** — Added `foundry_actor_type` to `EntityPresetDef` struct and `CharacterFieldsResponse` API. Draw Steel uses `"hero"` (not `"character"`). Generic adapter reads `actorType` from API. `actor-sync.mjs` uses `this._actorType` everywhere instead of hardcoded `'character'`.
16+
- **CORS Admin Whitelist (COMPLETE)**`cors.allowed_origins` in `site_settings`. `GetCORSOrigins()`/`UpdateCORSOrigins()` on SettingsService. CORS middleware accepts `DynamicOrigins` function that reads from DB. Admin UI on `/admin/api` page with textarea for managing origins. Fixes Foundry VTT CORS-blocked connections.
17+
- **SYSTEM_MAP_FALLBACK Fix (COMPLETE)** — Changed `drawsteel` key to `'draw-steel'` to match the Foundry system ID.
1218

1319
43. **Sprint: F-6 + F-7 (Armory, Inventory, Transactions, Shop Enhancement).**
1420
- **A-2: Chronicle Sync Dashboard Expansion (COMPLETE)** — Owner API Keys page expansion with sync status overview, mappings table, error display. Admin API Monitor expansion with per-campaign sync stats.

.ai/todo.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ is truly modular and self-service._
313313
### Deferred to Phase S+ (or community contributions)
314314

315315
- [ ] **Module Builder UI** — Guided wizard that helps users create custom game system modules through the web UI. Step-by-step: name/metadata → define categories → define fields per category → paste/upload reference data → preview tooltips → export as module directory. Eliminates need to hand-write manifest.json + data files.
316-
- [ ] Draw Steel module (system data + Foundry adapter)
316+
- [x] Draw Steel module (system data + Foundry adapter)
317317
- [ ] Dagger Heart module (system data + Foundry adapter)
318318
- [ ] Whiteboards / freeform canvas (Tldraw/Excalidraw)
319319
- [ ] Offline mode / service worker caching

foundry-module/scripts/actor-sync.mjs

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
* - Chronicle → Foundry: Entity changes arrive via WebSocket, create/update Actor.
1010
* - Foundry → Chronicle: Actor changes detected via Hooks, push to Chronicle API.
1111
*
12-
* System-specific field mapping is delegated to adapter modules
13-
* (dnd5e-adapter.mjs, pf2e-adapter.mjs).
12+
* System-specific field mapping is delegated to adapter modules. The adapter's
13+
* actorType property (e.g., "character" for D&D 5e, "hero" for Draw Steel)
14+
* determines which Foundry actor type to sync.
1415
*/
1516

1617
import { getSetting } from './settings.mjs';
@@ -45,6 +46,15 @@ export class ActorSync {
4546
this._onDeleteActor = this._handleDeleteActor.bind(this);
4647
}
4748

49+
/**
50+
* Returns the Foundry actor type this sync handles (from the adapter).
51+
* Defaults to "character" if no adapter is loaded.
52+
* @returns {string}
53+
*/
54+
get _actorType() {
55+
return this._adapter?.actorType || 'character';
56+
}
57+
4858
/**
4959
* Initialize the actor sync module.
5060
* Loads the appropriate system adapter and registers hooks.
@@ -73,7 +83,7 @@ export class ActorSync {
7383
Hooks.on('updateActor', this._onUpdateActor);
7484
Hooks.on('deleteActor', this._onDeleteActor);
7585

76-
console.log(`Chronicle: Actor sync initialized (adapter: ${this._adapter.systemId})`);
86+
console.log(`Chronicle: Actor sync initialized (adapter: ${this._adapter.systemId}, actorType: ${this._actorType})`);
7787
}
7888

7989
/**
@@ -172,6 +182,7 @@ export class ActorSync {
172182
/**
173183
* Handle a new character entity from Chronicle.
174184
* Creates a new Foundry Actor if one isn't already linked.
185+
* Uses the adapter's actorType to create the correct type (e.g., "hero").
175186
* @param {object} entity
176187
* @private
177188
*/
@@ -187,7 +198,7 @@ export class ActorSync {
187198

188199
const actorData = {
189200
name: entity.name,
190-
type: 'character',
201+
type: this._actorType,
191202
flags: {
192203
[FLAG_SCOPE]: {
193204
entityId: entity.id,
@@ -309,7 +320,7 @@ export class ActorSync {
309320

310321
/**
311322
* Handle Foundry createActor hook.
312-
* Only processes character-type actors created by the current user.
323+
* Only processes actors matching the adapter's actorType.
313324
* @param {Actor} actor
314325
* @param {object} options
315326
* @param {string} userId
@@ -318,7 +329,7 @@ export class ActorSync {
318329
async _handleCreateActor(actor, options, userId) {
319330
if (this._syncing) return;
320331
if (userId !== game.user.id) return;
321-
if (actor.type !== 'character') return;
332+
if (actor.type !== this._actorType) return;
322333
if (!this._adapter || !this._characterTypeId) return;
323334

324335
// Skip if already linked (came from Chronicle).
@@ -369,7 +380,7 @@ export class ActorSync {
369380
async _handleUpdateActor(actor, change, options, userId) {
370381
if (this._syncing) return;
371382
if (userId !== game.user.id) return;
372-
if (actor.type !== 'character') return;
383+
if (actor.type !== this._actorType) return;
373384
if (!this._adapter) return;
374385

375386
const entityId = actor.getFlag(FLAG_SCOPE, 'entityId');
@@ -537,13 +548,16 @@ export class ActorSync {
537548

538549
/**
539550
* Get all synced actors with their status for dashboard display.
551+
* Filters by the adapter's actor type so Draw Steel shows heroes,
552+
* D&D 5e shows characters, etc.
540553
* @returns {Array<{id: string, name: string, entityId: string|null, synced: boolean, lastSync: string|null}>}
541554
*/
542555
getSyncedActors() {
543556
if (!this._adapter) return [];
544557

558+
const targetType = this._actorType;
545559
return game.actors.contents
546-
.filter((a) => a.type === 'character')
560+
.filter((a) => a.type === targetType)
547561
.map((a) => {
548562
const entityId = a.getFlag(FLAG_SCOPE, 'entityId') || null;
549563
const lastSync = a.getFlag(FLAG_SCOPE, 'lastSync') || null;

foundry-module/scripts/adapters/dnd5e-adapter.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ export const systemId = 'dnd5e';
1717
*/
1818
export const characterTypeSlug = 'dnd5e-character';
1919

20+
/**
21+
* The Foundry VTT actor type that corresponds to characters.
22+
* @type {string}
23+
*/
24+
export const actorType = 'character';
25+
2026
/**
2127
* Extract Chronicle-compatible fields_data from a Foundry dnd5e Actor.
2228
* Reads from the Actor's system data paths and returns a flat object

foundry-module/scripts/adapters/generic-adapter.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ export async function createGenericAdapter(api, chronicleSystemId) {
5656
/** Character entity type slug from the manifest. */
5757
characterTypeSlug: fieldDefs.preset_slug || `${chronicleSystemId}-character`,
5858

59+
/**
60+
* Foundry actor type string from the manifest (e.g., "character", "hero").
61+
* Different game systems use different actor types — D&D 5e uses "character",
62+
* Draw Steel uses "hero". Defaults to "character" if not specified.
63+
* @type {string}
64+
*/
65+
actorType: fieldDefs.foundry_actor_type || 'character',
66+
5967
/**
6068
* Extract Chronicle-compatible fields_data from a Foundry Actor.
6169
* Reads each mapped field from the actor using its foundry_path.

foundry-module/scripts/adapters/pf2e-adapter.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ export const systemId = 'pathfinder2e';
1717
*/
1818
export const characterTypeSlug = 'pf2e-character';
1919

20+
/**
21+
* The Foundry VTT actor type that corresponds to characters.
22+
* @type {string}
23+
*/
24+
export const actorType = 'character';
25+
2026
/**
2127
* Extract Chronicle-compatible fields_data from a Foundry pf2e Actor.
2228
* PF2e stores ability modifiers rather than raw scores. It uses

foundry-module/scripts/sync-manager.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { getSetting, setSetting, isConfigured, getSyncDirections, getExcludedTag
1818
const SYSTEM_MAP_FALLBACK = {
1919
dnd5e: 'dnd5e',
2020
pf2e: 'pathfinder2e',
21-
drawsteel: 'drawsteel',
21+
'draw-steel': 'drawsteel',
2222
};
2323

2424
/**

internal/app/app.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package app
55

66
import (
77
"archive/zip"
8+
"context"
89
"database/sql"
910
"encoding/json"
1011
"errors"
@@ -15,6 +16,7 @@ import (
1516
"os"
1617
"path/filepath"
1718
"strings"
19+
"time"
1820

1921
"github.com/labstack/echo/v4"
2022
echomw "github.com/labstack/echo/v4/middleware"
@@ -25,6 +27,7 @@ import (
2527
"github.com/keyxmakerx/chronicle/internal/database"
2628
"github.com/keyxmakerx/chronicle/internal/extensions"
2729
"github.com/keyxmakerx/chronicle/internal/middleware"
30+
"github.com/keyxmakerx/chronicle/internal/plugins/settings"
2831
"github.com/keyxmakerx/chronicle/internal/templates/pages"
2932
)
3033

@@ -133,9 +136,23 @@ func (a *App) setupMiddleware() {
133136

134137
// CORS -- allow cross-origin requests for the REST API.
135138
// Only relevant for external clients (Foundry VTT module, etc.).
139+
// BaseURL is always allowed. Additional origins are loaded dynamically
140+
// from site_settings (managed by admin via /admin/api/cors).
141+
settingsRepo := settings.NewSettingsRepository(a.DB)
142+
settingsSvc := settings.NewSettingsService(settingsRepo)
136143
a.Echo.Use(middleware.CORS(middleware.CORSConfig{
137144
AllowedOrigins: []string{a.Config.BaseURL},
138145
AllowCredentials: true,
146+
DynamicOrigins: func() []string {
147+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
148+
defer cancel()
149+
origins, err := settingsSvc.GetCORSOrigins(ctx)
150+
if err != nil {
151+
slog.Warn("failed to load dynamic CORS origins", slog.Any("error", err))
152+
return nil
153+
}
154+
return origins
155+
},
139156
}))
140157

141158
// CSRF -- double-submit cookie pattern on all state-changing requests.

internal/app/routes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,7 @@ func (a *App) RegisterRoutes() {
10011001
syncMappingRepoEarly := syncapi.NewSyncMappingRepository(a.DB)
10021002
syncMappingSvcEarly := syncapi.NewSyncMappingService(syncMappingRepoEarly)
10031003
syncHandler.SetSyncMappingService(syncMappingSvcEarly)
1004+
syncHandler.SetCORSOriginLister(settingsService)
10041005
if a.PluginHealth.IsHealthy("syncapi") {
10051006
syncapi.RegisterAdminRoutes(adminGroup, syncHandler)
10061007
syncapi.RegisterCampaignRoutes(e, syncHandler, campaignService, authService)

internal/middleware/cors.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ type CORSConfig struct {
1919
// and auth headers in cross-origin requests. Required for session-based
2020
// auth from a different origin (e.g., Foundry VTT module).
2121
AllowCredentials bool
22+
23+
// DynamicOrigins is an optional function that returns additional allowed
24+
// origins at runtime. Called per-request to support admin-managed origin
25+
// whitelists stored in the database. May return nil.
26+
DynamicOrigins func() []string
2227
}
2328

2429
// CORS returns middleware that handles Cross-Origin Resource Sharing headers.
@@ -31,14 +36,14 @@ type CORSConfig struct {
3136
// all requests are same-origin. But the API must support cross-origin access for
3237
// external integrations.
3338
func CORS(cfg CORSConfig) echo.MiddlewareFunc {
34-
// Build a set for fast origin lookup.
39+
// Build a set for fast origin lookup of static origins.
3540
allowAll := false
36-
originSet := make(map[string]bool)
41+
staticOrigins := make(map[string]bool)
3742
for _, o := range cfg.AllowedOrigins {
3843
if o == "*" {
3944
allowAll = true
4045
}
41-
originSet[o] = true
46+
staticOrigins[o] = true
4247
}
4348

4449
// SECURITY: Wildcard origin with credentials is a dangerous misconfiguration.
@@ -60,8 +65,16 @@ func CORS(cfg CORSConfig) echo.MiddlewareFunc {
6065
return next(c)
6166
}
6267

63-
// Check if the origin is allowed.
64-
allowed := allowAll || originSet[origin]
68+
// Check if the origin is allowed: static list first, then dynamic.
69+
allowed := allowAll || staticOrigins[origin]
70+
if !allowed && cfg.DynamicOrigins != nil {
71+
for _, o := range cfg.DynamicOrigins() {
72+
if o == origin {
73+
allowed = true
74+
break
75+
}
76+
}
77+
}
6578
if !allowed {
6679
// Origin not in whitelist -- proceed without CORS headers.
6780
// The browser will block the response on the client side.

0 commit comments

Comments
 (0)