openvibers/openvibe.games Commits

Games on the platform (Wave 12): players keyed by canonical Network subjects with legacy ovn: adoption and a batch migration; guest tokens can no longer name an account key; the games client-credentials principal for every service call; lifecycle, progression, world-save and mod events through a transactional outbox (openvibe-sdk 0.2.2); map-editor assets mirrored to Media objects; mod manifest validation, registry with approved grants, audit and revoke, and the capability-checked runtime seam for games-content@1 packs; Contracts proposals

OP OpenVibers committed 23 Sept 2026 · 87cf770bc5 · 93 files, +5325 −38


Changes

ddiff --git a/apps/server/dist-types/config.d.ts b/apps/server/dist-types/config.d.ts
iindex 693a2af..7e9c37e 100644
---- a/apps/server/dist-types/config.d.ts
++++ b/apps/server/dist-types/config.d.ts
@@@ -18,6 +18,12 @@ export interface ServerConfig {
/** Public base of the play host (Host-routed game vhost). */
playUrl: string;
} | null;
++ /**
++ * Platform integration (roadmap Wave 12): Games calls OpenVibe services with
++ * a client-credentials token of its own `games` principal. Everything here
++ * is off until OV_OAUTH_CLIENT_SECRET (the same OAuth client as SSO) is set.
++ */
++ platform: PlatformConfig;
tickRate: number;
/** Send a snapshot every N ticks. */
snapshotEvery: number;
@@@ -29,5 +35,22 @@ export interface ServerConfig {
/** Multiplier on world-event cadences (tests shrink it). */
eventIntervalScale: number;
}
++export interface PlatformConfig {
++ /** OAuth client id of the `games` principal (shared with SSO). */
++ clientId: string;
++ /** Its client secret; null = no service calls at all. */
++ clientSecret: string | null;
++ /** Network base for /oauth/token, identity and the JWKS (host-internal in production). */
++ networkUrl: string;
++ /** OpenVibe.Events base; null = no durable events (the outbox is not even created). */
++ eventsUrl: string | null;
++ /** OpenVibe.Media base; null = map assets stay local only. */
++ mediaUrl: string | null;
++ /** Media namespace (tenant app id) Games writes objects into. */
++ mediaNamespace: string;
++ /** At most one `games.world.saved` checkpoint event per this many minutes. */
++ worldSavedEventMinutes: number;
++}
++export declare function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig;
export declare function loadConfig(env: NodeJS.ProcessEnv): ServerConfig;
//# sourceMappingURL=config.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/config.d.ts.map b/apps/server/dist-types/config.d.ts.map
iindex f22a471..32521d8 100644
---- a/apps/server/dist-types/config.d.ts.map
++++ b/apps/server/dist-types/config.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;IACd,0EAA0E;IAC1E,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,+EAA+E;IAC/E,cAAc,EAAE,OAAO,CAAA;IACvB,gFAAgF;IAChF,KAAK,EAAE;QACL,QAAQ,EAAE,MAAM,CAAA;QAChB,YAAY,EAAE,MAAM,CAAA;QACpB,OAAO,EAAE,MAAM,CAAA;QACf,OAAO,EAAE,MAAM,CAAA;QACf,6DAA6D;QAC7D,OAAO,EAAE,MAAM,CAAA;KAChB,GAAG,IAAI,CAAA;IACR,QAAQ,EAAE,MAAM,CAAA;IAChB,qCAAqC;IACrC,aAAa,EAAE,MAAM,CAAA;IACrB,8EAA8E;IAC9E,cAAc,EAAE,MAAM,CAAA;IACtB,UAAU,EAAE,MAAM,CAAA;IAClB,mBAAmB,EAAE,MAAM,CAAA;IAC3B,iBAAiB,EAAE,MAAM,CAAA;IACzB,4DAA4D;IAC5D,kBAAkB,EAAE,MAAM,CAAA;CAC3B;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,YAAY,CAiC/D"}
\\ No newline at end of file
++{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;IACd,0EAA0E;IAC1E,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,+EAA+E;IAC/E,cAAc,EAAE,OAAO,CAAA;IACvB,gFAAgF;IAChF,KAAK,EAAE;QACL,QAAQ,EAAE,MAAM,CAAA;QAChB,YAAY,EAAE,MAAM,CAAA;QACpB,OAAO,EAAE,MAAM,CAAA;QACf,OAAO,EAAE,MAAM,CAAA;QACf,6DAA6D;QAC7D,OAAO,EAAE,MAAM,CAAA;KAChB,GAAG,IAAI,CAAA;IACR;;;;OAIG;IACH,QAAQ,EAAE,cAAc,CAAA;IACxB,QAAQ,EAAE,MAAM,CAAA;IAChB,qCAAqC;IACrC,aAAa,EAAE,MAAM,CAAA;IACrB,8EAA8E;IAC9E,cAAc,EAAE,MAAM,CAAA;IACtB,UAAU,EAAE,MAAM,CAAA;IAClB,mBAAmB,EAAE,MAAM,CAAA;IAC3B,iBAAiB,EAAE,MAAM,CAAA;IACzB,4DAA4D;IAC5D,kBAAkB,EAAE,MAAM,CAAA;CAC3B;AAED,MAAM,WAAW,cAAc;IAC7B,kEAAkE;IAClE,QAAQ,EAAE,MAAM,CAAA;IAChB,yDAAyD;IACzD,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,0FAA0F;IAC1F,UAAU,EAAE,MAAM,CAAA;IAClB,uFAAuF;IACvF,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,8DAA8D;IAC9D,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,iEAAiE;IACjE,cAAc,EAAE,MAAM,CAAA;IACtB,8EAA8E;IAC9E,sBAAsB,EAAE,MAAM,CAAA;CAC/B;AAMD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,cAAc,CAazE;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,YAAY,CAkC/D"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/game/gameServer.d.ts b/apps/server/dist-types/game/gameServer.d.ts
iindex 57ac039..0f649be 100644
---- a/apps/server/dist-types/game/gameServer.d.ts
++++ b/apps/server/dist-types/game/gameServer.d.ts
@@@ -3,6 +3,8 @@ import type { PersistenceStore } from '@openvibe/persistence';
import { type ClientMessage } from '@openvibe/protocol';
import { type Logger } from '@openvibe/shared';
import type { ServerConfig } from '../config.js';
++import type { GameEventRecorder } from '../platform/gameEvents.js';
++import type { ModRuntime } from '../mods/runtime.js';
import type { ServerMetrics } from '../observability/metrics.js';
import type { GameWorld } from './gameWorld.js';
import { type PlayerSession } from './playerSession.js';
@@@ -15,12 +17,23 @@ export interface GameConnection {
send(text: string): void;
close(code: number, reason: string): void;
}
++/**
++ * Platform adapters the server drives (roadmap Wave 12). Both optional: the
++ * game runs exactly as before without them.
++ */
++export interface GameIntegrations {
++ /** Durable lifecycle/progression events through the outbox. */
++ events?: GameEventRecorder;
++ /** Installed mods (content packs), reconciled every tick. */
++ mods?: ModRuntime;
++}
export declare class GameServer {
private readonly config;
private readonly world;
private readonly store;
private readonly metrics;
private readonly log;
++ private readonly integrations;
private readonly sessions;
private readonly sessionsByConn;
private readonly sessionsByEntity;
@@@ -45,7 +58,7 @@ export declare class GameServer {
private readonly aggro;
/** Sound events (gunshots, fights) accumulated for NPC hearing. */
private sounds;
-- constructor(config: ServerConfig, world: GameWorld, store: PersistenceStore, metrics: ServerMetrics, log: Logger);
++ constructor(config: ServerConfig, world: GameWorld, store: PersistenceStore, metrics: ServerMetrics, log: Logger, integrations?: GameIntegrations);
get currentTick(): number;
/**
* Prop protection: world props (no owner) are free; otherwise the owner
@@@ -138,10 +151,20 @@ export declare class GameServer {
step(): void;
private stepSessionMovement;
private replicate;
-- flush(): void;
++ /**
++ * One transaction per flush: world rows, player rows and the outbox
++ * events describing them commit together (or not at all).
++ */
++ flush(reason?: 'checkpoint' | 'shutdown'): void;
/** Full save on shutdown. */
shutdown(): void;
++ /** Saves one character; `leaving` also records games.player.left in the same transaction. */
private savePlayer;
++ /**
++ * What mods may do to the world, lent to the mod runtime only. Mod props
++ * are owned by the mod id, so prop protection keeps players' hands off.
++ */
++ private readonly modHost;
private playerToDto;
private send;
private sendRaw;
ddiff --git a/apps/server/dist-types/game/gameServer.d.ts.map b/apps/server/dist-types/game/gameServer.d.ts.map
iindex 6711622..3b3bd30 100644
---- a/apps/server/dist-types/game/gameServer.d.ts.map
++++ b/apps/server/dist-types/game/gameServer.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"gameServer.d.ts","sourceRoot":"","sources":["../../src/game/gameServer.ts"],"names":[],"mappings":"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"}
\\ No newline at end of file
++{"version":3,"file":"gameServer.d.ts","sourceRoot":"","sources":["../../src/game/gameServer.ts"],"names":[],"mappings":"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"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/game/platformIntegration.test.d.ts b/apps/server/dist-types/game/platformIntegration.test.d.ts
nnew file mode 100644
iindex 0000000..0fb8eca
---- /dev/null
++++ b/apps/server/dist-types/game/platformIntegration.test.d.ts
@@@ -0,0 +1,2 @@
++export {};
++//# sourceMappingURL=platformIntegration.test.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/game/platformIntegration.test.d.ts.map b/apps/server/dist-types/game/platformIntegration.test.d.ts.map
nnew file mode 100644
iindex 0000000..f7345f5
---- /dev/null
++++ b/apps/server/dist-types/game/platformIntegration.test.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"platformIntegration.test.d.ts","sourceRoot":"","sources":["../../src/game/platformIntegration.test.ts"],"names":[],"mappings":""}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/game/playerSession.d.ts b/apps/server/dist-types/game/playerSession.d.ts
iindex 50bc7b0..4a4efb5 100644
---- a/apps/server/dist-types/game/playerSession.d.ts
++++ b/apps/server/dist-types/game/playerSession.d.ts
@@@ -35,6 +35,8 @@ export interface PlayerSession {
playerId: PlayerId;
entityId: EntityId;
token: string;
++ /** Canonical openvibe.network subject of the account; null for local guests. */
++ subjectId: string | null;
/** Character slot under the account token (0..2). */
charSlot: number;
/** openvibe.network rank; gates edit mode + moderation. */
@@@ -102,6 +104,7 @@ export interface SessionInit {
playerId: PlayerId;
entityId: EntityId;
token: string;
++ subjectId?: string | null;
charSlot?: number;
rank?: 'owner' | 'admin' | 'moderator' | null;
name: string;
ddiff --git a/apps/server/dist-types/game/playerSession.d.ts.map b/apps/server/dist-types/game/playerSession.d.ts.map
iindex b088b2d..114f3db 100644
---- a/apps/server/dist-types/game/playerSession.d.ts.map
++++ b/apps/server/dist-types/game/playerSession.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"playerSession.d.ts","sourceRoot":"","sources":["../../src/game/playerSession.ts"],"names":[],"mappings":"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"}
\\ No newline at end of file
++{"version":3,"file":"playerSession.d.ts","sourceRoot":"","sources":["../../src/game/playerSession.ts"],"names":[],"mappings":"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"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/contentPack.d.ts b/apps/server/dist-types/mods/contentPack.d.ts
nnew file mode 100644
iindex 0000000..0ee6c8b
---- /dev/null
++++ b/apps/server/dist-types/mods/contentPack.d.ts
@@@ -0,0 +1,106 @@
++/**
++ * `games-content@1`: the first mod kind — a declarative data pack, checked
++ * against the live `@openvibe/content` registry. It adds no code and no new
++ * definitions (the client ships the same registry, so a pack can only use
++ * what both sides already know). Each section needs one capability:
++ *
++ * announcements games.world.announce periodic server announcements
++ * props games.prop.place inert props placed in the world
++ *
++ * Mod props are owned by the mod, so prop protection keeps players from
++ * picking them up, moving or welding them, and only items without health,
++ * storage, shops, machines or vehicle parts may be placed: nothing a player
++ * could destroy for loot or use to move items into or out of the world.
++ */
++import type { ContentRegistry } from '@openvibe/content';
++import type { Validation } from './manifest.js';
++export declare const CAP_ANNOUNCE = "games.world.announce";
++export declare const CAP_PLACE_PROP = "games.prop.place";
++/** Capabilities the games-content@1 runtime can bind. Anything else is never granted here. */
++export declare const CONTENT_RUNTIME_CAPABILITIES: readonly string[];
++export interface ContentAnnouncement {
++ text: string;
++ everySeconds: number;
++}
++export interface ContentProp {
++ key: string;
++ item: string;
++ pos: [number, number, number];
++ yaw?: number;
++}
++export interface ContentPack {
++ announcements?: ContentAnnouncement[];
++ props?: ContentProp[];
++}
++export declare const CONTENT_PACK_SCHEMA: {
++ readonly $schema: "https://json-schema.org/draft/2020-12/schema";
++ readonly $id: "https://openvibe.games/schemas/games-content-pack.v1.json";
++ readonly title: "GamesContentPack";
++ readonly type: "object";
++ readonly additionalProperties: false;
++ readonly properties: {
++ readonly announcements: {
++ readonly type: "array";
++ readonly maxItems: 10;
++ readonly items: {
++ readonly type: "object";
++ readonly required: readonly ["text", "everySeconds"];
++ readonly additionalProperties: false;
++ readonly properties: {
++ readonly text: {
++ readonly type: "string";
++ readonly minLength: 1;
++ readonly maxLength: 200;
++ };
++ readonly everySeconds: {
++ readonly type: "integer";
++ readonly minimum: 60;
++ readonly maximum: 86400;
++ };
++ };
++ };
++ };
++ readonly props: {
++ readonly type: "array";
++ readonly maxItems: 50;
++ readonly items: {
++ readonly type: "object";
++ readonly required: readonly ["key", "item", "pos"];
++ readonly additionalProperties: false;
++ readonly properties: {
++ readonly key: {
++ readonly type: "string";
++ readonly pattern: "^[a-z0-9][a-z0-9_-]{0,39}$";
++ };
++ readonly item: {
++ readonly type: "string";
++ readonly minLength: 1;
++ readonly maxLength: 64;
++ };
++ readonly pos: {
++ readonly type: "array";
++ readonly minItems: 3;
++ readonly maxItems: 3;
++ readonly items: {
++ readonly type: "number";
++ readonly minimum: -4096;
++ readonly maximum: 4096;
++ };
++ };
++ readonly yaw: {
++ readonly type: "number";
++ readonly minimum: -10;
++ readonly maximum: 10;
++ };
++ };
++ };
++ };
++ };
++};
++/** Which capability each pack section needs. */
++export declare function capabilitiesUsedBy(pack: ContentPack): string[];
++/** Whether a content item may be placed as a mod prop (see the file comment). */
++export declare function placeableByMod(content: ContentRegistry, itemId: string): string | null;
++/** Structure (JSON Schema) plus the content-registry cross-checks. */
++export declare function validateContentPack(value: unknown, content: ContentRegistry): Validation<ContentPack>;
++//# sourceMappingURL=contentPack.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/contentPack.d.ts.map b/apps/server/dist-types/mods/contentPack.d.ts.map
nnew file mode 100644
iindex 0000000..10eb4b3
---- /dev/null
++++ b/apps/server/dist-types/mods/contentPack.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"contentPack.d.ts","sourceRoot":"","sources":["../../src/mods/contentPack.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AAExD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAA;AAE/C,eAAO,MAAM,YAAY,yBAAyB,CAAA;AAClD,eAAO,MAAM,cAAc,qBAAqB,CAAA;AAEhD,8FAA8F;AAC9F,eAAO,MAAM,4BAA4B,EAAE,SAAS,MAAM,EAAmC,CAAA;AAE7F,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAA;IACZ,YAAY,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IAC7B,GAAG,CAAC,EAAE,MAAM,CAAA;CACb;AAED,MAAM,WAAW,WAAW;IAC1B,aAAa,CAAC,EAAE,mBAAmB,EAAE,CAAA;IACrC,KAAK,CAAC,EAAE,WAAW,EAAE,CAAA;CACtB;AAED,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyCtB,CAAA;AAKV,gDAAgD;AAChD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,EAAE,CAK9D;AAED,iFAAiF;AACjF,wBAAgB,cAAc,CAAC,OAAO,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAStF;AAED,sEAAsE;AACtE,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,OAAO,EACd,OAAO,EAAE,eAAe,GACvB,UAAU,CAAC,WAAW,CAAC,CAmBzB"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/manifest.d.ts b/apps/server/dist-types/mods/manifest.d.ts
nnew file mode 100644
iindex 0000000..d9ef089
---- /dev/null
++++ b/apps/server/dist-types/mods/manifest.d.ts
@@@ -0,0 +1,55 @@
++export interface ModManifest {
++ id: string;
++ name: string;
++ version: string;
++ description?: string;
++ publisher: {
++ type: string;
++ id: string;
++ };
++ target: string;
++ runtime: string;
++ permissions: {
++ capabilities: string[];
++ events?: string[];
++ modules?: string[];
++ mediaNamespaces?: string[];
++ };
++ resources: {
++ cpuMs: number;
++ memoryMb: number;
++ storageMb: number;
++ outboundHosts?: string[];
++ };
++ assets?: {
++ media_id: string;
++ role?: string;
++ variant?: string;
++ }[];
++ compatibility: {
++ runtime: string;
++ contracts?: string;
++ };
++ homepage?: string;
++}
++export type Validation<T> = {
++ ok: true;
++ value: T;
++} | {
++ ok: false;
++ errors: string[];
++};
++/** Structural validation against mods/mod-manifest.v1. */
++export declare function validateManifest(value: unknown): Validation<ModManifest>;
++/** The browser game's only mod target and runtime today. */
++export declare const GAMES_TARGET = "games.browser";
++export declare const CONTENT_RUNTIME = "games-content@1";
++/** Version of the content runtime, matched against compatibility.runtime. */
++export declare const CONTENT_RUNTIME_VERSION = "1.0.0";
++/**
++ * Whether this server can run the manifest at all. Executable runtimes
++ * (scripts) are refused here: they wait for OpenVibe.Host's sandbox
++ * (Stage C). Only declarative content packs run in Games today.
++ */
++export declare function checkForGames(m: ModManifest): string[];
++//# sourceMappingURL=manifest.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/manifest.d.ts.map b/apps/server/dist-types/mods/manifest.d.ts.map
nnew file mode 100644
iindex 0000000..bb508a5
---- /dev/null
++++ b/apps/server/dist-types/mods/manifest.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../src/mods/manifest.ts"],"names":[],"mappings":"AAkBA,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAA;IACvC,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,EAAE;QACX,YAAY,EAAE,MAAM,EAAE,CAAA;QACtB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;QACjB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;QAClB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;KAC3B,CAAA;IACD,SAAS,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAA;IAC3F,MAAM,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IAChE,aAAa,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IACtD,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAOD,MAAM,MAAM,UAAU,CAAC,CAAC,IAAI;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAAA;AAEpF,0DAA0D;AAC1D,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,UAAU,CAAC,WAAW,CAAC,CAMxE;AAED,4DAA4D;AAC5D,eAAO,MAAM,YAAY,kBAAkB,CAAA;AAC3C,eAAO,MAAM,eAAe,oBAAoB,CAAA;AAChD,6EAA6E;AAC7E,eAAO,MAAM,uBAAuB,UAAU,CAAA;AAE9C;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,WAAW,GAAG,MAAM,EAAE,CAmBtD"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/manifest.test.d.ts b/apps/server/dist-types/mods/manifest.test.d.ts
nnew file mode 100644
iindex 0000000..ced3f96
---- /dev/null
++++ b/apps/server/dist-types/mods/manifest.test.d.ts
@@@ -0,0 +1,2 @@
++export {};
++//# sourceMappingURL=manifest.test.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/manifest.test.d.ts.map b/apps/server/dist-types/mods/manifest.test.d.ts.map
nnew file mode 100644
iindex 0000000..7315d6c
---- /dev/null
++++ b/apps/server/dist-types/mods/manifest.test.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"manifest.test.d.ts","sourceRoot":"","sources":["../../src/mods/manifest.test.ts"],"names":[],"mappings":""}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/manifestSchema.d.ts b/apps/server/dist-types/mods/manifestSchema.d.ts
nnew file mode 100644
iindex 0000000..d5bb538
---- /dev/null
++++ b/apps/server/dist-types/mods/manifestSchema.d.ts
@@@ -0,0 +1,163 @@
++/**
++ * The mod manifest JSON Schema (mods/mod-manifest.v1) as Games validates it.
++ * The proposal for OpenVibe.Contracts is docs/contracts-proposal/mods/mod-manifest.v1.json;
++ * manifest.test.ts fails if the two ever differ. Once Contracts releases the
++ * schema, import it from openvibe-contracts instead of keeping this copy.
++ */
++export declare const MOD_MANIFEST_SCHEMA: {
++ readonly $schema: "https://json-schema.org/draft/2020-12/schema";
++ readonly $id: "https://openvibe.network/contracts/mods/mod-manifest.v1.json";
++ readonly title: "ModManifest";
++ readonly description: "A mod as the platform knows it (ADR-013): who publishes it, which runtime runs it, which capabilities it asks for and the resources it may use. Requested capabilities are only a request: the install's approved subset is the grant, and trust tiers are install metadata that never change a grant check. The runtime-specific payload (a data pack, a script bundle) is not part of the manifest.";
++ readonly type: "object";
++ readonly required: readonly ["id", "name", "version", "publisher", "target", "runtime", "permissions", "resources", "compatibility"];
++ readonly additionalProperties: false;
++ readonly properties: {
++ readonly id: {
++ readonly type: "string";
++ readonly pattern: "^mod_[0-9A-HJKMNP-TV-Z]{26}$";
++ readonly description: "Stable mod id; its principal subject is mod:<id>.";
++ };
++ readonly name: {
++ readonly type: "string";
++ readonly minLength: 1;
++ readonly maxLength: 80;
++ };
++ readonly version: {
++ readonly type: "string";
++ readonly pattern: "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$";
++ readonly description: "Semantic version of this release.";
++ };
++ readonly description: {
++ readonly type: "string";
++ readonly maxLength: 1000;
++ };
++ readonly publisher: {
++ readonly $ref: "../identity/subject-ref.v1.json";
++ readonly description: "Who publishes the mod (a user or an app subject).";
++ };
++ readonly target: {
++ readonly type: "string";
++ readonly pattern: "^[a-z][a-z0-9-]{1,39}\\.[a-z][a-z0-9-]{1,39}$";
++ readonly description: "Where the mod runs: <service>.<surface>, e.g. games.browser, games.source, live.overlay.";
++ };
++ readonly runtime: {
++ readonly type: "string";
++ readonly pattern: "^[a-z][a-z0-9-]{1,39}@[1-9][0-9]{0,3}$";
++ readonly description: "Runtime adapter and its major version, e.g. games-content@1 (declarative data pack) or source-quickjs@1.";
++ };
++ readonly permissions: {
++ readonly type: "object";
++ readonly required: readonly ["capabilities"];
++ readonly additionalProperties: false;
++ readonly properties: {
++ readonly capabilities: {
++ readonly description: "Capability ids the mod asks for (3+ segments). The target runtime binds only those it implements, and only once granted.";
++ readonly type: "array";
++ readonly maxItems: 64;
++ readonly uniqueItems: true;
++ readonly items: {
++ readonly type: "string";
++ readonly pattern: "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$";
++ };
++ };
++ readonly events: {
++ readonly description: "Event types the mod wants delivered to it.";
++ readonly type: "array";
++ readonly maxItems: 64;
++ readonly uniqueItems: true;
++ readonly items: {
++ readonly type: "string";
++ readonly pattern: "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$";
++ };
++ };
++ readonly modules: {
++ readonly description: "User-module namespaces the mod wants to read or write (a trailing .* names a family).";
++ readonly type: "array";
++ readonly maxItems: 32;
++ readonly uniqueItems: true;
++ readonly items: {
++ readonly type: "string";
++ readonly pattern: "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)*(\\.\\*)?$";
++ };
++ };
++ readonly mediaNamespaces: {
++ readonly description: "Media namespaces the mod wants to read or write.";
++ readonly type: "array";
++ readonly maxItems: 32;
++ readonly uniqueItems: true;
++ readonly items: {
++ readonly type: "string";
++ readonly pattern: "^[a-z][a-z0-9_-]*(\\.[a-z0-9_-]+)*$";
++ };
++ };
++ };
++ };
++ readonly resources: {
++ readonly type: "object";
++ readonly required: readonly ["cpuMs", "memoryMb", "storageMb"];
++ readonly additionalProperties: false;
++ readonly description: "The budget the mod asks for. Runtimes meter it; the sandbox that enforces it lives in OpenVibe.Host (Stage C).";
++ readonly properties: {
++ readonly cpuMs: {
++ readonly type: "number";
++ readonly minimum: 0;
++ readonly maximum: 1000;
++ readonly description: "CPU milliseconds per tick (game runtimes) or per request.";
++ };
++ readonly memoryMb: {
++ readonly type: "integer";
++ readonly minimum: 0;
++ readonly maximum: 4096;
++ };
++ readonly storageMb: {
++ readonly type: "integer";
++ readonly minimum: 0;
++ readonly maximum: 102400;
++ };
++ readonly outboundHosts: {
++ readonly description: "Hosts the mod may reach over the network. Empty or absent = none.";
++ readonly type: "array";
++ readonly maxItems: 32;
++ readonly uniqueItems: true;
++ readonly items: {
++ readonly type: "string";
++ readonly pattern: "^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$";
++ };
++ };
++ };
++ };
++ readonly assets: {
++ readonly description: "Assets the mod ships, as Media object references.";
++ readonly type: "array";
++ readonly maxItems: 256;
++ readonly items: {
++ readonly $ref: "../media/media-ref.v1.json";
++ };
++ };
++ readonly compatibility: {
++ readonly type: "object";
++ readonly required: readonly ["runtime"];
++ readonly additionalProperties: false;
++ readonly properties: {
++ readonly runtime: {
++ readonly type: "string";
++ readonly minLength: 1;
++ readonly maxLength: 100;
++ readonly description: "Semver range of the runtime this release works with, e.g. \">=1.0.0 <2.0.0\".";
++ };
++ readonly contracts: {
++ readonly type: "string";
++ readonly minLength: 1;
++ readonly maxLength: 100;
++ readonly description: "Semver range of openvibe-contracts releases it was built against.";
++ };
++ };
++ };
++ readonly homepage: {
++ readonly type: "string";
++ readonly pattern: "^https://[^\\s]{1,2000}$";
++ };
++ };
++};
++//# sourceMappingURL=manifestSchema.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/manifestSchema.d.ts.map b/apps/server/dist-types/mods/manifestSchema.d.ts.map
nnew file mode 100644
iindex 0000000..99f7e40
---- /dev/null
++++ b/apps/server/dist-types/mods/manifestSchema.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"manifestSchema.d.ts","sourceRoot":"","sources":["../../src/mods/manifestSchema.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4KtB,CAAA"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/registry.d.ts b/apps/server/dist-types/mods/registry.d.ts
nnew file mode 100644
iindex 0000000..8ba4e74
---- /dev/null
++++ b/apps/server/dist-types/mods/registry.d.ts
@@@ -0,0 +1,96 @@
++/**
++ * The mod registry (ADR-013, roadmap Wave 12): installs, the approved subset
++ * of each install's requested capabilities, lifecycle and the audit log, all
++ * in Games' own SQLite database.
++ *
++ * install validate the manifest + pack, store it, grant the approved subset
++ * enable / disable
++ * grant / revokeGrant change one capability of an install
++ * revoke terminal: every grant ends and the runtime retracts the mod's
++ * effects on the next tick
++ *
++ * Every change is one transaction holding the registry rows, the audit rows
++ * and the `games.mod.*` outbox event. The in-memory view the runtime asks on
++ * its hot path (`isGranted`) is refreshed only after that commit.
++ *
++ * Trust tiers are metadata: `isGranted` never looks at them.
++ *
++ * ADR-013 puts grants in OpenVibe.Network (a `mod` principal per install).
++ * Until Network issues mod principals, the approved subset lives here; the
++ * shape (install -> approved capability ids, revocable at once) is the same
++ * so the source of truth can move without changing the runtime seam.
++ */
++import type { ContentRegistry } from '@openvibe/content';
++import type { ModAuditDto, ModInstallDto, ModTrustTier, PersistenceStore } from '@openvibe/persistence';
++import type { SubjectRef } from 'openvibe-sdk/core';
++import { type EventSink } from '../platform/gameEvents.js';
++import { type ContentPack } from './contentPack.js';
++import { type ModManifest } from './manifest.js';
++export declare const TRUST_TIERS: readonly ModTrustTier[];
++/** Who performed a registry change: an audit string and an event actor. */
++export interface ModActor {
++ /** `usr_…`, `ovn:<id>` for a pre-subject staff token, or `svc:<client>`. */
++ audit: string;
++ subject: SubjectRef;
++}
++export declare const SYSTEM_ACTOR: ModActor;
++export declare class ModError extends Error {
++ readonly code: string;
++ readonly status: number;
++ readonly errors: string[];
++ constructor(code: string, message: string, status?: number, errors?: string[]);
++}
++export interface InstallRequest {
++ manifest: unknown;
++ pack: unknown;
++ /** The approved subset of manifest.permissions.capabilities. */
++ approve?: unknown;
++ trustTier?: unknown;
++ enable?: unknown;
++}
++/** A mod as the runtime and the API see it. */
++export interface ModView {
++ mod: ModInstallDto;
++ manifest: ModManifest;
++ pack: ContentPack;
++ /** Capabilities currently granted (approved and not revoked). */
++ granted: ReadonlySet<string>;
++}
++export declare class ModRegistry {
++ private readonly store;
++ private readonly content;
++ private readonly events;
++ private readonly now;
++ private readonly views;
++ private revision;
++ constructor(store: PersistenceStore, content: ContentRegistry, events?: EventSink, now?: () => number);
++ /** Bumps on every committed change; the runtime reconciles when it moves. */
++ get version(): number;
++ list(): ModView[];
++ get(id: string): ModView | null;
++ isActive(id: string): boolean;
++ /**
++ * The one grant check. True only while the install is enabled and the
++ * capability is in its approved, unrevoked subset. The trust tier is not
++ * consulted — a tier label never grants anything.
++ */
++ isGranted(id: string, capability: string): boolean;
++ auditLog(id: string, limit?: number): ModAuditDto[];
++ /** Appends a runtime audit record (use, deny, retract, …). */
++ audit(id: string, action: string, capability: string | null, detail?: Record<string, unknown>): void;
++ install(req: InstallRequest, actor: ModActor): ModView;
++ enable(id: string, actor: ModActor): ModView;
++ disable(id: string, actor: ModActor): ModView;
++ /** Ends the install for good: every grant is revoked and the mod stops at the next tick. */
++ revoke(id: string, actor: ModActor, reason?: string): ModView;
++ grant(id: string, capability: string, actor: ModActor): ModView;
++ revokeGrant(id: string, capability: string, actor: ModActor): ModView;
++ private setStatus;
++ private assertGrantable;
++ private require;
++ private writeAudit;
++ private emit;
++ /** Re-reads one install after a committed change. */
++ private refresh;
++}
++//# sourceMappingURL=registry.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/registry.d.ts.map b/apps/server/dist-types/mods/registry.d.ts.map
nnew file mode 100644
iindex 0000000..1d6be22
---- /dev/null
++++ b/apps/server/dist-types/mods/registry.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/mods/registry.ts"],"names":[],"mappings":"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"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/routes.d.ts b/apps/server/dist-types/mods/routes.d.ts
nnew file mode 100644
iindex 0000000..3c718d6
---- /dev/null
++++ b/apps/server/dist-types/mods/routes.d.ts
@@@ -0,0 +1,30 @@
++/**
++ * Mod registry HTTP API (JSON; errors are RFC 9457 problem+json with the
++ * legacy `error` field).
++ *
++ * GET /api/v1/mods public: installs, status, grants
++ * GET /api/v1/mods/:id public: one install incl. manifest + pack
++ * GET /api/v1/mods/:id/audit?limit= staff: install/grant/use/deny/revoke log
++ * POST /api/v1/mods staff: { manifest, pack, approve[], trustTier, enable }
++ * POST /api/v1/mods/:id/enable|disable staff
++ * POST /api/v1/mods/:id/revoke staff: { reason? } — terminal
++ * POST /api/v1/mods/:id/grants staff: { capability }
++ * DELETE /api/v1/mods/:id/grants/:capability staff
++ *
++ * Staff = an openvibe.network owner/admin session, or a service principal
++ * whose token (audience openvibe.games) carries `games.mod.manage`.
++ */
++import type { IncomingMessage, ServerResponse } from 'node:http';
++import type { Logger } from '@openvibe/shared';
++import { type ModActor, type ModRegistry, type ModView } from './registry.js';
++export declare const MODS_PREFIX = "/api/v1/mods";
++export interface ModsApi {
++ registry: ModRegistry;
++ /** Resolves the staff actor behind a request, or null. */
++ authorize(req: IncomingMessage): Promise<ModActor | null>;
++ log: Logger;
++}
++export declare function publicView(v: ModView): Record<string, unknown>;
++/** Returns true when the request was a mods API request (handled or answered). */
++export declare function handleModsRequest(req: IncomingMessage, res: ServerResponse, api: ModsApi): boolean;
++//# sourceMappingURL=routes.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/routes.d.ts.map b/apps/server/dist-types/mods/routes.d.ts.map
nnew file mode 100644
iindex 0000000..2f202b6
---- /dev/null
++++ b/apps/server/dist-types/mods/routes.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/mods/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,EAAY,KAAK,QAAQ,EAAE,KAAK,WAAW,EAAE,KAAK,OAAO,EAAE,MAAM,eAAe,CAAA;AAcvF,eAAO,MAAM,WAAW,iBAAiB,CAAA;AAGzC,MAAM,WAAW,OAAO;IACtB,QAAQ,EAAE,WAAW,CAAA;IACrB,0DAA0D;IAC1D,SAAS,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAA;IACzD,GAAG,EAAE,MAAM,CAAA;CACZ;AAED,wBAAgB,UAAU,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAc9D;AA0DD,kFAAkF;AAClF,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,GAAG,EAAE,OAAO,GACX,OAAO,CA+FT"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/routes.test.d.ts b/apps/server/dist-types/mods/routes.test.d.ts
nnew file mode 100644
iindex 0000000..83da75b
---- /dev/null
++++ b/apps/server/dist-types/mods/routes.test.d.ts
@@@ -0,0 +1,2 @@
++export {};
++//# sourceMappingURL=routes.test.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/routes.test.d.ts.map b/apps/server/dist-types/mods/routes.test.d.ts.map
nnew file mode 100644
iindex 0000000..dbf75e2
---- /dev/null
++++ b/apps/server/dist-types/mods/routes.test.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"routes.test.d.ts","sourceRoot":"","sources":["../../src/mods/routes.test.ts"],"names":[],"mappings":""}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/runtime.d.ts b/apps/server/dist-types/mods/runtime.d.ts
nnew file mode 100644
iindex 0000000..593e136
---- /dev/null
++++ b/apps/server/dist-types/mods/runtime.d.ts
@@@ -0,0 +1,89 @@
++/**
++ * The mod runtime seam.
++ *
++ * A mod never touches the game server, the world or the database. It gets a
++ * `ModApi`: a frozen object of bindings, one per runtime capability, and
++ * every binding checks the registry AT CALL TIME. A capability the install
++ * was not granted — or one revoked a moment ago — throws before anything
++ * happens, so it cannot be reached from the mod side at all.
++ *
++ * `games-content@1` packs are interpreted here through that same API; an
++ * executable runtime (a script sandbox) would receive the same bindings.
++ * Executable mods are not accepted yet: sandboxed execution with enforced
++ * CPU, memory, storage and network budgets belongs to OpenVibe.Host
++ * (Stage C). Until then the manifest's resource budget is metered, not
++ * enforced by a sandbox.
++ *
++ * Each tick the runtime reconciles when the registry changed (and once a
++ * second otherwise): an inactive mod, or one that lost a capability, has
++ * its effects retracted — its props despawned, its announcements stopped —
++ * on that very tick.
++ */
++import type { ContentRegistry } from '@openvibe/content';
++import type { PersistenceStore } from '@openvibe/persistence';
++import type { Logger } from '@openvibe/shared';
++import type { ModRegistry } from './registry.js';
++/** What the game server lends the runtime. Only the runtime holds it. */
++export interface ModHost {
++ announce(text: string): void;
++ /** Spawns an inert prop owned by `owner`; returns its entity id. */
++ placeProp(spec: {
++ item: string;
++ pos: [number, number, number];
++ yaw: number;
++ owner: string;
++ }): string | null;
++ removeEntity(entityId: string): boolean;
++ entityExists(entityId: string): boolean;
++}
++/** The bindings a mod gets. Nothing else is reachable from a mod. */
++export interface ModApi {
++ readonly modId: string;
++ /** games.world.announce */
++ announce(text: string): void;
++ /** games.prop.place: places (or re-places) the prop for `key`. */
++ placeProp(key: string, item: string, pos: [number, number, number], yaw?: number): string | null;
++ /** games.prop.place: removes the prop for `key`. */
++ removeProp(key: string): boolean;
++}
++export declare class ModCapabilityError extends Error {
++ readonly modId: string;
++ readonly capability: string;
++ readonly code = "capability.denied";
++ constructor(modId: string, capability: string);
++}
++export interface ModRuntimeOptions {
++ /** Reconcile at least this often when nothing changed (ticks). */
++ reconcileEveryTicks: number;
++ now?: () => number;
++}
++export declare class ModRuntime {
++ private readonly registry;
++ private readonly store;
++ private readonly content;
++ private readonly log;
++ private readonly opts;
++ private seenVersion;
++ private nextTick;
++ /** modId -> last announcement time per announcement index. */
++ private readonly announced;
++ /** `${modId}|${cap}` denials already audited since the last registry change. */
++ private readonly deniedNoted;
++ private readonly budgetNotedAt;
++ private readonly now;
++ constructor(registry: ModRegistry, store: PersistenceStore, content: ContentRegistry, log: Logger, opts: ModRuntimeOptions);
++ /**
++ * The mod-facing API for one install. Frozen, and closed over nothing a
++ * mod could use to reach the host except through a checked binding.
++ */
++ bindingsFor(modId: string, host: ModHost): ModApi;
++ tick(tick: number, host: ModHost): void;
++ private runContentPack;
++ private attempt;
++ /** Despawns everything the mod placed. */
++ private retractProps;
++ private noteDenied;
++ /** Meters the declared per-tick CPU budget (enforcement: OpenVibe.Host, Stage C). */
++ private meter;
++}
++//# sourceMappingURL=runtime.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/runtime.d.ts.map b/apps/server/dist-types/mods/runtime.d.ts.map
nnew file mode 100644
iindex 0000000..9dbb5f9
---- /dev/null
++++ b/apps/server/dist-types/mods/runtime.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/mods/runtime.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AACxD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AAC7D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAE9C,OAAO,KAAK,EAAE,WAAW,EAAW,MAAM,eAAe,CAAA;AAEzD,yEAAyE;AACzE,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,oEAAoE;IACpE,SAAS,CAAC,IAAI,EAAE;QACd,IAAI,EAAE,MAAM,CAAA;QACZ,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;QAC7B,GAAG,EAAE,MAAM,CAAA;QACX,KAAK,EAAE,MAAM,CAAA;KACd,GAAG,MAAM,GAAG,IAAI,CAAA;IACjB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAA;IACvC,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAA;CACxC;AAED,qEAAqE;AACrE,MAAM,WAAW,MAAM;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,2BAA2B;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,kEAAkE;IAClE,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;IAChG,oDAAoD;IACpD,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACjC;AAED,qBAAa,kBAAmB,SAAQ,KAAK;IAGzC,QAAQ,CAAC,KAAK,EAAE,MAAM;IACtB,QAAQ,CAAC,UAAU,EAAE,MAAM;IAH7B,QAAQ,CAAC,IAAI,uBAAsB;gBAExB,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,MAAM;CAI9B;AAED,MAAM,WAAW,iBAAiB;IAChC,kEAAkE;IAClE,mBAAmB,EAAE,MAAM,CAAA;IAC3B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED,qBAAa,UAAU;IAWnB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAdvB,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,QAAQ,CAAI;IACpB,8DAA8D;IAC9D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA8B;IACxD,gFAAgF;IAChF,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAoB;IAChD,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA4B;IAC1D,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAc;gBAGf,QAAQ,EAAE,WAAW,EACrB,KAAK,EAAE,gBAAgB,EACvB,OAAO,EAAE,eAAe,EACxB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,iBAAiB;IAK1C;;;OAGG;IACH,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,MAAM;IA6CjD,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,IAAI;IAuBvC,OAAO,CAAC,cAAc;IA+CtB,OAAO,CAAC,OAAO;IASf,0CAA0C;IAC1C,OAAO,CAAC,YAAY;IAYpB,OAAO,CAAC,UAAU;IAOlB,qFAAqF;IACrF,OAAO,CAAC,KAAK;CAWd"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/runtime.test.d.ts b/apps/server/dist-types/mods/runtime.test.d.ts
nnew file mode 100644
iindex 0000000..4724582
---- /dev/null
++++ b/apps/server/dist-types/mods/runtime.test.d.ts
@@@ -0,0 +1,2 @@
++export {};
++//# sourceMappingURL=runtime.test.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/runtime.test.d.ts.map b/apps/server/dist-types/mods/runtime.test.d.ts.map
nnew file mode 100644
iindex 0000000..536b824
---- /dev/null
++++ b/apps/server/dist-types/mods/runtime.test.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"runtime.test.d.ts","sourceRoot":"","sources":["../../src/mods/runtime.test.ts"],"names":[],"mappings":""}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/testFixtures.d.ts b/apps/server/dist-types/mods/testFixtures.d.ts
nnew file mode 100644
iindex 0000000..b30703b
---- /dev/null
++++ b/apps/server/dist-types/mods/testFixtures.d.ts
@@@ -0,0 +1,5 @@
++/** Shared test fixtures for the mod platform (not used at runtime). */
++import type { ModManifest } from './manifest.js';
++export declare const MOD_ID = "mod_01JABCDEFGHJKMNPQRSTVWXYZ0";
++export declare function sampleManifest(overrides?: Partial<ModManifest>): ModManifest;
++//# sourceMappingURL=testFixtures.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/mods/testFixtures.d.ts.map b/apps/server/dist-types/mods/testFixtures.d.ts.map
nnew file mode 100644
iindex 0000000..88d1bf4
---- /dev/null
++++ b/apps/server/dist-types/mods/testFixtures.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"testFixtures.d.ts","sourceRoot":"","sources":["../../src/mods/testFixtures.ts"],"names":[],"mappings":"AAAA,uEAAuE;AACvE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAEhD,eAAO,MAAM,MAAM,mCAAmC,CAAA;AAEtD,wBAAgB,cAAc,CAAC,SAAS,GAAE,OAAO,CAAC,WAAW,CAAM,GAAG,WAAW,CAchF"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/net/httpServer.d.ts b/apps/server/dist-types/net/httpServer.d.ts
iindex 3f590d2..817eda3 100644
---- a/apps/server/dist-types/net/httpServer.d.ts
++++ b/apps/server/dist-types/net/httpServer.d.ts
@@@ -1,4 +1,4 @@
--import { type Server } from 'node:http';
++import { type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import type { Logger } from '@openvibe/shared';
import type { ServerMetrics } from '../observability/metrics.js';
import type { MapFileV2 } from '@openvibe/content';
@@@ -19,9 +19,21 @@ export interface OAuthConfig {
/** Public base of the play vhost, e.g. https://play.openvibe.games. */
playUrl: string;
}
++/** Platform seams (roadmap Wave 12): extra API routes and a stored-asset hook. */
++export interface HttpPlatformHooks {
++ /** Answers a request it owns (returns true), e.g. the mod registry API. */
++ handle?: (req: IncomingMessage, res: ServerResponse) => boolean;
++ /** A map asset was stored (or found already stored) locally. */
++ onAssetStored?: (asset: {
++ hash: string;
++ url: string;
++ bytes: number;
++ mime: string;
++ }) => void;
++}
export declare function createHttpServer(staticDir: string | null, metrics: ServerMetrics, log: Logger, mapPath?: string, editorAuth?: EditorAuth, onMapSaved?: (next: MapFileV2, revision: string) => void, listCharacters?: (token: string, auth: string | undefined) => Promise<{
slot: number;
name: string;
appearance: unknown;
--}[]>, oauth?: OAuthConfig | null): Server;
++}[]>, oauth?: OAuthConfig | null, platform?: HttpPlatformHooks): Server;
//# sourceMappingURL=httpServer.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/net/httpServer.d.ts.map b/apps/server/dist-types/net/httpServer.d.ts.map
iindex 53a577d..d472ff2 100644
---- a/apps/server/dist-types/net/httpServer.d.ts.map
++++ b/apps/server/dist-types/net/httpServer.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"httpServer.d.ts","sourceRoot":"","sources":["../../src/net/httpServer.ts"],"names":[],"mappings":"AAEA,OAAO,EAAsC,KAAK,MAAM,EAAuB,MAAM,WAAW,CAAA;AAEhG,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAsBhE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AAyBlD,MAAM,WAAW,UAAU;IACzB,8BAA8B;IAC9B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,gFAAgF;IAChF,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAED,qFAAqF;AACrF,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAA;IACf,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAA;IACf,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAA;CAChB;AA6CD,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,UAAU,EACvB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,EACxD,cAAc,CAAC,EAAE,CACf,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GAAG,SAAS,KACrB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,OAAO,CAAA;CAAE,EAAE,CAAC,EACnE,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,GACzB,MAAM,CAshBR"}
\\ No newline at end of file
++{"version":3,"file":"httpServer.d.ts","sourceRoot":"","sources":["../../src/net/httpServer.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,eAAe,EAAE,KAAK,MAAM,EAAE,KAAK,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhG,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAsBhE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AAyBlD,MAAM,WAAW,UAAU;IACzB,8BAA8B;IAC9B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,gFAAgF;IAChF,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAED,qFAAqF;AACrF,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAA;IACf,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAA;IACf,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAA;CAChB;AAmCD,kFAAkF;AAClF,MAAM,WAAW,iBAAiB;IAChC,2EAA2E;IAC3E,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,OAAO,CAAA;IAC/D,gEAAgE;IAChE,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;CAC5F;AAYD,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,UAAU,EACvB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,EACxD,cAAc,CAAC,EAAE,CACf,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GAAG,SAAS,KACrB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,OAAO,CAAA;CAAE,EAAE,CAAC,EACnE,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,EAC1B,QAAQ,CAAC,EAAE,iBAAiB,GAC3B,MAAM,CAyhBR"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/net/networkAuth.d.ts b/apps/server/dist-types/net/networkAuth.d.ts
iindex 7f92e17..7e21922 100644
---- a/apps/server/dist-types/net/networkAuth.d.ts
++++ b/apps/server/dist-types/net/networkAuth.d.ts
@@@ -7,16 +7,25 @@
*/
export type OpenVibeRank = 'owner' | 'admin' | 'moderator' | null;
export interface NetworkUser {
++ /** The Network's own account id (legacy; integer today). */
id: string;
name: string;
rank: OpenVibeRank;
++ /**
++ * Canonical subject (`usr_…`, or `gst_…` for a Network guest) from the
++ * token's `subject_id` claim; null only for tokens older than subjects.
++ */
++ subjectId: string | null;
}
++export declare function isCanonicalSubject(value: unknown): value is string;
/** The account record as openvibe.network's /api/auth/me returns it. */
export interface NetworkAccount {
id?: string | number;
username?: string;
role?: string;
is_banned?: number;
++ /** Canonical subject (Wave 1); mirrors the token's `subject_id` claim. */
++ subject_id?: string;
[key: string]: unknown;
}
/**
ddiff --git a/apps/server/dist-types/net/networkAuth.d.ts.map b/apps/server/dist-types/net/networkAuth.d.ts.map
iindex 3ec260a..3f561ad 100644
---- a/apps/server/dist-types/net/networkAuth.d.ts.map
++++ b/apps/server/dist-types/net/networkAuth.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"networkAuth.d.ts","sourceRoot":"","sources":["../../src/net/networkAuth.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,OAAO,GAAG,WAAW,GAAG,IAAI,CAAA;AAEjE,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,YAAY,CAAA;CACnB;AAID,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC7B,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,MAAM,GAAG,IAAI,EAClB,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAahC;AAED,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,GAAG,IAAI,EAClB,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAU7B;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAEtD"}
\\ No newline at end of file
++{"version":3,"file":"networkAuth.d.ts","sourceRoot":"","sources":["../../src/net/networkAuth.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,OAAO,GAAG,WAAW,GAAG,IAAI,CAAA;AAEjE,MAAM,WAAW,WAAW;IAC1B,4DAA4D;IAC5D,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,YAAY,CAAA;IAClB;;;OAGG;IACH,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;CACzB;AAKD,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAElE;AAqBD,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC7B,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,0EAA0E;IAC1E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,MAAM,GAAG,IAAI,EAClB,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAahC;AAED,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,GAAG,IAAI,EAClB,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAe7B;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAEtD"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/accounts.d.ts b/apps/server/dist-types/platform/accounts.d.ts
nnew file mode 100644
iindex 0000000..b6aba70
---- /dev/null
++++ b/apps/server/dist-types/platform/accounts.d.ts
@@@ -0,0 +1,44 @@
++/**
++ * Account keys (ADR-0006).
++ *
++ * A signed-in account is keyed by its canonical openvibe.network subject
++ * (`usr_…`, or `gst_…` for a Network guest) taken from the token's
++ * `subject_id` claim. Before subjects existed Games keyed it by
++ * `ovn:<network user id>`; those characters are adopted into the subject key
++ * the first time the account signs in (or by scripts/migrateIdentity.ts), and
++ * the mapping is kept in `identity_legacy_map`.
++ *
++ * Local guests keep their browser token (ADR-0004). A guest token may use
++ * letters, digits, `_` and `-` only (the client mints alphanumerics), and
++ * may not start with a subject prefix, so it can never equal a subject or a
++ * legacy key: a guest presenting `usr_…` or `ovn:…` as its token cannot open
++ * someone else's characters.
++ */
++import type { IdentityRepository, PlayerRepository } from '@openvibe/persistence';
++import type { NetworkUser } from '../net/networkAuth.js';
++/** Prefix of pre-subject account keys: `ovn:<openvibe.network user id>`. */
++export declare const LEGACY_NETWORK_PREFIX = "ovn:";
++export declare function isGuestToken(token: unknown): token is string;
++export declare function legacyNetworkKey(networkUserId: string): string;
++export interface AccountResolution {
++ /** The players.token value the account's characters live under. */
++ key: string;
++ /** Canonical subject, or null for a token older than subjects. */
++ subjectId: string | null;
++ /** Characters moved from the legacy key by this call. */
++ adopted: number;
++}
++export interface AccountStores {
++ identity: IdentityRepository;
++ players: PlayerRepository;
++}
++/**
++ * The account key for a signed-in Network user, adopting any characters
++ * still under its legacy key. Idempotent.
++ */
++export declare function accountForNetworkUser(stores: AccountStores, user: NetworkUser, now: number, onConflict?: (info: {
++ legacyKey: string;
++ subjectId: string;
++ error: string;
++}) => void): AccountResolution;
++//# sourceMappingURL=accounts.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/accounts.d.ts.map b/apps/server/dist-types/platform/accounts.d.ts.map
nnew file mode 100644
iindex 0000000..b486c88
---- /dev/null
++++ b/apps/server/dist-types/platform/accounts.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"accounts.d.ts","sourceRoot":"","sources":["../../src/platform/accounts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AACjF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAExD,4EAA4E;AAC5E,eAAO,MAAM,qBAAqB,SAAS,CAAA;AAO3C,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAE5D;AAED,wBAAgB,gBAAgB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED,MAAM,WAAW,iBAAiB;IAChC,mEAAmE;IACnE,GAAG,EAAE,MAAM,CAAA;IACX,kEAAkE;IAClE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,yDAAyD;IACzD,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,kBAAkB,CAAA;IAC5B,OAAO,EAAE,gBAAgB,CAAA;CAC1B;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,aAAa,EACrB,IAAI,EAAE,WAAW,EACjB,GAAG,EAAE,MAAM,EACX,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,KAAK,IAAI,GACnF,iBAAiB,CAmBnB"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/accounts.test.d.ts b/apps/server/dist-types/platform/accounts.test.d.ts
nnew file mode 100644
iindex 0000000..020ae4c
---- /dev/null
++++ b/apps/server/dist-types/platform/accounts.test.d.ts
@@@ -0,0 +1,2 @@
++export {};
++//# sourceMappingURL=accounts.test.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/accounts.test.d.ts.map b/apps/server/dist-types/platform/accounts.test.d.ts.map
nnew file mode 100644
iindex 0000000..f897ad2
---- /dev/null
++++ b/apps/server/dist-types/platform/accounts.test.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"accounts.test.d.ts","sourceRoot":"","sources":["../../src/platform/accounts.test.ts"],"names":[],"mappings":""}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/gameEvents.d.ts b/apps/server/dist-types/platform/gameEvents.d.ts
nnew file mode 100644
iindex 0000000..065a359
---- /dev/null
++++ b/apps/server/dist-types/platform/gameEvents.d.ts
@@@ -0,0 +1,111 @@
++/**
++ * Games → OpenVibe.Events: public lifecycle and progression as durable
++ * events (roadmap Wave 12, ADR-004).
++ *
++ * games.player.joined a character entered the world
++ * games.player.left it left (its state was saved in the same transaction)
++ * games.skill.leveled a persisted skill level went up (one event per skill per save)
++ * games.blueprint.unlocked a persisted blueprint recipe unlock
++ * games.world.saved the authoritative world was written (checkpoint / shutdown)
++ * games.mod.* mod install, enable, disable, grant changes and revoke
++ *
++ * Scraplandia has no achievement system, so there is no games.achievement.*
++ * event: skill levels and blueprint unlocks are the real progression
++ * milestones the game keeps.
++ *
++ * Every event is written to Games' own `event_outbox` table inside the SAME
++ * SQLite transaction as the state it describes (progression events are
++ * derived from what the save actually wrote), and a relay publishes them
++ * with the `games` service token (audience openvibe.events, capability
++ * events.event.publish). Events or Network being down never blocks a tick:
++ * rows wait and are retried.
++ */
++import type { EventInput, Outbox } from 'openvibe-sdk/events';
++import type { SubjectRef } from 'openvibe-sdk/core';
++export declare const EVENT_SOURCE = "games";
++/** A character as events describe it. */
++export interface EventPlayer {
++ playerId: string;
++ slot: number;
++ name: string;
++ /** Canonical subject for signed-in accounts; null for local guests. */
++ subjectId: string | null;
++}
++/** The persisted progression a save compares against. */
++export interface ProgressSnapshot {
++ /** Level per skill id. */
++ levels: Record<string, number>;
++ /** Unlocked blueprint recipe ids. */
++ unlocks: string[];
++}
++export declare function actorFor(subjectId: string | null): SubjectRef;
++export declare function playerJoinedEvent(p: EventPlayer, opts: {
++ world: string;
++ restored: boolean;
++}): EventInput;
++export declare function playerLeftEvent(p: EventPlayer, opts: {
++ world: string;
++ sessionSeconds: number;
++}): EventInput;
++/** Progression milestones between two persisted snapshots (never downgrades). */
++export declare function progressEvents(p: EventPlayer, before: ProgressSnapshot, after: ProgressSnapshot, world: string): EventInput[];
++export declare function worldSavedEvent(world: string, save: {
++ reason: 'checkpoint' | 'shutdown';
++ entities: number;
++ players: number;
++ sinceMs: number;
++}): EventInput;
++export type ModLifecycle = 'installed' | 'enabled' | 'disabled' | 'revoked' | 'grants_changed';
++export declare function modEvent(kind: ModLifecycle, mod: {
++ id: string;
++ name: string;
++ version: string;
++ target: string;
++ trustTier: string;
++}, actor: SubjectRef, detail: Record<string, unknown>): EventInput;
++/** Where events go. `enqueue` must run inside the transaction making the change. */
++export interface EventSink {
++ readonly enabled: boolean;
++ enqueue(event: EventInput): void;
++}
++export declare const NO_EVENTS: EventSink;
++/** An EventSink over the SDK outbox; wakes the relay right after the write. */
++export declare function outboxSink(outbox: Outbox): EventSink;
++/**
++ * Per-session bookkeeping the game server drives: the progression baseline
++ * each save is compared with, join times, and the world-save accumulator.
++ * All `record*` methods run inside the caller's store transaction and return
++ * a callback to run once it has committed.
++ */
++export declare class GameEventRecorder {
++ private readonly sink;
++ private readonly world;
++ private readonly checkpointEveryMs;
++ private readonly now;
++ private readonly tracked;
++ private sinceMs;
++ private pendingEntities;
++ private pendingPlayers;
++ private lastSavedEventMs;
++ constructor(sink: EventSink, world: string, checkpointEveryMs: number, now?: () => number);
++ get enabled(): boolean;
++ /** A character joined; `progress` is what the database holds for it. */
++ recordJoin(player: EventPlayer, progress: ProgressSnapshot, restored: boolean): void;
++ /**
++ * Players were written: emits the progression they newly persisted.
++ * Returns the commit callback that advances their baselines.
++ */
++ recordPlayersSaved(saved: {
++ player: EventPlayer;
++ progress: ProgressSnapshot;
++ }[]): () => void;
++ /** The character left; its final save is part of the same transaction. */
++ recordLeave(player: EventPlayer): () => void;
++ /**
++ * A flush wrote `entities` world rows and `players` player rows. Emits a
++ * `games.world.saved` checkpoint at most once per interval (and always on
++ * shutdown) with the totals since the previous one.
++ */
++ recordWorldSaved(entities: number, players: number, reason: 'checkpoint' | 'shutdown'): () => void;
++}
++//# sourceMappingURL=gameEvents.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/gameEvents.d.ts.map b/apps/server/dist-types/platform/gameEvents.d.ts.map
nnew file mode 100644
iindex 0000000..05da452
---- /dev/null
++++ b/apps/server/dist-types/platform/gameEvents.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"gameEvents.d.ts","sourceRoot":"","sources":["../../src/platform/gameEvents.ts"],"names":[],"mappings":"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"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/gameEvents.test.d.ts b/apps/server/dist-types/platform/gameEvents.test.d.ts
nnew file mode 100644
iindex 0000000..ec18d61
---- /dev/null
++++ b/apps/server/dist-types/platform/gameEvents.test.d.ts
@@@ -0,0 +1,2 @@
++export {};
++//# sourceMappingURL=gameEvents.test.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/gameEvents.test.d.ts.map b/apps/server/dist-types/platform/gameEvents.test.d.ts.map
nnew file mode 100644
iindex 0000000..9176827
---- /dev/null
++++ b/apps/server/dist-types/platform/gameEvents.test.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"gameEvents.test.d.ts","sourceRoot":"","sources":["../../src/platform/gameEvents.test.ts"],"names":[],"mappings":""}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/identityMigration.d.ts b/apps/server/dist-types/platform/identityMigration.d.ts
nnew file mode 100644
iindex 0000000..e4bd6e6
---- /dev/null
++++ b/apps/server/dist-types/platform/identityMigration.d.ts
@@@ -0,0 +1,31 @@
++/**
++ * Batch adoption of legacy accounts (ADR-0006): every account still keyed by
++ * `ovn:<network user id>` is resolved to its canonical subject through the
++ * Network identity service (capability identity.subject.resolve, audience
++ * openvibe.network, the `games` service token) and moved over.
++ *
++ * Sign-in adopts an account on its own; this pass covers the accounts that
++ * have not signed in since. Idempotent: adopted keys own no characters any
++ * more, so a second run finds nothing to do.
++ */
++import type { PersistenceStore } from '@openvibe/persistence';
++import type { IdentityClient } from 'openvibe-sdk/identity';
++export interface MigrationReport {
++ /** Legacy account keys that still owned characters. */
++ scanned: number;
++ /** Keys the Network resolved to a subject. */
++ resolved: number;
++ /** Characters moved to their subject key. */
++ moved: number;
++ /** Characters left under the legacy key because the subject already used that slot. */
++ conflicts: number;
++ /** Keys the Network did not know (left untouched). */
++ unresolved: string[];
++ dryRun: boolean;
++}
++export declare function migrateLegacyAccounts(store: PersistenceStore, identity: Pick<IdentityClient, 'resolveBatch'>, opts?: {
++ dryRun?: boolean;
++ batchSize?: number;
++ now?: () => number;
++}): Promise<MigrationReport>;
++//# sourceMappingURL=identityMigration.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/identityMigration.d.ts.map b/apps/server/dist-types/platform/identityMigration.d.ts.map
nnew file mode 100644
iindex 0000000..2df8a3b
---- /dev/null
++++ b/apps/server/dist-types/platform/identityMigration.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"identityMigration.d.ts","sourceRoot":"","sources":["../../src/platform/identityMigration.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AAC7D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAI3D,MAAM,WAAW,eAAe;IAC9B,uDAAuD;IACvD,OAAO,EAAE,MAAM,CAAA;IACf,8CAA8C;IAC9C,QAAQ,EAAE,MAAM,CAAA;IAChB,6CAA6C;IAC7C,KAAK,EAAE,MAAM,CAAA;IACb,uFAAuF;IACvF,SAAS,EAAE,MAAM,CAAA;IACjB,sDAAsD;IACtD,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,MAAM,EAAE,OAAO,CAAA;CAChB;AAED,wBAAsB,qBAAqB,CACzC,KAAK,EAAE,gBAAgB,EACvB,QAAQ,EAAE,IAAI,CAAC,cAAc,EAAE,cAAc,CAAC,EAC9C,IAAI,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CAAO,GACtE,OAAO,CAAC,eAAe,CAAC,CAmC1B"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/identityMigration.test.d.ts b/apps/server/dist-types/platform/identityMigration.test.d.ts
nnew file mode 100644
iindex 0000000..7b70afa
---- /dev/null
++++ b/apps/server/dist-types/platform/identityMigration.test.d.ts
@@@ -0,0 +1,2 @@
++export {};
++//# sourceMappingURL=identityMigration.test.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/identityMigration.test.d.ts.map b/apps/server/dist-types/platform/identityMigration.test.d.ts.map
nnew file mode 100644
iindex 0000000..3925e25
---- /dev/null
++++ b/apps/server/dist-types/platform/identityMigration.test.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"identityMigration.test.d.ts","sourceRoot":"","sources":["../../src/platform/identityMigration.test.ts"],"names":[],"mappings":""}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/mediaMirror.d.ts b/apps/server/dist-types/platform/mediaMirror.d.ts
nnew file mode 100644
iindex 0000000..966e9cd
---- /dev/null
++++ b/apps/server/dist-types/platform/mediaMirror.d.ts
@@@ -0,0 +1,39 @@
++import type { PersistenceStore } from '@openvibe/persistence';
++import type { Logger } from '@openvibe/shared';
++import type { OpenVibeClient } from 'openvibe-sdk/core';
++export interface MediaMirrorOptions {
++ client: OpenVibeClient;
++ store: PersistenceStore;
++ namespace: string;
++ assetsDir: string;
++ log: Logger;
++ now?: () => number;
++ intervalMs?: number;
++}
++export declare class MediaMirror {
++ private readonly opts;
++ private timer;
++ private running;
++ private readonly now;
++ constructor(opts: MediaMirrorOptions);
++ /** Queues one stored asset (idempotent) and wakes the worker. */
++ enqueue(asset: {
++ hash: string;
++ url: string;
++ bytes: number;
++ mime: string;
++ }): void;
++ /** Queues every content-addressed asset already on disk (a no-op for known ones). */
++ backfill(): Promise<number>;
++ start(): void;
++ stop(): void;
++ kick(): void;
++ /** Mirrors every due row once. Concurrent callers share the pass. */
++ runOnce(): Promise<void>;
++ private schedule;
++ private pass;
++ private base;
++ private mirror;
++ private fail;
++}
++//# sourceMappingURL=mediaMirror.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/mediaMirror.d.ts.map b/apps/server/dist-types/platform/mediaMirror.d.ts.map
nnew file mode 100644
iindex 0000000..e5389b8
---- /dev/null
++++ b/apps/server/dist-types/platform/mediaMirror.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"mediaMirror.d.ts","sourceRoot":"","sources":["../../src/platform/mediaMirror.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAkB,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AAC7E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AA2BvD,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,cAAc,CAAA;IACtB,KAAK,EAAE,gBAAgB,CAAA;IACvB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,qBAAa,WAAW;IAKV,OAAO,CAAC,QAAQ,CAAC,IAAI;IAJjC,OAAO,CAAC,KAAK,CAA8B;IAC3C,OAAO,CAAC,OAAO,CAA6B;IAC5C,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAc;gBAEL,IAAI,EAAE,kBAAkB;IAIrD,iEAAiE;IACjE,OAAO,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI;IAUhF,qFAAqF;IAC/E,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC;IAqBjC,KAAK,IAAI,IAAI;IAIb,IAAI,IAAI,IAAI;IAKZ,IAAI,IAAI,IAAI;IAIZ,qEAAqE;IACrE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IASxB,OAAO,CAAC,QAAQ;YASF,IAAI;IAUlB,OAAO,CAAC,IAAI;YAIE,MAAM;IA4DpB,OAAO,CAAC,IAAI;CAoBb"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/mediaMirror.test.d.ts b/apps/server/dist-types/platform/mediaMirror.test.d.ts
nnew file mode 100644
iindex 0000000..928998d
---- /dev/null
++++ b/apps/server/dist-types/platform/mediaMirror.test.d.ts
@@@ -0,0 +1,2 @@
++export {};
++//# sourceMappingURL=mediaMirror.test.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/mediaMirror.test.d.ts.map b/apps/server/dist-types/platform/mediaMirror.test.d.ts.map
nnew file mode 100644
iindex 0000000..69d813d
---- /dev/null
++++ b/apps/server/dist-types/platform/mediaMirror.test.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"mediaMirror.test.d.ts","sourceRoot":"","sources":["../../src/platform/mediaMirror.test.ts"],"names":[],"mappings":""}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/proposals.test.d.ts b/apps/server/dist-types/platform/proposals.test.d.ts
nnew file mode 100644
iindex 0000000..2cfa218
---- /dev/null
++++ b/apps/server/dist-types/platform/proposals.test.d.ts
@@@ -0,0 +1,2 @@
++export {};
++//# sourceMappingURL=proposals.test.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/proposals.test.d.ts.map b/apps/server/dist-types/platform/proposals.test.d.ts.map
nnew file mode 100644
iindex 0000000..52cae8a
---- /dev/null
++++ b/apps/server/dist-types/platform/proposals.test.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"proposals.test.d.ts","sourceRoot":"","sources":["../../src/platform/proposals.test.ts"],"names":[],"mappings":""}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/serviceClient.d.ts b/apps/server/dist-types/platform/serviceClient.d.ts
nnew file mode 100644
iindex 0000000..5e6bc7d
---- /dev/null
++++ b/apps/server/dist-types/platform/serviceClient.d.ts
@@@ -0,0 +1,9 @@
++import { type FetchLike, type OpenVibeClient } from 'openvibe-sdk/core';
++import type { PlatformConfig } from '../config.js';
++export interface PlatformClient {
++ client: OpenVibeClient;
++ clientId: string;
++}
++/** Null when no client secret is configured: Games then makes no service calls. */
++export declare function createPlatformClient(cfg: PlatformConfig, fetchImpl?: FetchLike): PlatformClient | null;
++//# sourceMappingURL=serviceClient.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/serviceClient.d.ts.map b/apps/server/dist-types/platform/serviceClient.d.ts.map
nnew file mode 100644
iindex 0000000..4dbd21a
---- /dev/null
++++ b/apps/server/dist-types/platform/serviceClient.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"serviceClient.d.ts","sourceRoot":"","sources":["../../src/platform/serviceClient.ts"],"names":[],"mappings":"AAYA,OAAO,EAAgB,KAAK,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,mBAAmB,CAAA;AACrF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAElD,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,cAAc,CAAA;IACtB,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,mFAAmF;AACnF,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,cAAc,EACnB,SAAS,CAAC,EAAE,SAAS,GACpB,cAAc,GAAG,IAAI,CAqBvB"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/staffAuth.d.ts b/apps/server/dist-types/platform/staffAuth.d.ts
nnew file mode 100644
iindex 0000000..e4233d3
---- /dev/null
++++ b/apps/server/dist-types/platform/staffAuth.d.ts
@@@ -0,0 +1,16 @@
++import type { IncomingMessage } from 'node:http';
++import { type UserTokenClaims } from 'openvibe-sdk/auth';
++import type { ModActor } from '../mods/registry.js';
++export declare const CAP_MOD_MANAGE = "games.mod.manage";
++export declare const GAMES_AUDIENCE = "openvibe.games";
++export interface StaffAuthOptions {
++ /** openvibe.network /api/auth/me, or null in local development. */
++ networkAuthUrl: string | null;
++ /** Network base for the JWKS (`/api/.well-known/jwks`). */
++ networkUrl: string;
++ editorKey: string | null;
++ /** Test seam: verifies a principal token. */
++ verifyPrincipal?: (token: string) => Promise<UserTokenClaims>;
++}
++export declare function createStaffAuthorizer(opts: StaffAuthOptions): (req: IncomingMessage) => Promise<ModActor | null>;
++//# sourceMappingURL=staffAuth.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/platform/staffAuth.d.ts.map b/apps/server/dist-types/platform/staffAuth.d.ts.map
nnew file mode 100644
iindex 0000000..76ed44c
---- /dev/null
++++ b/apps/server/dist-types/platform/staffAuth.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"staffAuth.d.ts","sourceRoot":"","sources":["../../src/platform/staffAuth.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAEhD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,mBAAmB,CAAA;AACzE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAInD,eAAO,MAAM,cAAc,qBAAqB,CAAA;AAChD,eAAO,MAAM,cAAc,mBAAmB,CAAA;AAO9C,MAAM,WAAW,gBAAgB;IAC/B,mEAAmE;IACnE,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,2DAA2D;IAC3D,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,6CAA6C;IAC7C,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,eAAe,CAAC,CAAA;CAC9D;AA0BD,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,gBAAgB,GACrB,CAAC,GAAG,EAAE,eAAe,KAAK,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CA4CpD"}
\\ No newline at end of file
ddiff --git a/apps/server/package.json b/apps/server/package.json
iindex 9892220..f5414e5 100644
---- a/apps/server/package.json
++++ b/apps/server/package.json
@@@ -17,6 +17,9 @@
"@openvibe/physics": "workspace:*",
"@openvibe/protocol": "workspace:*",
"@openvibe/shared": "workspace:*",
++ "ajv": "^8.17.1",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
"tsx": "^4.19.3",
"ws": "^8.18.1"
},
ddiff --git a/apps/server/scripts/migrateIdentity.ts b/apps/server/scripts/migrateIdentity.ts
nnew file mode 100644
iindex 0000000..1b1ef0e
---- /dev/null
++++ b/apps/server/scripts/migrateIdentity.ts
@@@ -0,0 +1,40 @@
++/**
++ * Adopts every account still keyed by `ovn:<network user id>` into its
++ * canonical openvibe.network subject (ADR-0006). Sign-in does this for each
++ * account on its own; this covers the ones that have not signed in since.
++ *
++ * DB_PATH=/opt/openvibe.games/data/world.db \
++ * OV_OAUTH_CLIENT_SECRET=… OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000 \
++ * node --import tsx apps/server/scripts/migrateIdentity.ts [--dry-run]
++ *
++ * Needs the grant [games, identity.subject.resolve, openvibe.network].
++ * Idempotent; safe to run while the game server is up (SQLite WAL).
++ */
++import { openSqliteStore } from '@openvibe/persistence/sqlite'
++import { createIdentityClient } from 'openvibe-sdk/identity'
++import { loadConfig } from '../src/config.js'
++import { migrateLegacyAccounts } from '../src/platform/identityMigration.js'
++import { createPlatformClient } from '../src/platform/serviceClient.js'
++
++async function main(): Promise<void> {
++ const config = loadConfig(process.env)
++ const platform = createPlatformClient(config.platform)
++ if (!platform) {
++ console.error('OV_OAUTH_CLIENT_SECRET is not set: cannot call the Network identity service')
++ process.exit(2)
++ }
++ const store = openSqliteStore(config.dbPath)
++ try {
++ const report = await migrateLegacyAccounts(store, createIdentityClient(platform.client), {
++ dryRun: process.argv.includes('--dry-run'),
++ })
++ console.log(JSON.stringify(report, null, 2))
++ } finally {
++ store.close()
++ }
++}
++
++main().catch((err: unknown) => {
++ console.error('identity migration failed:', err)
++ process.exit(1)
++})
ddiff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
iindex 58fac4f..bea9ba7 100644
---- a/apps/server/src/config.ts
++++ b/apps/server/src/config.ts
@@@ -18,6 +18,12 @@ export interface ServerConfig {
/** Public base of the play host (Host-routed game vhost). */
playUrl: string
} | null
++ /**
++ * Platform integration (roadmap Wave 12): Games calls OpenVibe services with
++ * a client-credentials token of its own `games` principal. Everything here
++ * is off until OV_OAUTH_CLIENT_SECRET (the same OAuth client as SSO) is set.
++ */
++ platform: PlatformConfig
tickRate: number
/** Send a snapshot every N ticks. */
snapshotEvery: number
@@@ -30,6 +36,42 @@ export interface ServerConfig {
eventIntervalScale: number
}
++export interface PlatformConfig {
++ /** OAuth client id of the `games` principal (shared with SSO). */
++ clientId: string
++ /** Its client secret; null = no service calls at all. */
++ clientSecret: string | null
++ /** Network base for /oauth/token, identity and the JWKS (host-internal in production). */
++ networkUrl: string
++ /** OpenVibe.Events base; null = no durable events (the outbox is not even created). */
++ eventsUrl: string | null
++ /** OpenVibe.Media base; null = map assets stay local only. */
++ mediaUrl: string | null
++ /** Media namespace (tenant app id) Games writes objects into. */
++ mediaNamespace: string
++ /** At most one `games.world.saved` checkpoint event per this many minutes. */
++ worldSavedEventMinutes: number
++}
++
++function trimSlash(v: string): string {
++ return v.replace(/\/+$/, '')
++}
++
++export function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig {
++ const secret = env.OV_OAUTH_CLIENT_SECRET ?? null
++ return {
++ clientId: env.OV_OAUTH_CLIENT_ID ?? 'games',
++ clientSecret: secret && secret.length > 0 ? secret : null,
++ networkUrl: trimSlash(
++ env.OV_NETWORK_INTERNAL_URL ?? env.OV_NETWORK_URL ?? 'https://openvibe.network',
++ ),
++ eventsUrl: env.EVENTS_URL && env.EVENTS_PUBLISH !== 'off' ? trimSlash(env.EVENTS_URL) : null,
++ mediaUrl: env.MEDIA_URL && env.MEDIA_MIRROR !== 'off' ? trimSlash(env.MEDIA_URL) : null,
++ mediaNamespace: env.MEDIA_NAMESPACE ?? 'games',
++ worldSavedEventMinutes: intEnv(env, 'WORLD_SAVED_EVENT_MINUTES', 15),
++ }
++}
++
export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
return {
port: intEnv(env, 'PORT', 8000),
@@@ -55,6 +97,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
playUrl: env.PLAY_URL ?? 'https://play.openvibe.games',
}
: null,
++ platform: loadPlatformConfig(env),
tickRate: 30,
snapshotEvery: 2,
interestRadius: intEnv(env, 'INTEREST_RADIUS', 80),
ddiff --git a/apps/server/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex b16042e..edc6a35 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -68,6 +68,9 @@ import {
} from '@openvibe/shared'
import type { ServerConfig } from '../config.js'
import { resolveNetworkUser } from '../net/networkAuth.js'
++import { accountForNetworkUser, isGuestToken } from '../platform/accounts.js'
++import type { EventPlayer, GameEventRecorder, ProgressSnapshot } from '../platform/gameEvents.js'
++import type { ModHost, ModRuntime } from '../mods/runtime.js'
import { worldSpawn } from '@openvibe/content'
import type { ServerMetrics } from '../observability/metrics.js'
import type { GameWorld } from './gameWorld.js'
@@@ -106,6 +109,17 @@ export interface GameConnection {
const MOVE = DEFAULT_MOVEMENT
const MAX_INPUT_QUEUE = 6
++/**
++ * Platform adapters the server drives (roadmap Wave 12). Both optional: the
++ * game runs exactly as before without them.
++ */
++export interface GameIntegrations {
++ /** Durable lifecycle/progression events through the outbox. */
++ events?: GameEventRecorder
++ /** Installed mods (content packs), reconciled every tick. */
++ mods?: ModRuntime
++}
++
export class GameServer {
private readonly sessions = new Map<PlayerId, PlayerSession>()
private readonly sessionsByConn = new Map<GameConnection, PlayerSession>()
@@@ -138,6 +152,7 @@ export class GameServer {
private readonly store: PersistenceStore,
private readonly metrics: ServerMetrics,
private readonly log: Logger,
++ private readonly integrations: GameIntegrations = {},
) {
// The world clock and weather survive restarts (a rainy dusk stays a
// rainy dusk).
@@@ -1173,7 +1188,7 @@ export class GameServer {
}
this.world.entities.remove(session.entityId)
this.world.spatial.remove(session.entityId)
-- this.savePlayer(session)
++ this.savePlayer(session, true)
this.broadcastDespawn(session.entityId)
this.metrics.sessions = this.sessions.size
this.log.info('player disconnected', { playerId: session.playerId, name: session.name })
@@@ -1197,6 +1212,7 @@ export class GameServer {
// character bound to their connection: browser token first, IP as the
// recovery path when the token is gone.
let token = msg.token
++ let subjectId: string | null = null
let rank: 'owner' | 'admin' | 'moderator' | null = null
if (msg.auth) {
const user = await resolveNetworkUser(this.config.networkAuthUrl, msg.auth)
@@@ -1205,9 +1221,29 @@ export class GameServer {
conn.close(4009, 'auth_failed')
return
}
-- token = `ovn:${user.id}`.slice(0, 64)
++ // Canonical subject key (ADR-0006); pre-subject characters are adopted
++ // from their legacy `ovn:<id>` key on the way in.
++ const account = accountForNetworkUser(this.store, user, Date.now(), (conflict) =>
++ this.log.warn('legacy identity conflict', conflict),
++ )
++ if (account.adopted > 0) {
++ this.log.info('legacy characters adopted', {
++ subject: account.subjectId ?? '',
++ moved: account.adopted,
++ })
++ }
++ token = account.key
++ subjectId = account.subjectId
rank = user.rank
} else {
++ // A guest token may never look like an account key (`usr_…`,
++ // `ovn:…`): that would open someone else's characters. The client
++ // mints plain alphanumerics; anything key-shaped is refused.
++ if (!isGuestToken(msg.token)) {
++ conn.send(encodeServerMessage({ t: 'reject', reason: 'invalid_hello' }))
++ conn.close(4007, 'invalid_guest_token')
++ return
++ }
if (slot > 0) {
conn.send(encodeServerMessage({ t: 'reject', reason: 'guest_one_character' }))
conn.close(4010, 'guest_one_character')
@@@ -1261,6 +1297,7 @@ export class GameServer {
charSlot: slot,
entityId: newEntityId(),
token,
++ subjectId,
rank,
name: msg.name,
spawn,
@@@ -1322,6 +1359,17 @@ export class GameServer {
this.send(session, this.timeWire())
this.sendReputation(session)
this.metrics.sessions = this.sessions.size
++ const recorder = this.integrations.events
++ if (recorder) {
++ // Baseline = what the database holds for this character (nothing for a
++ // new one), so the first save reports only what was earned since.
++ const stored = existing
++ ? progressOf(SkillSet.fromDto(existing.skills, this.world.content), existing.unlocks)
++ : { levels: {}, unlocks: [] }
++ this.store.transaction(() =>
++ recorder.recordJoin(eventPlayer(session), stored, existing !== null),
++ )
++ }
this.log.info('player connected', {
playerId: playerId as string,
name: msg.name,
@@@ -2264,7 +2312,11 @@ export class GameServer {
this.replicate()
}
-- // 8. Periodic persistence flush.
++ // 8. Mods: reconcile installs (a revoked or disabled mod's effects are
++ // retracted on the first tick after the change).
++ this.integrations.mods?.tick(this.tick, this.modHost)
++
++ // 9. Periodic persistence flush.
if (this.tick - this.lastFlushTick >= this.config.persistFlushSeconds * this.config.tickRate) {
this.lastFlushTick = this.tick
this.flush()
@@@ -2406,36 +2458,90 @@ export class GameServer {
// ── Persistence ────────────────────────────────────────────────────
-- flush(): void {
-- // World clock + weather ride along with every flush (tiny meta writes).
-- this.store.meta.set('env_time', String(this.env.timeOfDay))
-- this.store.meta.set('env_weather', this.env.weather)
-- this.persistMarkets()
-- this.npcs.flush(this.store, Date.now())
-- const wrote = this.world.flushDirty(this.store)
-- const dirtyPlayers: PlayerDto[] = []
-- for (const session of this.sessions.values()) {
-- if (!session.dirty) continue
-- dirtyPlayers.push(this.playerToDto(session))
-- session.dirty = false
-- }
-- if (dirtyPlayers.length > 0) this.store.players.upsertMany(dirtyPlayers)
++ /**
++ * One transaction per flush: world rows, player rows and the outbox
++ * events describing them commit together (or not at all).
++ */
++ flush(reason: 'checkpoint' | 'shutdown' = 'checkpoint'): void {
++ const recorder = this.integrations.events
++ const dirty: PlayerSession[] = []
++ const afterCommit = this.store.transaction(() => {
++ // World clock + weather ride along with every flush (tiny meta writes).
++ this.store.meta.set('env_time', String(this.env.timeOfDay))
++ this.store.meta.set('env_weather', this.env.weather)
++ this.persistMarkets()
++ this.npcs.flush(this.store, Date.now())
++ const wrote = this.world.flushDirty(this.store)
++ const dirtyPlayers: PlayerDto[] = []
++ for (const session of this.sessions.values()) {
++ if (!session.dirty) continue
++ dirtyPlayers.push(this.playerToDto(session))
++ dirty.push(session)
++ }
++ if (dirtyPlayers.length > 0) this.store.players.upsertMany(dirtyPlayers)
++ if (wrote > 0 || dirtyPlayers.length > 0) {
++ this.log.debug('persistence flush', { entities: wrote, players: dirtyPlayers.length })
++ }
++ if (!recorder) return []
++ return [
++ recorder.recordPlayersSaved(
++ dirty.map((sess) => ({ player: eventPlayer(sess), progress: sessionProgress(sess) })),
++ ),
++ recorder.recordWorldSaved(wrote, dirtyPlayers.length, reason),
++ ]
++ })
++ for (const session of dirty) session.dirty = false
++ for (const done of afterCommit) done()
this.metrics.dbDirtyQueue = 0
-- if (wrote > 0 || dirtyPlayers.length > 0) {
-- this.log.debug('persistence flush', { entities: wrote, players: dirtyPlayers.length })
-- }
}
/** Full save on shutdown. */
shutdown(): void {
-- for (const session of this.sessions.values()) this.savePlayer(session)
-- this.flush()
++ for (const session of this.sessions.values()) session.dirty = true
++ this.flush('shutdown')
this.log.info('world saved on shutdown', {})
}
-- private savePlayer(session: PlayerSession): void {
-- this.store.players.upsert(this.playerToDto(session))
++ /** Saves one character; `leaving` also records games.player.left in the same transaction. */
++ private savePlayer(session: PlayerSession, leaving = false): void {
++ const recorder = this.integrations.events
++ const afterCommit = this.store.transaction(() => {
++ this.store.players.upsert(this.playerToDto(session))
++ if (!recorder) return []
++ const player = eventPlayer(session)
++ const done = [recorder.recordPlayersSaved([{ player, progress: sessionProgress(session) }])]
++ if (leaving) done.push(recorder.recordLeave(player))
++ return done
++ })
session.dirty = false
++ for (const done of afterCommit) done()
++ }
++
++ /**
++ * What mods may do to the world, lent to the mod runtime only. Mod props
++ * are owned by the mod id, so prop protection keeps players' hands off.
++ */
++ private readonly modHost: ModHost = {
++ announce: (text) => this.broadcastAll({ t: 'announce', text }),
++ placeProp: ({ item, pos, yaw, owner }) => {
++ const entity = this.world.spawnProp({
++ defId: item,
++ pos: vec3(pos[0], pos[1] + terrainHeight(this.world.content.world, pos[0], pos[2]), pos[2]),
++ rot: qfromYaw(quat(), yaw),
++ motion: 'frozen',
++ owner: owner as PlayerId,
++ })
++ this.broadcastSpawn(entity)
++ return entity.id as string
++ },
++ removeEntity: (id) => {
++ const entityId = id as EntityId
++ if (!this.world.entities.get(entityId)) return false
++ this.world.despawn(entityId)
++ this.broadcastDespawn(entityId)
++ return true
++ },
++ entityExists: (id) => this.world.entities.get(id as EntityId) !== undefined,
}
private playerToDto(session: PlayerSession): PlayerDto {
@@@ -2443,6 +2549,7 @@ export class GameServer {
return {
id: session.playerId as string,
token: session.token,
++ ...(session.subjectId ? { subjectId: session.subjectId } : {}),
charSlot: session.charSlot,
name: session.name,
pos: [pos.x, pos.y, pos.z],
@@@ -2600,3 +2707,23 @@ function constraintStateWire(rec: ConstraintRecord, active: boolean): ServerMess
active,
}
}
++
++/** A session as the platform event recorder sees it. */
++function eventPlayer(session: PlayerSession): EventPlayer {
++ return {
++ playerId: session.playerId as string,
++ slot: session.charSlot,
++ name: session.name,
++ subjectId: session.subjectId,
++ }
++}
++
++function progressOf(skills: SkillSet, unlocks: Iterable<string>): ProgressSnapshot {
++ const levels: Record<string, number> = {}
++ for (const skill of skills.all()) levels[skill.id] = skill.level
++ return { levels, unlocks: [...unlocks] }
++}
++
++function sessionProgress(session: PlayerSession): ProgressSnapshot {
++ return progressOf(session.skills, session.unlocks)
++}
ddiff --git a/apps/server/src/game/platformIntegration.test.ts b/apps/server/src/game/platformIntegration.test.ts
nnew file mode 100644
iindex 0000000..f456ba4
---- /dev/null
++++ b/apps/server/src/game/platformIntegration.test.ts
@@@ -0,0 +1,303 @@
++/**
++ * The Wave 12 platform boundary exercised on the real authoritative server
++ * (headless Havok, real SQLite file, protocol-level connections), across a
++ * restart:
++ *
++ * - a signed-in player is keyed by the canonical subject, and a character
++ * from before subjects (`ovn:<id>`) is adopted with the same player id;
++ * - a guest presenting an account key as its token is refused;
++ * - a mod's props enter the world through the runtime seam, a denied
++ * capability never takes effect, and a revoked mod's props are gone on
++ * the next tick;
++ * - lifecycle events commit with the state they describe and reach
++ * OpenVibe.Events (mock) with the games service token;
++ * - a restart restores the authoritative state: characters, world props,
++ * the world clock and the mod registry — without duplicating mod props.
++ */
++import { mkdtempSync } from 'node:fs'
++import { tmpdir } from 'node:os'
++import { join } from 'node:path'
++import { createContent } from '@openvibe/content'
++import { openSqliteStore, type SqlitePersistenceStore } from '@openvibe/persistence/sqlite'
++import { createHeadlessHavokWorld } from '@openvibe/physics/havok'
++import {
++ PROTOCOL_VERSION,
++ defaultAppearance,
++ type ClientMessage,
++ type ServerMessage,
++} from '@openvibe/protocol'
++import { createConsoleLogger } from '@openvibe/shared'
++import {
++ createEventsClient,
++ createOutbox,
++ type Outbox,
++ type SqliteDatabase,
++} from 'openvibe-sdk/events'
++import { createMockPlatform } from 'openvibe-sdk/testing'
++import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
++import { loadConfig } from '../config.js'
++import { loadHavok } from '../havokLoader.js'
++import { CAP_ANNOUNCE, CAP_PLACE_PROP } from '../mods/contentPack.js'
++import { ModRegistry, type ModActor } from '../mods/registry.js'
++import { ModRuntime } from '../mods/runtime.js'
++import { sampleManifest } from '../mods/testFixtures.js'
++import { ServerMetrics } from '../observability/metrics.js'
++import { GameEventRecorder, outboxSink } from '../platform/gameEvents.js'
++import { createPlatformClient } from '../platform/serviceClient.js'
++import { GameServer, type GameConnection } from './gameServer.js'
++import { GameWorld } from './gameWorld.js'
++
++const SUBJECT = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'
++const MOD_A = 'mod_01JAAAAAAAAAAAAAAAAAAAAAAA'
++const MOD_B = 'mod_01JBBBBBBBBBBBBBBBBBBBBBBB'
++const STAFF: ModActor = { audit: SUBJECT, subject: { type: 'user', id: SUBJECT } }
++const log = createConsoleLogger({ app: 'test' }, 'error')
++
++const dir = mkdtempSync(join(tmpdir(), 'openvibe-platform-'))
++const dbPath = join(dir, 'world.db')
++const config = loadConfig({
++ DB_PATH: dbPath,
++ MAP_PATH: join(dir, 'map.json'),
++ GUEST_IP_BINDING: 'off',
++ OV_NETWORK_AUTH_URL: 'http://network.test/api/auth/me',
++})
++let havok: unknown
++
++beforeAll(async () => {
++ havok = await loadHavok()
++ // openvibe.network's /api/auth/me for the one signed-in test account.
++ vi.stubGlobal('fetch', async (_url: string, init?: RequestInit) => {
++ const auth = new Headers(init?.headers).get('authorization')
++ if (auth !== 'Bearer tok-ana') return new Response('{}', { status: 401 })
++ return Response.json({ user: { id: 57, username: 'ana', role: 'user', subject_id: SUBJECT } })
++ })
++}, 60_000)
++
++afterAll(() => {
++ vi.unstubAllGlobals()
++})
++
++interface Boot {
++ store: SqlitePersistenceStore
++ world: GameWorld
++ game: GameServer
++ registry: ModRegistry
++ outbox: Outbox
++ dispose(): void
++}
++
++function boot(platform: ReturnType<typeof createMockPlatform>): Boot {
++ const content = createContent()
++ const physics = createHeadlessHavokWorld(havok)
++ const store = openSqliteStore(dbPath)
++ const world = new GameWorld(content, physics, log)
++ world.seedOrRestore(store)
++ const pc = createPlatformClient(
++ {
++ ...config.platform,
++ clientSecret: 's3cret',
++ networkUrl: platform.origins.network,
++ eventsUrl: platform.origins.events,
++ },
++ platform.fetch,
++ )!
++ const outbox = createOutbox(store.db as unknown as SqliteDatabase, {
++ events: createEventsClient(pc.client, { source: 'games' }),
++ })
++ outbox.ensureSchema()
++ const sink = outboxSink(outbox)
++ const registry = new ModRegistry(store, content, sink)
++ const runtime = new ModRuntime(registry, store, content, log, { reconcileEveryTicks: 30 })
++ const recorder = new GameEventRecorder(sink, content.world.id, 0)
++ const game = new GameServer(config, world, store, new ServerMetrics(), log, {
++ events: recorder,
++ mods: runtime,
++ })
++ return {
++ store,
++ world,
++ game,
++ registry,
++ outbox,
++ dispose() {
++ store.close()
++ physics.dispose()
++ },
++ }
++}
++
++function connect(game: GameServer) {
++ const inbox: ServerMessage[] = []
++ let closed: { code: number; reason: string } | null = null
++ const conn: GameConnection = {
++ ip: '127.0.0.1',
++ send: (text) => inbox.push(JSON.parse(text) as ServerMessage),
++ close: (code, reason) => {
++ closed = { code, reason }
++ },
++ }
++ const hello = async (token: string, auth?: string) => {
++ const msg = {
++ t: 'hello',
++ v: PROTOCOL_VERSION,
++ token,
++ ...(auth ? { auth } : {}),
++ slot: 0,
++ name: 'Ana',
++ appearance: defaultAppearance(),
++ } as ClientMessage
++ game.onMessage(conn, msg)
++ for (let i = 0; i < 100 && !closed && !inbox.some((m) => m.t === 'welcome'); i++) {
++ await new Promise((r) => setTimeout(r, 5))
++ }
++ return { welcome: inbox.find((m) => m.t === 'welcome'), closed: closed as typeof closed }
++ }
++ return { conn, inbox, hello }
++}
++
++const propsOwnedBy = (world: GameWorld, owner: string) =>
++ [...world.entities.ofKind('prop')].filter((e) => e.owner === owner)
++
++describe('Games on platform identity, events and mods (real server, restart)', () => {
++ const platform = createMockPlatform({
++ clients: {
++ games: {
++ secret: 's3cret',
++ grants: [{ capability: 'events.event.publish', audience: 'openvibe.events' }],
++ },
++ },
++ })
++ let legacyPlayerId = ''
++ let timeBefore = ''
++
++ it('runs identity, mods and events against the live world', async () => {
++ // A character from before canonical subjects, keyed by the Network user id.
++ {
++ const seed = openSqliteStore(dbPath)
++ seed.players.upsert({
++ id: 'pl_legacy_57',
++ token: 'ovn:57',
++ name: 'Ana',
++ pos: [3, 2, 3],
++ yaw: 0,
++ inventory: { size: 24, hotbar: 6, slots: [] },
++ skills: { mining: 40 },
++ friends: [],
++ appearance: null,
++ charSlot: 0,
++ armor: null,
++ reputation: {},
++ unlocks: [],
++ activeJob: null,
++ stats: null,
++ updatedAt: 1,
++ })
++ seed.close()
++ legacyPlayerId = 'pl_legacy_57'
++ }
++ const a = boot(platform)
++
++ // A guest cannot claim an account key as its token.
++ const intruder = connect(a.game)
++ const refused = await intruder.hello('ovn:57')
++ expect(refused.welcome).toBeUndefined()
++ expect(refused.closed).toEqual({ code: 4007, reason: 'invalid_guest_token' })
++
++ // The signed-in player lands on the adopted legacy character.
++ const ana = connect(a.game)
++ const joined = await ana.hello('abcdefgh12', 'tok-ana')
++ expect(joined.welcome).toMatchObject({ t: 'welcome', playerId: legacyPlayerId })
++ expect(a.store.players.findById(legacyPlayerId)).toMatchObject({
++ token: SUBJECT,
++ subjectId: SUBJECT,
++ })
++ expect(a.store.identity.subjectForLegacy('ovn:57')).toBe(SUBJECT)
++
++ // Mod A: props granted, announcements requested but denied.
++ a.registry.install(
++ {
++ manifest: sampleManifest({ id: MOD_A }),
++ pack: {
++ announcements: [{ text: 'hello world', everySeconds: 60 }],
++ props: [{ key: 'bench', item: 'workbench', pos: [20, 0, 20] }],
++ },
++ approve: [CAP_PLACE_PROP],
++ enable: true,
++ },
++ STAFF,
++ )
++ // Mod B: stays installed across the restart.
++ a.registry.install(
++ {
++ manifest: sampleManifest({ id: MOD_B, name: 'Campfire' }),
++ pack: { props: [{ key: 'fire', item: 'campfire', pos: [-20, 0, -20] }] },
++ approve: [CAP_PLACE_PROP],
++ enable: true,
++ },
++ STAFF,
++ )
++ a.game.step()
++ const benches = propsOwnedBy(a.world, MOD_A)
++ expect(benches.map((e) => e.prop?.defId)).toEqual(['workbench'])
++ expect(propsOwnedBy(a.world, MOD_B).map((e) => e.prop?.defId)).toEqual(['campfire'])
++ expect(ana.inbox.some((m) => m.t === 'announce')).toBe(false)
++ expect(
++ a.registry.auditLog(MOD_A).some((x) => x.action === 'deny' && x.capability === CAP_ANNOUNCE),
++ ).toBe(true)
++
++ // Revoke: gone on the very next tick, and players are told it despawned.
++ a.registry.revoke(MOD_A, STAFF, 'test')
++ a.game.step()
++ expect(propsOwnedBy(a.world, MOD_A)).toEqual([])
++ expect(a.world.entities.get(benches[0]!.id)).toBeUndefined()
++
++ a.game.onDisconnect(ana.conn)
++ a.game.shutdown()
++ timeBefore = a.store.meta.get('env_time') ?? ''
++ expect(timeBefore).not.toBe('')
++
++ // Everything committed is published with the games principal.
++ await a.outbox.flush()
++ await a.outbox.stop()
++ const types = platform.state.events.map((e) => e.event.event_type)
++ expect(types).toEqual(
++ expect.arrayContaining([
++ 'games.player.joined',
++ 'games.player.left',
++ 'games.mod.installed',
++ 'games.mod.revoked',
++ 'games.world.saved',
++ ]),
++ )
++ expect(new Set(platform.state.events.map((e) => e.publisher))).toEqual(new Set(['svc:games']))
++ const joinedEvent = platform.state.events.find(
++ (e) => e.event.event_type === 'games.player.joined',
++ )
++ expect(joinedEvent?.event.actor).toEqual({ type: 'user', id: SUBJECT })
++ a.dispose()
++ }, 60_000)
++
++ it('restores the authoritative state after a restart', async () => {
++ const b = boot(platform)
++ // World clock, mod registry and mod props came back from disk.
++ expect(b.store.meta.get('env_time')).toBe(timeBefore)
++ expect(b.registry.get(MOD_A)?.mod.status).toBe('revoked')
++ expect(b.registry.get(MOD_B)?.mod.status).toBe('enabled')
++ expect(propsOwnedBy(b.world, MOD_A)).toEqual([])
++ expect(propsOwnedBy(b.world, MOD_B)).toHaveLength(1)
++ // The runtime adopts the restored prop instead of placing a second one.
++ for (let i = 0; i < 40; i++) b.game.step()
++ expect(propsOwnedBy(b.world, MOD_B)).toHaveLength(1)
++
++ // The account comes back to the same character, by subject.
++ const ana = connect(b.game)
++ const again = await ana.hello('zyxwvut98', 'tok-ana')
++ expect(again.welcome).toMatchObject({ playerId: legacyPlayerId })
++ expect(b.store.players.listByToken(SUBJECT).map((p) => p.id)).toEqual([legacyPlayerId])
++ expect(b.store.players.listByToken('ovn:57')).toEqual([])
++ b.game.onDisconnect(ana.conn)
++ b.game.shutdown()
++ await b.outbox.stop()
++ b.dispose()
++ }, 60_000)
++})
ddiff --git a/apps/server/src/game/playerSession.ts b/apps/server/src/game/playerSession.ts
iindex c717f36..6bbcce3 100644
---- a/apps/server/src/game/playerSession.ts
++++ b/apps/server/src/game/playerSession.ts
@@@ -45,6 +45,8 @@ export interface PlayerSession {
playerId: PlayerId
entityId: EntityId
token: string
++ /** Canonical openvibe.network subject of the account; null for local guests. */
++ subjectId: string | null
/** Character slot under the account token (0..2). */
charSlot: number
/** openvibe.network rank; gates edit mode + moderation. */
@@@ -110,6 +112,7 @@ export interface SessionInit {
playerId: PlayerId
entityId: EntityId
token: string
++ subjectId?: string | null
charSlot?: number
rank?: 'owner' | 'admin' | 'moderator' | null
name: string
@@@ -134,6 +137,7 @@ export function createSession(init: SessionInit): PlayerSession {
playerId: init.playerId,
entityId: init.entityId,
token: init.token,
++ subjectId: init.subjectId ?? null,
charSlot: init.charSlot ?? 0,
rank: init.rank ?? null,
name: init.name,
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 4c2933b..2a7c185 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -1,5 +1,11 @@
import { mkdirSync, existsSync, readFileSync } from 'node:fs'
--import { dirname } from 'node:path'
++import { dirname, join } from 'node:path'
++import {
++ createEventsClient,
++ createOutbox,
++ type Outbox,
++ type SqliteDatabase,
++} from 'openvibe-sdk/events'
import { compileMapFileV2, createContent, parseMapFile, setMapOverride } from '@openvibe/content'
import { openSqliteStore } from '@openvibe/persistence/sqlite'
import { createHeadlessHavokWorld } from '@openvibe/physics/havok'
@@@ -13,6 +19,20 @@ import { createHttpServer } from './net/httpServer.js'
import { resolveNetworkUser } from './net/networkAuth.js'
import { attachWebSocket } from './net/wsTransport.js'
import { ServerMetrics } from './observability/metrics.js'
++import { ModRegistry } from './mods/registry.js'
++import { handleModsRequest } from './mods/routes.js'
++import { ModRuntime } from './mods/runtime.js'
++import { accountForNetworkUser, isGuestToken } from './platform/accounts.js'
++import {
++ EVENT_SOURCE,
++ GameEventRecorder,
++ NO_EVENTS,
++ outboxSink,
++ type EventSink,
++} from './platform/gameEvents.js'
++import { MediaMirror } from './platform/mediaMirror.js'
++import { createPlatformClient } from './platform/serviceClient.js'
++import { createStaffAuthorizer } from './platform/staffAuth.js'
/**
* Dedicated authoritative server entry point.
@@@ -71,7 +91,66 @@ async function main(): Promise<void> {
const world = new GameWorld(content, physics, log.child({ system: 'world' }))
world.seedOrRestore(store)
-- const game = new GameServer(config, world, store, metrics, log.child({ system: 'game' }))
++ // ── Platform integration (roadmap Wave 12) ─────────────────────────
++ // Every service call carries the `games` principal's client-credentials
++ // token; with no client secret configured none of this talks to anything.
++ const platform = createPlatformClient(config.platform)
++ const platformLog = log.child({ system: 'platform' })
++ let outbox: Outbox | null = null
++ let sink: EventSink = NO_EVENTS
++ if (platform && config.platform.eventsUrl) {
++ let lastError: string | null = null
++ // better-sqlite3's Transaction<F> is a callable F; the SDK types it as plain F.
++ outbox = createOutbox(store.db as unknown as SqliteDatabase, {
++ events: createEventsClient(platform.client, { source: EVENT_SOURCE }),
++ intervalMs: 2000,
++ onError: (err) => {
++ const message = String((err as Error | undefined)?.message ?? err)
++ if (message !== lastError)
++ platformLog.warn('event publish failed (will retry)', { error: message })
++ lastError = message
++ },
++ })
++ outbox.ensureSchema()
++ sink = outboxSink(outbox)
++ outbox.start()
++ platformLog.info('events on', { url: config.platform.eventsUrl, pending: outbox.pending() })
++ }
++ const recorder = sink.enabled
++ ? new GameEventRecorder(sink, content.world.id, config.platform.worldSavedEventMinutes * 60_000)
++ : undefined
++ const mods = new ModRegistry(store, content, sink)
++ const modRuntime = new ModRuntime(mods, store, content, log.child({ system: 'mods' }), {
++ reconcileEveryTicks: config.tickRate,
++ })
++ const mirror =
++ platform && config.platform.mediaUrl
++ ? new MediaMirror({
++ client: platform.client,
++ store,
++ namespace: config.platform.mediaNamespace,
++ assetsDir: join(dirname(config.mapPath), 'map-assets'),
++ log: platformLog.child({ system: 'media-mirror' }),
++ })
++ : null
++ if (mirror) {
++ void mirror.backfill().then((queued) => {
++ platformLog.info('media mirror on', { url: config.platform.mediaUrl ?? '', queued })
++ mirror.start()
++ })
++ }
++ const authorizeStaff = createStaffAuthorizer({
++ networkAuthUrl: config.networkAuthUrl,
++ networkUrl: config.platform.networkUrl,
++ editorKey: config.editorKey,
++ })
++ const pruneTimer = setInterval(() => outbox?.prune(), 6 * 3600 * 1000)
++ pruneTimer.unref()
++
++ const game = new GameServer(config, world, store, metrics, log.child({ system: 'game' }), {
++ ...(recorder ? { events: recorder } : {}),
++ mods: modRuntime,
++ })
const http = createHttpServer(
config.staticDir,
@@@ -107,7 +186,10 @@ async function main(): Promise<void> {
if (auth) {
const user = await resolveNetworkUser(config.networkAuthUrl, auth)
if (!user) return []
-- account = `ovn:${user.id}`.slice(0, 64)
++ account = accountForNetworkUser(store, user, Date.now()).key
++ } else if (!isGuestToken(token)) {
++ // Never list an account key's characters for a guest query.
++ return []
}
return store.players
.listByToken(account)
@@@ -115,6 +197,40 @@ async function main(): Promise<void> {
.map((p) => ({ slot: p.charSlot, name: p.name, appearance: p.appearance }))
},
config.oauth,
++ {
++ handle: (req, res) => {
++ if ((req.url ?? '').split('?')[0] === '/api/v1/platform' && req.method === 'GET') {
++ // Operational status of the platform adapters; never secrets.
++ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
++ res.end(
++ JSON.stringify({
++ principal: platform ? platform.clientId : null,
++ events: outbox
++ ? { enabled: true, pending: outbox.pending(), rejected: outbox.rejected() }
++ : { enabled: false },
++ media: mirror
++ ? {
++ enabled: true,
++ namespace: config.platform.mediaNamespace,
++ ...store.mediaMirrors.counts(),
++ }
++ : { enabled: false },
++ mods: {
++ installed: mods.list().length,
++ active: mods.list().filter((m) => mods.isActive(m.mod.id)).length,
++ },
++ }),
++ )
++ return true
++ }
++ return handleModsRequest(req, res, {
++ registry: mods,
++ authorize: authorizeStaff,
++ log: log.child({ system: 'mods-api' }),
++ })
++ },
++ onAssetStored: (asset) => mirror?.enqueue(asset),
++ },
)
world.reconcileMapNodes()
world.reconcileMapProps()
@@@ -163,6 +279,9 @@ async function main(): Promise<void> {
running = false
clearInterval(metricsTimer)
game.shutdown()
++ mirror?.stop()
++ // Unsent events stay in the outbox table and go out after the restart.
++ void outbox?.stop()
store.close()
http.close()
physics.dispose()
ddiff --git a/apps/server/src/mods/contentPack.ts b/apps/server/src/mods/contentPack.ts
nnew file mode 100644
iindex 0000000..b4cb1c9
---- /dev/null
++++ b/apps/server/src/mods/contentPack.ts
@@@ -0,0 +1,131 @@
++/**
++ * `games-content@1`: the first mod kind — a declarative data pack, checked
++ * against the live `@openvibe/content` registry. It adds no code and no new
++ * definitions (the client ships the same registry, so a pack can only use
++ * what both sides already know). Each section needs one capability:
++ *
++ * announcements games.world.announce periodic server announcements
++ * props games.prop.place inert props placed in the world
++ *
++ * Mod props are owned by the mod, so prop protection keeps players from
++ * picking them up, moving or welding them, and only items without health,
++ * storage, shops, machines or vehicle parts may be placed: nothing a player
++ * could destroy for loot or use to move items into or out of the world.
++ */
++import type { ContentRegistry } from '@openvibe/content'
++import { Ajv2020 } from 'ajv/dist/2020.js'
++import type { Validation } from './manifest.js'
++
++export const CAP_ANNOUNCE = 'games.world.announce'
++export const CAP_PLACE_PROP = 'games.prop.place'
++
++/** Capabilities the games-content@1 runtime can bind. Anything else is never granted here. */
++export const CONTENT_RUNTIME_CAPABILITIES: readonly string[] = [CAP_ANNOUNCE, CAP_PLACE_PROP]
++
++export interface ContentAnnouncement {
++ text: string
++ everySeconds: number
++}
++
++export interface ContentProp {
++ key: string
++ item: string
++ pos: [number, number, number]
++ yaw?: number
++}
++
++export interface ContentPack {
++ announcements?: ContentAnnouncement[]
++ props?: ContentProp[]
++}
++
++export const CONTENT_PACK_SCHEMA = {
++ $schema: 'https://json-schema.org/draft/2020-12/schema',
++ $id: 'https://openvibe.games/schemas/games-content-pack.v1.json',
++ title: 'GamesContentPack',
++ type: 'object',
++ additionalProperties: false,
++ properties: {
++ announcements: {
++ type: 'array',
++ maxItems: 10,
++ items: {
++ type: 'object',
++ required: ['text', 'everySeconds'],
++ additionalProperties: false,
++ properties: {
++ text: { type: 'string', minLength: 1, maxLength: 200 },
++ everySeconds: { type: 'integer', minimum: 60, maximum: 86400 },
++ },
++ },
++ },
++ props: {
++ type: 'array',
++ maxItems: 50,
++ items: {
++ type: 'object',
++ required: ['key', 'item', 'pos'],
++ additionalProperties: false,
++ properties: {
++ key: { type: 'string', pattern: '^[a-z0-9][a-z0-9_-]{0,39}$' },
++ item: { type: 'string', minLength: 1, maxLength: 64 },
++ pos: {
++ type: 'array',
++ minItems: 3,
++ maxItems: 3,
++ items: { type: 'number', minimum: -4096, maximum: 4096 },
++ },
++ yaw: { type: 'number', minimum: -10, maximum: 10 },
++ },
++ },
++ },
++ },
++} as const
++
++const ajv = new Ajv2020({ allErrors: true, strict: true })
++const validateSchema = ajv.compile(CONTENT_PACK_SCHEMA)
++
++/** Which capability each pack section needs. */
++export function capabilitiesUsedBy(pack: ContentPack): string[] {
++ const used: string[] = []
++ if ((pack.announcements ?? []).length > 0) used.push(CAP_ANNOUNCE)
++ if ((pack.props ?? []).length > 0) used.push(CAP_PLACE_PROP)
++ return used
++}
++
++/** Whether a content item may be placed as a mod prop (see the file comment). */
++export function placeableByMod(content: ContentRegistry, itemId: string): string | null {
++ const def = content.item(itemId)
++ if (!def) return `unknown item '${itemId}'`
++ if (def.health) return `item '${itemId}' can be destroyed (health) and is not allowed`
++ if (def.container) return `item '${itemId}' stores items and is not allowed`
++ if (def.shop) return `item '${itemId}' is a shop and is not allowed`
++ if (def.machine) return `item '${itemId}' is a machine and is not allowed`
++ if (def.vehiclePart) return `item '${itemId}' is a vehicle part and is not allowed`
++ return null
++}
++
++/** Structure (JSON Schema) plus the content-registry cross-checks. */
++export function validateContentPack(
++ value: unknown,
++ content: ContentRegistry,
++): Validation<ContentPack> {
++ if (!validateSchema(value)) {
++ return {
++ ok: false,
++ errors: (validateSchema.errors ?? []).map(
++ (e) => `pack${e.instancePath || ''} ${e.message ?? ''}`,
++ ),
++ }
++ }
++ const pack = value as unknown as ContentPack
++ const errors: string[] = []
++ const keys = new Set<string>()
++ for (const prop of pack.props ?? []) {
++ if (keys.has(prop.key)) errors.push(`duplicate prop key '${prop.key}'`)
++ keys.add(prop.key)
++ const problem = placeableByMod(content, prop.item)
++ if (problem) errors.push(problem)
++ }
++ return errors.length > 0 ? { ok: false, errors } : { ok: true, value: pack }
++}
ddiff --git a/apps/server/src/mods/manifest.test.ts b/apps/server/src/mods/manifest.test.ts
nnew file mode 100644
iindex 0000000..9089d9e
---- /dev/null
++++ b/apps/server/src/mods/manifest.test.ts
@@@ -0,0 +1,110 @@
++import { readFileSync } from 'node:fs'
++import { createContent } from '@openvibe/content'
++import { describe, expect, it } from 'vitest'
++import { validateContentPack } from './contentPack.js'
++import { checkForGames, validateManifest } from './manifest.js'
++import { MOD_MANIFEST_SCHEMA } from './manifestSchema.js'
++import { sampleManifest } from './testFixtures.js'
++
++describe('mod manifest schema', () => {
++ it('is the same document as the Contracts proposal', () => {
++ const proposal: unknown = JSON.parse(
++ readFileSync(
++ new URL('../../../../docs/contracts-proposal/mods/mod-manifest.v1.json', import.meta.url),
++ 'utf8',
++ ),
++ )
++ expect(MOD_MANIFEST_SCHEMA).toEqual(proposal)
++ })
++
++ it('accepts a complete manifest, including Media asset refs', () => {
++ const r = validateManifest(
++ sampleManifest({
++ assets: [{ media_id: 'med_01JABCDEFGHJKMNPQRSTVWXYZ0', role: 'icon' }],
++ homepage: 'https://openvibe.games/mods/town-square',
++ permissions: {
++ capabilities: ['games.world.announce'],
++ events: ['games.player.joined'],
++ modules: ['games.progress.summary'],
++ mediaNamespaces: ['games'],
++ },
++ }),
++ )
++ expect(r).toMatchObject({ ok: true })
++ })
++
++ it('rejects malformed manifests with a reason', () => {
++ const bad: [string, unknown][] = [
++ ['id not a mod ULID', sampleManifest({ id: 'my-mod' })],
++ ['version not semver', sampleManifest({ version: 'v1' })],
++ ['publisher not a subject', sampleManifest({ publisher: { type: 'user', id: '57' } })],
++ [
++ '2-segment capability',
++ sampleManifest({ permissions: { capabilities: ['games.announce'] } }),
++ ],
++ ['unknown field', { ...sampleManifest(), trust_tier: 'first-party' }],
++ [
++ 'cpu budget too high',
++ sampleManifest({ resources: { cpuMs: 5000, memoryMb: 0, storageMb: 0 } }),
++ ],
++ ['asset not a Media id', sampleManifest({ assets: [{ media_id: 'https://x/y.png' }] })],
++ ['missing compatibility', { ...sampleManifest(), compatibility: undefined }],
++ ]
++ for (const [why, value] of bad) {
++ const r = validateManifest(JSON.parse(JSON.stringify(value)))
++ expect(r.ok, why).toBe(false)
++ }
++ })
++
++ it('only admits declarative content packs in Games (executable mods wait for Host)', () => {
++ expect(checkForGames(sampleManifest())).toEqual([])
++ expect(checkForGames(sampleManifest({ runtime: 'source-quickjs@1' }))[0]).toMatch(
++ /only games-content@1/,
++ )
++ expect(checkForGames(sampleManifest({ target: 'games.source' }))).toHaveLength(1)
++ expect(checkForGames(sampleManifest({ compatibility: { runtime: '>=2.0.0' } }))).toHaveLength(1)
++ expect(
++ checkForGames(
++ sampleManifest({
++ resources: { cpuMs: 1, memoryMb: 0, storageMb: 0, outboundHosts: ['example.com'] },
++ }),
++ ),
++ ).toHaveLength(1)
++ })
++})
++
++describe('games-content@1 packs', () => {
++ const content = createContent()
++
++ it('accept existing, inert items and announcements', () => {
++ const r = validateContentPack(
++ {
++ announcements: [{ text: 'Welcome to the square', everySeconds: 600 }],
++ props: [{ key: 'bench', item: 'workbench', pos: [10, 0, 10], yaw: 1.5 }],
++ },
++ content,
++ )
++ expect(r.ok).toBe(true)
++ })
++
++ it('refuse unknown items, loot-bearing or storage props, duplicates and bad shapes', () => {
++ const reject = (pack: unknown) => validateContentPack(pack, content).ok
++ expect(reject({ props: [{ key: 'x', item: 'no_such_item', pos: [0, 0, 0] }] })).toBe(false)
++ const storage = content.allItems().find((i) => i.container)
++ const breakable = content.allItems().find((i) => i.health)
++ expect(storage && breakable).toBeTruthy()
++ expect(reject({ props: [{ key: 'x', item: storage!.id, pos: [0, 0, 0] }] })).toBe(false)
++ expect(reject({ props: [{ key: 'x', item: breakable!.id, pos: [0, 0, 0] }] })).toBe(false)
++ expect(
++ reject({
++ props: [
++ { key: 'a', item: 'workbench', pos: [0, 0, 0] },
++ { key: 'a', item: 'campfire', pos: [1, 0, 0] },
++ ],
++ }),
++ ).toBe(false)
++ expect(reject({ announcements: [{ text: 'spam', everySeconds: 5 }] })).toBe(false)
++ expect(reject({ scripts: ['while(true){}'] })).toBe(false)
++ expect(reject({ props: [{ key: 'x', item: 'workbench', pos: [0, 0, 1e9] }] })).toBe(false)
++ })
++})
ddiff --git a/apps/server/src/mods/manifest.ts b/apps/server/src/mods/manifest.ts
nnew file mode 100644
iindex 0000000..03419f8
---- /dev/null
++++ b/apps/server/src/mods/manifest.ts
@@@ -0,0 +1,93 @@
++/**
++ * Mod manifest validation (mods/mod-manifest.v1, ADR-013).
++ *
++ * Structure is checked against the JSON Schema itself (Ajv, draft 2020-12,
++ * with the SubjectRef and MediaRef contracts from openvibe-contracts), so the
++ * schema proposed to Contracts and the one Games enforces are the same
++ * document. `checkForGames` adds what only this runtime can judge.
++ */
++import { createRequire } from 'node:module'
++import { Ajv2020 } from 'ajv/dist/2020.js'
++import { satisfiesRange } from 'openvibe-sdk/core'
++import { MOD_MANIFEST_SCHEMA } from './manifestSchema.js'
++
++interface ContractsModule {
++ schema(ref: string): Record<string, unknown>
++}
++const contracts = createRequire(import.meta.url)('openvibe-contracts') as ContractsModule
++
++export interface ModManifest {
++ id: string
++ name: string
++ version: string
++ description?: string
++ publisher: { type: string; id: string }
++ target: string
++ runtime: string
++ permissions: {
++ capabilities: string[]
++ events?: string[]
++ modules?: string[]
++ mediaNamespaces?: string[]
++ }
++ resources: { cpuMs: number; memoryMb: number; storageMb: number; outboundHosts?: string[] }
++ assets?: { media_id: string; role?: string; variant?: string }[]
++ compatibility: { runtime: string; contracts?: string }
++ homepage?: string
++}
++
++const ajv = new Ajv2020({ allErrors: true, strict: true, strictRequired: false })
++ajv.addSchema(contracts.schema('identity.subject-ref@1'))
++ajv.addSchema(contracts.schema('media.media-ref@1'))
++const validateSchema = ajv.compile(MOD_MANIFEST_SCHEMA)
++
++export type Validation<T> = { ok: true; value: T } | { ok: false; errors: string[] }
++
++/** Structural validation against mods/mod-manifest.v1. */
++export function validateManifest(value: unknown): Validation<ModManifest> {
++ if (validateSchema(value)) return { ok: true, value: value as unknown as ModManifest }
++ return {
++ ok: false,
++ errors: (validateSchema.errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message ?? ''}`),
++ }
++}
++
++/** The browser game's only mod target and runtime today. */
++export const GAMES_TARGET = 'games.browser'
++export const CONTENT_RUNTIME = 'games-content@1'
++/** Version of the content runtime, matched against compatibility.runtime. */
++export const CONTENT_RUNTIME_VERSION = '1.0.0'
++
++/**
++ * Whether this server can run the manifest at all. Executable runtimes
++ * (scripts) are refused here: they wait for OpenVibe.Host's sandbox
++ * (Stage C). Only declarative content packs run in Games today.
++ */
++export function checkForGames(m: ModManifest): string[] {
++ const errors: string[] = []
++ if (m.target !== GAMES_TARGET) errors.push(`target ${m.target} is not ${GAMES_TARGET}`)
++ if (m.runtime !== CONTENT_RUNTIME) {
++ errors.push(
++ `runtime ${m.runtime} is not available here: only ${CONTENT_RUNTIME} (declarative data packs) runs in Games until sandboxed execution exists`,
++ )
++ } else if (!safeSatisfies(CONTENT_RUNTIME_VERSION, m.compatibility.runtime)) {
++ errors.push(
++ `compatibility.runtime ${m.compatibility.runtime} does not include ${CONTENT_RUNTIME_VERSION}`,
++ )
++ }
++ if (m.publisher.type !== 'user' && m.publisher.type !== 'app') {
++ errors.push('publisher must be a user or an app subject')
++ }
++ if ((m.resources.outboundHosts ?? []).length > 0) {
++ errors.push('content packs cannot reach the network: resources.outboundHosts must be empty')
++ }
++ return errors
++}
++
++function safeSatisfies(version: string, range: string): boolean {
++ try {
++ return satisfiesRange(version, range)
++ } catch {
++ return false
++ }
++}
ddiff --git a/apps/server/src/mods/manifestSchema.ts b/apps/server/src/mods/manifestSchema.ts
nnew file mode 100644
iindex 0000000..3c8f8ad
---- /dev/null
++++ b/apps/server/src/mods/manifestSchema.ts
@@@ -0,0 +1,179 @@
++/**
++ * The mod manifest JSON Schema (mods/mod-manifest.v1) as Games validates it.
++ * The proposal for OpenVibe.Contracts is docs/contracts-proposal/mods/mod-manifest.v1.json;
++ * manifest.test.ts fails if the two ever differ. Once Contracts releases the
++ * schema, import it from openvibe-contracts instead of keeping this copy.
++ */
++export const MOD_MANIFEST_SCHEMA = {
++ $schema: 'https://json-schema.org/draft/2020-12/schema',
++ $id: 'https://openvibe.network/contracts/mods/mod-manifest.v1.json',
++ title: 'ModManifest',
++ description:
++ "A mod as the platform knows it (ADR-013): who publishes it, which runtime runs it, which capabilities it asks for and the resources it may use. Requested capabilities are only a request: the install's approved subset is the grant, and trust tiers are install metadata that never change a grant check. The runtime-specific payload (a data pack, a script bundle) is not part of the manifest.",
++ type: 'object',
++ required: [
++ 'id',
++ 'name',
++ 'version',
++ 'publisher',
++ 'target',
++ 'runtime',
++ 'permissions',
++ 'resources',
++ 'compatibility',
++ ],
++ additionalProperties: false,
++ properties: {
++ id: {
++ type: 'string',
++ pattern: '^mod_[0-9A-HJKMNP-TV-Z]{26}$',
++ description: 'Stable mod id; its principal subject is mod:<id>.',
++ },
++ name: {
++ type: 'string',
++ minLength: 1,
++ maxLength: 80,
++ },
++ version: {
++ type: 'string',
++ pattern: '^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$',
++ description: 'Semantic version of this release.',
++ },
++ description: {
++ type: 'string',
++ maxLength: 1000,
++ },
++ publisher: {
++ $ref: '../identity/subject-ref.v1.json',
++ description: 'Who publishes the mod (a user or an app subject).',
++ },
++ target: {
++ type: 'string',
++ pattern: '^[a-z][a-z0-9-]{1,39}\\.[a-z][a-z0-9-]{1,39}$',
++ description:
++ 'Where the mod runs: <service>.<surface>, e.g. games.browser, games.source, live.overlay.',
++ },
++ runtime: {
++ type: 'string',
++ pattern: '^[a-z][a-z0-9-]{1,39}@[1-9][0-9]{0,3}$',
++ description:
++ 'Runtime adapter and its major version, e.g. games-content@1 (declarative data pack) or source-quickjs@1.',
++ },
++ permissions: {
++ type: 'object',
++ required: ['capabilities'],
++ additionalProperties: false,
++ properties: {
++ capabilities: {
++ description:
++ 'Capability ids the mod asks for (3+ segments). The target runtime binds only those it implements, and only once granted.',
++ type: 'array',
++ maxItems: 64,
++ uniqueItems: true,
++ items: {
++ type: 'string',
++ pattern: '^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$',
++ },
++ },
++ events: {
++ description: 'Event types the mod wants delivered to it.',
++ type: 'array',
++ maxItems: 64,
++ uniqueItems: true,
++ items: {
++ type: 'string',
++ pattern: '^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$',
++ },
++ },
++ modules: {
++ description:
++ 'User-module namespaces the mod wants to read or write (a trailing .* names a family).',
++ type: 'array',
++ maxItems: 32,
++ uniqueItems: true,
++ items: {
++ type: 'string',
++ pattern: '^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)*(\\.\\*)?$',
++ },
++ },
++ mediaNamespaces: {
++ description: 'Media namespaces the mod wants to read or write.',
++ type: 'array',
++ maxItems: 32,
++ uniqueItems: true,
++ items: {
++ type: 'string',
++ pattern: '^[a-z][a-z0-9_-]*(\\.[a-z0-9_-]+)*$',
++ },
++ },
++ },
++ },
++ resources: {
++ type: 'object',
++ required: ['cpuMs', 'memoryMb', 'storageMb'],
++ additionalProperties: false,
++ description:
++ 'The budget the mod asks for. Runtimes meter it; the sandbox that enforces it lives in OpenVibe.Host (Stage C).',
++ properties: {
++ cpuMs: {
++ type: 'number',
++ minimum: 0,
++ maximum: 1000,
++ description: 'CPU milliseconds per tick (game runtimes) or per request.',
++ },
++ memoryMb: {
++ type: 'integer',
++ minimum: 0,
++ maximum: 4096,
++ },
++ storageMb: {
++ type: 'integer',
++ minimum: 0,
++ maximum: 102400,
++ },
++ outboundHosts: {
++ description: 'Hosts the mod may reach over the network. Empty or absent = none.',
++ type: 'array',
++ maxItems: 32,
++ uniqueItems: true,
++ items: {
++ type: 'string',
++ pattern: '^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$',
++ },
++ },
++ },
++ },
++ assets: {
++ description: 'Assets the mod ships, as Media object references.',
++ type: 'array',
++ maxItems: 256,
++ items: {
++ $ref: '../media/media-ref.v1.json',
++ },
++ },
++ compatibility: {
++ type: 'object',
++ required: ['runtime'],
++ additionalProperties: false,
++ properties: {
++ runtime: {
++ type: 'string',
++ minLength: 1,
++ maxLength: 100,
++ description:
++ 'Semver range of the runtime this release works with, e.g. ">=1.0.0 <2.0.0".',
++ },
++ contracts: {
++ type: 'string',
++ minLength: 1,
++ maxLength: 100,
++ description: 'Semver range of openvibe-contracts releases it was built against.',
++ },
++ },
++ },
++ homepage: {
++ type: 'string',
++ pattern: '^https://[^\\s]{1,2000}$',
++ },
++ },
++} as const
ddiff --git a/apps/server/src/mods/registry.ts b/apps/server/src/mods/registry.ts
nnew file mode 100644
iindex 0000000..a909ccc
---- /dev/null
++++ b/apps/server/src/mods/registry.ts
@@@ -0,0 +1,378 @@
++/**
++ * The mod registry (ADR-013, roadmap Wave 12): installs, the approved subset
++ * of each install's requested capabilities, lifecycle and the audit log, all
++ * in Games' own SQLite database.
++ *
++ * install validate the manifest + pack, store it, grant the approved subset
++ * enable / disable
++ * grant / revokeGrant change one capability of an install
++ * revoke terminal: every grant ends and the runtime retracts the mod's
++ * effects on the next tick
++ *
++ * Every change is one transaction holding the registry rows, the audit rows
++ * and the `games.mod.*` outbox event. The in-memory view the runtime asks on
++ * its hot path (`isGranted`) is refreshed only after that commit.
++ *
++ * Trust tiers are metadata: `isGranted` never looks at them.
++ *
++ * ADR-013 puts grants in OpenVibe.Network (a `mod` principal per install).
++ * Until Network issues mod principals, the approved subset lives here; the
++ * shape (install -> approved capability ids, revocable at once) is the same
++ * so the source of truth can move without changing the runtime seam.
++ */
++import type { ContentRegistry } from '@openvibe/content'
++import type {
++ ModAuditDto,
++ ModGrantDto,
++ ModInstallDto,
++ ModStatus,
++ ModTrustTier,
++ PersistenceStore,
++} from '@openvibe/persistence'
++import type { SubjectRef } from 'openvibe-sdk/core'
++import { modEvent, NO_EVENTS, type EventSink, type ModLifecycle } from '../platform/gameEvents.js'
++import {
++ capabilitiesUsedBy,
++ CONTENT_RUNTIME_CAPABILITIES,
++ validateContentPack,
++ type ContentPack,
++} from './contentPack.js'
++import { checkForGames, validateManifest, type ModManifest } from './manifest.js'
++
++export const TRUST_TIERS: readonly ModTrustTier[] = ['unreviewed', 'reviewed', 'first-party']
++
++/** Who performed a registry change: an audit string and an event actor. */
++export interface ModActor {
++ /** `usr_…`, `ovn:<id>` for a pre-subject staff token, or `svc:<client>`. */
++ audit: string
++ subject: SubjectRef
++}
++
++export const SYSTEM_ACTOR: ModActor = { audit: 'games', subject: { type: 'service', id: 'games' } }
++
++export class ModError extends Error {
++ constructor(
++ readonly code: string,
++ message: string,
++ readonly status = 400,
++ readonly errors: string[] = [],
++ ) {
++ super(message)
++ }
++}
++
++export interface InstallRequest {
++ manifest: unknown
++ pack: unknown
++ /** The approved subset of manifest.permissions.capabilities. */
++ approve?: unknown
++ trustTier?: unknown
++ enable?: unknown
++}
++
++/** A mod as the runtime and the API see it. */
++export interface ModView {
++ mod: ModInstallDto
++ manifest: ModManifest
++ pack: ContentPack
++ /** Capabilities currently granted (approved and not revoked). */
++ granted: ReadonlySet<string>
++}
++
++export class ModRegistry {
++ private readonly views = new Map<string, ModView>()
++ private revision = 0
++
++ constructor(
++ private readonly store: PersistenceStore,
++ private readonly content: ContentRegistry,
++ private readonly events: EventSink = NO_EVENTS,
++ private readonly now: () => number = Date.now,
++ ) {
++ for (const mod of store.mods.list()) this.refresh(mod.id)
++ }
++
++ /** Bumps on every committed change; the runtime reconciles when it moves. */
++ get version(): number {
++ return this.revision
++ }
++
++ list(): ModView[] {
++ return [...this.views.values()]
++ }
++
++ get(id: string): ModView | null {
++ return this.views.get(id) ?? null
++ }
++
++ isActive(id: string): boolean {
++ return this.views.get(id)?.mod.status === 'enabled'
++ }
++
++ /**
++ * The one grant check. True only while the install is enabled and the
++ * capability is in its approved, unrevoked subset. The trust tier is not
++ * consulted — a tier label never grants anything.
++ */
++ isGranted(id: string, capability: string): boolean {
++ const view = this.views.get(id)
++ return view !== undefined && view.mod.status === 'enabled' && view.granted.has(capability)
++ }
++
++ auditLog(id: string, limit = 100): ModAuditDto[] {
++ return this.store.mods.auditLog(id, Math.min(Math.max(limit, 1), 500))
++ }
++
++ /** Appends a runtime audit record (use, deny, retract, …). */
++ audit(id: string, action: string, capability: string | null, detail?: Record<string, unknown>) {
++ this.store.mods.audit({
++ modId: id,
++ action,
++ capability,
++ actor: SYSTEM_ACTOR.audit,
++ detail: detail ?? null,
++ at: this.now(),
++ })
++ }
++
++ install(req: InstallRequest, actor: ModActor): ModView {
++ const manifestCheck = validateManifest(req.manifest)
++ if (!manifestCheck.ok) {
++ throw new ModError('mod.manifest_invalid', 'manifest is invalid', 422, manifestCheck.errors)
++ }
++ const manifest = manifestCheck.value
++ const runtimeErrors = checkForGames(manifest)
++ if (runtimeErrors.length > 0) {
++ throw new ModError('mod.runtime_unsupported', 'manifest cannot run here', 422, runtimeErrors)
++ }
++ const packCheck = validateContentPack(req.pack ?? {}, this.content)
++ if (!packCheck.ok) {
++ throw new ModError('mod.pack_invalid', 'content pack is invalid', 422, packCheck.errors)
++ }
++ const pack = packCheck.value
++ const requested = new Set(manifest.permissions.capabilities)
++ const unrequested = capabilitiesUsedBy(pack).filter((c) => !requested.has(c))
++ if (unrequested.length > 0) {
++ throw new ModError(
++ 'mod.pack_invalid',
++ 'the pack uses capabilities the manifest does not request',
++ 422,
++ unrequested.map((c) => `pack needs ${c}`),
++ )
++ }
++ const approve = parseCapabilityList(req.approve ?? [])
++ for (const cap of approve) this.assertGrantable(manifest, cap)
++ const trustTier = parseTrustTier(req.trustTier ?? 'unreviewed')
++ const enable = req.enable === true
++ if (this.views.has(manifest.id) || this.store.mods.get(manifest.id)) {
++ throw new ModError('mod.exists', `mod ${manifest.id} is already installed`, 409)
++ }
++
++ const at = this.now()
++ const mod: ModInstallDto = {
++ id: manifest.id,
++ name: manifest.name,
++ version: manifest.version,
++ target: manifest.target,
++ runtime: manifest.runtime,
++ manifest: manifest as unknown as Record<string, unknown>,
++ pack: pack as unknown as Record<string, unknown>,
++ trustTier,
++ status: enable ? 'enabled' : 'disabled',
++ installedBy: actor.audit,
++ installedAt: at,
++ updatedAt: at,
++ }
++ this.store.transaction(() => {
++ this.store.mods.insert(mod)
++ this.writeAudit(mod.id, 'install', null, actor, {
++ version: mod.version,
++ runtime: mod.runtime,
++ trust_tier: trustTier,
++ requested: [...requested].sort(),
++ })
++ for (const cap of approve) {
++ this.store.mods.upsertGrant(grantRow(mod.id, cap, actor, at))
++ this.writeAudit(mod.id, 'grant', cap, actor, null)
++ }
++ if (enable) this.writeAudit(mod.id, 'enable', null, actor, null)
++ this.emit('installed', mod, actor, {
++ requested: [...requested].sort(),
++ granted: [...approve].sort(),
++ status: mod.status,
++ })
++ })
++ return this.refresh(mod.id)
++ }
++
++ enable(id: string, actor: ModActor): ModView {
++ return this.setStatus(id, 'enabled', actor)
++ }
++
++ disable(id: string, actor: ModActor): ModView {
++ return this.setStatus(id, 'disabled', actor)
++ }
++
++ /** Ends the install for good: every grant is revoked and the mod stops at the next tick. */
++ revoke(id: string, actor: ModActor, reason?: string): ModView {
++ const view = this.require(id)
++ if (view.mod.status === 'revoked') return view
++ const at = this.now()
++ this.store.transaction(() => {
++ for (const cap of view.granted) {
++ this.store.mods.revokeGrant(id, cap, actor.audit, at)
++ this.writeAudit(id, 'revoke_grant', cap, actor, null)
++ }
++ this.store.mods.setStatus(id, 'revoked', at)
++ this.writeAudit(id, 'revoke', null, actor, reason ? { reason } : null)
++ this.emit('revoked', { ...view.mod, status: 'revoked' }, actor, {
++ revoked_grants: [...view.granted].sort(),
++ ...(reason ? { reason } : {}),
++ })
++ })
++ return this.refresh(id)
++ }
++
++ grant(id: string, capability: string, actor: ModActor): ModView {
++ const view = this.require(id)
++ if (view.mod.status === 'revoked') {
++ throw new ModError('mod.revoked', 'a revoked install cannot be granted anything', 409)
++ }
++ this.assertGrantable(view.manifest, capability)
++ if (view.granted.has(capability)) return view
++ const at = this.now()
++ this.store.transaction(() => {
++ this.store.mods.upsertGrant(grantRow(id, capability, actor, at))
++ this.writeAudit(id, 'grant', capability, actor, null)
++ this.emit('grants_changed', view.mod, actor, { granted: [capability], revoked: [] })
++ })
++ return this.refresh(id)
++ }
++
++ revokeGrant(id: string, capability: string, actor: ModActor): ModView {
++ const view = this.require(id)
++ const at = this.now()
++ let changed = false
++ this.store.transaction(() => {
++ changed = this.store.mods.revokeGrant(id, capability, actor.audit, at)
++ if (!changed) return
++ this.writeAudit(id, 'revoke_grant', capability, actor, null)
++ this.emit('grants_changed', view.mod, actor, { granted: [], revoked: [capability] })
++ })
++ return changed ? this.refresh(id) : view
++ }
++
++ private setStatus(id: string, status: ModStatus, actor: ModActor): ModView {
++ const view = this.require(id)
++ if (view.mod.status === 'revoked') {
++ throw new ModError('mod.revoked', 'a revoked install stays revoked; install a new one', 409)
++ }
++ if (view.mod.status === status) return view
++ const at = this.now()
++ this.store.transaction(() => {
++ this.store.mods.setStatus(id, status, at)
++ const action = status === 'enabled' ? 'enable' : 'disable'
++ this.writeAudit(id, action, null, actor, null)
++ this.emit(status === 'enabled' ? 'enabled' : 'disabled', view.mod, actor, {})
++ })
++ return this.refresh(id)
++ }
++
++ private assertGrantable(manifest: ModManifest, capability: string): void {
++ if (!manifest.permissions.capabilities.includes(capability)) {
++ throw new ModError('mod.grant_not_requested', `${capability} was not requested`, 422)
++ }
++ if (!CONTENT_RUNTIME_CAPABILITIES.includes(capability)) {
++ throw new ModError(
++ 'mod.grant_unsupported',
++ `${capability} has no binding in ${manifest.runtime}`,
++ 422,
++ )
++ }
++ }
++
++ private require(id: string): ModView {
++ const view = this.views.get(id)
++ if (!view) throw new ModError('mod.not_found', `no mod ${id}`, 404)
++ return view
++ }
++
++ private writeAudit(
++ id: string,
++ action: string,
++ capability: string | null,
++ actor: ModActor,
++ detail: Record<string, unknown> | null,
++ ): void {
++ this.store.mods.audit({
++ modId: id,
++ action,
++ capability,
++ actor: actor.audit,
++ detail,
++ at: this.now(),
++ })
++ }
++
++ private emit(
++ kind: ModLifecycle,
++ mod: ModInstallDto,
++ actor: ModActor,
++ detail: Record<string, unknown>,
++ ): void {
++ this.events.enqueue(modEvent(kind, mod, actor.subject, detail))
++ }
++
++ /** Re-reads one install after a committed change. */
++ private refresh(id: string): ModView {
++ const mod = this.store.mods.get(id)
++ if (!mod) {
++ this.views.delete(id)
++ throw new ModError('mod.not_found', `no mod ${id}`, 404)
++ }
++ const granted = new Set(
++ this.store.mods
++ .grants(id)
++ .filter((g: ModGrantDto) => g.revokedAt === null)
++ .map((g) => g.capability),
++ )
++ const view: ModView = {
++ mod,
++ manifest: mod.manifest as unknown as ModManifest,
++ pack: mod.pack as unknown as ContentPack,
++ granted,
++ }
++ this.views.set(id, view)
++ this.revision++
++ return view
++ }
++}
++
++function grantRow(modId: string, capability: string, actor: ModActor, at: number): ModGrantDto {
++ return {
++ modId,
++ capability,
++ grantedBy: actor.audit,
++ grantedAt: at,
++ revokedAt: null,
++ revokedBy: null,
++ }
++}
++
++function parseCapabilityList(value: unknown): string[] {
++ if (!Array.isArray(value) || value.some((v) => typeof v !== 'string') || value.length > 64) {
++ throw new ModError('mod.invalid_request', 'approve must be an array of capability ids', 400)
++ }
++ return [...new Set(value as string[])]
++}
++
++function parseTrustTier(value: unknown): ModTrustTier {
++ if (typeof value === 'string' && (TRUST_TIERS as readonly string[]).includes(value)) {
++ return value as ModTrustTier
++ }
++ throw new ModError(
++ 'mod.invalid_request',
++ `trustTier must be one of ${TRUST_TIERS.join(', ')}`,
++ 400,
++ )
++}
ddiff --git a/apps/server/src/mods/routes.test.ts b/apps/server/src/mods/routes.test.ts
nnew file mode 100644
iindex 0000000..c62e8fa
---- /dev/null
++++ b/apps/server/src/mods/routes.test.ts
@@@ -0,0 +1,166 @@
++import { createServer, type IncomingMessage, type Server } from 'node:http'
++import type { AddressInfo } from 'node:net'
++import { createContent } from '@openvibe/content'
++import { openSqliteStore } from '@openvibe/persistence/sqlite'
++import { createConsoleLogger } from '@openvibe/shared'
++import { verifyUserToken } from 'openvibe-sdk/auth'
++import { createMockPlatform } from 'openvibe-sdk/testing'
++import { afterEach, describe, expect, it } from 'vitest'
++import { createStaffAuthorizer } from '../platform/staffAuth.js'
++import { CAP_ANNOUNCE, CAP_PLACE_PROP } from './contentPack.js'
++import { ModRegistry, type ModActor } from './registry.js'
++import { handleModsRequest } from './routes.js'
++import { MOD_ID, sampleManifest } from './testFixtures.js'
++
++const log = createConsoleLogger({ app: 'test' }, 'error')
++const STAFF: ModActor = {
++ audit: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ subject: { type: 'user', id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' },
++}
++let server: Server | null = null
++
++afterEach(() => {
++ server?.close()
++ server = null
++})
++
++async function start(authorize: (req: IncomingMessage) => Promise<ModActor | null>) {
++ const registry = new ModRegistry(openSqliteStore(':memory:'), createContent())
++ server = createServer((req, res) => {
++ if (!handleModsRequest(req, res, { registry, authorize, log })) {
++ res.writeHead(404)
++ res.end()
++ }
++ })
++ await new Promise<void>((r) => server!.listen(0, '127.0.0.1', r))
++ const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/v1/mods`
++ return { registry, base }
++}
++
++const installBody = {
++ manifest: sampleManifest(),
++ pack: { props: [{ key: 'bench', item: 'workbench', pos: [0, 0, 0] }] },
++ approve: [CAP_PLACE_PROP],
++ enable: true,
++}
++
++describe('mods HTTP API', () => {
++ it('lets staff install, grant and revoke, and anyone read', async () => {
++ const { base } = await start(async (req) =>
++ req.headers.authorization === 'Bearer staff' ? STAFF : null,
++ )
++ const post = (path: string, body: unknown, auth = 'Bearer staff') =>
++ fetch(`${base}${path}`, {
++ method: 'POST',
++ headers: { authorization: auth, 'content-type': 'application/json' },
++ body: JSON.stringify(body),
++ })
++
++ const denied = await post('', installBody, 'Bearer nobody')
++ expect(denied.status).toBe(403)
++ expect(denied.headers.get('content-type')).toContain('application/problem+json')
++ expect(await denied.json()).toMatchObject({
++ code: 'capability.denied',
++ error: expect.any(String),
++ })
++
++ const created = await post('', installBody)
++ expect(created.status).toBe(201)
++ expect(await created.json()).toMatchObject({
++ id: MOD_ID,
++ status: 'enabled',
++ granted: [CAP_PLACE_PROP],
++ })
++
++ const list = (await (await fetch(base)).json()) as { mods: { id: string }[] }
++ expect(list.mods.map((m) => m.id)).toEqual([MOD_ID])
++ expect(await (await fetch(`${base}/${MOD_ID}`)).json()).toMatchObject({
++ manifest: { id: MOD_ID },
++ })
++
++ expect((await post(`/${MOD_ID}/grants`, { capability: CAP_ANNOUNCE })).status).toBe(200)
++ const revoked = await (await post(`/${MOD_ID}/revoke`, { reason: 'test' })).json()
++ expect(revoked).toMatchObject({ status: 'revoked', granted: [] })
++ expect((await post(`/${MOD_ID}/enable`, {})).status).toBe(409)
++
++ const audit = await fetch(`${base}/${MOD_ID}/audit`, {
++ headers: { authorization: 'Bearer staff' },
++ })
++ const actions = ((await audit.json()) as { audit: { action: string }[] }).audit.map(
++ (a) => a.action,
++ )
++ expect(actions).toEqual(expect.arrayContaining(['install', 'grant', 'revoke_grant', 'revoke']))
++ expect((await fetch(`${base}/${MOD_ID}/audit`)).status).toBe(403)
++ })
++
++ it('answers invalid manifests with problem+json and the reasons', async () => {
++ const { base } = await start(async () => STAFF)
++ const r = await fetch(base, {
++ method: 'POST',
++ headers: { 'content-type': 'application/json' },
++ body: JSON.stringify({ ...installBody, manifest: { ...sampleManifest(), id: 'nope' } }),
++ })
++ expect(r.status).toBe(422)
++ const body = (await r.json()) as { code: string; errors: string[] }
++ expect(body.code).toBe('mod.manifest_invalid')
++ expect(body.errors.join(' ')).toMatch(/\/id/)
++ const bad = await fetch(base, { method: 'POST', body: '{not json' })
++ expect(bad.status).toBe(400)
++ })
++})
++
++describe('staff authorization', () => {
++ it('accepts a principal token carrying games.mod.manage and nothing less', async () => {
++ const platform = createMockPlatform()
++ const authorize = createStaffAuthorizer({
++ networkAuthUrl: null,
++ networkUrl: platform.origins.network,
++ editorKey: null,
++ verifyPrincipal: (t) =>
++ verifyUserToken(t, {
++ jwks: platform.keys.jwks,
++ audience: 'openvibe.games',
++ allowServiceTokens: true,
++ }),
++ })
++ const req = (token: string) =>
++ ({
++ headers: { authorization: `Bearer ${token}` },
++ }) as unknown as IncomingMessage
++ const good = platform.signServiceToken('codes', {
++ audience: 'openvibe.games',
++ capabilities: ['games.mod.manage'],
++ })
++ expect(await authorize(req(good))).toEqual({
++ audit: 'svc:codes',
++ subject: { type: 'service', id: 'codes' },
++ })
++ const noCap = platform.signServiceToken('codes', {
++ audience: 'openvibe.games',
++ capabilities: ['games.mod.read'],
++ })
++ expect(await authorize(req(noCap))).toBeNull()
++ const wrongAud = platform.signServiceToken('codes', {
++ audience: 'openvibe.media',
++ capabilities: ['games.mod.manage'],
++ })
++ expect(await authorize(req(wrongAud))).toBeNull()
++ expect(await authorize(req('not-a-token'))).toBeNull()
++ })
++
++ it('falls back to the editor key only without a Network', async () => {
++ const authorize = createStaffAuthorizer({
++ networkAuthUrl: null,
++ networkUrl: 'http://x',
++ editorKey: 'k3y',
++ })
++ const withKey = {
++ headers: { 'x-editor-key': 'k3y' },
++ } as unknown as IncomingMessage
++ expect(await authorize(withKey)).toMatchObject({ audit: 'editor-key' })
++ const wrong = {
++ headers: { 'x-editor-key': 'nope' },
++ } as unknown as IncomingMessage
++ expect(await authorize(wrong)).toBeNull()
++ })
++})
ddiff --git a/apps/server/src/mods/routes.ts b/apps/server/src/mods/routes.ts
nnew file mode 100644
iindex 0000000..1170af7
---- /dev/null
++++ b/apps/server/src/mods/routes.ts
@@@ -0,0 +1,216 @@
++/**
++ * Mod registry HTTP API (JSON; errors are RFC 9457 problem+json with the
++ * legacy `error` field).
++ *
++ * GET /api/v1/mods public: installs, status, grants
++ * GET /api/v1/mods/:id public: one install incl. manifest + pack
++ * GET /api/v1/mods/:id/audit?limit= staff: install/grant/use/deny/revoke log
++ * POST /api/v1/mods staff: { manifest, pack, approve[], trustTier, enable }
++ * POST /api/v1/mods/:id/enable|disable staff
++ * POST /api/v1/mods/:id/revoke staff: { reason? } — terminal
++ * POST /api/v1/mods/:id/grants staff: { capability }
++ * DELETE /api/v1/mods/:id/grants/:capability staff
++ *
++ * Staff = an openvibe.network owner/admin session, or a service principal
++ * whose token (audience openvibe.games) carries `games.mod.manage`.
++ */
++import type { IncomingMessage, ServerResponse } from 'node:http'
++import { createRequire } from 'node:module'
++import type { Logger } from '@openvibe/shared'
++import { ModError, type ModActor, type ModRegistry, type ModView } from './registry.js'
++
++interface ContractsHttp {
++ http: {
++ sendProblem(
++ res: ServerResponse,
++ status: number,
++ code: string,
++ opts?: { detail?: string; extra?: Record<string, unknown> },
++ ): unknown
++ }
++}
++const contracts = createRequire(import.meta.url)('openvibe-contracts') as ContractsHttp
++
++export const MODS_PREFIX = '/api/v1/mods'
++const MAX_BODY_BYTES = 256 * 1024
++
++export interface ModsApi {
++ registry: ModRegistry
++ /** Resolves the staff actor behind a request, or null. */
++ authorize(req: IncomingMessage): Promise<ModActor | null>
++ log: Logger
++}
++
++export function publicView(v: ModView): Record<string, unknown> {
++ return {
++ id: v.mod.id,
++ name: v.mod.name,
++ version: v.mod.version,
++ target: v.mod.target,
++ runtime: v.mod.runtime,
++ trust_tier: v.mod.trustTier,
++ status: v.mod.status,
++ requested: [...v.manifest.permissions.capabilities].sort(),
++ granted: [...v.granted].sort(),
++ installed_at: new Date(v.mod.installedAt).toISOString(),
++ updated_at: new Date(v.mod.updatedAt).toISOString(),
++ }
++}
++
++function json(res: ServerResponse, status: number, body: unknown): void {
++ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
++ res.end(JSON.stringify(body))
++}
++
++function problem(
++ res: ServerResponse,
++ status: number,
++ code: string,
++ detail: string,
++ errors?: string[],
++) {
++ contracts.http.sendProblem(res, status, code, {
++ detail,
++ ...(errors && errors.length > 0 ? { extra: { errors: errors.slice(0, 50) } } : {}),
++ })
++}
++
++function readJson(
++ req: IncomingMessage,
++): Promise<
++ { ok: true; body: Record<string, unknown> } | { ok: false; status: number; code: string }
++> {
++ return new Promise((resolve) => {
++ const chunks: Buffer[] = []
++ let size = 0
++ let done = false
++ const finish = (r: Parameters<typeof resolve>[0]) => {
++ if (!done) {
++ done = true
++ resolve(r)
++ }
++ }
++ req.on('data', (c: Buffer) => {
++ size += c.length
++ if (size > MAX_BODY_BYTES) {
++ finish({ ok: false, status: 413, code: 'request.too_large' })
++ req.destroy()
++ } else chunks.push(c)
++ })
++ req.on('end', () => {
++ const raw = Buffer.concat(chunks).toString('utf8')
++ if (raw.trim() === '') return finish({ ok: true, body: {} })
++ try {
++ const parsed: unknown = JSON.parse(raw)
++ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
++ finish({ ok: false, status: 400, code: 'request.malformed_json' })
++ } else finish({ ok: true, body: parsed as Record<string, unknown> })
++ } catch {
++ finish({ ok: false, status: 400, code: 'request.malformed_json' })
++ }
++ })
++ req.on('error', () => finish({ ok: false, status: 400, code: 'request.aborted' }))
++ })
++}
++
++/** Returns true when the request was a mods API request (handled or answered). */
++export function handleModsRequest(
++ req: IncomingMessage,
++ res: ServerResponse,
++ api: ModsApi,
++): boolean {
++ const url = new URL(req.url ?? '/', 'http://games.invalid')
++ const path = url.pathname
++ if (path !== MODS_PREFIX && !path.startsWith(`${MODS_PREFIX}/`)) return false
++ let parts: string[]
++ try {
++ parts = path.slice(MODS_PREFIX.length).split('/').filter(Boolean).map(decodeURIComponent)
++ } catch {
++ problem(res, 400, 'request.malformed_path', 'bad path encoding')
++ return true
++ }
++ const method = req.method ?? 'GET'
++
++ // Public reads.
++ if (method === 'GET' && parts.length === 0) {
++ json(res, 200, { mods: api.registry.list().map(publicView) })
++ return true
++ }
++ if (method === 'GET' && parts.length === 1) {
++ const view = api.registry.get(parts[0] ?? '')
++ if (!view) problem(res, 404, 'mod.not_found', 'no such mod')
++ else json(res, 200, { ...publicView(view), manifest: view.manifest, pack: view.pack })
++ return true
++ }
++
++ void (async () => {
++ const actor = await api.authorize(req)
++ if (!actor) {
++ req.resume()
++ problem(res, 403, 'capability.denied', 'games.mod.manage required')
++ return
++ }
++ const id = parts[0] ?? ''
++ if (method === 'GET' && parts.length === 2 && parts[1] === 'audit') {
++ const limit = Number(url.searchParams.get('limit') ?? 100)
++ if (!api.registry.get(id)) return problem(res, 404, 'mod.not_found', 'no such mod')
++ json(res, 200, { audit: api.registry.auditLog(id, Number.isFinite(limit) ? limit : 100) })
++ return
++ }
++ if (method === 'DELETE' && parts.length === 3 && parts[1] === 'grants') {
++ req.resume()
++ json(res, 200, publicView(api.registry.revokeGrant(id, parts[2] ?? '', actor)))
++ return
++ }
++ if (method !== 'POST') return problem(res, 405, 'request.method_not_allowed', method)
++ const body = await readJson(req)
++ if (!body.ok) return problem(res, body.status, body.code, 'request body must be a JSON object')
++ const b = body.body
++ if (parts.length === 0) {
++ const view = api.registry.install(
++ {
++ manifest: b.manifest,
++ pack: b.pack,
++ approve: b.approve,
++ trustTier: b.trustTier,
++ enable: b.enable,
++ },
++ actor,
++ )
++ api.log.info('mod installed', { mod: view.mod.id, by: actor.audit, status: view.mod.status })
++ json(res, 201, publicView(view))
++ return
++ }
++ if (parts.length === 2 && (parts[1] === 'enable' || parts[1] === 'disable')) {
++ const view =
++ parts[1] === 'enable' ? api.registry.enable(id, actor) : api.registry.disable(id, actor)
++ api.log.info(`mod ${parts[1]}d`, { mod: id, by: actor.audit })
++ json(res, 200, publicView(view))
++ return
++ }
++ if (parts.length === 2 && parts[1] === 'revoke') {
++ const reason = typeof b.reason === 'string' ? b.reason.slice(0, 500) : undefined
++ const view = api.registry.revoke(id, actor, reason)
++ api.log.info('mod revoked', { mod: id, by: actor.audit })
++ json(res, 200, publicView(view))
++ return
++ }
++ if (parts.length === 2 && parts[1] === 'grants') {
++ if (typeof b.capability !== 'string') {
++ return problem(res, 400, 'mod.invalid_request', 'capability is required')
++ }
++ json(res, 200, publicView(api.registry.grant(id, b.capability, actor)))
++ return
++ }
++ problem(res, 404, 'request.not_found', 'no such mods route')
++ })().catch((err: unknown) => {
++ if (res.headersSent) return
++ if (err instanceof ModError) {
++ problem(res, err.status, err.code, err.message, err.errors)
++ return
++ }
++ api.log.warn('mods api failed', { error: String(err) })
++ problem(res, 500, 'mod.internal', 'the mod registry failed')
++ })
++ return true
++}
ddiff --git a/apps/server/src/mods/runtime.test.ts b/apps/server/src/mods/runtime.test.ts
nnew file mode 100644
iindex 0000000..8e57365
---- /dev/null
++++ b/apps/server/src/mods/runtime.test.ts
@@@ -0,0 +1,277 @@
++import { mkdtempSync } from 'node:fs'
++import { tmpdir } from 'node:os'
++import { join } from 'node:path'
++import { createContent } from '@openvibe/content'
++import { openSqliteStore, type SqlitePersistenceStore } from '@openvibe/persistence/sqlite'
++import { createConsoleLogger } from '@openvibe/shared'
++import { describe, expect, it } from 'vitest'
++import type { EventSink } from '../platform/gameEvents.js'
++import { CAP_ANNOUNCE, CAP_PLACE_PROP } from './contentPack.js'
++import { ModError, ModRegistry, type ModActor } from './registry.js'
++import { ModCapabilityError, ModRuntime, type ModHost } from './runtime.js'
++import { MOD_ID, sampleManifest } from './testFixtures.js'
++
++const STAFF: ModActor = {
++ audit: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ subject: { type: 'user', id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' },
++}
++const content = createContent()
++const log = createConsoleLogger({ app: 'test' }, 'error')
++
++const PACK = {
++ announcements: [{ text: 'Welcome to the square', everySeconds: 60 }],
++ props: [
++ { key: 'bench', item: 'workbench', pos: [10, 0, 10] as [number, number, number], yaw: 0 },
++ { key: 'fire', item: 'campfire', pos: [12, 0, 10] as [number, number, number] },
++ ],
++}
++
++/** The world as the runtime sees it: a map of entities plus what was said. */
++function fakeHost() {
++ const entities = new Map<string, { item: string; owner: string }>()
++ const said: string[] = []
++ let n = 0
++ const host: ModHost = {
++ announce: (text) => said.push(text),
++ placeProp: ({ item, owner }) => {
++ const id = `ent_${++n}`
++ entities.set(id, { item, owner })
++ return id
++ },
++ removeEntity: (id) => entities.delete(id),
++ entityExists: (id) => entities.has(id),
++ }
++ return { host, entities, said }
++}
++
++function captureSink(): EventSink & { types: string[] } {
++ const types: string[] = []
++ return { enabled: true, types, enqueue: (e) => types.push(e.event_type) }
++}
++
++function setup(store: SqlitePersistenceStore = openSqliteStore(':memory:')) {
++ let now = 1_000_000
++ const clock = { now: () => now, advance: (ms: number) => (now += ms) }
++ const sink = captureSink()
++ const registry = new ModRegistry(store, content, sink, clock.now)
++ const runtime = new ModRuntime(registry, store, content, log, {
++ reconcileEveryTicks: 30,
++ now: clock.now,
++ })
++ return { store, registry, runtime, sink, clock }
++}
++
++describe('mod registry', () => {
++ it('installs with the approved subset only and audits install + grants', () => {
++ const { registry, sink } = setup()
++ const view = registry.install(
++ { manifest: sampleManifest(), pack: PACK, approve: [CAP_PLACE_PROP], enable: true },
++ STAFF,
++ )
++ expect([...view.granted]).toEqual([CAP_PLACE_PROP])
++ expect(registry.isGranted(MOD_ID, CAP_PLACE_PROP)).toBe(true)
++ expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)
++ expect(
++ registry
++ .auditLog(MOD_ID)
++ .map((a) => [a.action, a.capability])
++ .reverse(),
++ ).toEqual([
++ ['install', null],
++ ['grant', CAP_PLACE_PROP],
++ ['enable', null],
++ ])
++ expect(sink.types).toEqual(['games.mod.installed'])
++ })
++
++ it('refuses grants that were not requested or have no binding', () => {
++ const { registry } = setup()
++ const manifest = sampleManifest({
++ permissions: { capabilities: [CAP_ANNOUNCE, 'media.object.read'] },
++ })
++ expect(() =>
++ registry.install({ manifest, pack: {}, approve: [CAP_PLACE_PROP] }, STAFF),
++ ).toThrow(ModError)
++ expect(() =>
++ registry.install({ manifest, pack: {}, approve: ['media.object.read'] }, STAFF),
++ ).toThrow(/no binding/)
++ // A pack section needs its capability to at least be requested.
++ expect(() =>
++ registry.install({ manifest, pack: { props: PACK.props }, approve: [] }, STAFF),
++ ).toThrow(/does not request/)
++ })
++
++ it('never lets a trust tier stand in for a grant', () => {
++ const { registry } = setup()
++ registry.install(
++ {
++ manifest: sampleManifest(),
++ pack: PACK,
++ approve: [],
++ trustTier: 'first-party',
++ enable: true,
++ },
++ STAFF,
++ )
++ expect(registry.get(MOD_ID)?.mod.trustTier).toBe('first-party')
++ expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)
++ expect(registry.isGranted(MOD_ID, CAP_PLACE_PROP)).toBe(false)
++ })
++
++ it('refuses executable runtimes until sandboxed execution exists', () => {
++ const { registry } = setup()
++ expect(() =>
++ registry.install(
++ { manifest: sampleManifest({ runtime: 'source-quickjs@1' }), pack: {}, approve: [] },
++ STAFF,
++ ),
++ ).toThrow(/cannot run here/)
++ })
++})
++
++describe('mod runtime seam', () => {
++ it('a denied capability cannot be reached: the binding throws and the host is never called', () => {
++ const { registry, runtime, clock } = setup()
++ registry.install(
++ { manifest: sampleManifest(), pack: PACK, approve: [CAP_PLACE_PROP], enable: true },
++ STAFF,
++ )
++ const { host, said } = fakeHost()
++ const api = runtime.bindingsFor(MOD_ID, host)
++ expect(() => api.announce('hello')).toThrow(ModCapabilityError)
++ expect(said).toEqual([])
++ // The API object is all a mod gets: frozen, no host or registry on it.
++ expect(Object.isFrozen(api)).toBe(true)
++ expect(Object.keys(api).sort()).toEqual(['announce', 'modId', 'placeProp', 'removeProp'])
++ expect(() => {
++ ;(api as unknown as Record<string, unknown>).announce = () => host.announce('bypass')
++ }).toThrow(TypeError)
++ expect(() => api.announce('hello')).toThrow(ModCapabilityError)
++ expect(said).toEqual([])
++
++ // The pack's announcements are denied every time they come due, and audited once.
++ runtime.tick(1, host)
++ for (let t = 2; t < 400; t++) {
++ clock.advance(10_000)
++ runtime.tick(t, host)
++ }
++ expect(said).toEqual([])
++ const denials = registry.auditLog(MOD_ID).filter((a) => a.action === 'deny')
++ expect(denials.map((a) => a.capability)).toEqual([CAP_ANNOUNCE])
++ })
++
++ it('places the pack, and a revoked mod stops affecting the world on the next tick', () => {
++ const { registry, runtime, clock, sink } = setup()
++ registry.install(
++ {
++ manifest: sampleManifest(),
++ pack: PACK,
++ approve: [CAP_PLACE_PROP, CAP_ANNOUNCE],
++ enable: true,
++ },
++ STAFF,
++ )
++ const { host, entities, said } = fakeHost()
++ runtime.tick(1, host)
++ expect([...entities.values()]).toEqual([
++ { item: 'workbench', owner: MOD_ID },
++ { item: 'campfire', owner: MOD_ID },
++ ])
++ clock.advance(61_000)
++ runtime.tick(31, host)
++ expect(said).toEqual(['[Town Square] Welcome to the square'])
++
++ registry.revoke(MOD_ID, STAFF, 'test')
++ runtime.tick(32, host) // the very next tick
++ expect(entities.size).toBe(0)
++ clock.advance(600_000)
++ for (let t = 33; t < 200; t++) runtime.tick(t, host)
++ expect(said).toHaveLength(1)
++ expect(entities.size).toBe(0)
++ expect(() => runtime.bindingsFor(MOD_ID, host).placeProp('x', 'workbench', [0, 0, 0])).toThrow(
++ ModCapabilityError,
++ )
++ // Revoked is terminal.
++ expect(() => registry.enable(MOD_ID, STAFF)).toThrow(/stays revoked/)
++ expect(() => registry.grant(MOD_ID, CAP_ANNOUNCE, STAFF)).toThrow(/revoked/)
++
++ const actions = registry.auditLog(MOD_ID).map((a) => a.action)
++ for (const a of ['install', 'grant', 'use', 'revoke_grant', 'revoke', 'retract']) {
++ expect(actions, a).toContain(a)
++ }
++ expect(sink.types).toEqual(['games.mod.installed', 'games.mod.revoked'])
++ })
++
++ it('revoking one capability retracts only what it allowed', () => {
++ const { registry, runtime, sink } = setup()
++ registry.install(
++ {
++ manifest: sampleManifest(),
++ pack: PACK,
++ approve: [CAP_PLACE_PROP, CAP_ANNOUNCE],
++ enable: true,
++ },
++ STAFF,
++ )
++ const { host, entities } = fakeHost()
++ runtime.tick(1, host)
++ expect(entities.size).toBe(2)
++ registry.revokeGrant(MOD_ID, CAP_PLACE_PROP, STAFF)
++ runtime.tick(2, host)
++ expect(entities.size).toBe(0)
++ expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(true)
++ registry.grant(MOD_ID, CAP_PLACE_PROP, STAFF)
++ runtime.tick(3, host)
++ expect(entities.size).toBe(2)
++ expect(sink.types).toEqual([
++ 'games.mod.installed',
++ 'games.mod.grants_changed',
++ 'games.mod.grants_changed',
++ ])
++ })
++
++ it('disable retracts, enable restores, and a missing prop is placed again', () => {
++ const { registry, runtime } = setup()
++ registry.install(
++ { manifest: sampleManifest(), pack: PACK, approve: [CAP_PLACE_PROP], enable: true },
++ STAFF,
++ )
++ const { host, entities } = fakeHost()
++ runtime.tick(1, host)
++ registry.disable(MOD_ID, STAFF)
++ runtime.tick(2, host)
++ expect(entities.size).toBe(0)
++ registry.enable(MOD_ID, STAFF)
++ runtime.tick(3, host)
++ expect(entities.size).toBe(2)
++ const [first] = [...entities.keys()]
++ entities.delete(first!)
++ runtime.tick(4, host) // not a registry change: waits for the once-a-second reconcile
++ expect(entities.size).toBe(1)
++ runtime.tick(3 + 30, host)
++ expect(entities.size).toBe(2)
++ })
++
++ it('revocation survives a restart: the next boot retracts what the old process left', () => {
++ const dir = mkdtempSync(join(tmpdir(), 'openvibe-mods-'))
++ const path = join(dir, 'world.db')
++ const a = setup(openSqliteStore(path))
++ a.registry.install(
++ { manifest: sampleManifest(), pack: PACK, approve: [CAP_PLACE_PROP], enable: true },
++ STAFF,
++ )
++ const world = fakeHost()
++ a.runtime.tick(1, world.host)
++ expect(world.entities.size).toBe(2)
++ a.registry.revoke(MOD_ID, STAFF)
++ // The process dies before its next tick: the props are still in the world.
++ a.store.close()
++ expect(world.entities.size).toBe(2)
++
++ const b = setup(openSqliteStore(path))
++ expect(b.registry.get(MOD_ID)?.mod.status).toBe('revoked')
++ b.runtime.tick(1, world.host)
++ expect(world.entities.size).toBe(0)
++ b.store.close()
++ })
++})
ddiff --git a/apps/server/src/mods/runtime.ts b/apps/server/src/mods/runtime.ts
nnew file mode 100644
iindex 0000000..84a56ca
---- /dev/null
++++ b/apps/server/src/mods/runtime.ts
@@@ -0,0 +1,249 @@
++/**
++ * The mod runtime seam.
++ *
++ * A mod never touches the game server, the world or the database. It gets a
++ * `ModApi`: a frozen object of bindings, one per runtime capability, and
++ * every binding checks the registry AT CALL TIME. A capability the install
++ * was not granted — or one revoked a moment ago — throws before anything
++ * happens, so it cannot be reached from the mod side at all.
++ *
++ * `games-content@1` packs are interpreted here through that same API; an
++ * executable runtime (a script sandbox) would receive the same bindings.
++ * Executable mods are not accepted yet: sandboxed execution with enforced
++ * CPU, memory, storage and network budgets belongs to OpenVibe.Host
++ * (Stage C). Until then the manifest's resource budget is metered, not
++ * enforced by a sandbox.
++ *
++ * Each tick the runtime reconciles when the registry changed (and once a
++ * second otherwise): an inactive mod, or one that lost a capability, has
++ * its effects retracted — its props despawned, its announcements stopped —
++ * on that very tick.
++ */
++import type { ContentRegistry } from '@openvibe/content'
++import type { PersistenceStore } from '@openvibe/persistence'
++import type { Logger } from '@openvibe/shared'
++import { CAP_ANNOUNCE, CAP_PLACE_PROP, placeableByMod } from './contentPack.js'
++import type { ModRegistry, ModView } from './registry.js'
++
++/** What the game server lends the runtime. Only the runtime holds it. */
++export interface ModHost {
++ announce(text: string): void
++ /** Spawns an inert prop owned by `owner`; returns its entity id. */
++ placeProp(spec: {
++ item: string
++ pos: [number, number, number]
++ yaw: number
++ owner: string
++ }): string | null
++ removeEntity(entityId: string): boolean
++ entityExists(entityId: string): boolean
++}
++
++/** The bindings a mod gets. Nothing else is reachable from a mod. */
++export interface ModApi {
++ readonly modId: string
++ /** games.world.announce */
++ announce(text: string): void
++ /** games.prop.place: places (or re-places) the prop for `key`. */
++ placeProp(key: string, item: string, pos: [number, number, number], yaw?: number): string | null
++ /** games.prop.place: removes the prop for `key`. */
++ removeProp(key: string): boolean
++}
++
++export class ModCapabilityError extends Error {
++ readonly code = 'capability.denied'
++ constructor(
++ readonly modId: string,
++ readonly capability: string,
++ ) {
++ super(`${modId} is not granted ${capability}`)
++ }
++}
++
++export interface ModRuntimeOptions {
++ /** Reconcile at least this often when nothing changed (ticks). */
++ reconcileEveryTicks: number
++ now?: () => number
++}
++
++export class ModRuntime {
++ private seenVersion = -1
++ private nextTick = 0
++ /** modId -> last announcement time per announcement index. */
++ private readonly announced = new Map<string, number[]>()
++ /** `${modId}|${cap}` denials already audited since the last registry change. */
++ private readonly deniedNoted = new Set<string>()
++ private readonly budgetNotedAt = new Map<string, number>()
++ private readonly now: () => number
++
++ constructor(
++ private readonly registry: ModRegistry,
++ private readonly store: PersistenceStore,
++ private readonly content: ContentRegistry,
++ private readonly log: Logger,
++ private readonly opts: ModRuntimeOptions,
++ ) {
++ this.now = opts.now ?? Date.now
++ }
++
++ /**
++ * The mod-facing API for one install. Frozen, and closed over nothing a
++ * mod could use to reach the host except through a checked binding.
++ */
++ bindingsFor(modId: string, host: ModHost): ModApi {
++ const check = (capability: string) => {
++ if (!this.registry.isGranted(modId, capability)) {
++ throw new ModCapabilityError(modId, capability)
++ }
++ }
++ const name = () => this.registry.get(modId)?.mod.name ?? modId
++ const api: ModApi = {
++ modId,
++ announce: (text: string) => {
++ check(CAP_ANNOUNCE)
++ const clean = String(text).replace(/\s+/g, ' ').trim().slice(0, 200)
++ if (!clean) return
++ host.announce(`[${name()}] ${clean}`)
++ this.registry.audit(modId, 'use', CAP_ANNOUNCE, { chars: clean.length })
++ },
++ placeProp: (key, item, pos, yaw = 0) => {
++ check(CAP_PLACE_PROP)
++ const problem = placeableByMod(this.content, item)
++ if (problem) throw new Error(problem)
++ if (!pos.every((v) => Number.isFinite(v)) || !Number.isFinite(yaw)) {
++ throw new Error('position must be finite')
++ }
++ const existing = this.store.mods.placements(modId).find((p) => p.key === key)
++ if (existing?.entityId && host.entityExists(existing.entityId)) {
++ host.removeEntity(existing.entityId)
++ }
++ const entityId = host.placeProp({ item, pos: [pos[0], pos[1], pos[2]], yaw, owner: modId })
++ this.store.mods.setPlacement({ modId, key, entityId, at: this.now() })
++ this.registry.audit(modId, 'use', CAP_PLACE_PROP, { key, item, entity: entityId })
++ return entityId
++ },
++ removeProp: (key) => {
++ check(CAP_PLACE_PROP)
++ const existing = this.store.mods.placements(modId).find((p) => p.key === key)
++ if (!existing) return false
++ if (existing.entityId) host.removeEntity(existing.entityId)
++ this.store.mods.deletePlacement(modId, key)
++ this.registry.audit(modId, 'use', CAP_PLACE_PROP, { key, removed: true })
++ return true
++ },
++ }
++ return Object.freeze(api)
++ }
++
++ tick(tick: number, host: ModHost): void {
++ const changed = this.registry.version !== this.seenVersion
++ if (!changed && tick < this.nextTick) return
++ if (changed) this.deniedNoted.clear()
++ this.seenVersion = this.registry.version
++ this.nextTick = tick + this.opts.reconcileEveryTicks
++ for (const view of this.registry.list()) {
++ try {
++ if (!this.registry.isActive(view.mod.id)) {
++ this.retractProps(view.mod.id, host)
++ this.announced.delete(view.mod.id)
++ continue
++ }
++ const started = performance.now()
++ this.runContentPack(view, host)
++ this.meter(view, performance.now() - started)
++ } catch (err) {
++ // One broken mod never stops the tick or the other mods.
++ this.log.warn('mod tick failed', { mod: view.mod.id, error: String(err) })
++ }
++ }
++ }
++
++ private runContentPack(view: ModView, host: ModHost): void {
++ const id = view.mod.id
++ const api = this.bindingsFor(id, host)
++
++ // Props: present exactly while games.prop.place is granted.
++ const props = view.pack.props ?? []
++ if (!this.registry.isGranted(id, CAP_PLACE_PROP)) {
++ this.retractProps(id, host)
++ if (props.length > 0) this.noteDenied(id, CAP_PLACE_PROP)
++ } else {
++ const placed = new Map(this.store.mods.placements(id).map((p) => [p.key, p]))
++ const wanted = new Set(props.map((p) => p.key))
++ for (const prop of props) {
++ const p = placed.get(prop.key)
++ if (p?.entityId && host.entityExists(p.entityId)) continue
++ this.attempt(id, () => api.placeProp(prop.key, prop.item, prop.pos, prop.yaw ?? 0))
++ }
++ // Placements the pack no longer lists (an older version) go away.
++ for (const [key] of placed) {
++ if (!wanted.has(key)) this.attempt(id, () => api.removeProp(key))
++ }
++ }
++
++ // Announcements: on their own cadence, only while granted.
++ const announcements = view.pack.announcements ?? []
++ if (announcements.length === 0) return
++ if (!this.registry.isGranted(id, CAP_ANNOUNCE)) {
++ this.announced.delete(id)
++ this.noteDenied(id, CAP_ANNOUNCE)
++ return
++ }
++ const nowMs = this.now()
++ let last = this.announced.get(id)
++ if (!last) {
++ // First sight of an active mod: the first announcement comes one
++ // interval later, so restarts do not replay every announcement.
++ last = announcements.map(() => nowMs)
++ this.announced.set(id, last)
++ return
++ }
++ announcements.forEach((a, i) => {
++ if (nowMs - (last[i] ?? nowMs) < a.everySeconds * 1000) return
++ last[i] = nowMs
++ this.attempt(id, () => api.announce(a.text))
++ })
++ }
++
++ private attempt(id: string, fn: () => unknown): void {
++ try {
++ fn()
++ } catch (err) {
++ if (err instanceof ModCapabilityError) this.noteDenied(id, err.capability)
++ else throw err
++ }
++ }
++
++ /** Despawns everything the mod placed. */
++ private retractProps(id: string, host: ModHost): void {
++ const placements = this.store.mods.placements(id)
++ if (placements.length === 0) return
++ let removed = 0
++ for (const p of placements) {
++ if (p.entityId && host.removeEntity(p.entityId)) removed++
++ this.store.mods.deletePlacement(id, p.key)
++ }
++ this.registry.audit(id, 'retract', CAP_PLACE_PROP, { placements: placements.length, removed })
++ this.log.info('mod props retracted', { mod: id, removed })
++ }
++
++ private noteDenied(id: string, capability: string): void {
++ const key = `${id}|${capability}`
++ if (this.deniedNoted.has(key)) return
++ this.deniedNoted.add(key)
++ this.registry.audit(id, 'deny', capability)
++ }
++
++ /** Meters the declared per-tick CPU budget (enforcement: OpenVibe.Host, Stage C). */
++ private meter(view: ModView, ms: number): void {
++ const budget = view.manifest.resources.cpuMs
++ if (ms <= budget) return
++ const nowMs = this.now()
++ if (nowMs - (this.budgetNotedAt.get(view.mod.id) ?? 0) < 60_000) return
++ this.budgetNotedAt.set(view.mod.id, nowMs)
++ this.registry.audit(view.mod.id, 'budget_exceeded', null, {
++ cpu_ms: Math.round(ms * 1000) / 1000,
++ budget_ms: budget,
++ })
++ }
++}
ddiff --git a/apps/server/src/mods/testFixtures.ts b/apps/server/src/mods/testFixtures.ts
nnew file mode 100644
iindex 0000000..a4bb6e6
---- /dev/null
++++ b/apps/server/src/mods/testFixtures.ts
@@@ -0,0 +1,20 @@
++/** Shared test fixtures for the mod platform (not used at runtime). */
++import type { ModManifest } from './manifest.js'
++
++export const MOD_ID = 'mod_01JABCDEFGHJKMNPQRSTVWXYZ0'
++
++export function sampleManifest(overrides: Partial<ModManifest> = {}): ModManifest {
++ return {
++ id: MOD_ID,
++ name: 'Town Square',
++ version: '1.0.0',
++ description: 'A public workbench and a greeting.',
++ publisher: { type: 'user', id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' },
++ target: 'games.browser',
++ runtime: 'games-content@1',
++ permissions: { capabilities: ['games.world.announce', 'games.prop.place'] },
++ resources: { cpuMs: 1, memoryMb: 0, storageMb: 0 },
++ compatibility: { runtime: '>=1.0.0 <2.0.0' },
++ ...overrides,
++ }
++}
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex 986eaac..305dbc7 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -102,6 +102,14 @@ function readBody(req: IncomingMessage, limit: number): Promise<string | null> {
})
}
++/** Platform seams (roadmap Wave 12): extra API routes and a stored-asset hook. */
++export interface HttpPlatformHooks {
++ /** Answers a request it owns (returns true), e.g. the mod registry API. */
++ handle?: (req: IncomingMessage, res: ServerResponse) => boolean
++ /** A map asset was stored (or found already stored) locally. */
++ onAssetStored?: (asset: { hash: string; url: string; bytes: number; mime: string }) => void
++}
++
/** The token endpoint's answer, for both the code and the jwt-bearer grant. */
interface TokenResponse {
access_token?: string
@@@ -124,6 +132,7 @@ export function createHttpServer(
auth: string | undefined,
) => Promise<{ slot: number; name: string; appearance: unknown }[]>,
oauth?: OAuthConfig | null,
++ platform?: HttpPlatformHooks,
): Server {
const root = staticDir ? resolve(staticDir) : null
// Sessions the Network confirmed recently: lets a silent login on a signed-in
@@@ -135,6 +144,7 @@ export function createHttpServer(
// (the apex root is the games portal). Everything else — /assets,
// /map.json, /api, /auth, websockets — behaves identically on both.
const playHost = (req.headers.host ?? '').toLowerCase().startsWith('play.')
++ if (platform?.handle?.(req, res)) return
if (url === '/map.json' && mapPath) {
// The canonical v2 document, with its revision as an ETag. This IS the
// wire format: there is no v1 projection any more.
@@@ -255,6 +265,7 @@ export function createHttpServer(
res.end(JSON.stringify({ error: outcome.error }))
return
}
++ platform?.onAssetStored?.(outcome.asset)
log.info('map asset stored', {
hash: outcome.asset.hash,
bytes: outcome.asset.bytes,
@@@ -523,7 +534,8 @@ export function createHttpServer(
log.warn('fedcm exchange rejected', { error: data.error ?? 'no_token' })
json(401, {
error: data.error ?? 'invalid_grant',
-- error_description: data.error_description ?? 'openvibe.network rejected the assertion',
++ error_description:
++ data.error_description ?? 'openvibe.network rejected the assertion',
})
return
}
ddiff --git a/apps/server/src/net/networkAuth.ts b/apps/server/src/net/networkAuth.ts
iindex 8fe4540..77be5d0 100644
---- a/apps/server/src/net/networkAuth.ts
++++ b/apps/server/src/net/networkAuth.ts
@@@ -8,9 +8,39 @@
export type OpenVibeRank = 'owner' | 'admin' | 'moderator' | null
export interface NetworkUser {
++ /** The Network's own account id (legacy; integer today). */
id: string
name: string
rank: OpenVibeRank
++ /**
++ * Canonical subject (`usr_…`, or `gst_…` for a Network guest) from the
++ * token's `subject_id` claim; null only for tokens older than subjects.
++ */
++ subjectId: string | null
++}
++
++/** A canonical openvibe.network subject id a Games account may be keyed by. */
++const SUBJECT_ID = /^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/
++
++export function isCanonicalSubject(value: unknown): value is string {
++ return typeof value === 'string' && SUBJECT_ID.test(value)
++}
++
++/**
++ * Reads the `subject_id` claim of a JWT the Network has ALREADY accepted
++ * (via /api/auth/me). Never used to trust a token on its own.
++ */
++function subjectClaim(token: string): string | null {
++ const parts = token.split('.')
++ if (parts.length !== 3) return null
++ try {
++ const claims = JSON.parse(Buffer.from(parts[1] ?? '', 'base64url').toString('utf8')) as {
++ subject_id?: unknown
++ }
++ return isCanonicalSubject(claims.subject_id) ? claims.subject_id : null
++ } catch {
++ return null
++ }
}
const OWNER_USERNAME = (process.env.OWNER_USERNAME ?? 'goosely').toLowerCase()
@@@ -21,6 +51,8 @@ export interface NetworkAccount {
username?: string
role?: string
is_banned?: number
++ /** Canonical subject (Wave 1); mirrors the token's `subject_id` claim. */
++ subject_id?: string
[key: string]: unknown
}
@@@ -59,7 +91,12 @@ export async function resolveNetworkUser(
if (username.toLowerCase() === OWNER_USERNAME) rank = 'owner'
else if (u.role === 'admin') rank = 'admin'
else if (u.role === 'moderator' || u.role === 'global_mod') rank = 'moderator'
-- return { id: String(id), name: username, rank }
++ const subjectId = isCanonicalSubject(u.subject_id)
++ ? u.subject_id
++ : auth
++ ? subjectClaim(auth)
++ : null
++ return { id: String(id), name: username, rank, subjectId }
}
export function canEditMap(rank: OpenVibeRank): boolean {
ddiff --git a/apps/server/src/platform/accounts.test.ts b/apps/server/src/platform/accounts.test.ts
nnew file mode 100644
iindex 0000000..aca8ea2
---- /dev/null
++++ b/apps/server/src/platform/accounts.test.ts
@@@ -0,0 +1,107 @@
++import { openSqliteStore } from '@openvibe/persistence/sqlite'
++import type { PlayerDto } from '@openvibe/persistence'
++import { describe, expect, it } from 'vitest'
++import { accountForNetworkUser, isGuestToken, legacyNetworkKey } from './accounts.js'
++
++const SUBJECT = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'
++
++function legacyChar(id: string, token: string, slot: number): PlayerDto {
++ return {
++ id,
++ token,
++ name: id,
++ pos: [4, 5, 6],
++ yaw: 0,
++ inventory: { size: 24, hotbar: 6, slots: [] },
++ skills: {},
++ friends: [],
++ appearance: null,
++ charSlot: slot,
++ armor: null,
++ reputation: {},
++ unlocks: [],
++ activeJob: null,
++ stats: null,
++ updatedAt: 1,
++ }
++}
++
++describe('guest tokens (hostile input)', () => {
++ it('accepts what the client mints', () => {
++ expect(isGuestToken('a1B2c3D4')).toBe(true)
++ expect(isGuestToken('0123456789abcdef0123456789abcdef')).toBe(true)
++ // Older clients and the slice test use `_`/`-`; still fine.
++ expect(isGuestToken('token_aaaaaaaaaaaa')).toBe(true)
++ expect(isGuestToken('guest-1234-abcd')).toBe(true)
++ })
++
++ it('refuses anything shaped like an account key or out of range', () => {
++ for (const bad of [
++ SUBJECT,
++ 'gst_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ 'ovn:57',
++ 'ovn:1',
++ 'short',
++ 'x'.repeat(65),
++ 'has space1',
++ 'usr_whatever123',
++ 'GST_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ 'ovn:57:x',
++ '',
++ null,
++ 42,
++ ]) {
++ expect(isGuestToken(bad), String(bad)).toBe(false)
++ }
++ })
++})
++
++describe('accountForNetworkUser', () => {
++ it('keys a signed-in account by its subject and adopts legacy characters once', () => {
++ const store = openSqliteStore(':memory:')
++ store.players.upsertMany([legacyChar('p1', 'ovn:57', 0), legacyChar('p2', 'ovn:57', 1)])
++ const user = { id: '57', name: 'ana', rank: null, subjectId: SUBJECT }
++
++ const first = accountForNetworkUser(store, user, 1)
++ expect(first).toEqual({ key: SUBJECT, subjectId: SUBJECT, adopted: 2 })
++ expect(store.players.listByToken(SUBJECT).map((p) => p.id)).toEqual(['p1', 'p2'])
++ // Same player ids: friends lists, prop ownership and trust still point at them.
++ expect(store.players.findById('p1')?.pos).toEqual([4, 5, 6])
++
++ const again = accountForNetworkUser(store, user, 2)
++ expect(again).toEqual({ key: SUBJECT, subjectId: SUBJECT, adopted: 0 })
++ store.close()
++ })
++
++ it('keeps the legacy key for a token issued before subjects', () => {
++ const store = openSqliteStore(':memory:')
++ store.players.upsert(legacyChar('p1', 'ovn:8', 0))
++ const res = accountForNetworkUser(
++ store,
++ { id: '8', name: 'old', rank: null, subjectId: null },
++ 1,
++ )
++ expect(res).toEqual({ key: legacyNetworkKey('8'), subjectId: null, adopted: 0 })
++ expect(store.players.findByTokenSlot('ovn:8', 0)?.id).toBe('p1')
++ store.close()
++ })
++
++ it('reports a conflicting legacy map instead of re-homing characters', () => {
++ const store = openSqliteStore(':memory:')
++ store.players.upsert(legacyChar('p1', 'ovn:3', 0))
++ store.identity.adoptLegacyAccount('ovn:3', SUBJECT, 'network', 1)
++ store.players.upsert(legacyChar('p2', 'ovn:3', 1))
++ const conflicts: unknown[] = []
++ const other = 'usr_01JZZZZZZZZZZZZZZZZZZZZZZZ'
++ const res = accountForNetworkUser(
++ store,
++ { id: '3', name: 'x', rank: null, subjectId: other },
++ 2,
++ (c) => conflicts.push(c),
++ )
++ expect(res.key).toBe(other)
++ expect(conflicts).toHaveLength(1)
++ expect(store.players.findById('p2')?.token).toBe('ovn:3')
++ store.close()
++ })
++})
ddiff --git a/apps/server/src/platform/accounts.ts b/apps/server/src/platform/accounts.ts
nnew file mode 100644
iindex 0000000..bdffdba
---- /dev/null
++++ b/apps/server/src/platform/accounts.ts
@@@ -0,0 +1,78 @@
++/**
++ * Account keys (ADR-0006).
++ *
++ * A signed-in account is keyed by its canonical openvibe.network subject
++ * (`usr_…`, or `gst_…` for a Network guest) taken from the token's
++ * `subject_id` claim. Before subjects existed Games keyed it by
++ * `ovn:<network user id>`; those characters are adopted into the subject key
++ * the first time the account signs in (or by scripts/migrateIdentity.ts), and
++ * the mapping is kept in `identity_legacy_map`.
++ *
++ * Local guests keep their browser token (ADR-0004). A guest token may use
++ * letters, digits, `_` and `-` only (the client mints alphanumerics), and
++ * may not start with a subject prefix, so it can never equal a subject or a
++ * legacy key: a guest presenting `usr_…` or `ovn:…` as its token cannot open
++ * someone else's characters.
++ */
++import type { IdentityRepository, PlayerRepository } from '@openvibe/persistence'
++import type { NetworkUser } from '../net/networkAuth.js'
++
++/** Prefix of pre-subject account keys: `ovn:<openvibe.network user id>`. */
++export const LEGACY_NETWORK_PREFIX = 'ovn:'
++
++/** The client mints `[A-Za-z0-9]{32}` (apps/client/src/net/connection.ts getIdentity). */
++const GUEST_TOKEN = /^[A-Za-z0-9_-]{8,64}$/
++/** Prefixes of canonical subject ids; a guest token may not look like one. */
++const SUBJECT_PREFIX = /^(usr|gst|app|mod|svc)_/i
++
++export function isGuestToken(token: unknown): token is string {
++ return typeof token === 'string' && GUEST_TOKEN.test(token) && !SUBJECT_PREFIX.test(token)
++}
++
++export function legacyNetworkKey(networkUserId: string): string {
++ return `${LEGACY_NETWORK_PREFIX}${networkUserId}`.slice(0, 64)
++}
++
++export interface AccountResolution {
++ /** The players.token value the account's characters live under. */
++ key: string
++ /** Canonical subject, or null for a token older than subjects. */
++ subjectId: string | null
++ /** Characters moved from the legacy key by this call. */
++ adopted: number
++}
++
++export interface AccountStores {
++ identity: IdentityRepository
++ players: PlayerRepository
++}
++
++/**
++ * The account key for a signed-in Network user, adopting any characters
++ * still under its legacy key. Idempotent.
++ */
++export function accountForNetworkUser(
++ stores: AccountStores,
++ user: NetworkUser,
++ now: number,
++ onConflict?: (info: { legacyKey: string; subjectId: string; error: string }) => void,
++): AccountResolution {
++ const legacyKey = legacyNetworkKey(user.id)
++ if (!user.subjectId) {
++ // A token from before subjects: nothing canonical to key by yet. The
++ // characters stay reachable under the legacy key until the next sign-in
++ // with a current token adopts them.
++ return { key: legacyKey, subjectId: null, adopted: 0 }
++ }
++ const pending = stores.players.listByToken(legacyKey).length > 0
++ if (!pending && stores.identity.subjectForLegacy(legacyKey) !== null) {
++ return { key: user.subjectId, subjectId: user.subjectId, adopted: 0 }
++ }
++ try {
++ const { moved } = stores.identity.adoptLegacyAccount(legacyKey, user.subjectId, 'network', now)
++ return { key: user.subjectId, subjectId: user.subjectId, adopted: moved }
++ } catch (err) {
++ onConflict?.({ legacyKey, subjectId: user.subjectId, error: String(err) })
++ return { key: user.subjectId, subjectId: user.subjectId, adopted: 0 }
++ }
++}
ddiff --git a/apps/server/src/platform/gameEvents.test.ts b/apps/server/src/platform/gameEvents.test.ts
nnew file mode 100644
iindex 0000000..629c149
---- /dev/null
++++ b/apps/server/src/platform/gameEvents.test.ts
@@@ -0,0 +1,248 @@
++import { createRequire } from 'node:module'
++import { openSqliteStore } from '@openvibe/persistence/sqlite'
++import { createEventsClient, createOutbox, type SqliteDatabase } from 'openvibe-sdk/events'
++import { createMockPlatform } from 'openvibe-sdk/testing'
++import { describe, expect, it } from 'vitest'
++import { loadPlatformConfig } from '../config.js'
++import {
++ EVENT_SOURCE,
++ GameEventRecorder,
++ modEvent,
++ outboxSink,
++ playerJoinedEvent,
++ playerLeftEvent,
++ progressEvents,
++ worldSavedEvent,
++ type EventSink,
++} from './gameEvents.js'
++import { createPlatformClient } from './serviceClient.js'
++
++interface Contracts {
++ validate(
++ ref: string,
++ value: unknown,
++ ): { valid: boolean; errors: { path: string; message: string }[] }
++}
++const contracts = createRequire(import.meta.url)('openvibe-contracts') as Contracts
++
++const SUBJECT = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'
++const signedIn = { playerId: 'pl_1', slot: 0, name: 'Ana', subjectId: SUBJECT }
++const guest = { playerId: 'pl_2', slot: 0, name: 'Guest', subjectId: null }
++
++function capture(): EventSink & { events: { event_type: string; payload: unknown }[] } {
++ const events: { event_type: string; payload: unknown }[] = []
++ return {
++ enabled: true,
++ events,
++ enqueue: (e) => events.push(e as { event_type: string; payload: unknown }),
++ }
++}
++
++describe('event envelopes', () => {
++ it('are valid events.event-envelope@1 once prepared', () => {
++ const client = createEventsClient(
++ createPlatformClient({
++ ...loadPlatformConfig({}),
++ clientSecret: 'x',
++ eventsUrl: 'http://127.0.0.1:1',
++ })!.client,
++ { source: EVENT_SOURCE },
++ )
++ const all = [
++ playerJoinedEvent(signedIn, { world: 'scrapcity', restored: true }),
++ playerJoinedEvent(guest, { world: 'scrapcity', restored: false }),
++ playerLeftEvent(signedIn, { world: 'scrapcity', sessionSeconds: 61.4 }),
++ ...progressEvents(
++ signedIn,
++ { levels: { mining: 2 }, unlocks: [] },
++ { levels: { mining: 4, woodcutting: 1 }, unlocks: ['craft_still'] },
++ 'scrapcity',
++ ),
++ worldSavedEvent('scrapcity', { reason: 'shutdown', entities: 3, players: 1, sinceMs: 0 }),
++ modEvent(
++ 'revoked',
++ {
++ id: 'mod_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ name: 'M',
++ version: '1.0.0',
++ target: 'games.browser',
++ trustTier: 'unreviewed',
++ },
++ { type: 'user', id: SUBJECT },
++ { reason: 'test' },
++ ),
++ ]
++ for (const input of all) {
++ const env = client.prepare(input)
++ const r = contracts.validate('events.event-envelope@1', env)
++ expect(r.errors, env.event_type).toEqual([])
++ expect(env.source).toBe('games')
++ }
++ })
++
++ it('name the subject for signed-in players and keep guests internal', () => {
++ const a = playerJoinedEvent(signedIn, { world: 'w', restored: false })
++ expect(a.actor).toEqual({ type: 'user', id: SUBJECT })
++ expect(a.visibility).toBe('subject')
++ const b = playerJoinedEvent(guest, { world: 'w', restored: false })
++ expect(b.actor).toEqual({ type: 'service', id: 'games' })
++ expect(b.visibility).toBe('internal')
++ expect((b.payload as { player: Record<string, unknown> }).player.subject).toBeUndefined()
++ })
++
++ it('report only real progression gains: one event per raised skill, one per new blueprint', () => {
++ const events = progressEvents(
++ signedIn,
++ { levels: { mining: 3, farming: 5 }, unlocks: ['a'] },
++ { levels: { mining: 5, farming: 5 }, unlocks: ['a', 'b'] },
++ 'w',
++ )
++ expect(events.map((e) => [e.event_type, e.payload])).toEqual([
++ [
++ 'games.skill.leveled',
++ expect.objectContaining({ skill: 'mining', level: 5, previous_level: 3 }),
++ ],
++ ['games.blueprint.unlocked', expect.objectContaining({ recipe: 'b' })],
++ ])
++ expect(
++ progressEvents(
++ signedIn,
++ { levels: { m: 4 }, unlocks: [] },
++ { levels: { m: 2 }, unlocks: [] },
++ 'w',
++ ),
++ ).toEqual([])
++ })
++})
++
++describe('GameEventRecorder', () => {
++ it('diffs each save against the last persisted progress', () => {
++ const sink = capture()
++ const rec = new GameEventRecorder(sink, 'w', 60_000, () => 0)
++ rec.recordJoin(signedIn, { levels: { mining: 2 }, unlocks: [] }, true)
++ rec.recordPlayersSaved([
++ { player: signedIn, progress: { levels: { mining: 3 }, unlocks: [] } },
++ ])()
++ // Saved again with no change: nothing new.
++ rec.recordPlayersSaved([
++ { player: signedIn, progress: { levels: { mining: 3 }, unlocks: [] } },
++ ])()
++ rec.recordLeave(signedIn)()
++ expect(sink.events.map((e) => e.event_type)).toEqual([
++ 'games.player.joined',
++ 'games.skill.leveled',
++ 'games.player.left',
++ ])
++ })
++
++ it('does not advance the baseline when the save is not committed', () => {
++ const sink = capture()
++ const rec = new GameEventRecorder(sink, 'w', 60_000, () => 0)
++ rec.recordJoin(signedIn, { levels: { mining: 2 }, unlocks: [] }, true)
++ // Rolled back: the commit callback never runs, so the next save reports it again.
++ rec.recordPlayersSaved([{ player: signedIn, progress: { levels: { mining: 3 }, unlocks: [] } }])
++ rec.recordPlayersSaved([
++ { player: signedIn, progress: { levels: { mining: 3 }, unlocks: [] } },
++ ])()
++ expect(sink.events.filter((e) => e.event_type === 'games.skill.leveled')).toHaveLength(2)
++ })
++
++ it('checkpoints world saves at most once per interval, always on shutdown', () => {
++ let now = 0
++ const sink = capture()
++ const rec = new GameEventRecorder(sink, 'w', 60_000, () => now)
++ now = 10_000
++ rec.recordWorldSaved(5, 1, 'checkpoint')()
++ now = 70_000
++ rec.recordWorldSaved(2, 0, 'checkpoint')()
++ now = 80_000
++ rec.recordWorldSaved(1, 1, 'checkpoint')()
++ rec.recordWorldSaved(0, 0, 'shutdown')()
++ const saved = sink.events.filter((e) => e.event_type === 'games.world.saved')
++ expect(saved.map((e) => e.payload)).toEqual([
++ expect.objectContaining({ reason: 'checkpoint', entities_written: 7, players_written: 1 }),
++ expect.objectContaining({ reason: 'shutdown', entities_written: 1, players_written: 1 }),
++ ])
++ })
++})
++
++describe('transactional outbox → OpenVibe.Events (mock platform)', () => {
++ it('publishes with the games service token only what committed', async () => {
++ const platform = createMockPlatform({
++ clients: {
++ games: {
++ secret: 's3cret',
++ grants: [{ capability: 'events.event.publish', audience: 'openvibe.events' }],
++ },
++ },
++ })
++ const cfg = {
++ ...loadPlatformConfig({}),
++ clientSecret: 's3cret',
++ networkUrl: platform.origins.network,
++ eventsUrl: platform.origins.events,
++ }
++ const pc = createPlatformClient(cfg, platform.fetch)!
++ const store = openSqliteStore(':memory:')
++ const outbox = createOutbox(store.db as unknown as SqliteDatabase, {
++ events: createEventsClient(pc.client, { source: EVENT_SOURCE }),
++ })
++ outbox.ensureSchema()
++ const sink = outboxSink(outbox)
++
++ // Committed with the change it describes.
++ store.transaction(() => {
++ store.meta.set('env_time', '0.5')
++ sink.enqueue(playerJoinedEvent(signedIn, { world: 'w', restored: false }))
++ })
++ // Rolled back: the event never existed.
++ expect(() =>
++ store.transaction(() => {
++ store.meta.set('env_time', '0.9')
++ sink.enqueue(playerLeftEvent(signedIn, { world: 'w', sessionSeconds: 1 }))
++ throw new Error('rollback')
++ }),
++ ).toThrow('rollback')
++ // Outside a transaction the outbox refuses.
++ expect(() =>
++ sink.enqueue(playerLeftEvent(signedIn, { world: 'w', sessionSeconds: 1 })),
++ ).toThrow()
++
++ expect(outbox.pending()).toBe(1)
++ const stats = await outbox.flush()
++ expect(stats).toEqual({ sent: 1, failed: 0, rejected: 0 })
++ expect(platform.state.events.map((e) => [e.event.event_type, e.publisher])).toEqual([
++ ['games.player.joined', 'svc:games'],
++ ])
++ expect(store.meta.get('env_time')).toBe('0.5')
++ await outbox.stop()
++ store.close()
++ })
++
++ it('keeps events queued while the grant is missing', async () => {
++ const platform = createMockPlatform({ clients: { games: { secret: 's3cret', grants: [] } } })
++ const cfg = {
++ ...loadPlatformConfig({}),
++ clientSecret: 's3cret',
++ networkUrl: platform.origins.network,
++ eventsUrl: platform.origins.events,
++ }
++ const store = openSqliteStore(':memory:')
++ const outbox = createOutbox(store.db as unknown as SqliteDatabase, {
++ events: createEventsClient(createPlatformClient(cfg, platform.fetch)!.client, {
++ source: EVENT_SOURCE,
++ }),
++ })
++ outbox.ensureSchema()
++ store.transaction(() =>
++ outboxSink(outbox).enqueue(
++ worldSavedEvent('w', { reason: 'shutdown', entities: 0, players: 0, sinceMs: 0 }),
++ ),
++ )
++ const stats = await outbox.flush()
++ expect(stats.sent).toBe(0)
++ expect(outbox.pending() + outbox.rejected()).toBe(1)
++ await outbox.stop()
++ store.close()
++ })
++})
ddiff --git a/apps/server/src/platform/gameEvents.ts b/apps/server/src/platform/gameEvents.ts
nnew file mode 100644
iindex 0000000..ddc2788
---- /dev/null
++++ b/apps/server/src/platform/gameEvents.ts
@@@ -0,0 +1,316 @@
++/**
++ * Games → OpenVibe.Events: public lifecycle and progression as durable
++ * events (roadmap Wave 12, ADR-004).
++ *
++ * games.player.joined a character entered the world
++ * games.player.left it left (its state was saved in the same transaction)
++ * games.skill.leveled a persisted skill level went up (one event per skill per save)
++ * games.blueprint.unlocked a persisted blueprint recipe unlock
++ * games.world.saved the authoritative world was written (checkpoint / shutdown)
++ * games.mod.* mod install, enable, disable, grant changes and revoke
++ *
++ * Scraplandia has no achievement system, so there is no games.achievement.*
++ * event: skill levels and blueprint unlocks are the real progression
++ * milestones the game keeps.
++ *
++ * Every event is written to Games' own `event_outbox` table inside the SAME
++ * SQLite transaction as the state it describes (progression events are
++ * derived from what the save actually wrote), and a relay publishes them
++ * with the `games` service token (audience openvibe.events, capability
++ * events.event.publish). Events or Network being down never blocks a tick:
++ * rows wait and are retried.
++ */
++import type { EventInput, Outbox } from 'openvibe-sdk/events'
++import type { SubjectRef } from 'openvibe-sdk/core'
++
++export const EVENT_SOURCE = 'games'
++
++/** A character as events describe it. */
++export interface EventPlayer {
++ playerId: string
++ slot: number
++ name: string
++ /** Canonical subject for signed-in accounts; null for local guests. */
++ subjectId: string | null
++}
++
++/** The persisted progression a save compares against. */
++export interface ProgressSnapshot {
++ /** Level per skill id. */
++ levels: Record<string, number>
++ /** Unlocked blueprint recipe ids. */
++ unlocks: string[]
++}
++
++const GAMES_ACTOR: SubjectRef = { type: 'service', id: EVENT_SOURCE }
++
++export function actorFor(subjectId: string | null): SubjectRef {
++ if (subjectId?.startsWith('usr_')) return { type: 'user', id: subjectId }
++ if (subjectId?.startsWith('gst_')) return { type: 'guest', id: subjectId }
++ return GAMES_ACTOR
++}
++
++function playerPayload(p: EventPlayer): Record<string, unknown> {
++ const actor = actorFor(p.subjectId)
++ return {
++ id: p.playerId,
++ slot: p.slot,
++ name: p.name,
++ account: p.subjectId ? 'network' : 'guest',
++ ...(actor.type === 'user' || actor.type === 'guest' ? { subject: actor } : {}),
++ }
++}
++
++/** Events about one player are visible to that subject; guests' stay internal. */
++function playerVisibility(p: EventPlayer): 'subject' | 'internal' {
++ return p.subjectId ? 'subject' : 'internal'
++}
++
++export function playerJoinedEvent(
++ p: EventPlayer,
++ opts: { world: string; restored: boolean },
++): EventInput {
++ return {
++ event_type: 'games.player.joined',
++ actor: actorFor(p.subjectId),
++ subject: { type: 'player', id: p.playerId },
++ visibility: playerVisibility(p),
++ priority: 'low',
++ payload: { player: playerPayload(p), world: opts.world, restored: opts.restored },
++ }
++}
++
++export function playerLeftEvent(
++ p: EventPlayer,
++ opts: { world: string; sessionSeconds: number },
++): EventInput {
++ return {
++ event_type: 'games.player.left',
++ actor: actorFor(p.subjectId),
++ subject: { type: 'player', id: p.playerId },
++ visibility: playerVisibility(p),
++ priority: 'low',
++ payload: {
++ player: playerPayload(p),
++ world: opts.world,
++ session_seconds: Math.max(0, Math.round(opts.sessionSeconds)),
++ },
++ }
++}
++
++/** Progression milestones between two persisted snapshots (never downgrades). */
++export function progressEvents(
++ p: EventPlayer,
++ before: ProgressSnapshot,
++ after: ProgressSnapshot,
++ world: string,
++): EventInput[] {
++ const out: EventInput[] = []
++ for (const skill of Object.keys(after.levels).sort()) {
++ const level = after.levels[skill] ?? 0
++ const previous = before.levels[skill] ?? 1
++ if (level <= previous) continue
++ out.push({
++ event_type: 'games.skill.leveled',
++ actor: actorFor(p.subjectId),
++ subject: { type: 'player', id: p.playerId },
++ visibility: playerVisibility(p),
++ priority: 'low',
++ payload: { player: playerPayload(p), world, skill, level, previous_level: previous },
++ })
++ }
++ const had = new Set(before.unlocks)
++ for (const recipe of [...after.unlocks].sort()) {
++ if (had.has(recipe)) continue
++ out.push({
++ event_type: 'games.blueprint.unlocked',
++ actor: actorFor(p.subjectId),
++ subject: { type: 'player', id: p.playerId },
++ visibility: playerVisibility(p),
++ priority: 'low',
++ payload: { player: playerPayload(p), world, recipe },
++ })
++ }
++ return out
++}
++
++export function worldSavedEvent(
++ world: string,
++ save: { reason: 'checkpoint' | 'shutdown'; entities: number; players: number; sinceMs: number },
++): EventInput {
++ return {
++ event_type: 'games.world.saved',
++ actor: GAMES_ACTOR,
++ subject: { type: 'world', id: world },
++ visibility: 'internal',
++ priority: 'low',
++ payload: {
++ world,
++ reason: save.reason,
++ entities_written: save.entities,
++ players_written: save.players,
++ since: new Date(save.sinceMs).toISOString(),
++ },
++ }
++}
++
++export type ModLifecycle = 'installed' | 'enabled' | 'disabled' | 'revoked' | 'grants_changed'
++
++export function modEvent(
++ kind: ModLifecycle,
++ mod: { id: string; name: string; version: string; target: string; trustTier: string },
++ actor: SubjectRef,
++ detail: Record<string, unknown>,
++): EventInput {
++ return {
++ event_type: `games.mod.${kind}`,
++ actor,
++ subject: { type: 'mod', id: mod.id },
++ visibility: 'internal',
++ priority: kind === 'revoked' ? 'important' : 'low',
++ payload: {
++ mod: {
++ id: mod.id,
++ name: mod.name,
++ version: mod.version,
++ target: mod.target,
++ trust_tier: mod.trustTier,
++ },
++ ...detail,
++ },
++ }
++}
++
++/** Where events go. `enqueue` must run inside the transaction making the change. */
++export interface EventSink {
++ readonly enabled: boolean
++ enqueue(event: EventInput): void
++}
++
++export const NO_EVENTS: EventSink = { enabled: false, enqueue: () => {} }
++
++/** An EventSink over the SDK outbox; wakes the relay right after the write. */
++export function outboxSink(outbox: Outbox): EventSink {
++ return {
++ enabled: true,
++ enqueue(event) {
++ outbox.enqueue(event)
++ setImmediate(() => outbox.kick())
++ },
++ }
++}
++
++interface Tracked {
++ player: EventPlayer
++ progress: ProgressSnapshot
++ joinedAt: number
++}
++
++/**
++ * Per-session bookkeeping the game server drives: the progression baseline
++ * each save is compared with, join times, and the world-save accumulator.
++ * All `record*` methods run inside the caller's store transaction and return
++ * a callback to run once it has committed.
++ */
++export class GameEventRecorder {
++ private readonly tracked = new Map<string, Tracked>()
++ private sinceMs: number
++ private pendingEntities = 0
++ private pendingPlayers = 0
++ private lastSavedEventMs: number
++
++ constructor(
++ private readonly sink: EventSink,
++ private readonly world: string,
++ private readonly checkpointEveryMs: number,
++ private readonly now: () => number = Date.now,
++ ) {
++ this.sinceMs = now()
++ this.lastSavedEventMs = now()
++ }
++
++ get enabled(): boolean {
++ return this.sink.enabled
++ }
++
++ /** A character joined; `progress` is what the database holds for it. */
++ recordJoin(player: EventPlayer, progress: ProgressSnapshot, restored: boolean): void {
++ this.tracked.set(player.playerId, {
++ player,
++ progress: cloneProgress(progress),
++ joinedAt: this.now(),
++ })
++ this.sink.enqueue(playerJoinedEvent(player, { world: this.world, restored }))
++ }
++
++ /**
++ * Players were written: emits the progression they newly persisted.
++ * Returns the commit callback that advances their baselines.
++ */
++ recordPlayersSaved(saved: { player: EventPlayer; progress: ProgressSnapshot }[]): () => void {
++ const advance: { id: string; progress: ProgressSnapshot }[] = []
++ for (const { player, progress } of saved) {
++ const t = this.tracked.get(player.playerId)
++ if (t) {
++ for (const ev of progressEvents(player, t.progress, progress, this.world)) {
++ this.sink.enqueue(ev)
++ }
++ }
++ advance.push({ id: player.playerId, progress: cloneProgress(progress) })
++ }
++ return () => {
++ for (const a of advance) {
++ const t = this.tracked.get(a.id)
++ if (t) t.progress = a.progress
++ }
++ }
++ }
++
++ /** The character left; its final save is part of the same transaction. */
++ recordLeave(player: EventPlayer): () => void {
++ const t = this.tracked.get(player.playerId)
++ const sessionSeconds = t ? (this.now() - t.joinedAt) / 1000 : 0
++ this.sink.enqueue(playerLeftEvent(player, { world: this.world, sessionSeconds }))
++ return () => {
++ this.tracked.delete(player.playerId)
++ }
++ }
++
++ /**
++ * A flush wrote `entities` world rows and `players` player rows. Emits a
++ * `games.world.saved` checkpoint at most once per interval (and always on
++ * shutdown) with the totals since the previous one.
++ */
++ recordWorldSaved(
++ entities: number,
++ players: number,
++ reason: 'checkpoint' | 'shutdown',
++ ): () => void {
++ this.pendingEntities += entities
++ this.pendingPlayers += players
++ const nowMs = this.now()
++ const due =
++ reason === 'shutdown' ||
++ (nowMs - this.lastSavedEventMs >= this.checkpointEveryMs &&
++ this.pendingEntities + this.pendingPlayers > 0)
++ if (!due) return () => {}
++ this.sink.enqueue(
++ worldSavedEvent(this.world, {
++ reason,
++ entities: this.pendingEntities,
++ players: this.pendingPlayers,
++ sinceMs: this.sinceMs,
++ }),
++ )
++ return () => {
++ this.pendingEntities = 0
++ this.pendingPlayers = 0
++ this.sinceMs = nowMs
++ this.lastSavedEventMs = nowMs
++ }
++ }
++}
++
++function cloneProgress(p: ProgressSnapshot): ProgressSnapshot {
++ return { levels: { ...p.levels }, unlocks: [...p.unlocks] }
++}
ddiff --git a/apps/server/src/platform/identityMigration.test.ts b/apps/server/src/platform/identityMigration.test.ts
nnew file mode 100644
iindex 0000000..d7f5756
---- /dev/null
++++ b/apps/server/src/platform/identityMigration.test.ts
@@@ -0,0 +1,88 @@
++import { openSqliteStore } from '@openvibe/persistence/sqlite'
++import type { PlayerDto } from '@openvibe/persistence'
++import { createIdentityClient } from 'openvibe-sdk/identity'
++import { createMockPlatform } from 'openvibe-sdk/testing'
++import { describe, expect, it } from 'vitest'
++import { loadPlatformConfig } from '../config.js'
++import { migrateLegacyAccounts } from './identityMigration.js'
++import { createPlatformClient } from './serviceClient.js'
++
++function char(id: string, token: string, slot = 0): PlayerDto {
++ return {
++ id,
++ token,
++ name: id,
++ pos: [0, 0, 0],
++ yaw: 0,
++ inventory: { size: 24, hotbar: 6, slots: [] },
++ skills: {},
++ friends: [],
++ appearance: null,
++ charSlot: slot,
++ armor: null,
++ reputation: {},
++ unlocks: [],
++ activeJob: null,
++ stats: null,
++ updatedAt: 1,
++ }
++}
++
++describe('batch identity migration (games service token -> Network identity)', () => {
++ it('adopts every resolvable legacy account, leaves unknown ones, and is idempotent', async () => {
++ const platform = createMockPlatform({
++ clients: {
++ games: {
++ secret: 's3cret',
++ grants: [{ capability: 'identity.subject.resolve', audience: 'openvibe.network' }],
++ },
++ },
++ users: [
++ { id: 57, username: 'ana' },
++ { id: 58, username: 'bo' },
++ ],
++ })
++ const [ana, bo] = [...platform.state.users.values()]
++ const pc = createPlatformClient(
++ { ...loadPlatformConfig({}), clientSecret: 's3cret', networkUrl: platform.origins.network },
++ platform.fetch,
++ )!
++ const identity = createIdentityClient(pc.client)
++ const store = openSqliteStore(':memory:')
++ store.players.upsertMany([
++ char('a0', 'ovn:57', 0),
++ char('a1', 'ovn:57', 1),
++ char('b0', 'ovn:58', 0),
++ char('x0', 'ovn:999', 0),
++ char('g0', 'guesttoken1', 0),
++ ])
++
++ const dry = await migrateLegacyAccounts(store, identity, { dryRun: true })
++ expect(dry).toMatchObject({ scanned: 3, resolved: 2, moved: 3, unresolved: ['ovn:999'] })
++ expect(store.players.findById('a0')?.token).toBe('ovn:57')
++
++ const report = await migrateLegacyAccounts(store, identity)
++ expect(report).toMatchObject({ scanned: 3, resolved: 2, moved: 3, conflicts: 0 })
++ expect(store.players.listByToken(ana!.subject_id).map((p) => p.id)).toEqual(['a0', 'a1'])
++ expect(store.players.listByToken(bo!.subject_id).map((p) => p.id)).toEqual(['b0'])
++ expect(store.players.findById('x0')?.token).toBe('ovn:999')
++ expect(store.players.findById('g0')?.token).toBe('guesttoken1')
++
++ const again = await migrateLegacyAccounts(store, identity)
++ expect(again).toMatchObject({ scanned: 1, resolved: 0, moved: 0, unresolved: ['ovn:999'] })
++ store.close()
++ })
++
++ it('fails loudly without the identity grant (nothing is moved)', async () => {
++ const platform = createMockPlatform({ clients: { games: { secret: 's3cret', grants: [] } } })
++ const pc = createPlatformClient(
++ { ...loadPlatformConfig({}), clientSecret: 's3cret', networkUrl: platform.origins.network },
++ platform.fetch,
++ )!
++ const store = openSqliteStore(':memory:')
++ store.players.upsert(char('a0', 'ovn:57'))
++ await expect(migrateLegacyAccounts(store, createIdentityClient(pc.client))).rejects.toThrow()
++ expect(store.players.findById('a0')?.token).toBe('ovn:57')
++ store.close()
++ })
++})
ddiff --git a/apps/server/src/platform/identityMigration.ts b/apps/server/src/platform/identityMigration.ts
nnew file mode 100644
iindex 0000000..d76558a
---- /dev/null
++++ b/apps/server/src/platform/identityMigration.ts
@@@ -0,0 +1,69 @@
++/**
++ * Batch adoption of legacy accounts (ADR-0006): every account still keyed by
++ * `ovn:<network user id>` is resolved to its canonical subject through the
++ * Network identity service (capability identity.subject.resolve, audience
++ * openvibe.network, the `games` service token) and moved over.
++ *
++ * Sign-in adopts an account on its own; this pass covers the accounts that
++ * have not signed in since. Idempotent: adopted keys own no characters any
++ * more, so a second run finds nothing to do.
++ */
++import type { PersistenceStore } from '@openvibe/persistence'
++import type { IdentityClient } from 'openvibe-sdk/identity'
++import { isCanonicalSubject } from '../net/networkAuth.js'
++import { LEGACY_NETWORK_PREFIX } from './accounts.js'
++
++export interface MigrationReport {
++ /** Legacy account keys that still owned characters. */
++ scanned: number
++ /** Keys the Network resolved to a subject. */
++ resolved: number
++ /** Characters moved to their subject key. */
++ moved: number
++ /** Characters left under the legacy key because the subject already used that slot. */
++ conflicts: number
++ /** Keys the Network did not know (left untouched). */
++ unresolved: string[]
++ dryRun: boolean
++}
++
++export async function migrateLegacyAccounts(
++ store: PersistenceStore,
++ identity: Pick<IdentityClient, 'resolveBatch'>,
++ opts: { dryRun?: boolean; batchSize?: number; now?: () => number } = {},
++): Promise<MigrationReport> {
++ const dryRun = opts.dryRun ?? false
++ const batchSize = Math.min(Math.max(opts.batchSize ?? 200, 1), 500)
++ const now = opts.now ?? Date.now
++ const keys = store.identity.legacyAccountKeys(LEGACY_NETWORK_PREFIX)
++ const report: MigrationReport = {
++ scanned: keys.length,
++ resolved: 0,
++ moved: 0,
++ conflicts: 0,
++ unresolved: [],
++ dryRun,
++ }
++ for (let i = 0; i < keys.length; i += batchSize) {
++ const batch = keys.slice(i, i + batchSize)
++ const ids = batch.map((k) => k.slice(LEGACY_NETWORK_PREFIX.length))
++ const results = await identity.resolveBatch({ system: 'network', type: 'user', ids })
++ for (const [n, key] of batch.entries()) {
++ const projection = results[ids[n] ?? '']
++ const subjectId = projection?.subject?.id
++ if (!isCanonicalSubject(subjectId)) {
++ report.unresolved.push(key)
++ continue
++ }
++ report.resolved++
++ if (dryRun) {
++ report.moved += store.players.listByToken(key).length
++ continue
++ }
++ const r = store.identity.adoptLegacyAccount(key, subjectId, 'network-batch', now())
++ report.moved += r.moved
++ report.conflicts += r.conflicts
++ }
++ }
++ return report
++}
ddiff --git a/apps/server/src/platform/mediaMirror.test.ts b/apps/server/src/platform/mediaMirror.test.ts
nnew file mode 100644
iindex 0000000..8fdb1c4
---- /dev/null
++++ b/apps/server/src/platform/mediaMirror.test.ts
@@@ -0,0 +1,189 @@
++import { createHash } from 'node:crypto'
++import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
++import { tmpdir } from 'node:os'
++import { join } from 'node:path'
++import { openSqliteStore } from '@openvibe/persistence/sqlite'
++import { createConsoleLogger } from '@openvibe/shared'
++import { describe, expect, it } from 'vitest'
++import { loadPlatformConfig } from '../config.js'
++import { MediaMirror } from './mediaMirror.js'
++import { createPlatformClient } from './serviceClient.js'
++
++const NETWORK = 'http://network.test'
++const MEDIA = 'http://media.test'
++const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3])
++const log = createConsoleLogger({ app: 'test' }, 'error')
++
++function fakeJwt(claims: Record<string, unknown>): string {
++ const b = (v: unknown) => Buffer.from(JSON.stringify(v)).toString('base64url')
++ return `${b({ alg: 'RS256' })}.${b(claims)}.sig`
++}
++
++/**
++ * Network's token endpoint + Media's object API v2, in memory. Media checks
++ * the bearer's audience and grants like the real tenant auth does.
++ */
++function fakePlatform(opts: { failPuts?: number; completeStatus?: number } = {}) {
++ const objects = new Map<string, { bytes: Buffer | null; hash: string; status: string }>()
++ const calls: string[] = []
++ let failPuts = opts.failPuts ?? 0
++ let n = 0
++ const fetchImpl = async (input: string | URL | Request, init?: RequestInit) => {
++ const url = new URL(
++ typeof input === 'string' ? input : input instanceof URL ? input : input.url,
++ )
++ const method = init?.method ?? 'GET'
++ calls.push(`${method} ${url.pathname}`)
++ const reply = (status: number, body: unknown) =>
++ new Response(JSON.stringify(body), {
++ status,
++ headers: { 'content-type': 'application/json' },
++ })
++ if (url.origin === NETWORK && url.pathname === '/oauth/token') {
++ const form = new URLSearchParams(String(init?.body ?? ''))
++ expect(form.get('grant_type')).toBe('client_credentials')
++ const audience = form.get('audience')
++ return reply(200, {
++ access_token: fakeJwt({
++ sub: 'svc:games',
++ aud: [audience],
++ cap: audience === 'openvibe.media' ? ['media.object.upload'] : [],
++ ns: ['games'],
++ }),
++ token_type: 'Bearer',
++ expires_in: 3600,
++ })
++ }
++ if (url.origin !== MEDIA) return reply(404, {})
++ const auth = new Headers(init?.headers).get('authorization') ?? ''
++ const claims = JSON.parse(
++ Buffer.from(auth.replace('Bearer ', '').split('.')[1] ?? '', 'base64url').toString(),
++ ) as { aud: string[]; cap: string[] }
++ if (!claims.aud.includes('openvibe.media') || !claims.cap.includes('media.object.upload')) {
++ return reply(403, { code: 'capability.denied' })
++ }
++ const m = /^\/api\/v2\/games\/objects(?:\/([^/]+)\/(content|complete))?$/.exec(url.pathname)
++ if (!m) return reply(404, {})
++ if (method === 'POST' && !m[1]) {
++ const body = JSON.parse(String(init?.body)) as { content_hash: string; kind: string }
++ expect(body.kind).toBe('asset')
++ const id = `med_${String(++n).padStart(26, '0')}`
++ objects.set(id, { bytes: null, hash: body.content_hash, status: 'uploading' })
++ return reply(201, { id })
++ }
++ const obj = objects.get(m[1] ?? '')
++ if (!obj) return reply(404, {})
++ if (m[2] === 'content') {
++ if (failPuts > 0) {
++ failPuts--
++ return reply(503, { code: 'media.unavailable' })
++ }
++ if (obj.status !== 'uploading') return reply(409, { code: 'media.object.not_uploading' })
++ obj.bytes = Buffer.from(init?.body as Uint8Array)
++ return reply(200, { id: m[1] })
++ }
++ if (opts.completeStatus)
++ return reply(opts.completeStatus, { code: 'media.object.hash_mismatch' })
++ const actual = createHash('sha256')
++ .update(obj.bytes ?? Buffer.alloc(0))
++ .digest('hex')
++ if (actual !== obj.hash) return reply(422, { code: 'media.object.hash_mismatch' })
++ obj.status = 'ready'
++ return reply(200, { id: m[1], public_url: `https://openvibe.media/o/${m[1]}` })
++ }
++ return { fetchImpl, objects, calls }
++}
++
++function setup(platform: ReturnType<typeof fakePlatform>) {
++ const dir = mkdtempSync(join(tmpdir(), 'openvibe-mirror-'))
++ const assetsDir = join(dir, 'map-assets')
++ mkdirSync(assetsDir)
++ const hash = `sha256-${createHash('sha256').update(PNG).digest('hex')}`
++ const fileName = `${hash}.png`
++ writeFileSync(join(assetsDir, fileName), PNG)
++ const store = openSqliteStore(':memory:')
++ let now = 1_000
++ const pc = createPlatformClient(
++ {
++ ...loadPlatformConfig({}),
++ clientSecret: 's3cret',
++ networkUrl: NETWORK,
++ mediaUrl: MEDIA,
++ },
++ platform.fetchImpl as never,
++ )!
++ const mirror = new MediaMirror({
++ client: pc.client,
++ store,
++ namespace: 'games',
++ assetsDir,
++ log,
++ now: () => now,
++ })
++ return {
++ store,
++ mirror,
++ hash,
++ asset: { hash, url: `/map-assets/${fileName}`, bytes: PNG.length, mime: 'image/png' },
++ advance: (ms: number) => (now += ms),
++ }
++}
++
++describe('map assets → Media objects (games service token)', () => {
++ it('mirrors a stored asset with init, content and a hash-verified complete', async () => {
++ const platform = fakePlatform()
++ const { store, mirror, hash, asset } = setup(platform)
++ mirror.enqueue(asset)
++ mirror.stop()
++ await mirror.runOnce()
++ const row = store.mediaMirrors.get(hash)!
++ expect(row).toMatchObject({ status: 'mirrored', attempts: 1 })
++ expect(row.mediaId).toMatch(/^med_/)
++ expect(row.publicUrl).toBe(`https://openvibe.media/o/${row.mediaId}`)
++ expect(platform.objects.get(row.mediaId!)?.bytes?.equals(PNG)).toBe(true)
++ expect(platform.calls.filter((c) => c.startsWith('POST /oauth'))).toHaveLength(1)
++ // Enqueueing again is a no-op: one object per asset.
++ mirror.enqueue(asset)
++ mirror.stop()
++ await mirror.runOnce()
++ expect(platform.objects.size).toBe(1)
++ })
++
++ it('backfills assets already on disk', async () => {
++ const platform = fakePlatform()
++ const { store, mirror, hash } = setup(platform)
++ expect(await mirror.backfill()).toBe(1)
++ expect(await mirror.backfill()).toBe(0)
++ await mirror.runOnce()
++ expect(store.mediaMirrors.get(hash)?.status).toBe('mirrored')
++ })
++
++ it('resumes the same object after a failure instead of creating another', async () => {
++ const platform = fakePlatform({ failPuts: 1 })
++ const { store, mirror, hash, asset, advance } = setup(platform)
++ mirror.enqueue(asset)
++ mirror.stop()
++ await mirror.runOnce()
++ const failed = store.mediaMirrors.get(hash)!
++ expect(failed).toMatchObject({ status: 'pending', attempts: 1 })
++ expect(failed.lastError).toMatch(/503|unavailable/)
++ await mirror.runOnce() // not due yet
++ expect(store.mediaMirrors.get(hash)?.attempts).toBe(1)
++ advance(60_000)
++ await mirror.runOnce()
++ expect(store.mediaMirrors.get(hash)).toMatchObject({
++ status: 'mirrored',
++ mediaId: failed.mediaId,
++ })
++ expect(platform.objects.size).toBe(1)
++ })
++
++ it('gives up on refusals retrying cannot fix', async () => {
++ const platform = fakePlatform({ completeStatus: 422 })
++ const { store, mirror, hash, asset } = setup(platform)
++ mirror.enqueue(asset)
++ mirror.stop()
++ await mirror.runOnce()
++ expect(store.mediaMirrors.get(hash)?.status).toBe('failed')
++ })
++})
ddiff --git a/apps/server/src/platform/mediaMirror.ts b/apps/server/src/platform/mediaMirror.ts
nnew file mode 100644
iindex 0000000..7c17a19
---- /dev/null
++++ b/apps/server/src/platform/mediaMirror.ts
@@@ -0,0 +1,230 @@
++/**
++ * Durable copies of map assets in OpenVibe.Media (roadmap Wave 12, ADR-006).
++ *
++ * The only user-uploaded files Games has today are the map editor's assets
++ * (textures, paint masks, glb models; see net/mapAssetStore.ts). They stay
++ * served from local disk — the map must load when Media is down — and every
++ * one is also copied into a Media object (`kind: asset`, public, namespace
++ * MEDIA_NAMESPACE) so it has a platform Media id and an off-host copy.
++ * Scraplandia has no screenshots or shareable blueprint files; blueprints are
++ * per-player recipe unlocks, not documents.
++ *
++ * Flow per asset, all with the `games` service token (capability
++ * media.object.upload, audience openvibe.media):
++ * POST /api/v2/<ns>/objects init (declares size + sha256)
++ * PUT /api/v2/<ns>/objects/<id>/content the bytes
++ * POST /api/v2/<ns>/objects/<id>/complete Media verifies the hash
++ *
++ * A queue row per asset (`media_mirrors`) makes it restart-safe: the object
++ * id is remembered after init, so a retry resumes rather than starting over.
++ */
++import { readdir, readFile } from 'node:fs/promises'
++import { join } from 'node:path'
++import type { MediaMirrorDto, PersistenceStore } from '@openvibe/persistence'
++import type { Logger } from '@openvibe/shared'
++import type { OpenVibeClient } from 'openvibe-sdk/core'
++
++const CONTENT_ADDRESSED = /^(sha256-[0-9a-f]{64})\.(png|jpg|webp|glb)$/
++const MIME_BY_EXT: Record<string, string> = {
++ png: 'image/png',
++ jpg: 'image/jpeg',
++ webp: 'image/webp',
++ glb: 'model/gltf-binary',
++}
++/** Retry schedule; after the last step the row is marked failed. */
++const BACKOFF_MS = [60_000, 300_000, 1_800_000, 7_200_000, 43_200_000]
++const MAX_ATTEMPTS = 10
++/** Refusals that retrying cannot fix (the bytes or the request are wrong). */
++const TERMINAL_STATUS = new Set([400, 413, 415, 422])
++
++interface ObjectBody {
++ id: string
++ public_url?: string | null
++ lifecycle_status?: string
++}
++
++interface HttpError {
++ status?: number
++ code?: string
++ message?: string
++}
++
++export interface MediaMirrorOptions {
++ client: OpenVibeClient
++ store: PersistenceStore
++ namespace: string
++ assetsDir: string
++ log: Logger
++ now?: () => number
++ intervalMs?: number
++}
++
++export class MediaMirror {
++ private timer: NodeJS.Timeout | null = null
++ private running: Promise<void> | null = null
++ private readonly now: () => number
++
++ constructor(private readonly opts: MediaMirrorOptions) {
++ this.now = opts.now ?? Date.now
++ }
++
++ /** Queues one stored asset (idempotent) and wakes the worker. */
++ enqueue(asset: { hash: string; url: string; bytes: number; mime: string }): void {
++ const fileName = asset.url.split('/').pop() ?? ''
++ if (!CONTENT_ADDRESSED.test(fileName)) return
++ const added = this.opts.store.mediaMirrors.enqueue(
++ { assetHash: asset.hash, fileName, mime: asset.mime, bytes: asset.bytes },
++ this.now(),
++ )
++ if (added) this.kick()
++ }
++
++ /** Queues every content-addressed asset already on disk (a no-op for known ones). */
++ async backfill(): Promise<number> {
++ let names: string[]
++ try {
++ names = await readdir(this.opts.assetsDir)
++ } catch {
++ return 0
++ }
++ let added = 0
++ for (const name of names) {
++ const m = CONTENT_ADDRESSED.exec(name)
++ if (!m) continue
++ const bytes = (await readFile(join(this.opts.assetsDir, name))).length
++ const queued = this.opts.store.mediaMirrors.enqueue(
++ { assetHash: m[1] ?? '', fileName: name, mime: MIME_BY_EXT[m[2] ?? ''] ?? '', bytes },
++ this.now(),
++ )
++ if (queued) added++
++ }
++ return added
++ }
++
++ start(): void {
++ this.schedule(0)
++ }
++
++ stop(): void {
++ if (this.timer) clearTimeout(this.timer)
++ this.timer = null
++ }
++
++ kick(): void {
++ this.schedule(0)
++ }
++
++ /** Mirrors every due row once. Concurrent callers share the pass. */
++ runOnce(): Promise<void> {
++ if (!this.running) {
++ this.running = this.pass().finally(() => {
++ this.running = null
++ })
++ }
++ return this.running
++ }
++
++ private schedule(ms: number): void {
++ if (this.timer) clearTimeout(this.timer)
++ this.timer = setTimeout(() => {
++ this.timer = null
++ void this.runOnce().finally(() => this.schedule(this.opts.intervalMs ?? 60_000))
++ }, ms)
++ this.timer.unref()
++ }
++
++ private async pass(): Promise<void> {
++ for (const row of this.opts.store.mediaMirrors.due(this.now(), 20)) {
++ try {
++ await this.mirror(row)
++ } catch (err) {
++ this.fail(row, err as HttpError)
++ }
++ }
++ }
++
++ private base(): string {
++ return `/api/v2/${encodeURIComponent(this.opts.namespace)}/objects`
++ }
++
++ private async mirror(row: MediaMirrorDto): Promise<void> {
++ const { client, store } = this.opts
++ let bytes: Buffer
++ try {
++ bytes = await readFile(join(this.opts.assetsDir, row.fileName))
++ } catch {
++ store.mediaMirrors.markFailed(
++ row.assetHash,
++ 'local file missing',
++ this.now(),
++ true,
++ this.now(),
++ )
++ return
++ }
++ const sha256 = row.assetHash.slice('sha256-'.length)
++ let id = row.mediaId
++ if (!id) {
++ const init = await client.json<{ id: string }>({
++ service: 'media',
++ method: 'POST',
++ path: this.base(),
++ json: {
++ kind: 'asset',
++ visibility: 'public',
++ size_bytes: bytes.length,
++ mime_type: row.mime,
++ content_hash: sha256,
++ filename: row.fileName,
++ metadata: { source: 'games.map-asset', asset_hash: row.assetHash },
++ },
++ idempotencyKey: false,
++ })
++ id = init.id
++ store.mediaMirrors.noteObject(row.assetHash, id, this.now())
++ }
++ try {
++ await client.request({
++ service: 'media',
++ method: 'PUT',
++ path: `${this.base()}/${encodeURIComponent(id)}/content`,
++ body: bytes,
++ headers: { 'content-type': row.mime },
++ idempotent: true,
++ })
++ } catch (err) {
++ // 409: the bytes already arrived on an earlier attempt — go on to complete.
++ if ((err as HttpError).status !== 409) throw err
++ }
++ const done = await client.json<ObjectBody>({
++ service: 'media',
++ method: 'POST',
++ path: `${this.base()}/${encodeURIComponent(id)}/complete`,
++ json: { content_hash: sha256 },
++ idempotent: true,
++ })
++ store.mediaMirrors.markMirrored(row.assetHash, done.id, done.public_url ?? null, this.now())
++ this.opts.log.info('map asset mirrored to media', { asset: row.assetHash, media: done.id })
++ }
++
++ private fail(row: MediaMirrorDto, err: HttpError): void {
++ const status = err.status ?? 0
++ const attempts = row.attempts + 1
++ const terminal = TERMINAL_STATUS.has(status) || attempts >= MAX_ATTEMPTS
++ const wait = BACKOFF_MS[Math.min(row.attempts, BACKOFF_MS.length - 1)] ?? 60_000
++ const message = `${err.code ?? `http.${status}`}: ${err.message ?? 'failed'}`
++ this.opts.store.mediaMirrors.markFailed(
++ row.assetHash,
++ message,
++ this.now() + wait,
++ terminal,
++ this.now(),
++ )
++ this.opts.log.warn('media mirror failed', {
++ asset: row.assetHash,
++ status,
++ terminal,
++ error: message,
++ })
++ }
++}
ddiff --git a/apps/server/src/platform/proposals.test.ts b/apps/server/src/platform/proposals.test.ts
nnew file mode 100644
iindex 0000000..371c9e7
---- /dev/null
++++ b/apps/server/src/platform/proposals.test.ts
@@@ -0,0 +1,64 @@
++/**
++ * The Contracts proposals in docs/ stay valid and in step with the code:
++ * capability manifests and the service manifest validate against the
++ * released contracts, and every event type the service manifest declares is
++ * one the code can actually emit.
++ */
++import { readdirSync, readFileSync } from 'node:fs'
++import { createRequire } from 'node:module'
++import { describe, expect, it } from 'vitest'
++import { CONTENT_RUNTIME_CAPABILITIES } from '../mods/contentPack.js'
++import { CAP_MOD_MANAGE } from './staffAuth.js'
++
++interface Contracts {
++ validate(
++ ref: string,
++ value: unknown,
++ ): { valid: boolean; errors: { path: string; message: string }[] }
++}
++const contracts = createRequire(import.meta.url)('openvibe-contracts') as Contracts
++const DOCS = new URL('../../../../docs/', import.meta.url)
++const readJson = (rel: string): Record<string, unknown> =>
++ JSON.parse(readFileSync(new URL(rel, DOCS), 'utf8')) as Record<string, unknown>
++
++describe('Contracts proposals', () => {
++ const capFiles = readdirSync(new URL('capabilities-proposal/', DOCS)).filter((f) =>
++ f.endsWith('.json'),
++ )
++
++ it('capability manifests are valid capabilities.capability@1 with 3-segment ids', () => {
++ expect(capFiles.length).toBeGreaterThan(0)
++ for (const file of capFiles) {
++ const cap = readJson(`capabilities-proposal/${file}`)
++ expect(contracts.validate('capabilities.capability@1', cap).errors, file).toEqual([])
++ expect(`${String(cap.id)}.json`).toBe(file)
++ }
++ })
++
++ it('the service manifest is a valid registry.service-manifest@1 listing only games-owned capabilities', () => {
++ const manifest = readJson('service-manifest-proposal.json')
++ expect(contracts.validate('registry.service-manifest@1', manifest).errors).toEqual([])
++ const listed = manifest.capabilities as string[]
++ for (const id of listed) {
++ const cap = readJson(`capabilities-proposal/${id}.json`)
++ expect(cap.owner, id).toBe('games')
++ }
++ // Every capability Games checks is declared.
++ for (const id of [...CONTENT_RUNTIME_CAPABILITIES, CAP_MOD_MANAGE]) {
++ expect(listed).toContain(id)
++ }
++ })
++
++ it('declares exactly the events the code emits', async () => {
++ const manifest = readJson('service-manifest-proposal.json')
++ const source = ['gameEvents.ts', '../mods/registry.ts']
++ .map((f) => readFileSync(new URL(f, import.meta.url), 'utf8'))
++ .join('\n')
++ const lifecycles = ['installed', 'enabled', 'disabled', 'revoked', 'grants_changed']
++ for (const type of manifest.eventsProduced as string[]) {
++ const literal = source.includes(`'${type}'`)
++ const mod = type.startsWith('games.mod.') && lifecycles.includes(type.slice(10))
++ expect(literal || mod, type).toBe(true)
++ }
++ })
++})
ddiff --git a/apps/server/src/platform/serviceClient.ts b/apps/server/src/platform/serviceClient.ts
nnew file mode 100644
iindex 0000000..f2ece3f
---- /dev/null
++++ b/apps/server/src/platform/serviceClient.ts
@@@ -0,0 +1,46 @@
++/**
++ * Games as a platform principal (ADR-003): every call Games makes to another
++ * OpenVibe service — Events, Media, Network identity — carries a
++ * client-credentials token of the `games` OAuth client, one cached token per
++ * audience (`openvibe.events`, `openvibe.media`, `openvibe.network`). There
++ * is no shared key anywhere: the Network grants each capability explicitly.
++ *
++ * The SSO calls made on a player's behalf (/api/auth/me, the code and FedCM
++ * exchanges in httpServer.ts) keep using the player's own token and the
++ * OAuth client authentication they need; they are not service calls.
++ */
++import { createServiceTokenClient } from 'openvibe-sdk/auth'
++import { createClient, type FetchLike, type OpenVibeClient } from 'openvibe-sdk/core'
++import type { PlatformConfig } from '../config.js'
++
++export interface PlatformClient {
++ client: OpenVibeClient
++ clientId: string
++}
++
++/** Null when no client secret is configured: Games then makes no service calls. */
++export function createPlatformClient(
++ cfg: PlatformConfig,
++ fetchImpl?: FetchLike,
++): PlatformClient | null {
++ if (!cfg.clientSecret) return null
++ const tokens = createServiceTokenClient({
++ tokenUrl: `${cfg.networkUrl}/oauth/token`,
++ clientId: cfg.clientId,
++ clientSecret: cfg.clientSecret,
++ ...(fetchImpl ? { fetch: fetchImpl } : {}),
++ })
++ const baseUrls: Record<string, string> = { network: cfg.networkUrl }
++ if (cfg.eventsUrl) baseUrls.events = cfg.eventsUrl
++ if (cfg.mediaUrl) baseUrls.media = cfg.mediaUrl
++ const client = createClient({
++ network: cfg.networkUrl,
++ baseUrls,
++ tokenProvider: tokens,
++ autoDiscover: false,
++ // The outbox and the mirror queue do their own retrying with backoff.
++ retries: 0,
++ ...(fetchImpl ? { fetch: fetchImpl } : {}),
++ })
++ return { client, clientId: cfg.clientId }
++}
ddiff --git a/apps/server/src/platform/staffAuth.ts b/apps/server/src/platform/staffAuth.ts
nnew file mode 100644
iindex 0000000..3fba55f
---- /dev/null
++++ b/apps/server/src/platform/staffAuth.ts
@@@ -0,0 +1,105 @@
++/**
++ * Who may manage mods: an openvibe.network owner/admin session (the same
++ * rank that may edit the map), or a platform principal whose client-
++ * credentials token for audience `openvibe.games` carries the
++ * `games.mod.manage` capability (verified offline against the Network JWKS).
++ * Without a Network configured (local development) the EDITOR_KEY is
++ * accepted, exactly as for the map editor.
++ */
++import { timingSafeEqual } from 'node:crypto'
++import type { IncomingMessage } from 'node:http'
++import { createRequire } from 'node:module'
++import { verifyUserToken, type UserTokenClaims } from 'openvibe-sdk/auth'
++import type { ModActor } from '../mods/registry.js'
++import { canEditMap, resolveNetworkUser } from '../net/networkAuth.js'
++import { legacyNetworkKey } from './accounts.js'
++
++export const CAP_MOD_MANAGE = 'games.mod.manage'
++export const GAMES_AUDIENCE = 'openvibe.games'
++
++interface ContractsCaps {
++ capabilities: { grants(granted: unknown, capability: string): boolean }
++}
++const contracts = createRequire(import.meta.url)('openvibe-contracts') as ContractsCaps
++
++export interface StaffAuthOptions {
++ /** openvibe.network /api/auth/me, or null in local development. */
++ networkAuthUrl: string | null
++ /** Network base for the JWKS (`/api/.well-known/jwks`). */
++ networkUrl: string
++ editorKey: string | null
++ /** Test seam: verifies a principal token. */
++ verifyPrincipal?: (token: string) => Promise<UserTokenClaims>
++}
++
++/** Reads a JWT payload without trusting it — only to pick the verification path. */
++function looksLikePrincipal(token: string): boolean {
++ const part = token.split('.')[1]
++ if (!part) return false
++ try {
++ const claims = JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as {
++ sub?: unknown
++ actor_type?: unknown
++ }
++ return (
++ ['service', 'app', 'mod'].includes(String(claims.actor_type)) ||
++ /^(svc|app|mod):/.test(String(claims.sub ?? ''))
++ )
++ } catch {
++ return false
++ }
++}
++
++function sameSecret(a: string, b: string): boolean {
++ const x = Buffer.from(a)
++ const y = Buffer.from(b)
++ return x.length === y.length && timingSafeEqual(x, y)
++}
++
++export function createStaffAuthorizer(
++ opts: StaffAuthOptions,
++): (req: IncomingMessage) => Promise<ModActor | null> {
++ const verifyPrincipal =
++ opts.verifyPrincipal ??
++ ((token: string) =>
++ verifyUserToken(token, {
++ jwks: `${opts.networkUrl}/api/.well-known/jwks`,
++ audience: GAMES_AUDIENCE,
++ allowServiceTokens: true,
++ }))
++
++ return async (req) => {
++ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]?.trim()
++ const editorKey = req.headers['x-editor-key']
++ const token = bearer ?? (typeof editorKey === 'string' ? editorKey : undefined)
++ if (!token) return null
++
++ if (bearer && looksLikePrincipal(bearer)) {
++ try {
++ const claims = await verifyPrincipal(bearer)
++ if (!contracts.capabilities.grants(claims.cap, CAP_MOD_MANAGE)) return null
++ const sub = String(claims.sub)
++ const slug = /^svc:([a-z][a-z0-9-]{1,39})$/.exec(sub)?.[1]
++ return {
++ audit: sub,
++ subject: slug ? { type: 'service', id: slug } : { type: 'service', id: 'games' },
++ }
++ } catch {
++ return null
++ }
++ }
++
++ if (opts.networkAuthUrl) {
++ const user = await resolveNetworkUser(opts.networkAuthUrl, token)
++ if (!user || !canEditMap(user.rank)) return null
++ if (user.subjectId?.startsWith('usr_')) {
++ return { audit: user.subjectId, subject: { type: 'user', id: user.subjectId } }
++ }
++ return { audit: legacyNetworkKey(user.id), subject: { type: 'service', id: 'games' } }
++ }
++ if (opts.editorKey && sameSecret(token, opts.editorKey)) {
++ return { audit: 'editor-key', subject: { type: 'service', id: 'games' } }
++ }
++ return null
++ }
++}
ddiff --git a/docs/capabilities-proposal/games.mod.manage.json b/docs/capabilities-proposal/games.mod.manage.json
nnew file mode 100644
iindex 0000000..00e3193
---- /dev/null
++++ b/docs/capabilities-proposal/games.mod.manage.json
@@@ -0,0 +1,28 @@
++{
++ "id": "games.mod.manage",
++ "version": "1.0",
++ "owner": "games",
++ "status": "active",
++ "visibility": "first-party",
++ "description": "Manage mod installs in the browser game's registry (ADR-013): install a mods/mod-manifest.v1 manifest with its games-content@1 pack, approve or revoke individual capabilities of an install, enable, disable, and revoke an install for good (its effects end on the next server tick). Every action is audited and published as a games.mod.* event. Trust tiers are metadata and never grant anything. Also accepted from an openvibe.network owner/admin session.",
++ "inputSchema": "mods.mod-manifest@1",
++ "permissions": ["games:mods:admin"],
++ "resourceConstraints": ["none"],
++ "quotaClass": "games-admin",
++ "events": [
++ "games.mod.installed",
++ "games.mod.enabled",
++ "games.mod.disabled",
++ "games.mod.grants_changed",
++ "games.mod.revoked"
++ ],
++ "implementedBy": [
++ "POST /api/v1/mods",
++ "POST /api/v1/mods/:id/enable",
++ "POST /api/v1/mods/:id/disable",
++ "POST /api/v1/mods/:id/revoke",
++ "POST /api/v1/mods/:id/grants",
++ "DELETE /api/v1/mods/:id/grants/:capability",
++ "GET /api/v1/mods/:id/audit"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/games.mod.read.json b/docs/capabilities-proposal/games.mod.read.json
nnew file mode 100644
iindex 0000000..e1e63cf
---- /dev/null
++++ b/docs/capabilities-proposal/games.mod.read.json
@@@ -0,0 +1,14 @@
++{
++ "id": "games.mod.read",
++ "version": "1.0",
++ "owner": "games",
++ "status": "active",
++ "visibility": "public",
++ "description": "Read the browser game's installed mods: manifest, content pack, status, trust tier, requested and granted capabilities. Public; no token needed.",
++ "outputSchema": "mods.mod-manifest@1",
++ "permissions": ["games:mods:read"],
++ "resourceConstraints": ["none"],
++ "quotaClass": "games-read",
++ "events": [],
++ "implementedBy": ["GET /api/v1/mods", "GET /api/v1/mods/:id"]
++}
ddiff --git a/docs/capabilities-proposal/games.prop.place.json b/docs/capabilities-proposal/games.prop.place.json
nnew file mode 100644
iindex 0000000..aa0d8d9
---- /dev/null
++++ b/docs/capabilities-proposal/games.prop.place.json
@@@ -0,0 +1,15 @@
++{
++ "id": "games.prop.place",
++ "version": "1.0",
++ "owner": "games",
++ "status": "active",
++ "visibility": "public",
++ "description": "Runtime binding for mods (games-content@1): place and remove inert props (existing content items without health, storage, shop, machine or vehicle-part capabilities) in the world, owned by the mod so players cannot pick them up or move them. The props exist only while the capability is granted and the install enabled: revoking either despawns them on the next tick.",
++ "permissions": ["games:world:place"],
++ "resourceConstraints": ["none"],
++ "quotaClass": "games-mod-runtime",
++ "events": [],
++ "implementedBy": [
++ "mod runtime binding ModApi.placeProp / ModApi.removeProp (apps/server/src/mods/runtime.ts)"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/games.world.announce.json b/docs/capabilities-proposal/games.world.announce.json
nnew file mode 100644
iindex 0000000..d3976cc
---- /dev/null
++++ b/docs/capabilities-proposal/games.world.announce.json
@@@ -0,0 +1,13 @@
++{
++ "id": "games.world.announce",
++ "version": "1.0",
++ "owner": "games",
++ "status": "active",
++ "visibility": "public",
++ "description": "Runtime binding for mods (games-content@1): broadcast a short server announcement to every player in the world, prefixed with the mod's name, at most once a minute per announcement. Checked at call time against the install's approved capabilities; a revoked grant stops it on the next tick.",
++ "permissions": ["games:world:announce"],
++ "resourceConstraints": ["none"],
++ "quotaClass": "games-mod-runtime",
++ "events": [],
++ "implementedBy": ["mod runtime binding ModApi.announce (apps/server/src/mods/runtime.ts)"]
++}
ddiff --git a/docs/capabilities-proposal/mods.grant.manage.json b/docs/capabilities-proposal/mods.grant.manage.json
nnew file mode 100644
iindex 0000000..9ddcb23
---- /dev/null
++++ b/docs/capabilities-proposal/mods.grant.manage.json
@@@ -0,0 +1,14 @@
++{
++ "id": "mods.grant.manage",
++ "version": "1.0",
++ "owner": "network",
++ "status": "planned",
++ "visibility": "first-party",
++ "description": "Planned (ADR-013, grants in Network): create the `mod` principal (mod:mod_<ULID>) for an install and approve or revoke the subset of its manifest's requested capabilities, so a mod's grants are checked like any other principal's. Until Network implements it, each runtime keeps the approved subset itself (OpenVibe.Games: mod_grants).",
++ "inputSchema": "mods.mod-manifest@1",
++ "permissions": ["network:grants:admin"],
++ "resourceConstraints": ["none"],
++ "quotaClass": "network-admin",
++ "events": [],
++ "implementedBy": []
++}
ddiff --git a/docs/contracts-proposal/catalog-entry.json b/docs/contracts-proposal/catalog-entry.json
nnew file mode 100644
iindex 0000000..20df671
---- /dev/null
++++ b/docs/contracts-proposal/catalog-entry.json
@@@ -0,0 +1,11 @@
++{
++ "_about": "Proposed catalog entry for contracts/catalog.json (the schema is mods/mod-manifest.v1.json next to this file; it references ../identity/subject-ref.v1.json and ../media/media-ref.v1.json).",
++ "id": "mods.mod-manifest",
++ "version": "1.0.0",
++ "owner": "contracts",
++ "schema": "mods/mod-manifest.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-013"
++}
ddiff --git a/docs/contracts-proposal/mods/mod-manifest.v1.json b/docs/contracts-proposal/mods/mod-manifest.v1.json
nnew file mode 100644
iindex 0000000..2f5ae9b
---- /dev/null
++++ b/docs/contracts-proposal/mods/mod-manifest.v1.json
@@@ -0,0 +1,134 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/mods/mod-manifest.v1.json",
++ "title": "ModManifest",
++ "description": "A mod as the platform knows it (ADR-013): who publishes it, which runtime runs it, which capabilities it asks for and the resources it may use. Requested capabilities are only a request: the install's approved subset is the grant, and trust tiers are install metadata that never change a grant check. The runtime-specific payload (a data pack, a script bundle) is not part of the manifest.",
++ "type": "object",
++ "required": [
++ "id",
++ "name",
++ "version",
++ "publisher",
++ "target",
++ "runtime",
++ "permissions",
++ "resources",
++ "compatibility"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^mod_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Stable mod id; its principal subject is mod:<id>."
++ },
++ "name": { "type": "string", "minLength": 1, "maxLength": 80 },
++ "version": {
++ "type": "string",
++ "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$",
++ "description": "Semantic version of this release."
++ },
++ "description": { "type": "string", "maxLength": 1000 },
++ "publisher": {
++ "$ref": "../identity/subject-ref.v1.json",
++ "description": "Who publishes the mod (a user or an app subject)."
++ },
++ "target": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}\\.[a-z][a-z0-9-]{1,39}$",
++ "description": "Where the mod runs: <service>.<surface>, e.g. games.browser, games.source, live.overlay."
++ },
++ "runtime": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}@[1-9][0-9]{0,3}$",
++ "description": "Runtime adapter and its major version, e.g. games-content@1 (declarative data pack) or source-quickjs@1."
++ },
++ "permissions": {
++ "type": "object",
++ "required": ["capabilities"],
++ "additionalProperties": false,
++ "properties": {
++ "capabilities": {
++ "description": "Capability ids the mod asks for (3+ segments). The target runtime binds only those it implements, and only once granted.",
++ "type": "array",
++ "maxItems": 64,
++ "uniqueItems": true,
++ "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$" }
++ },
++ "events": {
++ "description": "Event types the mod wants delivered to it.",
++ "type": "array",
++ "maxItems": 64,
++ "uniqueItems": true,
++ "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$" }
++ },
++ "modules": {
++ "description": "User-module namespaces the mod wants to read or write (a trailing .* names a family).",
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)*(\\.\\*)?$" }
++ },
++ "mediaNamespaces": {
++ "description": "Media namespaces the mod wants to read or write.",
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": { "type": "string", "pattern": "^[a-z][a-z0-9_-]*(\\.[a-z0-9_-]+)*$" }
++ }
++ }
++ },
++ "resources": {
++ "type": "object",
++ "required": ["cpuMs", "memoryMb", "storageMb"],
++ "additionalProperties": false,
++ "description": "The budget the mod asks for. Runtimes meter it; the sandbox that enforces it lives in OpenVibe.Host (Stage C).",
++ "properties": {
++ "cpuMs": {
++ "type": "number",
++ "minimum": 0,
++ "maximum": 1000,
++ "description": "CPU milliseconds per tick (game runtimes) or per request."
++ },
++ "memoryMb": { "type": "integer", "minimum": 0, "maximum": 4096 },
++ "storageMb": { "type": "integer", "minimum": 0, "maximum": 102400 },
++ "outboundHosts": {
++ "description": "Hosts the mod may reach over the network. Empty or absent = none.",
++ "type": "array",
++ "maxItems": 32,
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "pattern": "^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$"
++ }
++ }
++ }
++ },
++ "assets": {
++ "description": "Assets the mod ships, as Media object references.",
++ "type": "array",
++ "maxItems": 256,
++ "items": { "$ref": "../media/media-ref.v1.json" }
++ },
++ "compatibility": {
++ "type": "object",
++ "required": ["runtime"],
++ "additionalProperties": false,
++ "properties": {
++ "runtime": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 100,
++ "description": "Semver range of the runtime this release works with, e.g. \">=1.0.0 <2.0.0\"."
++ },
++ "contracts": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 100,
++ "description": "Semver range of openvibe-contracts releases it was built against."
++ }
++ }
++ },
++ "homepage": { "type": "string", "pattern": "^https://[^\\s]{1,2000}$" }
++ }
++}
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
nnew file mode 100644
iindex 0000000..6f6a5c7
---- /dev/null
++++ b/docs/service-manifest-proposal.json
@@@ -0,0 +1,34 @@
++{
++ "version": "0.2.0",
++ "contractRanges": {
++ "openvibe-contracts": ">=0.8.0 <1.0.0"
++ },
++ "id": "games",
++ "name": "OpenVibe.Games",
++ "status": "stable",
++ "repository": "OpenVibers/OpenVibe.Games",
++ "domains": ["openvibe.games", "play.openvibe.games"],
++ "publicOrigin": "https://openvibe.games",
++ "health": "/healthz",
++ "capabilities": [
++ "games.mod.manage",
++ "games.mod.read",
++ "games.world.announce",
++ "games.prop.place"
++ ],
++ "eventsProduced": [
++ "games.player.joined",
++ "games.player.left",
++ "games.skill.leveled",
++ "games.blueprint.unlocked",
++ "games.world.saved",
++ "games.mod.installed",
++ "games.mod.enabled",
++ "games.mod.disabled",
++ "games.mod.grants_changed",
++ "games.mod.revoked"
++ ],
++ "eventsConsumed": [],
++ "namespacesOwned": ["games.*"],
++ "notes": "Scraplandia (browser runtime). Players are keyed by canonical Network subjects (ADR-0006 in the repo). Calls Events (events.event.publish), Media (media.object.upload, namespace games: map-editor assets) and Network identity (identity.subject.resolve, batch legacy adoption) with the games client-credentials principal. Mods: games-content@1 declarative packs only; executable mods wait for OpenVibe.Host Stage C. No achievement system exists, so there are no games.achievement.* events."
++}
ddiff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
iindex e0e8c86..0fc0fa5 100644
---- a/pnpm-lock.yaml
++++ b/pnpm-lock.yaml
@@@ -104,6 +104,15 @@ importers:
'@openvibe/shared':
specifier: workspace:*
version: link:../../packages/shared
++ ajv:
++ specifier: ^8.17.1
++ version: 8.20.0
++ openvibe-contracts:
++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0
++ version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0
++ openvibe-sdk:
++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2
++ version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2
tsx:
specifier: ^4.19.3
version: 4.23.11
@@@ -1297,12 +1306,29 @@ packages:
engines: { node: '>=0.4.0' }
hasBin: true
++ resolution:
++ {
++ integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==,
++ }
++ peerDependencies:
++ ajv: ^8.0.0
++ peerDependenciesMeta:
++ ajv:
++ optional: true
++
resolution:
{
integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==,
}
++ resolution:
++ {
++ integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==,
++ }
++
resolution:
{
@@@ -1680,6 +1706,12 @@ packages:
integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==,
}
++ resolution:
++ {
++ integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==,
++ }
++
resolution:
{
@@@ -1890,6 +1922,12 @@ packages:
integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==,
}
++ resolution:
++ {
++ integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==,
++ }
++
resolution:
{
@@@ -2018,6 +2056,19 @@ packages:
integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==,
}
++ openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0:
++ resolution:
++ { tarball: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0 }
++ version: 0.8.0
++ engines: { node: '>=20' }
++ hasBin: true
++
++ openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2:
++ resolution:
++ { tarball: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2 }
++ version: 0.2.2
++ engines: { node: '>=20' }
++
resolution:
{
@@@ -3213,6 +3264,10 @@ snapshots:
++ optionalDependencies:
++ ajv: 8.20.0
++
dependencies:
fast-deep-equal: 3.1.3
@@@ -3220,6 +3275,13 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
++ dependencies:
++ fast-deep-equal: 3.1.3
++ fast-uri: 3.1.8
++ json-schema-traverse: 1.0.0
++ require-from-string: 2.0.2
++
dependencies:
color-convert: 2.0.1
@@@ -3484,6 +3546,8 @@ snapshots:
++
optionalDependencies:
picomatch: 4.0.5
@@@ -3593,6 +3657,8 @@ snapshots:
++
@@@ -3650,6 +3716,13 @@ snapshots:
dependencies:
wrappy: 1.0.2
++ openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0:
++ dependencies:
++ ajv: 8.20.0
++ ajv-formats: 3.0.1([email protected])
++
++ openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2: {}
++
dependencies:
deep-is: 0.1.4