Module API 1.6.0 — De Facto Specification
Status: Descriptive. This documents the module-facing contract as it actually ships today (
MODULE_API_VERSION = '1.7.0'). Unlike the Action API, the Module API is formally versioned in code. The body below was written from production source on branchversion/0.40.0; §1.6.0 was added onversion/0.45.0. Where the implementation drifts from prior docs or has sharp edges, it is flagged ⚠︎.1.7.0 shipped (v0.45.0, task P3a): CS-6
projectTokenmust be callable on the server. Additive — the audit found no signature change needed, so every 1.6.0 module keeps working; what it asks for is a server-reachable registration. See the CS-6 note incharacter-sheet-api-0.1.md.⚠︎ This file is still named
1.6and documents the 1.6.0 surface. Rename it when 1.7.0 gains a body beyond the CS-6 obligation.Companion design doc (philosophy, hosting model, deployment tiers):
modules.md. This file is the contract/surface reference;modules.mdis the design narrative.
1. What the Module API is
The platform follows an Open Core model. A module is an npm package that
declares a purple-cat-module field in its package.json and extends the
platform through two complementary directions:
- Contribute → the platform (declarative registration): a module registers
UI/system extensions with a singleton
ModuleRegistry(packages/shared/src/services/moduleRegistry.ts). The platform renders/consumes whatever is registered, with no module-specific code. - Contribute → the backend (server routers): a module exports an Express
router that the platform auto-mounts at
/api/modules/{moduleId}.
The reverse direction — consume ← the platform (imperative calls a module
makes into platform services) — has a proposed home (the PlatformAPI facade,
§7) but is not built; today modules consume the platform via authFetch +
the ordinary REST API.
Modules are build-time dependencies, delivered to the client via code-split dynamic imports. There is no runtime plugin loader and no arbitrary code execution — adding/updating a module requires a rebuild/redeploy.
Real modules validating this surface: genai (artwork source + extender +
settings panel), system8 (system module), scenes, vtt-assets.
2. Versioning & compatibility
MODULE_API_VERSION = '1.7.0'(module.ts). SemVer, and independent of the product release version — it bumps only when the module-facing contract changes. A per-bump changelog lives in the same file.- 1.6.0 (Character Sheet Overhaul, Phase 0): additive throughout — every
1.5.0 module keeps working unchanged.
- CS-1.6
SystemModule.formatAttribute(attribute, { surface, format })— pure, synchronous display formatting. Returnsnullto fall through, so a module formats only what it cares about. Tier 2 ofresolveAttributeFormat: campaign-scoped override → module →String(value). Stock formatters move from seededrenderFunctionstrings into module code, so updating one no longer needs a fixture version bump plus a destructive re-seed. SystemFixture.attributeGroups— ordered character-sheet sections ("Ability Scores", "Combat"). Property definitions reference one viaattributeGroupand gain a display-onlyabbreviation, deliberately distinct from script-facingaliases(1d20+strresolves throughaliases, neverabbreviation).IPropTypethree-axis model —valueType(number/string/boolean/enum/ dice-expression),shapes(scalar/composed/range/faceted, combinable), and presentation (formats— a named map superseding the singlerenderFunction— plusunit,signed,options). "Ability Score" is no longer a primitive: it is number + composed + a d20-modifier format.IPropType.rollValueFunction— what an attribute contributes when substituted into a dice expression. Replaces the platform's hardcoded/(^|_)ability-score$/match, which is now deleted. ⚠︎ A module that does not declare it substitutes raw values, so ability rolls regress until the module is published — publish before deploying the platform.IPropertyDefinition.facets— named alternate totals under one heading, in two modes:includeComponents(a derived view — 3.x touch AC) andownComponents(an independent channel — fly speed, death saves).- CS-1.2 consolidation —
NumericAttributeSchema.propTypereferences anIPropTypeid.NumericAttributeFormatis@deprecatedbut still honoured, makingIPropTypethe single attribute-type registry. It was a closed enum a module could not extend without a platform release.
- CS-1.6
- 1.5.0 (System-Platform Separation): the Character Sheet / Monster /
Content API surfaces on
SystemModule(all optional — see system-api-0.1.md §3); theCharacterSheetSectionExtension(CS-7) registry point; andModuleServerExport.systemFixtures(plural),.aiParseSchema,.allowedHosts. Fully additive — all pre-1.5.0 modules stay compatible.
- 1.6.0 (Character Sheet Overhaul, Phase 0): additive throughout — every
1.5.0 module keeps working unchanged.
- Enforced at load. During
initializeModules()each module's declaredenginerange is tested with a hand-rolled SemVer matcher (semverSatisfies(MODULE_API_VERSION, engineRange),modules/index.ts:304-326, matcher atmodules/utils.ts:54-125). Outcome per module is surfaced ascompatibility: 'compatible' | 'incompatible' | 'no-requirement'.- Satisfied → load normally.
- Incompatible → warn and skip (never crash), but still reported in
moduleInfos. engineomitted → treated as compatible ('no-requirement').
- Served to the client:
GET /api/modulesreturns{ moduleApiVersion, modules }(index.ts:846-851). - Guard test pins the constant:
libraryModuleApi.test.ts:143-148. - Real-world engine usage: only
system8declares one ("engine": "^1.1.0"); genai/scenes/vtt-assets omit it. ⚠︎ System 8 now implements 1.5.0 members but keeps^1.1.0— those members are optional, so it stays loadable on older platforms (which simply never call them).
Deprecation pipeline (infra-ready, currently unused ⚠︎): a three-phase cycle
(introduce → deprecate w/ console.warn → remove at major) is documented in
modules.md §4.3, and the helper exists
(moduleDeprecation.ts:32 moduleDeprecated, :72 withDeprecation),
but there are zero production call sites — no API is actually mid-deprecation.
3. Module descriptor (purple-cat-module)
Declared in the module's package.json; replaces a separate manifest. Types:
ModuleDescriptor (modules/types.ts:17).
| Field | Required | Description |
|---|---|---|
type |
yes | Category: "assets", "integration", … |
client |
no | Relative path to client ES module exporting register() |
server |
no | Relative path to server ES module exporting ModuleServerExport |
assetPaths |
no | Asset modules: category → relative path in dist/ |
buildCommand |
no | Build override (default npm run build) |
engine |
no | SemVer range of the Module API this module targets (see §2) |
moduleId is derived from the package name by stripping any @scope/
(deriveModuleId, modules/utils.ts),
e.g. @purple-cat-games/system8 → system8.
⚠︎ Scope inconsistency: shipped modules use @purple-cat-games/…, but
doc-comments in module.ts/modules.md use @purple-cat/vtt-genai. Harmless
(the scope is stripped) but the canonical scope is @purple-cat-games.
4. Loading model
4.1 Server (two-stage)
Config resolution (modules/config.ts:66-116):
MODULES_CONFIG env → modules.local.json → modules.hosted.json (first
non-empty wins).
- Stage 1 — register (fast, ms/module):
initializeModules()(modules/index.ts:272) resolves each package (resolveModule, 3 strategies incl. ESM walk-up,index.ts:87-170), validatesengine, dynamically imports the server entry (loadServerEntry,index.ts:233-263), and mounts routers at/api/modules/{id}(index.ts:826-839). - Stage 2 — initialize (lazy):
initializeModule()builds ifdist/is missing, uploads assets on version change, and callsinitialize(context); kicked off in the background viasetImmediate(index.ts:438-442). This keeps hundreds of backend modules cheap to register on a shared host.
4.2 Client (build-time codegen + code splitting)
The Vite plugin
(apps/web/vite-plugin-modules.ts)
reads the same JSON configs at build time and emits a virtual module
(virtual:purple-cat-modules) exporting moduleLoaders (moduleId → dynamic
import()). At runtime loadModules()
(apps/web/src/moduleInit.ts:28) calls
each enabled chunk's register() before React mounts (no flash of missing
UI). useModuleLoader
(apps/web/src/hooks/useModuleLoader.ts:91-113)
intersects user prefs with build-available modules and calls
moduleRegistry.setActiveModules(...) so registry getters gate extensions by the
server's active list. The plugin also enforces peer-dep hygiene
(vite-plugin-modules.ts:304-367).
4.3 Hosted vs self-hosted
- Hosted (multi-tenant): all first-party backend modules load for everyone
(safe because the backend API is additive-only); frontend modules load
per-user via code splitting. Custom/third-party backend modules are not
supported on hosted servers (deliberate boundary). Install scripts:
scripts/install-hosted-modules.sh(server runtime) and…-frontend.sh(Vite build resolution), driven bymodules.hosted.json. - Self-hosted: full control via
modules.local.json(uncommitted).
5. Extension points (contribute → platform)
Every point is registered on moduleRegistry and consumed by a specific
platform site. Getters gate on the active-module list (§4.2).
| Extension point | Interface (module.ts) |
Register method (moduleRegistry.ts) |
Consumed by (file:line) |
|---|---|---|---|
| Artwork source | ArtworkSourceExtension (:56) |
registerArtworkSource (:48) |
TokenArtworkEditor.tsx:671,859; genai probe WebGLMap.tsx:4182 |
| Artwork extender / outpaint | ArtworkExtenderExtension (:133) |
registerArtworkExtender (:67) |
SourceImageEditorDrawer.tsx:187,307; BackgroundTokenBar.tsx:415,505 |
| Settings panel | SettingsPanelExtension (:162) |
registerSettingsPanel (:86) |
SettingsPage.tsx:122 |
| Dice roller (replaces built-in 3D dice) | DiceRollerExtension (:264) |
registerDiceRoller (:169); active = getActiveDiceRoller (:190) |
diceExtensionAdapter.ts:35; DiceExtensionBridge.tsx:60-100 |
| System module | SystemModuleExtension (:307) |
registerSystemModule (:106) |
system loader systems/index.ts:129,156 |
| System fixture (DB seeding) | SystemFixtureExtension (:324) |
registerSystemFixture (:126); also auto-registered from serverExport.systemFixture (index.ts:831-836) |
fixtures/index.ts:30,39; seedAllSystems() |
| System service (opaque per-system object, e.g. templates) | (generic) | registerSystemService (:146); getSystemService<T>() (:151) |
TokenSheet.tsx:30 |
| Server API router | ModuleServerExport.router (:366) |
(exported from server entry) | auto-mounted /api/modules/{id} index.ts:826-830 |
Server initialize(ctx) |
ModuleInitializeContext (:343) |
(exported from server entry) | called lazily with {moduleId, dataDir} modules/index.ts:418-423 |
| Static assets | assetPaths in descriptor |
(descriptor) | tracked/uploaded in Stage 2 modules/index.ts:348-353,404-416 |
| Character sheet section (CS-7, 1.5.0) | CharacterSheetSectionExtension (module.ts) |
registerCharacterSheetSection / getCharacterSheetSections (moduleRegistry.ts) |
Character Sheet / Play Mode (resolved dynamically) — character-sheet-api-0.1.md §CS-7 |
Character sheet renderer (NS.3, 1.39.0; the seam's corrections 1.40.0: SheetApi.rest / .equipment / .treasury.claim, the equipment prop, openPicker('attack', attackId), CharacterSheetSchema.metadataFields) |
CharacterSheetRendererExtension (module.ts) |
registerCharacterSheetRenderer / getCharacterSheetRenderer(systemId) (moduleRegistry.ts; the registration is validated) |
SheetHost.tsx (rendererFor) — character-sheet-renderer.md |
| Buffs (B.9, 1.42.0: the keys a buff may target and the token field each rewrites, how the system stacks modifiers, its buff categories; and the modifiers in play handed to the module so every derived number resolves through them) | SystemModule.modifierTargets(): ModifierTarget[], .buffPolicy: Partial<BuffPolicy>, .buffCategories; a trailing modifiers?: ModifierSet on getAttributes / getTokenAttributes / getDerivedStats / projectToken, and SheetRendererProps.modifiers (systems/types.ts, buffs/modifierSet.ts) |
declared on the system module; none = the generic attribute.<slug> vocabulary and the stack-everything policy |
collectModifiers → makeModifierSet (fan-out, sheet host, token overlay) — Buffs |
| Trait buffs (1.43.0, v0.119.0: the buffs the system's own rules give a character's powers and feats, held while the trait is — System 8's Large is Strength +8) | SystemModule.traitBuffs(character): TraitBuffs[] (systems/types.ts, buffs/collect.ts); evaluateBuffExpression (buffs/expression.ts) for an expression value |
declared on the system module; none = a trait contributes only the buffs stored on it | collectModifiers({ traitBuffs }) via traitBuffsOf(module, character) (fan-out, sheet host) |
| Mini-Me's action menus (ST.10, 1.41.0: which menus the impersonated character's overlay on the map offers — Ability Scores, Saves, Attacks, Powers, Spells, Skills, or every action — each filled by the platform from the token's actions) | SystemModule.actionMenus: ActionMenuSpec[] (systems/types.ts) |
declared on the system module; none = one menu of every action | MiniMeMenus.tsx (miniMeMenus) |
| AI parse schema (R3.3, 1.5.0) | ModuleServerExport.aiParseSchema |
registerAiParseSchema (auto from server entry at boot) |
AI import routes/ai.ts — content-api-0.1.md §4 |
| External-host allowlist (R3.2, 1.5.0) | ModuleServerExport.allowedHosts |
registerAllowedHosts / getAllowedHosts (auto from server entry at boot) |
proxy systemDataProxy.ts + image SSRF tokens.ts |
| Sidebar tab (1.44.0, procedural maps M1: a module's own tab in the play bar's strip, beside the built-ins, by the v0.113.0 tab rules) | SidebarTabExtension, SidebarTabContext, SidebarTabProps |
registerSidebarTab / getSidebarTabs(side?) / getSidebarTab(id) |
PlayBar.tsx and SidebarTabList.tsx via moduleTabs.ts; drawn by contextContent.tsx (moduleTab) — §5.2 |
Map layer renderer (1.44.0: an r3f component drawn at the map's ground or overlay slot) |
MapLayerRendererExtension, MapLayerRendererProps, GeneratedMapRendererProps |
registerMapLayerRenderer / getMapLayerRenderers(slot?) |
MapLayerRenderers.tsx, mounted by WebGLMap.tsx — §5.2 |
The platform object (1.44.0: how module code reaches PlatformClientAPI) |
PlatformClientAPI, PlatformMapsAPI (platformApi.ts) |
getPlatform() (the host calls setPlatform before any module registers) |
apps/web/src/platform/platformClient.ts — §5.2, §7 |
⚠︎ registerSystemModule / registerSystemService / registerSystemFixture do
not throw on duplicate id (plain .set()), unlike the artwork/settings/dice
registrars which guard against duplicates — last registration silently wins.
5.1 Buffs (1.42.0)
The platform collects the modifiers in play for a character (its equipped
items, enabled feats and powers, conditions) and for a token (plus the
TimedEffects running on it), and hands them to the module as a
ModifierSet — the trailing argument of getAttributes,
getTokenAttributes, getDerivedStats and projectToken, and
SheetRendererProps.modifiers. The module derives every number through it,
inputs before outputs:
const str = modifiers.total('ability.str', storedStr); // abilities first…
const toHit = modifiers.total('attack.melee', mod(str) + prof); // …then what derives from them
const ac = modifiers.resolve('ac', base); // { total, parts } — breakdownOf(ac) for the sheet
A module declares what may be targeted (modifierTargets(): keys, labels,
groups, and the flat token field each rewrites on an unlinked token), how
it stacks (buffPolicy: sameSource stack | mostPotent, groups
ignore | highest | highestBonusWorstPenalty, percentOf), and its
buffCategories. A module that declares nothing gets the generic
attribute.<slug> keys and the stack-everything policy; one that ignores
the argument derives exactly as before. Never write a resolved number back
into a stored attribute — a buff saved as a base applies twice. dnd5e 0.21.0
is the reference implementation (core/buffs.ts, core/buffedCharacter.ts).
Trait buffs (1.43.0). A system whose own rules give a trait a buff —
System 8's Large, held rather than switched on — returns them from
traitBuffs(character), pure and synchronous, from what the module bundles.
The platform collects them unconditionally, labelled by the trait, with
everything else; a buff a user authors on a power still needs the power
enabled. evaluateBuffExpression(expr, scope) is the arithmetic for an
expression value ('(2 * @rank)'); the system builds the scope. System 8
0.20.0 is the reference (traitBuffs.ts, buffedCharacter.ts, and the
generated generated/traitBuffs.ts).
See Buffs for the rules as players see them.
5.2 Map-level extensions (1.44.0)
Three seams a module needs to work on a map (procedural maps M1; the
map-generator module is the first user). A module's register() takes no
arguments, so everything goes through moduleRegistry:
import { moduleRegistry } from '@purple-cat/shared';
import { IconTrees } from '@tabler/icons-react';
export function register() {
moduleRegistry.registerSidebarTab({
id: 'map-generator:generate', moduleId: 'map-generator', side: 'left',
label: 'Generate', description: 'Generate this map', icon: IconTrees,
component: GenerateTab, // ComponentType<SidebarTabProps>
visible: (ctx) => ctx.canEditMap && !ctx.isMobile,
});
moduleRegistry.registerMapLayerRenderer({
id: 'map-generator:preview', moduleId: 'map-generator', slot: 'ground',
component: Preview, // ComponentType<MapLayerRendererProps>
visible: (ctx) => ctx.canEditMap,
});
}
// anywhere in module code, after registration:
const platform = moduleRegistry.getPlatform();
await platform.maps.setModuleState(mapId, 'map-generator', recipe, version);
- Sidebar tabs. A tab sits in its side's strip after the built-in tabs,
ordered by
order(default 100), and behaves like them: one tab open per side, a click on the active tab closes it, a popped-out tab's click closes its window. Its content is drawn under the tab'slabelas a header, givenSidebarTabProps(campaignId,mapId,close()).visible(ctx)decides who sees it;SidebarTabContext.isGMis the campaign owner not previewing as a player, andcanEditMapis the server'sediton the map (the verbmayEditMapchecks), both false while the GM views as a player. A tab is opened by id withopenSidebarTab(id)(components/UI/PlayBar/moduleTabs.ts), as Generate Map does. The ids are namespaced:<module>:<tab>. - Map layer renderers. An r3f component inside the map's canvas, on
every map, for every viewer
visibleallows.grounddraws above the foundation and the committed generated map and below every map layer;overlayabove every map layer and below lighting and fog. Its props (MapLayerRendererProps) give the map,gridSize, the slot'szBase(draw withinzBase … zBase + 0.09), the squares in view (plus a margin) from the camera,isGM, andGeneratedMap: the platform's own generated-map renderer, so a preview is drawn exactly as its committed map will be, with no three.js in the module. - The platform object.
moduleRegistry.getPlatform()returns the platform'sPlatformClientAPI:authFetch(the user's token, the API's origin),uploadImageFromUrl/uploadImageFromFile,library, andmaps— the map write facade (bulkCreate,removeGeneration), per-map module state (get/set/deleteModuleState, GM only, never broadcast;setModuleStatewithifVersionrejects a stale write with aPlatformHttpErrorof status 409), and committed generated maps (getGenerated,commitGenerated,removeGenerated). It throws if called before the host set it, which in the app is before any module registers.
6. Type surface (packages/shared/src/types/module.ts)
| Type | Purpose | Line |
|---|---|---|
ArtworkSourceProps / ArtworkSourceExtension |
Props + registration for an artwork source | :22 / :56 |
ArtworkExtendDirection / ArtworkExtenderProps / ArtworkExtenderExtension |
Outpaint direction, props, registration | :87 / :104 / :133 |
SettingsPanelExtension |
Settings-page section registration | :162 |
DiceRollContext / DiceRollDieResult / DiceRollResolution / DiceRollerExtension |
Dice roller context, results, registration | :192 / :229 / :241 / :264 |
SystemModuleExtension |
TTRPG system registration (lazy loadModule()) |
:307 |
CharacterSheetSectionProps / CharacterSheetSectionExtension (1.5.0, CS-7) |
Props + registration for a custom sheet section | — |
SystemFixtureExtension |
DB-seeding fixture registration | :324 |
ModuleInitializeContext |
initialize() arg (moduleId + persistent dataDir) |
:343 |
ModuleServerExport |
Server entry: router?, initialize?, systemFixture?, systemFixtures? (plural), aiParseSchema?, allowedHosts? (1.5.0) |
:366 |
AiParseSchema (1.5.0) |
AI import target attributes + guidelines (services/aiParseSchema.ts) |
— |
resolveSystemFixtures() (1.5.0) |
Normalize singular/plural fixtures into a list | — |
ModuleInfo / ModulesResponse |
Per-module metadata + GET /api/modules response |
:388 / :441 |
MODULE_API_VERSION |
Canonical version constant ('1.5.0') |
:436 |
Supporting types: ModuleDescriptor / ResolvedModule / ModuleManifest
(modules/types.ts:17,59,71);
SystemFixture (types/systemFixture.ts);
SystemModule (the System API — see system-api-0.1.md).
7. The PlatformAPI facade
Since 1.44.0 the client half is built: apps/web/src/platform/platformClient.ts
implements PlatformClientAPI (including library over its four routes and
maps), and module code reaches it with moduleRegistry.getPlatform()
(§5.2). PlatformServerAPI is still a contract only. What follows is the
history from before 1.44.0.
Before 1.44.0 (proposed — NOT built) ⚠︎
The "consume ← platform" direction is declared but unimplemented:
PlatformClientAPI (platformApi.ts:27),
LibraryAPI (:72),
PlatformServerAPI (:162).
The header explicitly says "implementation will be provided in a future phase"
(platformApi.ts:8-11); no
object satisfies these interfaces anywhere (grep finds only type references + the
contract guard test). The underlying library REST routes do exist
(routes/library.ts), so
the facade is an un-wrapped layer today. This is where version compatibility /
deprecation tracking is intended to apply.
8. Known gaps
PlatformServerAPIunbuilt (§7). The client half is built since 1.44.0 (moduleRegistry.getPlatform()); server code still uses the platform's services directly.- Docs undercount extension points.
modules.mdlists 2;MODULE_AUTHOR_GUIDE.mdlists 3 (adds DiceRoller). Code ships 6+ registry points. Undocumented in both: ArtworkExtender, SystemModule, SystemFixture,registerSystemService. (This spec is the corrected inventory.) ModuleServerExport.systemFixtureis a live contribution path (consumedindex.ts:831) but absent frommodules.md's interface snippet.- Deprecation cycle dormant (§2): helper exists + tested, zero production uses.
- Duplicate-id silent-win for system/systemService/systemFixture registrars (§5).
- Module routers are public by platform default — and, since v0.95.0
(MODULE_API_VERSION 1.37.0), every request they see carries
req.actorandreq.effectivePermissionswhen the caller sent a token (ModuleRequestContext, mounted bymiddleware/moduleRequestContext.ts). A module that needs an actor checks for one and answers 401 itself; the resolver it gets is the platform's own. See Permissions, read routes and the Gatekeeper. - Scope-name inconsistency across docs vs. shipped modules (§3).