From bfae85c690969de897d26e5137f4672716e79fd6 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Tue, 4 Aug 2026 17:52:53 +0200 Subject: [PATCH 1/8] fix: enforce typed plugin container access --- packages/core/src/contracts/adapters/index.ts | 1 + packages/core/src/contracts/adapters/token.ts | 6 +- packages/core/src/server/kernel/container.ts | 21 ++++-- .../__tests__/typed-plugin.test.ts | 64 +++++++++++++++++++ .../src/server/plugin-host/define-plugin.ts | 38 ++++++++--- packages/core/src/server/plugin-host/index.ts | 3 +- .../src/server/plugin-host/load-plugins.ts | 2 +- .../src/server/plugin-host/module-registry.ts | 12 ++-- 8 files changed, 123 insertions(+), 24 deletions(-) diff --git a/packages/core/src/contracts/adapters/index.ts b/packages/core/src/contracts/adapters/index.ts index 74b2d1ea..2a57f771 100644 --- a/packages/core/src/contracts/adapters/index.ts +++ b/packages/core/src/contracts/adapters/index.ts @@ -9,6 +9,7 @@ export type { SealedToken, ClientPageToken, TokenCatalog, + TokenCatalogValues, TokenValue, } from './token.js'; export { createToken, createSealedToken, createClientPageToken } from './token.js'; diff --git a/packages/core/src/contracts/adapters/token.ts b/packages/core/src/contracts/adapters/token.ts index 4e4f8dd3..0c19f308 100644 --- a/packages/core/src/contracts/adapters/token.ts +++ b/packages/core/src/contracts/adapters/token.ts @@ -11,7 +11,11 @@ export type AnyToken = symbol & { readonly __key?: K; }; -export type TokenCatalog = Record>; +export type TokenCatalogValues = Record; + +export type TokenCatalog = { + [K in keyof Values]: AnyToken; +}; export type TokenValue = T extends AnyToken ? Value : never; diff --git a/packages/core/src/server/kernel/container.ts b/packages/core/src/server/kernel/container.ts index 0deda7bd..b70c9c4d 100644 --- a/packages/core/src/server/kernel/container.ts +++ b/packages/core/src/server/kernel/container.ts @@ -8,7 +8,7 @@ import type { AnyToken, TokenCatalog, TokenValue } from '@openora/core/contracts // sealed/overlay-rejection rules live one layer up, in ModuleRegistry's // provide()/provideSealed() (see plugin-host/module-registry.ts). -export type Factory = (c: Container) => T; +export type Factory = (c: Container) => T; /** * Functional DI container - no decorators, no reflection. `get()` resolves @@ -21,24 +21,33 @@ export type Factory = (c: Container) => T; * register dependencies before their dependents so teardown happens safely. */ export class Container { - private readonly factories = new Map>(); + private readonly factories = new Map>(); private readonly instances = new Map(); private readonly resolving = new Set(); private readonly disposers: Array<() => void | Promise> = []; - register(token: AnyToken, factory: Factory): void { - this.factories.set(token, factory as Factory); + register( + token: C[K], + factory: (container: Container) => TokenValue, + ): void; + register(token: [C] extends [never] ? AnyToken : never, factory: Factory): void; + register(token: AnyToken, factory: Factory): void { + this.registerUnsafe(token, factory); + } + + registerUnsafe(token: AnyToken, factory: Factory): void { + this.factories.set(token, factory); this.instances.delete(token); } has(token: C[K]): boolean; - has(token: AnyToken): boolean; + has(token: [C] extends [never] ? AnyToken : C[keyof C] & AnyToken): boolean; has(token: AnyToken): boolean { return this.factories.has(token); } get(token: C[K]): TokenValue; - get(token: AnyToken): T; + get(token: [C] extends [never] ? AnyToken : never): T; get(token: AnyToken): T { if (this.instances.has(token)) { return this.instances.get(token) as T; diff --git a/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts b/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts index 25f11a1a..dcd8860c 100644 --- a/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts @@ -8,6 +8,36 @@ const SEED = createToken('SEED'); const OTHER = createToken('OTHER'); const catalog = { COUNT, SEED } satisfies TokenCatalog; +const typedCatalog = { COUNT, SEED } satisfies TokenCatalog<{ COUNT: number; SEED: number }>; + +void typedCatalog; + +const typedCatalogWrongValue = { + // @ts-expect-error The catalog value type must match the token value. + COUNT: createToken('COUNT'), +} satisfies TokenCatalog<{ COUNT: number }>; + +void typedCatalogWrongValue; + +function assertTypedContainer() { + const typedContainer = new Container(); + const typedCount: number = typedContainer.get(COUNT); + void typedCount; + + // @ts-expect-error A catalogued container factory must return the token value. + typedContainer.register(COUNT, () => 'wrong-value'); + + // @ts-expect-error A catalogued container cannot register an unknown token. + typedContainer.register(OTHER, () => 'not-registered'); + + // @ts-expect-error A catalogued container cannot resolve an unknown token. + typedContainer.get(OTHER); + + // @ts-expect-error A catalogued container cannot check an unknown token. + typedContainer.has(OTHER); +} + +void assertTypedContainer; const typedPlugin = definePluginWithCatalog()({ id: 'typed-plugin', @@ -15,11 +45,34 @@ const typedPlugin = definePluginWithCatalog()({ register(ctx) { ctx.provide(COUNT, (container) => container.get(SEED) + 1); + ctx.routers.add('typed', (container) => { + const seed: number = container.get(SEED); + return { seed }; + }); + // @ts-expect-error A token outside the catalog is not a valid provider. ctx.provide(OTHER, () => 'not-registered'); }, }); +definePluginWithCatalog()({ + id: 'invalid-typed-plugin', + register(ctx) { + // @ts-expect-error A provider must return the catalog token value. + ctx.provide(COUNT, () => 'wrong-value'); + + ctx.routers.add('invalid', (container) => { + // @ts-expect-error A router can only resolve catalogued tokens. + container.get(OTHER); + + // @ts-expect-error A router can only check catalogued tokens. + container.has(OTHER); + + return undefined; + }); + }, +}); + const typedPluginId: 'typed-plugin' = typedPlugin.id; const typedDependency: readonly ['foundation'] = typedPlugin.dependsOn ?? ['foundation']; @@ -50,4 +103,15 @@ describe('typed plugin surface', () => { expect(validGraph[1]?.dependsOn).toEqual(['foundation']); expect(container.get(COUNT)).toBe(42); }); + + it('keeps router factories on the catalogued container view', () => { + const container = new Container(); + const registry = new ModuleRegistryImpl(container); + + container.register(SEED, () => 7); + typedPlugin.register(registry); + + const router = registry.routers.getAll().get('typed'); + expect(router?.(container)).toEqual({ seed: 7 }); + }); }); diff --git a/packages/core/src/server/plugin-host/define-plugin.ts b/packages/core/src/server/plugin-host/define-plugin.ts index ddeca569..dd3e453b 100644 --- a/packages/core/src/server/plugin-host/define-plugin.ts +++ b/packages/core/src/server/plugin-host/define-plugin.ts @@ -16,7 +16,9 @@ export type McpToolDefinition = { }; // Runs once at boot, after every plugin has registered its providers, so adapter overrides (last registration wins) are in effect. -export type RouterFactory = (c: Container) => unknown; +export type RouterFactory = ( + c: [C] extends [never] ? Container : TypedContainer, +) => unknown; export type TypedContainer = { get(token: C[K]): TokenValue; @@ -26,22 +28,38 @@ export type TypedContainer = { export type EventHandler = (payload: unknown, envelope?: EventEnvelope) => void | Promise; +type CatalogToken = [C] extends [never] + ? Token + : C[keyof C] & Token; + +type PluginContainer = Container; + +type CatalogTokenValue = TokenValue; + export type ModuleRegistry = { // Last registration wins - an overlay loaded after a module can rebind its adapter token. + provide( + token: C[K] & Token>, + factory: (container: TypedContainer) => CatalogTokenValue, + ): void; provide( - token: [C] extends [never] ? Token : C[keyof C] & Token, - factory: (container: [C] extends [never] ? Container : TypedContainer) => T, + token: [C] extends [never] ? Token : never, + factory: (container: PluginContainer) => T, ): void; // Bind-once, owner-only. The ONLY legitimate way to bind a SealedToken - provide() // rejects sealed tokens outright. A second call for the same token (an overlay // trying to override a regulator-mandated service) throws instead of rebinding. + provideSealed( + token: C[K] & SealedToken>, + factory: (container: TypedContainer) => CatalogTokenValue, + ): void; provideSealed( - token: [C] extends [never] ? SealedToken : C[keyof C] & SealedToken, - factory: (container: [C] extends [never] ? Container : TypedContainer) => T, + token: [C] extends [never] ? SealedToken : never, + factory: (container: PluginContainer) => T, ): void; routers: { - add(namespace: string, factory: RouterFactory): void; - getAll(): Map; + add(namespace: string, factory: RouterFactory): void; + getAll(): Map>; }; slots: { fill(slotName: string, component: unknown): void; @@ -62,6 +80,8 @@ export type ModuleRegistry = { }; }; +export type PluginContext = ModuleRegistry; + export type PluginDefinition< C extends TokenCatalog = never, Id extends string = string, @@ -70,8 +90,8 @@ export type PluginDefinition< id: Id; dependsOn?: Dependencies; // Verified once after all plugins register - a missing port fails fast. See ADR-0024. - requiresPorts?: Token[]; - register: (ctx: ModuleRegistry) => void | Promise; + requiresPorts?: CatalogToken[]; + register: (ctx: PluginContext) => void | Promise; }; export type Plugin< diff --git a/packages/core/src/server/plugin-host/index.ts b/packages/core/src/server/plugin-host/index.ts index f910c1de..51e792ff 100644 --- a/packages/core/src/server/plugin-host/index.ts +++ b/packages/core/src/server/plugin-host/index.ts @@ -3,8 +3,9 @@ export type { Plugin, PluginDefinition, ModuleRegistry, - McpToolDefinition, + PluginContext, RouterFactory, + McpToolDefinition, EventHandler, TypedContainer, } from './define-plugin.js'; diff --git a/packages/core/src/server/plugin-host/load-plugins.ts b/packages/core/src/server/plugin-host/load-plugins.ts index e3050bcd..6fd61df9 100644 --- a/packages/core/src/server/plugin-host/load-plugins.ts +++ b/packages/core/src/server/plugin-host/load-plugins.ts @@ -174,7 +174,7 @@ export async function loadPlugins( */ export function assertRequiredPorts( plugins: Plugin[], - container: Container, + container: Container, ): void { const unbound = plugins.flatMap((plugin) => (plugin.requiresPorts ?? []) diff --git a/packages/core/src/server/plugin-host/module-registry.ts b/packages/core/src/server/plugin-host/module-registry.ts index e745188d..30c7feca 100644 --- a/packages/core/src/server/plugin-host/module-registry.ts +++ b/packages/core/src/server/plugin-host/module-registry.ts @@ -8,7 +8,7 @@ import type { } from './define-plugin.js'; export class ModuleRegistryImpl implements ModuleRegistry { - private _routers = new Map(); + private _routers = new Map>(); private _slots = new Map(); private _events = new Map(); private _jobs: WorkerRegistration[] = []; @@ -21,7 +21,7 @@ export class ModuleRegistryImpl implements Modul // Sealed tokens (Symbol description prefixed `sealed:`) are rejected at runtime // even though the type system already blocks them - catches plain-JS callers and cast escapes. // Canonical sealed list lives in `@openora/core/compliance`. - provide = (token: Token, factory: Factory): void => { + provide = (token: Token, factory: Factory): void => { const desc = token.description ?? ''; if (desc.startsWith('sealed:')) { throw new Error( @@ -32,14 +32,14 @@ export class ModuleRegistryImpl implements Modul `See @openora/core/compliance for the canonical list.`, ); } - this.container.register(token, factory); + this.container.registerUnsafe(token, factory); }; // Bind-once. The owning module calls this during its own register() to bind the // canonical implementation; a second call for the same token - an overlay trying // to slip past provide()'s rejection, or a duplicate registration - throws instead // of silently rebinding (there is no "last-wins" for a sealed token). - provideSealed = (token: SealedToken, factory: Factory): void => { + provideSealed = (token: SealedToken, factory: Factory): void => { if (this._sealedBound.has(token)) { throw new Error( `[plugin-host] Sealed token (${token.description ?? '(unnamed)'}) is already bound. ` + @@ -47,11 +47,11 @@ export class ModuleRegistryImpl implements Modul ); } this._sealedBound.add(token); - this.container.register(token, factory); + this.container.registerUnsafe(token, factory); }; routers = { - add: (namespace: string, factory: RouterFactory) => { + add: (namespace: string, factory: RouterFactory) => { if (this._routers.has(namespace)) { throw new Error(`Router namespace "${namespace}" is already registered`); } From 344523cbe25921eaa6ca818ae0c35ddc39fe80bc Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Tue, 4 Aug 2026 18:48:20 +0200 Subject: [PATCH 2/8] fix: enforce catalogued plugin containers --- packages/core/src/admin-console/plugin.ts | 4 +- packages/core/src/analytics/plugin.ts | 9 +- packages/core/src/audit/plugin.ts | 11 +- packages/core/src/casino/gaming/plugin.ts | 9 +- packages/core/src/casino/lobby/plugin.ts | 4 +- packages/core/src/cms/plugin.ts | 10 +- packages/core/src/compliance/plugin.ts | 11 +- .../src/contracts/schemas/igaming-config.ts | 3 +- .../src/engagement/chat-commands/plugin.ts | 10 +- packages/core/src/engagement/chat/plugin.ts | 41 +++---- .../src/engagement/notifications/plugin.ts | 10 +- packages/core/src/iam/plugin.ts | 11 +- packages/core/src/pam/identity/plugin.ts | 11 +- .../core/src/pam/player-management/plugin.ts | 10 +- packages/core/src/pam/player-note/plugin.ts | 9 +- packages/core/src/pam/profile/plugin.ts | 4 +- packages/core/src/pam/tag/plugin.ts | 14 +-- .../server/kernel/__tests__/container.test.ts | 41 +++---- packages/core/src/server/kernel/container.ts | 37 ++++--- packages/core/src/server/kernel/index.ts | 2 +- .../__tests__/load-plugins.test.ts | 5 +- .../__tests__/module-registry.test.ts | 20 ++-- .../__tests__/required-ports.test.ts | 15 +-- .../__tests__/typed-plugin.test.ts | 13 ++- .../src/server/plugin-host/define-plugin.ts | 55 +++------- packages/core/src/server/plugin-host/index.ts | 2 +- .../src/server/plugin-host/load-plugins.ts | 4 +- .../src/server/plugin-host/module-registry.ts | 27 +++-- .../src/server/runtime/core-token-catalog.ts | 101 ++++++++++++++++++ .../core/src/server/runtime/create-app.ts | 10 +- packages/core/src/server/runtime/index.ts | 11 +- packages/core/src/wallet/plugin.ts | 10 +- .../src/__tests__/analytics.e2e.test.ts | 16 ++- .../fixtures/test-kyc-config-plugin.ts | 4 +- ...allet-auto-withdrawal-cap-config-plugin.ts | 4 +- ...st-wallet-auto-withdrawal-config-plugin.ts | 4 +- .../__tests__/gaming-stake-debit.e2e.test.ts | 9 +- .../testing/src/__tests__/kyc.e2e.test.ts | 9 +- packages/testing/src/__tests__/rg.e2e.test.ts | 15 ++- .../src/__tests__/tag-bf317.e2e.test.ts | 13 ++- .../testing/src/__tests__/tag.e2e.test.ts | 22 +++- .../wallet-ledger-auto-withdrawal.e2e.test.ts | 9 +- packages/testing/src/app.ts | 5 +- packages/testing/src/seed.ts | 10 +- 44 files changed, 448 insertions(+), 206 deletions(-) create mode 100644 packages/core/src/server/runtime/core-token-catalog.ts diff --git a/packages/core/src/admin-console/plugin.ts b/packages/core/src/admin-console/plugin.ts index e467356c..fe64fd02 100644 --- a/packages/core/src/admin-console/plugin.ts +++ b/packages/core/src/admin-console/plugin.ts @@ -5,11 +5,11 @@ import { ADMIN_WALLET_REPORTING, AUDIT_WRITER, } from '@openora/core/contracts'; -import { definePlugin, ADMIN_GUARD } from '@openora/core/server'; +import { definePluginWithCatalog, ADMIN_GUARD, type CoreTokenCatalog } from '@openora/core/server'; import { BackofficeService } from './service/backoffice.service.js'; import { createBackofficeRouter } from './router/index.js'; -export default definePlugin({ +export default definePluginWithCatalog()({ id: 'admin-console', dependsOn: ['identity', 'wallet', 'audit', 'gaming', 'iam'], register(ctx) { diff --git a/packages/core/src/analytics/plugin.ts b/packages/core/src/analytics/plugin.ts index 081eaced..c7e8b0cb 100644 --- a/packages/core/src/analytics/plugin.ts +++ b/packages/core/src/analytics/plugin.ts @@ -1,10 +1,15 @@ import { CACHE } from '@openora/core/contracts'; -import { definePlugin, ADMIN_GUARD, DRIZZLE } from '@openora/core/server'; +import { + definePluginWithCatalog, + ADMIN_GUARD, + DRIZZLE, + type CoreTokenCatalog, +} from '@openora/core/server'; import { FinancialAnalyticsService } from './service/financial-analytics.service.js'; import { FunnelAnalyticsService } from './service/funnel-analytics.service.js'; import { createAnalyticsRouter } from './router/index.js'; -export default definePlugin({ +export default definePluginWithCatalog()({ id: 'analytics', dependsOn: ['wallet', 'identity', 'profile', 'gaming'], register(ctx) { diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index 2f5c3fb8..1156408a 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -1,4 +1,11 @@ -import { definePlugin, EVENT_BUS, DRIZZLE, ADMIN_GUARD, createLogger } from '@openora/core/server'; +import { + definePluginWithCatalog, + EVENT_BUS, + DRIZZLE, + ADMIN_GUARD, + createLogger, + type CoreTokenCatalog, +} from '@openora/core/server'; import { AUDIT_WRITER, type DomainEventName } from '@openora/core/contracts'; import { AuditService, type RecordInput } from './service/audit.service.js'; import { createAuditRouter } from './router/index.js'; @@ -558,7 +565,7 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [ 'player.level.changed', ] as const; -export default definePlugin({ +export default definePluginWithCatalog()({ id: 'audit', register(ctx) { const logger = createLogger('audit'); diff --git a/packages/core/src/casino/gaming/plugin.ts b/packages/core/src/casino/gaming/plugin.ts index 6f2a4f29..79049d7b 100644 --- a/packages/core/src/casino/gaming/plugin.ts +++ b/packages/core/src/casino/gaming/plugin.ts @@ -1,4 +1,9 @@ -import { definePlugin, EVENT_BUS, DRIZZLE } from '@openora/core/server'; +import { + definePluginWithCatalog, + EVENT_BUS, + DRIZZLE, + type CoreTokenCatalog, +} from '@openora/core/server'; import { ADMIN_GAME_REPORTING, GAME_ADAPTER, @@ -12,7 +17,7 @@ import { MockGameAdapter } from './adapters/mock/mock-game-adapter.js'; import { MockRngAdapter } from './adapters/mock/mock-rng-adapter.js'; import { DrizzleAdminGameReporting } from './admin-reporting.js'; -export default definePlugin({ +export default definePluginWithCatalog()({ id: 'gaming', requiresPorts: [PLAY_ELIGIBILITY], dependsOn: ['wallet'], diff --git a/packages/core/src/casino/lobby/plugin.ts b/packages/core/src/casino/lobby/plugin.ts index 25d41942..4fa1274b 100644 --- a/packages/core/src/casino/lobby/plugin.ts +++ b/packages/core/src/casino/lobby/plugin.ts @@ -1,9 +1,9 @@ -import { definePlugin, DRIZZLE } from '@openora/core/server'; +import { definePluginWithCatalog, DRIZZLE, type CoreTokenCatalog } from '@openora/core/server'; import { CACHE } from '@openora/core/contracts'; import { LobbyService } from './service/lobby.service.js'; import { createLobbyRouter } from './router/index.js'; -export default definePlugin({ +export default definePluginWithCatalog()({ id: 'lobby', register(ctx) { ctx.routers.add('lobby', (c) => diff --git a/packages/core/src/cms/plugin.ts b/packages/core/src/cms/plugin.ts index 6c97484d..1483495a 100644 --- a/packages/core/src/cms/plugin.ts +++ b/packages/core/src/cms/plugin.ts @@ -1,9 +1,15 @@ -import { definePlugin, EVENT_BUS, DRIZZLE, ADMIN_GUARD } from '@openora/core/server'; +import { + definePluginWithCatalog, + EVENT_BUS, + DRIZZLE, + ADMIN_GUARD, + type CoreTokenCatalog, +} from '@openora/core/server'; import { CACHE } from '@openora/core/contracts'; import { CmsService } from './service/cms.service.js'; import { createCmsRouter } from './router/index.js'; -export default definePlugin({ +export default definePluginWithCatalog()({ id: 'cms', register(ctx) { ctx.routers.add('cms', (c) => diff --git a/packages/core/src/compliance/plugin.ts b/packages/core/src/compliance/plugin.ts index f3f668e5..ab8e2311 100644 --- a/packages/core/src/compliance/plugin.ts +++ b/packages/core/src/compliance/plugin.ts @@ -1,4 +1,11 @@ -import { definePlugin, EVENT_BUS, DRIZZLE, ADMIN_GUARD, createLogger } from '@openora/core/server'; +import { + definePluginWithCatalog, + EVENT_BUS, + DRIZZLE, + ADMIN_GUARD, + createLogger, + type CoreTokenCatalog, +} from '@openora/core/server'; import * as z from 'zod'; import { ADMIN_USER_DIRECTORY, @@ -51,7 +58,7 @@ const KycDecisionSyncJobSchema = z.object({ receivedAt: z.iso.datetime(), }); -export default definePlugin({ +export default definePluginWithCatalog()({ id: 'compliance', dependsOn: ['player-management', 'identity', 'wallet', 'gaming', 'audit'], requiresPorts: [LOGIN_ENFORCEMENT], diff --git a/packages/core/src/contracts/schemas/igaming-config.ts b/packages/core/src/contracts/schemas/igaming-config.ts index 954197ad..528031e4 100644 --- a/packages/core/src/contracts/schemas/igaming-config.ts +++ b/packages/core/src/contracts/schemas/igaming-config.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { createToken } from '../adapters/token.js'; // Centralized, schema-validated igaming configuration. A downstream operator // declares one of these and passes it to `createApp({ igaming })`; it is the single @@ -93,4 +94,4 @@ export function defineIgamingConfig(config: IgamingConfigInput): IgamingConfig { } /** DI token to inject the active IgamingConfig into services/adapters. */ -export const IGAMING_CONFIG = Symbol('IGAMING_CONFIG'); +export const IGAMING_CONFIG = createToken('IGAMING_CONFIG'); diff --git a/packages/core/src/engagement/chat-commands/plugin.ts b/packages/core/src/engagement/chat-commands/plugin.ts index 5619cd3a..dbcd1795 100644 --- a/packages/core/src/engagement/chat-commands/plugin.ts +++ b/packages/core/src/engagement/chat-commands/plugin.ts @@ -1,4 +1,10 @@ -import { definePlugin, DRIZZLE, EVENT_BUS, ADMIN_GUARD } from '@openora/core/server'; +import { + definePluginWithCatalog, + DRIZZLE, + EVENT_BUS, + ADMIN_GUARD, + type CoreTokenCatalog, +} from '@openora/core/server'; import { WALLET_COMMANDS, ADMIN_USER_DIRECTORY, @@ -13,7 +19,7 @@ import { import { ChatCommandsService } from './service/chat-commands.service.js'; import { createChatCommandsRouter } from './router/index.js'; -export default definePlugin({ +export default definePluginWithCatalog()({ id: 'chat-commands', dependsOn: ['chat', 'wallet', 'iam', 'audit', 'gaming'], register(ctx) { diff --git a/packages/core/src/engagement/chat/plugin.ts b/packages/core/src/engagement/chat/plugin.ts index 0f13ec5a..e04142b6 100644 --- a/packages/core/src/engagement/chat/plugin.ts +++ b/packages/core/src/engagement/chat/plugin.ts @@ -1,4 +1,11 @@ -import { definePlugin, EVENT_BUS, DRIZZLE, ADMIN_GUARD } from '@openora/core/server'; +import { + definePluginWithCatalog, + EVENT_BUS, + DRIZZLE, + ADMIN_GUARD, + type CoreTokenCatalog, + type TypedContainer, +} from '@openora/core/server'; import { CHAT_REALTIME_TRANSPORT, CHAT_REALTIME_CLIENT_AUTHORIZER, @@ -7,42 +14,38 @@ import { CHAT_BLOCK_WRITER, CHAT_ROOM_ACCESS, ADMIN_USER_DIRECTORY, - createToken, REALTIME_TRANSPORT, REALTIME_CLIENT_AUTHORIZER, } from '@openora/core/contracts'; import { ChatService } from './service/chat.service.js'; import { createChatRouter } from './router/index.js'; -const CHAT_SERVICE = createToken('_ChatService'); - -export default definePlugin({ +export default definePluginWithCatalog()({ id: 'chat', dependsOn: ['identity'], register(ctx) { + let chatService: ChatService | null = null; + const getChatService = (container: TypedContainer) => + (chatService ??= new ChatService( + container.get(DRIZZLE), + container.get(EVENT_BUS), + container.get(CHAT_REALTIME_TRANSPORT), + container.get(ADMIN_USER_DIRECTORY), + )); + ctx.provide(CHAT_REALTIME_TRANSPORT, (c) => c.get(REALTIME_TRANSPORT)); ctx.provide(CHAT_REALTIME_CLIENT_AUTHORIZER, (c) => c.get(REALTIME_CLIENT_AUTHORIZER)); - ctx.provide( - CHAT_SERVICE, - (c) => - new ChatService( - c.get(DRIZZLE), - c.get(EVENT_BUS), - c.get(CHAT_REALTIME_TRANSPORT), - c.get(ADMIN_USER_DIRECTORY), - ), - ); - ctx.provide(CHAT_SYSTEM_WRITER, (c) => c.get(CHAT_SERVICE)); - ctx.provide(CHAT_BLOCK_WRITER, (c) => c.get(CHAT_SERVICE)); + ctx.provide(CHAT_SYSTEM_WRITER, (c) => getChatService(c)); + ctx.provide(CHAT_BLOCK_WRITER, (c) => getChatService(c)); ctx.provide(CHAT_ROOM_ACCESS, (c) => ({ verifyRoomAccess: async (roomId, viewerId) => { - await c.get(CHAT_SERVICE).verifyRoomAccess(roomId, viewerId); + await getChatService(c).verifyRoomAccess(roomId, viewerId); }, })); ctx.routers.add('chat', (c) => createChatRouter({ - chatService: c.get(CHAT_SERVICE), + chatService: getChatService(c), authorizer: c.get(CHAT_REALTIME_CLIENT_AUTHORIZER), adminGuard: c.get(ADMIN_GUARD), limiter: c.get(RATE_LIMITER), diff --git a/packages/core/src/engagement/notifications/plugin.ts b/packages/core/src/engagement/notifications/plugin.ts index 2c35f097..3810390d 100644 --- a/packages/core/src/engagement/notifications/plugin.ts +++ b/packages/core/src/engagement/notifications/plugin.ts @@ -10,7 +10,13 @@ import { type JobQueueAdapter, type NotificationDeliveryAdapter, } from '@openora/core/contracts'; -import { createLogger, definePlugin, EVENT_BUS, DRIZZLE } from '@openora/core/server'; +import { + createLogger, + definePluginWithCatalog, + EVENT_BUS, + DRIZZLE, + type CoreTokenCatalog, +} from '@openora/core/server'; import { MockNotificationDeliveryAdapter } from './adapters/mock/mock-notification-adapter.js'; import { createNotificationsRouter } from './router/index.js'; import { NotificationsService } from './service/notifications.service.js'; @@ -22,7 +28,7 @@ const KycResubmissionNotifyJobSchema = z.object({ reason: z.string().nullable(), }); -export default definePlugin({ +export default definePluginWithCatalog()({ id: 'notifications', // ADMIN_USER_DIRECTORY (owned by identity) resolves the player's email for the // withdrawal delivery emails; pin load order so a split still finds the port. See ADR-0017. diff --git a/packages/core/src/iam/plugin.ts b/packages/core/src/iam/plugin.ts index 385a1cdf..c6e794ba 100644 --- a/packages/core/src/iam/plugin.ts +++ b/packages/core/src/iam/plugin.ts @@ -1,4 +1,11 @@ -import { definePlugin, EVENT_BUS, DRIZZLE, ADMIN_GUARD, createLogger } from '@openora/core/server'; +import { + definePluginWithCatalog, + EVENT_BUS, + DRIZZLE, + ADMIN_GUARD, + createLogger, + type CoreTokenCatalog, +} from '@openora/core/server'; import { ADMIN_PERMISSION_RESOLVER, ADMIN_PLAYER_ACTIVITY, @@ -13,7 +20,7 @@ import { DrizzleAdminPlayerActivity } from './adapters/admin-player-activity.js' const logger = createLogger('iam'); -export default definePlugin({ +export default definePluginWithCatalog()({ id: 'iam', dependsOn: ['identity'], register(ctx) { diff --git a/packages/core/src/pam/identity/plugin.ts b/packages/core/src/pam/identity/plugin.ts index 81fd52c2..07c0b2be 100644 --- a/packages/core/src/pam/identity/plugin.ts +++ b/packages/core/src/pam/identity/plugin.ts @@ -14,7 +14,14 @@ import { SESSION_COMMANDS, SMS_ADAPTER, } from '@openora/core/contracts'; -import { definePlugin, ADMIN_GUARD, EVENT_BUS, DRIZZLE, AUTH_SESSION } from '@openora/core/server'; +import { + definePluginWithCatalog, + ADMIN_GUARD, + EVENT_BUS, + DRIZZLE, + AUTH_SESSION, + type CoreTokenCatalog, +} from '@openora/core/server'; import { MockKycAdapter } from './adapters/mock/mock-kyc-adapter.js'; import { MockSmsAdapter } from './adapters/mock/mock-sms-adapter.js'; import { PhoneLoginService } from './service/phone-login.service.js'; @@ -27,7 +34,7 @@ import { SessionService } from './service/session.service.js'; import { LoginEnforcementService } from './service/login-enforcement.service.js'; import { PlayEligibilityService } from './service/play-eligibility.service.js'; -export default definePlugin({ +export default definePluginWithCatalog()({ id: 'identity', register(ctx) { ctx.provide(KYC_ADAPTER, () => new MockKycAdapter()); diff --git a/packages/core/src/pam/player-management/plugin.ts b/packages/core/src/pam/player-management/plugin.ts index ca5dfbf1..74202161 100644 --- a/packages/core/src/pam/player-management/plugin.ts +++ b/packages/core/src/pam/player-management/plugin.ts @@ -1,4 +1,10 @@ -import { definePlugin, EVENT_BUS, DRIZZLE, ADMIN_GUARD } from '@openora/core/server'; +import { + definePluginWithCatalog, + EVENT_BUS, + DRIZZLE, + ADMIN_GUARD, + type CoreTokenCatalog, +} from '@openora/core/server'; import { AUDIT_WRITER, KYC_STATUS_WRITER } from '@openora/core/contracts'; import { PlayerService } from './service/player.service.js'; import { PlayerKycStatusWriter } from './service/kyc-status-writer.js'; @@ -6,7 +12,7 @@ import { createPlayerRouter } from './router/index.js'; // Owns the player table writes, so it binds the single KYC_STATUS_WRITER seam // (compliance + the admin override route consume it). Reads identity via /schema. See ADR-0020. -export default definePlugin({ +export default definePluginWithCatalog()({ id: 'player-management', dependsOn: ['audit'], register(ctx) { diff --git a/packages/core/src/pam/player-note/plugin.ts b/packages/core/src/pam/player-note/plugin.ts index 301a566a..93ed2991 100644 --- a/packages/core/src/pam/player-note/plugin.ts +++ b/packages/core/src/pam/player-note/plugin.ts @@ -1,8 +1,13 @@ -import { definePlugin, DRIZZLE, ADMIN_GUARD } from '@openora/core/server'; +import { + definePluginWithCatalog, + DRIZZLE, + ADMIN_GUARD, + type CoreTokenCatalog, +} from '@openora/core/server'; import { PlayerNoteService } from './service/player-note.service.js'; import { createPlayerNoteRouter } from './router/index.js'; -export default definePlugin({ +export default definePluginWithCatalog()({ id: 'player-note', register(ctx) { ctx.routers.add('player-note', (c) => diff --git a/packages/core/src/pam/profile/plugin.ts b/packages/core/src/pam/profile/plugin.ts index 322e7b9a..3c3390c8 100644 --- a/packages/core/src/pam/profile/plugin.ts +++ b/packages/core/src/pam/profile/plugin.ts @@ -1,8 +1,8 @@ -import { definePlugin, DRIZZLE } from '@openora/core/server'; +import { definePluginWithCatalog, DRIZZLE, type CoreTokenCatalog } from '@openora/core/server'; import { ProfileService } from './service/profile.service.js'; import { createProfileRouter } from './router/index.js'; -export default definePlugin({ +export default definePluginWithCatalog()({ id: 'profile', register(ctx) { ctx.routers.add('profile', (c) => createProfileRouter(new ProfileService(c.get(DRIZZLE)))); diff --git a/packages/core/src/pam/tag/plugin.ts b/packages/core/src/pam/tag/plugin.ts index a65403ed..7cb6ce3b 100644 --- a/packages/core/src/pam/tag/plugin.ts +++ b/packages/core/src/pam/tag/plugin.ts @@ -1,9 +1,10 @@ import { - definePlugin, + definePluginWithCatalog, EVENT_BUS, DRIZZLE, ADMIN_GUARD, - type Container, + type CoreTokenCatalog, + type TypedContainer, } from '@openora/core/server'; import { PLAYER_TAGS, @@ -20,17 +21,18 @@ import { TagRuleService } from './service/tag-rule.service.js'; import { TagEvaluationService } from './service/tag-evaluation.service.js'; import { createTagRouter } from './router/index.js'; -export default definePlugin({ +export default definePluginWithCatalog()({ id: 'tag', dependsOn: ['wallet', 'identity'], register(ctx) { // One memoized instance backs the PLAYER_TAGS port and the router closure. let svc: TagService | null = null; - const tagService = (c: Container) => (svc ??= new TagService(c.get(DRIZZLE), c.get(EVENT_BUS))); + const tagService = (c: TypedContainer) => + (svc ??= new TagService(c.get(DRIZZLE), c.get(EVENT_BUS))); // One memoized instance backs the router closure and the TAG_EVALUATION_COMMANDS port. let ruleSvc: TagRuleService | null = null; - const ruleService = (c: Container) => + const ruleService = (c: TypedContainer) => (ruleSvc ??= new TagRuleService(c.get(DRIZZLE), c.get(EVENT_BUS))); // One memoized instance backs the daily job, the event subscriptions below, and the @@ -39,7 +41,7 @@ export default definePlugin({ // inside the router factory, so wallet's synchronous TAG_EVALUATION_COMMANDS call is // never blocked on this module's own router having mounted first. let evalSvc: TagEvaluationService | null = null; - const tagEvaluationService = (c: Container) => + const tagEvaluationService = (c: TypedContainer) => (evalSvc ??= new TagEvaluationService({ tag: tagService(c), rule: ruleService(c), diff --git a/packages/core/src/server/kernel/__tests__/container.test.ts b/packages/core/src/server/kernel/__tests__/container.test.ts index dc30d34e..5ce7759b 100644 --- a/packages/core/src/server/kernel/__tests__/container.test.ts +++ b/packages/core/src/server/kernel/__tests__/container.test.ts @@ -1,47 +1,50 @@ import { describe, it, expect, vi } from 'vitest'; -import { createToken } from '@openora/core/contracts'; -import { Container } from '../container.js'; +import { createToken, type TokenCatalog } from '@openora/core/contracts'; +import { createContainer } from '../container.js'; + +const CACHE = createToken<{ n: number }>('cache'); +const REBIND = createToken('rebind'); +const MISSING = createToken('missing'); +const A = createToken('a'); +const B = createToken('b'); +const catalog = { CACHE, REBIND, MISSING, A, B } satisfies TokenCatalog; describe('Container', () => { it('resolves a registered factory and caches the instance', () => { - const TOKEN = createToken<{ n: number }>('cache'); - const c = new Container(); + const c = createContainer(catalog); const factory = vi.fn(() => ({ n: 1 })); - c.register(TOKEN, factory); + c.register(CACHE, factory); - const a = c.get(TOKEN); - const b = c.get(TOKEN); + const a = c.get(CACHE); + const b = c.get(CACHE); expect(a).toBe(b); expect(factory).toHaveBeenCalledTimes(1); }); it('last registration wins and drops the cached instance', () => { - const TOKEN = createToken('rebind'); - const c = new Container(); - c.register(TOKEN, () => 'first'); - expect(c.get(TOKEN)).toBe('first'); + const c = createContainer(catalog); + c.register(REBIND, () => 'first'); + expect(c.get(REBIND)).toBe('first'); - c.register(TOKEN, () => 'second'); - expect(c.get(TOKEN)).toBe('second'); + c.register(REBIND, () => 'second'); + expect(c.get(REBIND)).toBe('second'); }); it('throws for an unregistered token', () => { - const c = new Container(); - expect(() => c.get(createToken('missing'))).toThrow(/No provider registered/); + const c = createContainer(catalog); + expect(() => c.get(MISSING)).toThrow(/No provider registered/); }); it('detects circular dependencies', () => { - const A = createToken('a'); - const B = createToken('b'); - const c = new Container(); + const c = createContainer(catalog); c.register(A, (cc) => cc.get(B)); c.register(B, (cc) => cc.get(A)); expect(() => c.get(A)).toThrow(/Circular dependency/); }); it('runs disposers in reverse registration order', async () => { - const c = new Container(); + const c = createContainer(catalog); const order: string[] = []; c.onDispose(() => { order.push('first'); diff --git a/packages/core/src/server/kernel/container.ts b/packages/core/src/server/kernel/container.ts index b70c9c4d..ff3183f4 100644 --- a/packages/core/src/server/kernel/container.ts +++ b/packages/core/src/server/kernel/container.ts @@ -1,4 +1,4 @@ -import type { AnyToken, TokenCatalog, TokenValue } from '@openora/core/contracts'; +import type { TokenCatalog, TokenValue } from '@openora/core/contracts'; // Functional DI container. Resolution is lazy and cached; last `register` for a // token wins - overlays rebind adapters by registering after the default binding. @@ -8,7 +8,7 @@ import type { AnyToken, TokenCatalog, TokenValue } from '@openora/core/contracts // sealed/overlay-rejection rules live one layer up, in ModuleRegistry's // provide()/provideSealed() (see plugin-host/module-registry.ts). -export type Factory = (c: Container) => T; +export type Factory = (c: Container) => T; /** * Functional DI container - no decorators, no reflection. `get()` resolves @@ -20,35 +20,33 @@ export type Factory = (c: Container) => T; * `dispose()` runs every `onDispose` callback in REVERSE registration order - * register dependencies before their dependents so teardown happens safely. */ -export class Container { +export class Container { private readonly factories = new Map>(); private readonly instances = new Map(); private readonly resolving = new Set(); private readonly disposers: Array<() => void | Promise> = []; - register( - token: C[K], - factory: (container: Container) => TokenValue, - ): void; - register(token: [C] extends [never] ? AnyToken : never, factory: Factory): void; - register(token: AnyToken, factory: Factory): void { - this.registerUnsafe(token, factory); + private constructor(_catalog: C) {} + + static create(catalog: C): Container { + return new Container(catalog); } - registerUnsafe(token: AnyToken, factory: Factory): void { + register( + token: T, + factory: (container: Container) => TokenValue, + ): void { this.factories.set(token, factory); this.instances.delete(token); } - has(token: C[K]): boolean; - has(token: [C] extends [never] ? AnyToken : C[keyof C] & AnyToken): boolean; - has(token: AnyToken): boolean { + has(token: T): boolean; + has(token: C[keyof C]): boolean { return this.factories.has(token); } - get(token: C[K]): TokenValue; - get(token: [C] extends [never] ? AnyToken : never): T; - get(token: AnyToken): T { + get(token: T): TokenValue; + get(token: C[keyof C]): T { if (this.instances.has(token)) { return this.instances.get(token) as T; } @@ -80,3 +78,8 @@ export class Container { } } } + +/** Creates a container whose token catalog is inferred from the catalog value. */ +export function createContainer(catalog: C): Container { + return Container.create(catalog); +} diff --git a/packages/core/src/server/kernel/index.ts b/packages/core/src/server/kernel/index.ts index bf7a843c..e3b8b031 100644 --- a/packages/core/src/server/kernel/index.ts +++ b/packages/core/src/server/kernel/index.ts @@ -20,7 +20,7 @@ export { RedisStreamsBroker } from './redis-streams-broker.js'; export { InProcessRealtimeTransport } from './realtime-transport.js'; export { SseClientAuthorizer } from './realtime-authorizer.js'; -export { Container } from './container.js'; +export { Container, createContainer } from './container.js'; export type { Factory } from './container.js'; export { createLogger } from './logger.js'; diff --git a/packages/core/src/server/plugin-host/__tests__/load-plugins.test.ts b/packages/core/src/server/plugin-host/__tests__/load-plugins.test.ts index f441d741..fa75ab10 100644 --- a/packages/core/src/server/plugin-host/__tests__/load-plugins.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/load-plugins.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest'; +import type { TokenCatalog } from '@openora/core/contracts'; import type { Plugin } from '../define-plugin.js'; import { topoSort } from '../load-plugins.js'; -function plugin(id: string, dependsOn: string[] = []): Plugin { +const catalog = {} satisfies TokenCatalog; + +function plugin(id: string, dependsOn: string[] = []): Plugin { return { id, dependsOn, diff --git a/packages/core/src/server/plugin-host/__tests__/module-registry.test.ts b/packages/core/src/server/plugin-host/__tests__/module-registry.test.ts index aad862d1..8dfa245d 100644 --- a/packages/core/src/server/plugin-host/__tests__/module-registry.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/module-registry.test.ts @@ -1,17 +1,20 @@ import { describe, it, expect } from 'vitest'; -import { Container } from '../../kernel/index.js'; -import { createToken, createSealedToken } from '@openora/core/contracts'; +import { createContainer } from '../../kernel/index.js'; +import { createToken, createSealedToken, type TokenCatalog } from '@openora/core/contracts'; import { ModuleRegistryImpl } from '../module-registry.js'; +const TOKEN = createToken('svc'); +const SEALED = createSealedToken('audit-log-writer'); +const catalog = { TOKEN, SEALED } satisfies TokenCatalog; + function newRegistry() { - const container = new Container(); + const container = createContainer(catalog); return { container, reg: new ModuleRegistryImpl(container) }; } describe('ModuleRegistryImpl', () => { it('provide() binds to the container (last-wins)', () => { const { container, reg } = newRegistry(); - const TOKEN = createToken('svc'); reg.provide(TOKEN, () => 'a'); reg.provide(TOKEN, () => 'b'); expect(container.get(TOKEN)).toBe('b'); @@ -19,20 +22,21 @@ describe('ModuleRegistryImpl', () => { it('provide() refuses to bind a sealed token', () => { const { reg } = newRegistry(); - const SEALED = createSealedToken('rg-enforcement'); - expect(() => reg.provide(SEALED as never, () => 'x')).toThrow(/sealed token/i); + expect(() => + reg.provide(SEALED as never, () => { + throw new Error('unreachable'); + }), + ).toThrow(/sealed token/i); }); it('provideSealed() binds a sealed token exactly once', () => { const { container, reg } = newRegistry(); - const SEALED = createSealedToken('audit-log-writer'); reg.provideSealed(SEALED, () => 'canonical'); expect(container.get(SEALED)).toBe('canonical'); }); it('provideSealed() rejects a second bind of the same sealed token', () => { const { reg } = newRegistry(); - const SEALED = createSealedToken('audit-log-writer'); reg.provideSealed(SEALED, () => 'canonical'); expect(() => reg.provideSealed(SEALED, () => 'overlay-attempt')).toThrow(/already bound/i); }); diff --git a/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts b/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts index b394a0f2..836a834f 100644 --- a/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { Container } from '../../kernel/index.js'; -import { createToken } from '@openora/core/contracts'; +import { createContainer } from '../../kernel/index.js'; +import { createToken, type TokenCatalog } from '@openora/core/contracts'; import { assertRequiredPorts } from '../load-plugins.js'; import type { Plugin } from '../define-plugin.js'; const WALLET_COMMANDS = createToken<{ debit: () => void }>('WALLET_COMMANDS'); +const catalog = { WALLET_COMMANDS } satisfies TokenCatalog; -const consumer: Plugin = { +const consumer: Plugin = { id: 'gaming', requiresPorts: [WALLET_COMMANDS], register: () => {}, @@ -14,19 +15,19 @@ const consumer: Plugin = { describe('assertRequiredPorts (ADR-0024 boot fail-fast)', () => { it('passes when every required port is bound', () => { - const container = new Container(); + const container = createContainer(catalog); container.register(WALLET_COMMANDS, () => ({ debit: () => {} })); expect(() => assertRequiredPorts([consumer], container)).not.toThrow(); }); it('throws an actionable error naming the plugin and the unbound port', () => { - const container = new Container(); + const container = createContainer(catalog); expect(() => assertRequiredPorts([consumer], container)).toThrow(/gaming.*WALLET_COMMANDS/s); }); it('is a no-op for plugins that declare no required ports', () => { - const container = new Container(); - const plain: Plugin = { id: 'audit', register: () => {} }; + const container = createContainer(catalog); + const plain: Plugin = { id: 'audit', register: () => {} }; expect(() => assertRequiredPorts([plain], container)).not.toThrow(); }); }); diff --git a/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts b/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts index dcd8860c..ea7fd859 100644 --- a/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { createToken, type TokenCatalog } from '@openora/core/contracts'; -import { Container } from '../../kernel/index.js'; +import { Container, createContainer } from '../../kernel/index.js'; import { defineExtensions, definePluginWithCatalog, ModuleRegistryImpl } from '../index.js'; const COUNT = createToken('COUNT'); @@ -20,7 +20,7 @@ const typedCatalogWrongValue = { void typedCatalogWrongValue; function assertTypedContainer() { - const typedContainer = new Container(); + const typedContainer = createContainer(catalog); const typedCount: number = typedContainer.get(COUNT); void typedCount; @@ -39,6 +39,11 @@ function assertTypedContainer() { void assertTypedContainer; +// @ts-expect-error Containers are created from an explicit token catalog. +const untypedContainer = new Container(); + +void untypedContainer; + const typedPlugin = definePluginWithCatalog()({ id: 'typed-plugin', dependsOn: ['foundation'], @@ -92,7 +97,7 @@ defineExtensions([ describe('typed plugin surface', () => { it('keeps literal plugin metadata and resolves catalog services', () => { - const container = new Container(); + const container = createContainer(catalog); const registry = new ModuleRegistryImpl(container); container.register(SEED, () => 41); @@ -105,7 +110,7 @@ describe('typed plugin surface', () => { }); it('keeps router factories on the catalogued container view', () => { - const container = new Container(); + const container = createContainer(catalog); const registry = new ModuleRegistryImpl(container); container.register(SEED, () => 7); diff --git a/packages/core/src/server/plugin-host/define-plugin.ts b/packages/core/src/server/plugin-host/define-plugin.ts index dd3e453b..2209ad87 100644 --- a/packages/core/src/server/plugin-host/define-plugin.ts +++ b/packages/core/src/server/plugin-host/define-plugin.ts @@ -1,4 +1,3 @@ -import type { Container } from '../kernel/index.js'; import type { EventEnvelope, SealedToken, @@ -16,46 +15,28 @@ export type McpToolDefinition = { }; // Runs once at boot, after every plugin has registered its providers, so adapter overrides (last registration wins) are in effect. -export type RouterFactory = ( - c: [C] extends [never] ? Container : TypedContainer, -) => unknown; +export type RouterFactory = (c: TypedContainer) => unknown; export type TypedContainer = { - get(token: C[K]): TokenValue; - has(token: C[K]): boolean; + get(token: T): TokenValue; + has(token: T): boolean; onDispose(fn: () => void | Promise): void; }; export type EventHandler = (payload: unknown, envelope?: EventEnvelope) => void | Promise; -type CatalogToken = [C] extends [never] - ? Token - : C[keyof C] & Token; - -type PluginContainer = Container; - -type CatalogTokenValue = TokenValue; - -export type ModuleRegistry = { +export type ModuleRegistry = { // Last registration wins - an overlay loaded after a module can rebind its adapter token. - provide( - token: C[K] & Token>, - factory: (container: TypedContainer) => CatalogTokenValue, - ): void; - provide( - token: [C] extends [never] ? Token : never, - factory: (container: PluginContainer) => T, + provide>( + token: T, + factory: (container: TypedContainer) => TokenValue, ): void; // Bind-once, owner-only. The ONLY legitimate way to bind a SealedToken - provide() // rejects sealed tokens outright. A second call for the same token (an overlay // trying to override a regulator-mandated service) throws instead of rebinding. - provideSealed( - token: C[K] & SealedToken>, - factory: (container: TypedContainer) => CatalogTokenValue, - ): void; - provideSealed( - token: [C] extends [never] ? SealedToken : never, - factory: (container: PluginContainer) => T, + provideSealed>( + token: T, + factory: (container: TypedContainer) => TokenValue, ): void; routers: { add(namespace: string, factory: RouterFactory): void; @@ -80,34 +61,26 @@ export type ModuleRegistry = { }; }; -export type PluginContext = ModuleRegistry; +export type PluginContext = ModuleRegistry; export type PluginDefinition< - C extends TokenCatalog = never, + C extends TokenCatalog, Id extends string = string, Dependencies extends readonly string[] = string[], > = { id: Id; dependsOn?: Dependencies; // Verified once after all plugins register - a missing port fails fast. See ADR-0024. - requiresPorts?: CatalogToken[]; + requiresPorts?: Array>; register: (ctx: PluginContext) => void | Promise; }; export type Plugin< - C extends TokenCatalog = never, + C extends TokenCatalog, Id extends string = string, Dependencies extends readonly string[] = string[], > = PluginDefinition; -export function definePlugin< - C extends TokenCatalog = never, - const Id extends string = string, - const Dependencies extends readonly string[] = [], ->(definition: PluginDefinition): Plugin { - return definition; -} - export function definePluginWithCatalog() { return function defineCataloguedPlugin< const Id extends string, diff --git a/packages/core/src/server/plugin-host/index.ts b/packages/core/src/server/plugin-host/index.ts index 51e792ff..3ed2d72a 100644 --- a/packages/core/src/server/plugin-host/index.ts +++ b/packages/core/src/server/plugin-host/index.ts @@ -1,4 +1,4 @@ -export { definePlugin, definePluginWithCatalog } from './define-plugin.js'; +export { definePluginWithCatalog } from './define-plugin.js'; export type { Plugin, PluginDefinition, diff --git a/packages/core/src/server/plugin-host/load-plugins.ts b/packages/core/src/server/plugin-host/load-plugins.ts index 6fd61df9..27c8fa9d 100644 --- a/packages/core/src/server/plugin-host/load-plugins.ts +++ b/packages/core/src/server/plugin-host/load-plugins.ts @@ -134,7 +134,7 @@ export function topoSort(plugins: Plugin[]): Plugin( +export async function loadPlugins( entries: PluginEntry[], container: Container, ): Promise> { @@ -172,7 +172,7 @@ export async function loadPlugins( * registration and throw one actionable error naming the plugin, the port, and the * likely-missing package. */ -export function assertRequiredPorts( +export function assertRequiredPorts( plugins: Plugin[], container: Container, ): void { diff --git a/packages/core/src/server/plugin-host/module-registry.ts b/packages/core/src/server/plugin-host/module-registry.ts index 30c7feca..eaf5ae0f 100644 --- a/packages/core/src/server/plugin-host/module-registry.ts +++ b/packages/core/src/server/plugin-host/module-registry.ts @@ -1,13 +1,20 @@ -import type { Container, Factory } from '../kernel/index.js'; -import type { SealedToken, Token, TokenCatalog, WorkerRegistration } from '@openora/core/contracts'; +import type { Container } from '../kernel/index.js'; +import type { + SealedToken, + Token, + TokenCatalog, + TokenValue, + WorkerRegistration, +} from '@openora/core/contracts'; import type { ModuleRegistry, McpToolDefinition, RouterFactory, + TypedContainer, EventHandler, } from './define-plugin.js'; -export class ModuleRegistryImpl implements ModuleRegistry { +export class ModuleRegistryImpl implements ModuleRegistry { private _routers = new Map>(); private _slots = new Map(); private _events = new Map(); @@ -21,7 +28,10 @@ export class ModuleRegistryImpl implements Modul // Sealed tokens (Symbol description prefixed `sealed:`) are rejected at runtime // even though the type system already blocks them - catches plain-JS callers and cast escapes. // Canonical sealed list lives in `@openora/core/compliance`. - provide = (token: Token, factory: Factory): void => { + provide = >( + token: T, + factory: (container: TypedContainer) => TokenValue, + ): void => { const desc = token.description ?? ''; if (desc.startsWith('sealed:')) { throw new Error( @@ -32,14 +42,17 @@ export class ModuleRegistryImpl implements Modul `See @openora/core/compliance for the canonical list.`, ); } - this.container.registerUnsafe(token, factory); + this.container.register(token, factory); }; // Bind-once. The owning module calls this during its own register() to bind the // canonical implementation; a second call for the same token - an overlay trying // to slip past provide()'s rejection, or a duplicate registration - throws instead // of silently rebinding (there is no "last-wins" for a sealed token). - provideSealed = (token: SealedToken, factory: Factory): void => { + provideSealed = >( + token: T, + factory: (container: TypedContainer) => TokenValue, + ): void => { if (this._sealedBound.has(token)) { throw new Error( `[plugin-host] Sealed token (${token.description ?? '(unnamed)'}) is already bound. ` + @@ -47,7 +60,7 @@ export class ModuleRegistryImpl implements Modul ); } this._sealedBound.add(token); - this.container.registerUnsafe(token, factory); + this.container.register(token, factory); }; routers = { diff --git a/packages/core/src/server/runtime/core-token-catalog.ts b/packages/core/src/server/runtime/core-token-catalog.ts new file mode 100644 index 00000000..9c2f0b07 --- /dev/null +++ b/packages/core/src/server/runtime/core-token-catalog.ts @@ -0,0 +1,101 @@ +import type { TokenCatalog } from '@openora/core/contracts'; +import { + ADMIN_GAME_REPORTING, + ADMIN_PERMISSION_RESOLVER, + ADMIN_PLAYER_ACTIVITY, + ADMIN_USER_DIRECTORY, + ADMIN_WALLET_REPORTING, + AUDIT_WRITER, + CACHE, + CHAT_BLOCK_WRITER, + CHAT_REALTIME_CLIENT_AUTHORIZER, + CHAT_REALTIME_TRANSPORT, + CHAT_ROOM_ACCESS, + CHAT_SYSTEM_WRITER, + EMAIL_TEMPLATE_RENDERER, + ERROR_TRACKING, + GAME_ADAPTER, + GEO_IP_ADAPTER, + IDENTITY_OPTIONS, + IDENTITY_READER, + IGAMING_CONFIG, + JOB_QUEUE, + KYC_ADAPTER, + KYC_STATUS_WRITER, + KYC_WEBHOOK_VERIFIER, + LOGIN_ENFORCEMENT, + MESSAGE_BROKER, + NOTIFICATION_DELIVERY_ADAPTER, + OUTBOX, + PAYMENT_ADAPTER, + PAYMENT_WEBHOOK_VERIFIER, + PLATFORM_CONFIG, + PLAYER_TAGS, + PLAY_ELIGIBILITY, + RATE_LIMITER, + REALTIME_CLIENT_AUTHORIZER, + REALTIME_TRANSPORT, + RNG_ADAPTER, + SEND_EMAIL, + SESSION_COMMANDS, + SMS_ADAPTER, + TAG_EVALUATION_COMMANDS, + WALLET_COMMANDS, + WALLET_READER, +} from '@openora/core/contracts'; +import { ADMIN_GUARD, AUTH_SESSION } from '../auth/index.js'; +import { DRIZZLE } from '../db/index.js'; +import { EVENT_BUS } from '../kernel/index.js'; + +const coreTokenCatalog = { + ADMIN_GAME_REPORTING, + ADMIN_GUARD, + ADMIN_PERMISSION_RESOLVER, + ADMIN_PLAYER_ACTIVITY, + ADMIN_USER_DIRECTORY, + ADMIN_WALLET_REPORTING, + AUDIT_WRITER, + AUTH_SESSION, + CACHE, + CHAT_BLOCK_WRITER, + CHAT_REALTIME_CLIENT_AUTHORIZER, + CHAT_REALTIME_TRANSPORT, + CHAT_ROOM_ACCESS, + CHAT_SYSTEM_WRITER, + DRIZZLE, + EMAIL_TEMPLATE_RENDERER, + ERROR_TRACKING, + EVENT_BUS, + GAME_ADAPTER, + GEO_IP_ADAPTER, + IDENTITY_OPTIONS, + IDENTITY_READER, + IGAMING_CONFIG, + JOB_QUEUE, + KYC_ADAPTER, + KYC_STATUS_WRITER, + KYC_WEBHOOK_VERIFIER, + LOGIN_ENFORCEMENT, + MESSAGE_BROKER, + NOTIFICATION_DELIVERY_ADAPTER, + OUTBOX, + PAYMENT_ADAPTER, + PAYMENT_WEBHOOK_VERIFIER, + PLATFORM_CONFIG, + PLAYER_TAGS, + PLAY_ELIGIBILITY, + RATE_LIMITER, + REALTIME_CLIENT_AUTHORIZER, + REALTIME_TRANSPORT, + RNG_ADAPTER, + SEND_EMAIL, + SESSION_COMMANDS, + SMS_ADAPTER, + TAG_EVALUATION_COMMANDS, + WALLET_COMMANDS, + WALLET_READER, +}; + +export const CORE_TOKEN_CATALOG = coreTokenCatalog satisfies TokenCatalog; + +export type CoreTokenCatalog = typeof CORE_TOKEN_CATALOG; diff --git a/packages/core/src/server/runtime/create-app.ts b/packages/core/src/server/runtime/create-app.ts index 338f5233..d254c018 100644 --- a/packages/core/src/server/runtime/create-app.ts +++ b/packages/core/src/server/runtime/create-app.ts @@ -10,7 +10,7 @@ import { serve, type ServerType } from '@hono/node-server'; import { resolve } from 'node:path'; import { generateOpenApiSpec } from './openapi.js'; import { - Container, + createContainer, BullMqJobQueue, RedisCache, RedisRateLimiter, @@ -22,6 +22,7 @@ import { setErrorReporter, EVENT_BUS, extractClientMeta, + type Container, type OssContext, } from '../kernel/index.js'; import { randomUUID } from 'node:crypto'; @@ -44,6 +45,7 @@ import { AdminGuard, ADMIN_GUARD, SessionResolver, AUTH_SESSION } from '../auth/ import { loadPlugins, type PluginEntry } from '../plugin-host/index.js'; import { assertDurableSeamsBound } from './assert-durable-seams.js'; import { loadPlatformConfig, resolvePlatformConfigPath } from '../kernel/platform-config-loader.js'; +import { CORE_TOKEN_CATALOG, type CoreTokenCatalog } from './core-token-catalog.js'; // Path prefixes safe to cache at the HTTP layer: public, non-personalized reads // only (lobby feeds, public CMS content, the game catalogue). NOTHING @@ -120,14 +122,14 @@ export type CreateAppConfig = { // supplied override). `false` disables HTTP response caching entirely. httpCache?: { paths?: string[]; maxAgeSeconds?: number } | false; - configure?: (container: Container) => void | Promise; + configure?: (container: Container) => void | Promise; disableHealthModule?: boolean; }; export type CreatedApp = { app: Hono; - container: Container; + container: Container; port: number; listen(): Promise; emitOpenApiSpec(): Promise; @@ -207,7 +209,7 @@ export async function createApp(config: CreateAppConfig): Promise { process.env['DATABASE_URL'] = config.databaseUrl; } - const container = new Container(); + const container = createContainer(CORE_TOKEN_CATALOG); container.register(DRIZZLE, () => { const svc = new DrizzleService(); container.onDispose(() => svc.dispose()); diff --git a/packages/core/src/server/runtime/index.ts b/packages/core/src/server/runtime/index.ts index 433c7540..dc06ab97 100644 --- a/packages/core/src/server/runtime/index.ts +++ b/packages/core/src/server/runtime/index.ts @@ -1,13 +1,10 @@ export { createApp } from './create-app.js'; export type { CreateAppConfig, CreatedApp } from './create-app.js'; +export { CORE_TOKEN_CATALOG } from './core-token-catalog.js'; +export type { CoreTokenCatalog } from './core-token-catalog.js'; export { generateOpenApiSpec } from './openapi.js'; export type { GenerateOpenApiSpecOptions } from './openapi.js'; -export { - definePlugin, - type Plugin, - type PluginEntry, - type ModuleRegistry, -} from '../plugin-host/index.js'; -export { Container } from '../kernel/index.js'; +export { type Plugin, type PluginEntry, type ModuleRegistry } from '../plugin-host/index.js'; +export { Container, createContainer } from '../kernel/index.js'; diff --git a/packages/core/src/wallet/plugin.ts b/packages/core/src/wallet/plugin.ts index 8c1c5035..49e9e661 100644 --- a/packages/core/src/wallet/plugin.ts +++ b/packages/core/src/wallet/plugin.ts @@ -1,4 +1,10 @@ -import { definePlugin, ADMIN_GUARD, EVENT_BUS, DRIZZLE } from '@openora/core/server'; +import { + definePluginWithCatalog, + ADMIN_GUARD, + EVENT_BUS, + DRIZZLE, + type CoreTokenCatalog, +} from '@openora/core/server'; import * as z from 'zod'; import { ADMIN_USER_DIRECTORY, @@ -22,7 +28,7 @@ import { createWalletRouter } from './router/index.js'; import { MockPaymentAdapter } from './adapters/mock/mock-payment-adapter.js'; import { HmacPaymentWebhookVerifier } from './adapters/hmac-payment-webhook-verifier.js'; -export default definePlugin({ +export default definePluginWithCatalog()({ // NOT dependsOn 'tag': that would cycle (tag hard-depends on wallet's WALLET_READER). // wallet's use of tag's PLAYER_TAGS / TAG_EVALUATION_COMMANDS is optional and resolved // lazily in the router factory (`c.has(...)`), which runs after every plugin has diff --git a/packages/testing/src/__tests__/analytics.e2e.test.ts b/packages/testing/src/__tests__/analytics.e2e.test.ts index 4d6ab17e..9f8a8510 100644 --- a/packages/testing/src/__tests__/analytics.e2e.test.ts +++ b/packages/testing/src/__tests__/analytics.e2e.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; -import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; +import { + loadExtensions, + DRIZZLE, + type Container, + type CoreTokenCatalog, +} from '@openora/core/server'; import { user } from '@openora/core/pam/schema/identity'; import { wallet, walletTransaction } from '@openora/core/wallet/schema'; import { @@ -37,7 +42,7 @@ async function registerPlayer(email: string) { return body.user.id; } -async function verifyEmail(container: Container, userId: string) { +async function verifyEmail(container: Container, userId: string) { await container .get(DRIZZLE) .db.update(user) @@ -52,7 +57,10 @@ async function deposit(client: TestClient, amount: string, currency = 'USD') { } } -async function walletIdFor(container: Container, userId: string): Promise { +async function walletIdFor( + container: Container, + userId: string, +): Promise { const [row] = await container .get(DRIZZLE) .db.select() @@ -65,7 +73,7 @@ async function walletIdFor(container: Container, userId: string): Promise, walletId: string, type: 'bonus' | 'bet' | 'win', amount: string, diff --git a/packages/testing/src/__tests__/fixtures/test-kyc-config-plugin.ts b/packages/testing/src/__tests__/fixtures/test-kyc-config-plugin.ts index 8788ce3a..260f926f 100644 --- a/packages/testing/src/__tests__/fixtures/test-kyc-config-plugin.ts +++ b/packages/testing/src/__tests__/fixtures/test-kyc-config-plugin.ts @@ -1,4 +1,4 @@ -import { definePlugin } from '@openora/core/server'; +import { definePluginWithCatalog, type CoreTokenCatalog } from '@openora/core/server'; import { PLATFORM_CONFIG, KYC_ADAPTER, @@ -43,7 +43,7 @@ class ControllablePendingKycAdapter implements KycAdapter { * `KYC_ADAPTER` for a controllable stub. Append last in a test's `plugins` array so both * bindings win over the defaults (last-registration-wins; see docs/standards/module-structure.md > ports). */ -export default definePlugin({ +export default definePluginWithCatalog()({ id: 'test-kyc-config', dependsOn: ['identity'], register(ctx) { diff --git a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts index 3b733e88..c86baa68 100644 --- a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts +++ b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts @@ -1,9 +1,9 @@ -import { definePlugin } from '@openora/core/server'; +import { definePluginWithCatalog, type CoreTokenCatalog } from '@openora/core/server'; import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts'; // PLATFORM_CONFIG overlay for the daily-cap scenario: dailyCapCount 1 trips on the 2nd withdrawal, still below // the high_frequency heuristic (>= 3) so the cap gate is tested in isolation. Separate app since config is boot-once. -export default definePlugin({ +export default definePluginWithCatalog()({ id: 'test-wallet-auto-withdrawal-cap-config', dependsOn: ['identity'], register(ctx) { diff --git a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts index a9c4f85a..3d8ef71b 100644 --- a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts +++ b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts @@ -1,10 +1,10 @@ -import { definePlugin } from '@openora/core/server'; +import { definePluginWithCatalog, type CoreTokenCatalog } from '@openora/core/server'; import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts'; // PLATFORM_CONFIG overlay for the auto-withdrawal e2e suite: autoWithdrawal enabled (threshold 2, // high_risk/bonus_abuser excluded, caps set high). kyc.gateWithdrawals stays false so the KYC-not-passing // scenario hits the auto-approval KYC gate, not the withdraw-time one. Append last so this binding wins. -export default definePlugin({ +export default definePluginWithCatalog()({ id: 'test-wallet-auto-withdrawal-config', dependsOn: ['identity'], register(ctx) { diff --git a/packages/testing/src/__tests__/gaming-stake-debit.e2e.test.ts b/packages/testing/src/__tests__/gaming-stake-debit.e2e.test.ts index 714aa733..c923d425 100644 --- a/packages/testing/src/__tests__/gaming-stake-debit.e2e.test.ts +++ b/packages/testing/src/__tests__/gaming-stake-debit.e2e.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; -import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; +import { + loadExtensions, + DRIZZLE, + type Container, + type CoreTokenCatalog, +} from '@openora/core/server'; import { game, gameRound } from '@openora/core/casino/schema/gaming'; import { wallet, walletTransaction } from '@openora/core/wallet/schema'; import { @@ -43,7 +48,7 @@ async function deposit(client: TestClient, amount: string, currency = 'USD') { } } -async function balanceOf(container: Container, userId: string): Promise { +async function balanceOf(container: Container, userId: string): Promise { const [row] = await container .get(DRIZZLE) .db.select() diff --git a/packages/testing/src/__tests__/kyc.e2e.test.ts b/packages/testing/src/__tests__/kyc.e2e.test.ts index af04e4b6..cd66d632 100644 --- a/packages/testing/src/__tests__/kyc.e2e.test.ts +++ b/packages/testing/src/__tests__/kyc.e2e.test.ts @@ -2,7 +2,12 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { createHmac, randomUUID } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { eq } from 'drizzle-orm'; -import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; +import { + loadExtensions, + DRIZZLE, + type Container, + type CoreTokenCatalog, +} from '@openora/core/server'; import { player } from '@openora/core/pam/schema/profile'; import { setupTestDb, @@ -71,7 +76,7 @@ async function registerAndMaterializePlayer(app: TestApp['app'], email: string) return { client, playerId: profile.id, userId: profile.userId }; } -async function seedLegacyVerifiedStatus(container: Container, userId: string) { +async function seedLegacyVerifiedStatus(container: Container, userId: string) { await container .get(DRIZZLE) .db.update(player) diff --git a/packages/testing/src/__tests__/rg.e2e.test.ts b/packages/testing/src/__tests__/rg.e2e.test.ts index 061f6995..706624b6 100644 --- a/packages/testing/src/__tests__/rg.e2e.test.ts +++ b/packages/testing/src/__tests__/rg.e2e.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; -import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; +import { + loadExtensions, + DRIZZLE, + type Container, + type CoreTokenCatalog, +} from '@openora/core/server'; import { JOB_QUEUE, PLAY_ELIGIBILITY, queue } from '@openora/core/contracts'; import { rgExclusion } from '@openora/core/compliance/schema'; import { user } from '@openora/core/pam/schema/identity'; @@ -63,11 +68,11 @@ async function attemptLogin(email: string, password: string) { }); } -async function setRole(container: Container, userId: string, role: string) { +async function setRole(container: Container, userId: string, role: string) { await container.get(DRIZZLE).db.update(user).set({ role }).where(eq(user.id, userId)); } -async function expireExclusion(container: Container, exclusionId: string) { +async function expireExclusion(container: Container, exclusionId: string) { await container .get(DRIZZLE) .db.update(rgExclusion) @@ -75,7 +80,7 @@ async function expireExclusion(container: Container, exclusionId: string) { .where(eq(rgExclusion.id, exclusionId)); } -async function exclusionStatus(container: Container, exclusionId: string) { +async function exclusionStatus(container: Container, exclusionId: string) { const [row] = await container .get(DRIZZLE) .db.select({ status: rgExclusion.status }) @@ -84,7 +89,7 @@ async function exclusionStatus(container: Container, exclusionId: string) { return row?.status; } -async function triggerRgMonitorSweep(container: Container) { +async function triggerRgMonitorSweep(container: Container) { await container.get(JOB_QUEUE).enqueue(queue('rg-monitor'), {}); } diff --git a/packages/testing/src/__tests__/tag-bf317.e2e.test.ts b/packages/testing/src/__tests__/tag-bf317.e2e.test.ts index c53d70fe..97feaa23 100644 --- a/packages/testing/src/__tests__/tag-bf317.e2e.test.ts +++ b/packages/testing/src/__tests__/tag-bf317.e2e.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; -import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; +import { + loadExtensions, + DRIZZLE, + type Container, + type CoreTokenCatalog, +} from '@openora/core/server'; import { rgExclusion } from '@openora/core/compliance/schema'; import { setupTestDb, @@ -84,7 +89,11 @@ async function activeTagKeys(admin: TestClient, playerId: string): Promise t.key); } -async function backdateExclusionExpiry(container: Container, exclusionId: string, daysAgo: number) { +async function backdateExclusionExpiry( + container: Container, + exclusionId: string, + daysAgo: number, +) { const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); await container .get(DRIZZLE) diff --git a/packages/testing/src/__tests__/tag.e2e.test.ts b/packages/testing/src/__tests__/tag.e2e.test.ts index 23d37462..86368ebb 100644 --- a/packages/testing/src/__tests__/tag.e2e.test.ts +++ b/packages/testing/src/__tests__/tag.e2e.test.ts @@ -1,7 +1,13 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; -import { loadExtensions, DRIZZLE, EVENT_BUS, type Container } from '@openora/core/server'; +import { + loadExtensions, + DRIZZLE, + EVENT_BUS, + type Container, + type CoreTokenCatalog, +} from '@openora/core/server'; import { JOB_QUEUE, queue } from '@openora/core/contracts'; import { session } from '@openora/core/pam/schema/identity'; import { walletTransaction } from '@openora/core/wallet/schema'; @@ -51,7 +57,11 @@ async function registerAndMaterializePlayer(honoApp: TestApp['app'], email: stri return { client, playerId: profile.id, userId: profile.userId }; } -async function backdateSessions(container: Container, userId: string, daysAgo: number) { +async function backdateSessions( + container: Container, + userId: string, + daysAgo: number, +) { const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); await container .get(DRIZZLE) @@ -60,7 +70,11 @@ async function backdateSessions(container: Container, userId: string, daysAgo: n .where(eq(session.userId, userId)); } -async function backdateTransaction(container: Container, transactionId: string, daysAgo: number) { +async function backdateTransaction( + container: Container, + transactionId: string, + daysAgo: number, +) { const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); await container .get(DRIZZLE) @@ -96,7 +110,7 @@ async function assignTagManually(admin: TestClient, playerId: string, tagKey: st return readJson(res); } -async function runDailySweep(container: Container) { +async function runDailySweep(container: Container) { await container.get(JOB_QUEUE).enqueue(queue('tag.daily-evaluation'), {}); } diff --git a/packages/testing/src/__tests__/wallet-ledger-auto-withdrawal.e2e.test.ts b/packages/testing/src/__tests__/wallet-ledger-auto-withdrawal.e2e.test.ts index 932b54fa..72c3904b 100644 --- a/packages/testing/src/__tests__/wallet-ledger-auto-withdrawal.e2e.test.ts +++ b/packages/testing/src/__tests__/wallet-ledger-auto-withdrawal.e2e.test.ts @@ -2,7 +2,12 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { eq } from 'drizzle-orm'; -import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; +import { + loadExtensions, + DRIZZLE, + type Container, + type CoreTokenCatalog, +} from '@openora/core/server'; import { user } from '@openora/core/pam/schema/identity'; import { setupTestDb, @@ -52,7 +57,7 @@ async function registerAndMaterializePlayer(app: TestApp['app'], email: string) return { client, playerId: profile.id, userId: profile.userId }; } -async function setRole(container: Container, userId: string, role: string) { +async function setRole(container: Container, userId: string, role: string) { await container.get(DRIZZLE).db.update(user).set({ role }).where(eq(user.id, userId)); } diff --git a/packages/testing/src/app.ts b/packages/testing/src/app.ts index 810c026b..dc1a77b7 100644 --- a/packages/testing/src/app.ts +++ b/packages/testing/src/app.ts @@ -8,6 +8,7 @@ import { RedisStreamsBroker, type CreateAppConfig, type Container, + type CoreTokenCatalog, } from '@openora/core/server'; import { MESSAGE_BROKER, @@ -26,7 +27,7 @@ export type TestApp = { /** The Hono app - drive it directly with `app.request(path, init)`. */ app: Hono; /** The composition container, for resolving services/tokens in assertions. */ - container: Container; + container: Container; /** Dispose the container (closes the DB pool, drains workers, frees the Redis db). */ close(): Promise; }; @@ -68,7 +69,7 @@ export async function bootTestApp(config: BootTestAppConfig): Promise { databaseUrl: config.databaseUrl, authSchema: { user, session, account, verification, twoFactor }, openapi: { enabled: false }, - configure(container: Container) { + configure(container) { const redis = createRedisClient(redisDatabase.url); container.onDispose(() => redis.close()); diff --git a/packages/testing/src/seed.ts b/packages/testing/src/seed.ts index 2876b83d..3eed9eb6 100644 --- a/packages/testing/src/seed.ts +++ b/packages/testing/src/seed.ts @@ -1,7 +1,13 @@ import { Pool } from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; import { seedDemoData, type SeedResult } from './seed-demo-data.js'; -import { createAuth, DRIZZLE, type DrizzleDb, Container } from '@openora/core/server'; +import { + createAuth, + DRIZZLE, + type CoreTokenCatalog, + type DrizzleDb, + type Container, +} from '@openora/core/server'; import { seedIam } from '@openora/core/iam/seed'; import { seedTag } from '@openora/core/pam/tag/seed'; import { user, session, account, verification } from '@openora/core/pam/schema/identity'; @@ -23,7 +29,7 @@ export type SeedMinimalOptions = { * for the direct table inserts (players, wallets, ...). */ export async function seedMinimal( - container: Container, + container: Container, options: SeedMinimalOptions = {}, ): Promise { const drizzleSvc = container.get(DRIZZLE); From 22f5a650232f3df17450dc3a8d9fbba60bb29de6 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Tue, 4 Aug 2026 20:27:03 +0200 Subject: [PATCH 3/8] fix(server): keep definePlugin and Container as the sole plugin-host api The prior commit renamed definePlugin to definePluginWithCatalog and Container's constructor to createContainer across every core plugin, which also broke the pnpm gen plugin/module scaffolds (they still call definePlugin({...}) directly). Restore the original identifiers - definePlugin is now overloaded to support both the plain uncatalogued call and definePlugin()({...}) for catalog-constrained container access, and Container keeps its public constructor. Also fixes a real generic-inference bug in provide()/Container.get() that collapsed a factory's container view to a union of every catalog value instead of the one requested token. --- packages/core/src/admin-console/plugin.ts | 4 +- packages/core/src/analytics/plugin.ts | 9 +- packages/core/src/audit/plugin.ts | 4 +- packages/core/src/casino/gaming/plugin.ts | 9 +- packages/core/src/casino/lobby/plugin.ts | 4 +- packages/core/src/cms/plugin.ts | 4 +- packages/core/src/compliance/plugin.ts | 4 +- .../src/engagement/chat-commands/plugin.ts | 4 +- packages/core/src/engagement/chat/plugin.ts | 35 ++++---- .../src/engagement/notifications/plugin.ts | 4 +- packages/core/src/iam/plugin.ts | 4 +- packages/core/src/pam/identity/plugin.ts | 4 +- .../core/src/pam/player-management/plugin.ts | 4 +- packages/core/src/pam/player-note/plugin.ts | 9 +- packages/core/src/pam/profile/plugin.ts | 4 +- packages/core/src/pam/tag/plugin.ts | 6 +- .../server/kernel/__tests__/container.test.ts | 41 +++++----- packages/core/src/server/kernel/container.ts | 41 +++++----- packages/core/src/server/kernel/index.ts | 2 +- .../__tests__/load-plugins.test.ts | 5 +- .../__tests__/module-registry.test.ts | 22 +++-- .../__tests__/required-ports.test.ts | 15 ++-- .../__tests__/typed-plugin.test.ts | 39 ++++++--- .../src/server/plugin-host/define-plugin.ts | 82 +++++++++++++++---- packages/core/src/server/plugin-host/index.ts | 2 +- .../src/server/plugin-host/load-plugins.ts | 4 +- .../src/server/plugin-host/module-registry.ts | 16 ++-- .../core/src/server/runtime/create-app.ts | 7 +- packages/core/src/server/runtime/index.ts | 10 ++- packages/core/src/wallet/plugin.ts | 4 +- .../src/__tests__/analytics.e2e.test.ts | 16 +--- .../fixtures/test-kyc-config-plugin.ts | 4 +- ...allet-auto-withdrawal-cap-config-plugin.ts | 4 +- ...st-wallet-auto-withdrawal-config-plugin.ts | 4 +- .../__tests__/gaming-stake-debit.e2e.test.ts | 9 +- .../testing/src/__tests__/kyc.e2e.test.ts | 9 +- packages/testing/src/__tests__/rg.e2e.test.ts | 15 ++-- .../src/__tests__/tag-bf317.e2e.test.ts | 13 +-- .../testing/src/__tests__/tag.e2e.test.ts | 22 +---- .../wallet-ledger-auto-withdrawal.e2e.test.ts | 9 +- packages/testing/src/app.ts | 5 +- packages/testing/src/seed.ts | 10 +-- 42 files changed, 252 insertions(+), 270 deletions(-) diff --git a/packages/core/src/admin-console/plugin.ts b/packages/core/src/admin-console/plugin.ts index fe64fd02..ae4ef3c7 100644 --- a/packages/core/src/admin-console/plugin.ts +++ b/packages/core/src/admin-console/plugin.ts @@ -5,11 +5,11 @@ import { ADMIN_WALLET_REPORTING, AUDIT_WRITER, } from '@openora/core/contracts'; -import { definePluginWithCatalog, ADMIN_GUARD, type CoreTokenCatalog } from '@openora/core/server'; +import { definePlugin, ADMIN_GUARD, type CoreTokenCatalog } from '@openora/core/server'; import { BackofficeService } from './service/backoffice.service.js'; import { createBackofficeRouter } from './router/index.js'; -export default definePluginWithCatalog()({ +export default definePlugin()({ id: 'admin-console', dependsOn: ['identity', 'wallet', 'audit', 'gaming', 'iam'], register(ctx) { diff --git a/packages/core/src/analytics/plugin.ts b/packages/core/src/analytics/plugin.ts index c7e8b0cb..02de8348 100644 --- a/packages/core/src/analytics/plugin.ts +++ b/packages/core/src/analytics/plugin.ts @@ -1,15 +1,10 @@ import { CACHE } from '@openora/core/contracts'; -import { - definePluginWithCatalog, - ADMIN_GUARD, - DRIZZLE, - type CoreTokenCatalog, -} from '@openora/core/server'; +import { definePlugin, ADMIN_GUARD, DRIZZLE, type CoreTokenCatalog } from '@openora/core/server'; import { FinancialAnalyticsService } from './service/financial-analytics.service.js'; import { FunnelAnalyticsService } from './service/funnel-analytics.service.js'; import { createAnalyticsRouter } from './router/index.js'; -export default definePluginWithCatalog()({ +export default definePlugin()({ id: 'analytics', dependsOn: ['wallet', 'identity', 'profile', 'gaming'], register(ctx) { diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index 1156408a..1f23b6dc 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -1,5 +1,5 @@ import { - definePluginWithCatalog, + definePlugin, EVENT_BUS, DRIZZLE, ADMIN_GUARD, @@ -565,7 +565,7 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [ 'player.level.changed', ] as const; -export default definePluginWithCatalog()({ +export default definePlugin()({ id: 'audit', register(ctx) { const logger = createLogger('audit'); diff --git a/packages/core/src/casino/gaming/plugin.ts b/packages/core/src/casino/gaming/plugin.ts index 79049d7b..a3b0e9eb 100644 --- a/packages/core/src/casino/gaming/plugin.ts +++ b/packages/core/src/casino/gaming/plugin.ts @@ -1,9 +1,4 @@ -import { - definePluginWithCatalog, - EVENT_BUS, - DRIZZLE, - type CoreTokenCatalog, -} from '@openora/core/server'; +import { definePlugin, EVENT_BUS, DRIZZLE, type CoreTokenCatalog } from '@openora/core/server'; import { ADMIN_GAME_REPORTING, GAME_ADAPTER, @@ -17,7 +12,7 @@ import { MockGameAdapter } from './adapters/mock/mock-game-adapter.js'; import { MockRngAdapter } from './adapters/mock/mock-rng-adapter.js'; import { DrizzleAdminGameReporting } from './admin-reporting.js'; -export default definePluginWithCatalog()({ +export default definePlugin()({ id: 'gaming', requiresPorts: [PLAY_ELIGIBILITY], dependsOn: ['wallet'], diff --git a/packages/core/src/casino/lobby/plugin.ts b/packages/core/src/casino/lobby/plugin.ts index 4fa1274b..a1b7be15 100644 --- a/packages/core/src/casino/lobby/plugin.ts +++ b/packages/core/src/casino/lobby/plugin.ts @@ -1,9 +1,9 @@ -import { definePluginWithCatalog, DRIZZLE, type CoreTokenCatalog } from '@openora/core/server'; +import { definePlugin, DRIZZLE, type CoreTokenCatalog } from '@openora/core/server'; import { CACHE } from '@openora/core/contracts'; import { LobbyService } from './service/lobby.service.js'; import { createLobbyRouter } from './router/index.js'; -export default definePluginWithCatalog()({ +export default definePlugin()({ id: 'lobby', register(ctx) { ctx.routers.add('lobby', (c) => diff --git a/packages/core/src/cms/plugin.ts b/packages/core/src/cms/plugin.ts index 1483495a..3c538c4a 100644 --- a/packages/core/src/cms/plugin.ts +++ b/packages/core/src/cms/plugin.ts @@ -1,5 +1,5 @@ import { - definePluginWithCatalog, + definePlugin, EVENT_BUS, DRIZZLE, ADMIN_GUARD, @@ -9,7 +9,7 @@ import { CACHE } from '@openora/core/contracts'; import { CmsService } from './service/cms.service.js'; import { createCmsRouter } from './router/index.js'; -export default definePluginWithCatalog()({ +export default definePlugin()({ id: 'cms', register(ctx) { ctx.routers.add('cms', (c) => diff --git a/packages/core/src/compliance/plugin.ts b/packages/core/src/compliance/plugin.ts index ab8e2311..2c8e3764 100644 --- a/packages/core/src/compliance/plugin.ts +++ b/packages/core/src/compliance/plugin.ts @@ -1,5 +1,5 @@ import { - definePluginWithCatalog, + definePlugin, EVENT_BUS, DRIZZLE, ADMIN_GUARD, @@ -58,7 +58,7 @@ const KycDecisionSyncJobSchema = z.object({ receivedAt: z.iso.datetime(), }); -export default definePluginWithCatalog()({ +export default definePlugin()({ id: 'compliance', dependsOn: ['player-management', 'identity', 'wallet', 'gaming', 'audit'], requiresPorts: [LOGIN_ENFORCEMENT], diff --git a/packages/core/src/engagement/chat-commands/plugin.ts b/packages/core/src/engagement/chat-commands/plugin.ts index dbcd1795..738b58c2 100644 --- a/packages/core/src/engagement/chat-commands/plugin.ts +++ b/packages/core/src/engagement/chat-commands/plugin.ts @@ -1,5 +1,5 @@ import { - definePluginWithCatalog, + definePlugin, DRIZZLE, EVENT_BUS, ADMIN_GUARD, @@ -19,7 +19,7 @@ import { import { ChatCommandsService } from './service/chat-commands.service.js'; import { createChatCommandsRouter } from './router/index.js'; -export default definePluginWithCatalog()({ +export default definePlugin()({ id: 'chat-commands', dependsOn: ['chat', 'wallet', 'iam', 'audit', 'gaming'], register(ctx) { diff --git a/packages/core/src/engagement/chat/plugin.ts b/packages/core/src/engagement/chat/plugin.ts index e04142b6..6596a467 100644 --- a/packages/core/src/engagement/chat/plugin.ts +++ b/packages/core/src/engagement/chat/plugin.ts @@ -1,10 +1,9 @@ import { - definePluginWithCatalog, + definePlugin, EVENT_BUS, DRIZZLE, ADMIN_GUARD, type CoreTokenCatalog, - type TypedContainer, } from '@openora/core/server'; import { CHAT_REALTIME_TRANSPORT, @@ -14,38 +13,42 @@ import { CHAT_BLOCK_WRITER, CHAT_ROOM_ACCESS, ADMIN_USER_DIRECTORY, + createToken, REALTIME_TRANSPORT, REALTIME_CLIENT_AUTHORIZER, } from '@openora/core/contracts'; import { ChatService } from './service/chat.service.js'; import { createChatRouter } from './router/index.js'; -export default definePluginWithCatalog()({ +const CHAT_SERVICE = createToken('_ChatService'); + +export default definePlugin()({ id: 'chat', dependsOn: ['identity'], register(ctx) { - let chatService: ChatService | null = null; - const getChatService = (container: TypedContainer) => - (chatService ??= new ChatService( - container.get(DRIZZLE), - container.get(EVENT_BUS), - container.get(CHAT_REALTIME_TRANSPORT), - container.get(ADMIN_USER_DIRECTORY), - )); - ctx.provide(CHAT_REALTIME_TRANSPORT, (c) => c.get(REALTIME_TRANSPORT)); ctx.provide(CHAT_REALTIME_CLIENT_AUTHORIZER, (c) => c.get(REALTIME_CLIENT_AUTHORIZER)); - ctx.provide(CHAT_SYSTEM_WRITER, (c) => getChatService(c)); - ctx.provide(CHAT_BLOCK_WRITER, (c) => getChatService(c)); + ctx.provide( + CHAT_SERVICE, + (c) => + new ChatService( + c.get(DRIZZLE), + c.get(EVENT_BUS), + c.get(CHAT_REALTIME_TRANSPORT), + c.get(ADMIN_USER_DIRECTORY), + ), + ); + ctx.provide(CHAT_SYSTEM_WRITER, (c) => c.get(CHAT_SERVICE)); + ctx.provide(CHAT_BLOCK_WRITER, (c) => c.get(CHAT_SERVICE)); ctx.provide(CHAT_ROOM_ACCESS, (c) => ({ verifyRoomAccess: async (roomId, viewerId) => { - await getChatService(c).verifyRoomAccess(roomId, viewerId); + await c.get(CHAT_SERVICE).verifyRoomAccess(roomId, viewerId); }, })); ctx.routers.add('chat', (c) => createChatRouter({ - chatService: getChatService(c), + chatService: c.get(CHAT_SERVICE), authorizer: c.get(CHAT_REALTIME_CLIENT_AUTHORIZER), adminGuard: c.get(ADMIN_GUARD), limiter: c.get(RATE_LIMITER), diff --git a/packages/core/src/engagement/notifications/plugin.ts b/packages/core/src/engagement/notifications/plugin.ts index 3810390d..089a8b27 100644 --- a/packages/core/src/engagement/notifications/plugin.ts +++ b/packages/core/src/engagement/notifications/plugin.ts @@ -12,7 +12,7 @@ import { } from '@openora/core/contracts'; import { createLogger, - definePluginWithCatalog, + definePlugin, EVENT_BUS, DRIZZLE, type CoreTokenCatalog, @@ -28,7 +28,7 @@ const KycResubmissionNotifyJobSchema = z.object({ reason: z.string().nullable(), }); -export default definePluginWithCatalog()({ +export default definePlugin()({ id: 'notifications', // ADMIN_USER_DIRECTORY (owned by identity) resolves the player's email for the // withdrawal delivery emails; pin load order so a split still finds the port. See ADR-0017. diff --git a/packages/core/src/iam/plugin.ts b/packages/core/src/iam/plugin.ts index c6e794ba..18459292 100644 --- a/packages/core/src/iam/plugin.ts +++ b/packages/core/src/iam/plugin.ts @@ -1,5 +1,5 @@ import { - definePluginWithCatalog, + definePlugin, EVENT_BUS, DRIZZLE, ADMIN_GUARD, @@ -20,7 +20,7 @@ import { DrizzleAdminPlayerActivity } from './adapters/admin-player-activity.js' const logger = createLogger('iam'); -export default definePluginWithCatalog()({ +export default definePlugin()({ id: 'iam', dependsOn: ['identity'], register(ctx) { diff --git a/packages/core/src/pam/identity/plugin.ts b/packages/core/src/pam/identity/plugin.ts index 07c0b2be..fe8a7284 100644 --- a/packages/core/src/pam/identity/plugin.ts +++ b/packages/core/src/pam/identity/plugin.ts @@ -15,7 +15,7 @@ import { SMS_ADAPTER, } from '@openora/core/contracts'; import { - definePluginWithCatalog, + definePlugin, ADMIN_GUARD, EVENT_BUS, DRIZZLE, @@ -34,7 +34,7 @@ import { SessionService } from './service/session.service.js'; import { LoginEnforcementService } from './service/login-enforcement.service.js'; import { PlayEligibilityService } from './service/play-eligibility.service.js'; -export default definePluginWithCatalog()({ +export default definePlugin()({ id: 'identity', register(ctx) { ctx.provide(KYC_ADAPTER, () => new MockKycAdapter()); diff --git a/packages/core/src/pam/player-management/plugin.ts b/packages/core/src/pam/player-management/plugin.ts index 74202161..26e6c7d3 100644 --- a/packages/core/src/pam/player-management/plugin.ts +++ b/packages/core/src/pam/player-management/plugin.ts @@ -1,5 +1,5 @@ import { - definePluginWithCatalog, + definePlugin, EVENT_BUS, DRIZZLE, ADMIN_GUARD, @@ -12,7 +12,7 @@ import { createPlayerRouter } from './router/index.js'; // Owns the player table writes, so it binds the single KYC_STATUS_WRITER seam // (compliance + the admin override route consume it). Reads identity via /schema. See ADR-0020. -export default definePluginWithCatalog()({ +export default definePlugin()({ id: 'player-management', dependsOn: ['audit'], register(ctx) { diff --git a/packages/core/src/pam/player-note/plugin.ts b/packages/core/src/pam/player-note/plugin.ts index 93ed2991..53f503d9 100644 --- a/packages/core/src/pam/player-note/plugin.ts +++ b/packages/core/src/pam/player-note/plugin.ts @@ -1,13 +1,8 @@ -import { - definePluginWithCatalog, - DRIZZLE, - ADMIN_GUARD, - type CoreTokenCatalog, -} from '@openora/core/server'; +import { definePlugin, DRIZZLE, ADMIN_GUARD, type CoreTokenCatalog } from '@openora/core/server'; import { PlayerNoteService } from './service/player-note.service.js'; import { createPlayerNoteRouter } from './router/index.js'; -export default definePluginWithCatalog()({ +export default definePlugin()({ id: 'player-note', register(ctx) { ctx.routers.add('player-note', (c) => diff --git a/packages/core/src/pam/profile/plugin.ts b/packages/core/src/pam/profile/plugin.ts index 3c3390c8..37c868dc 100644 --- a/packages/core/src/pam/profile/plugin.ts +++ b/packages/core/src/pam/profile/plugin.ts @@ -1,8 +1,8 @@ -import { definePluginWithCatalog, DRIZZLE, type CoreTokenCatalog } from '@openora/core/server'; +import { definePlugin, DRIZZLE, type CoreTokenCatalog } from '@openora/core/server'; import { ProfileService } from './service/profile.service.js'; import { createProfileRouter } from './router/index.js'; -export default definePluginWithCatalog()({ +export default definePlugin()({ id: 'profile', register(ctx) { ctx.routers.add('profile', (c) => createProfileRouter(new ProfileService(c.get(DRIZZLE)))); diff --git a/packages/core/src/pam/tag/plugin.ts b/packages/core/src/pam/tag/plugin.ts index 7cb6ce3b..157b8737 100644 --- a/packages/core/src/pam/tag/plugin.ts +++ b/packages/core/src/pam/tag/plugin.ts @@ -1,10 +1,10 @@ import { - definePluginWithCatalog, + definePlugin, EVENT_BUS, DRIZZLE, ADMIN_GUARD, - type CoreTokenCatalog, type TypedContainer, + type CoreTokenCatalog, } from '@openora/core/server'; import { PLAYER_TAGS, @@ -21,7 +21,7 @@ import { TagRuleService } from './service/tag-rule.service.js'; import { TagEvaluationService } from './service/tag-evaluation.service.js'; import { createTagRouter } from './router/index.js'; -export default definePluginWithCatalog()({ +export default definePlugin()({ id: 'tag', dependsOn: ['wallet', 'identity'], register(ctx) { diff --git a/packages/core/src/server/kernel/__tests__/container.test.ts b/packages/core/src/server/kernel/__tests__/container.test.ts index 5ce7759b..dc30d34e 100644 --- a/packages/core/src/server/kernel/__tests__/container.test.ts +++ b/packages/core/src/server/kernel/__tests__/container.test.ts @@ -1,50 +1,47 @@ import { describe, it, expect, vi } from 'vitest'; -import { createToken, type TokenCatalog } from '@openora/core/contracts'; -import { createContainer } from '../container.js'; - -const CACHE = createToken<{ n: number }>('cache'); -const REBIND = createToken('rebind'); -const MISSING = createToken('missing'); -const A = createToken('a'); -const B = createToken('b'); -const catalog = { CACHE, REBIND, MISSING, A, B } satisfies TokenCatalog; +import { createToken } from '@openora/core/contracts'; +import { Container } from '../container.js'; describe('Container', () => { it('resolves a registered factory and caches the instance', () => { - const c = createContainer(catalog); + const TOKEN = createToken<{ n: number }>('cache'); + const c = new Container(); const factory = vi.fn(() => ({ n: 1 })); - c.register(CACHE, factory); + c.register(TOKEN, factory); - const a = c.get(CACHE); - const b = c.get(CACHE); + const a = c.get(TOKEN); + const b = c.get(TOKEN); expect(a).toBe(b); expect(factory).toHaveBeenCalledTimes(1); }); it('last registration wins and drops the cached instance', () => { - const c = createContainer(catalog); - c.register(REBIND, () => 'first'); - expect(c.get(REBIND)).toBe('first'); + const TOKEN = createToken('rebind'); + const c = new Container(); + c.register(TOKEN, () => 'first'); + expect(c.get(TOKEN)).toBe('first'); - c.register(REBIND, () => 'second'); - expect(c.get(REBIND)).toBe('second'); + c.register(TOKEN, () => 'second'); + expect(c.get(TOKEN)).toBe('second'); }); it('throws for an unregistered token', () => { - const c = createContainer(catalog); - expect(() => c.get(MISSING)).toThrow(/No provider registered/); + const c = new Container(); + expect(() => c.get(createToken('missing'))).toThrow(/No provider registered/); }); it('detects circular dependencies', () => { - const c = createContainer(catalog); + const A = createToken('a'); + const B = createToken('b'); + const c = new Container(); c.register(A, (cc) => cc.get(B)); c.register(B, (cc) => cc.get(A)); expect(() => c.get(A)).toThrow(/Circular dependency/); }); it('runs disposers in reverse registration order', async () => { - const c = createContainer(catalog); + const c = new Container(); const order: string[] = []; c.onDispose(() => { order.push('first'); diff --git a/packages/core/src/server/kernel/container.ts b/packages/core/src/server/kernel/container.ts index ff3183f4..1617be97 100644 --- a/packages/core/src/server/kernel/container.ts +++ b/packages/core/src/server/kernel/container.ts @@ -1,4 +1,4 @@ -import type { TokenCatalog, TokenValue } from '@openora/core/contracts'; +import type { AnyToken, TokenCatalog, TokenValue } from '@openora/core/contracts'; // Functional DI container. Resolution is lazy and cached; last `register` for a // token wins - overlays rebind adapters by registering after the default binding. @@ -8,7 +8,13 @@ import type { TokenCatalog, TokenValue } from '@openora/core/contracts'; // sealed/overlay-rejection rules live one layer up, in ModuleRegistry's // provide()/provideSealed() (see plugin-host/module-registry.ts). -export type Factory = (c: Container) => T; +export type Factory = (c: Container) => T; + +// The token shape register()/has()/get() accept: any token when uncatalogued, or a +// catalog-listed one otherwise. T is inferred directly from the token argument +// (never a keyof reverse lookup), which is what makes TokenValue resolve to +// that one entry instead of a union of every catalog value. +type ContainerToken = [C] extends [never] ? AnyToken : C[keyof C]; /** * Functional DI container - no decorators, no reflection. `get()` resolves @@ -20,35 +26,31 @@ export type Factory = (c: Container) => T; * `dispose()` runs every `onDispose` callback in REVERSE registration order - * register dependencies before their dependents so teardown happens safely. */ -export class Container { +export class Container { private readonly factories = new Map>(); private readonly instances = new Map(); private readonly resolving = new Set(); private readonly disposers: Array<() => void | Promise> = []; - private constructor(_catalog: C) {} - - static create(catalog: C): Container { - return new Container(catalog); - } - - register( + register>( token: T, factory: (container: Container) => TokenValue, ): void { - this.factories.set(token, factory); + this.registerUnsafe(token, factory); + } + + registerUnsafe(token: AnyToken, factory: Factory): void { + this.factories.set(token, factory as Factory); this.instances.delete(token); } - has(token: T): boolean; - has(token: C[keyof C]): boolean { + has>(token: T): boolean { return this.factories.has(token); } - get(token: T): TokenValue; - get(token: C[keyof C]): T { + get>(token: T): TokenValue { if (this.instances.has(token)) { - return this.instances.get(token) as T; + return this.instances.get(token) as TokenValue; } const factory = this.factories.get(token); @@ -62,7 +64,7 @@ export class Container { } this.resolving.add(token); - const instance = factory(this) as T; + const instance = factory(this) as TokenValue; this.resolving.delete(token); this.instances.set(token, instance); return instance; @@ -78,8 +80,3 @@ export class Container { } } } - -/** Creates a container whose token catalog is inferred from the catalog value. */ -export function createContainer(catalog: C): Container { - return Container.create(catalog); -} diff --git a/packages/core/src/server/kernel/index.ts b/packages/core/src/server/kernel/index.ts index e3b8b031..bf7a843c 100644 --- a/packages/core/src/server/kernel/index.ts +++ b/packages/core/src/server/kernel/index.ts @@ -20,7 +20,7 @@ export { RedisStreamsBroker } from './redis-streams-broker.js'; export { InProcessRealtimeTransport } from './realtime-transport.js'; export { SseClientAuthorizer } from './realtime-authorizer.js'; -export { Container, createContainer } from './container.js'; +export { Container } from './container.js'; export type { Factory } from './container.js'; export { createLogger } from './logger.js'; diff --git a/packages/core/src/server/plugin-host/__tests__/load-plugins.test.ts b/packages/core/src/server/plugin-host/__tests__/load-plugins.test.ts index fa75ab10..f441d741 100644 --- a/packages/core/src/server/plugin-host/__tests__/load-plugins.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/load-plugins.test.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from 'vitest'; -import type { TokenCatalog } from '@openora/core/contracts'; import type { Plugin } from '../define-plugin.js'; import { topoSort } from '../load-plugins.js'; -const catalog = {} satisfies TokenCatalog; - -function plugin(id: string, dependsOn: string[] = []): Plugin { +function plugin(id: string, dependsOn: string[] = []): Plugin { return { id, dependsOn, diff --git a/packages/core/src/server/plugin-host/__tests__/module-registry.test.ts b/packages/core/src/server/plugin-host/__tests__/module-registry.test.ts index 8dfa245d..dfefd9ff 100644 --- a/packages/core/src/server/plugin-host/__tests__/module-registry.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/module-registry.test.ts @@ -1,20 +1,17 @@ import { describe, it, expect } from 'vitest'; -import { createContainer } from '../../kernel/index.js'; -import { createToken, createSealedToken, type TokenCatalog } from '@openora/core/contracts'; +import { Container } from '../../kernel/index.js'; +import { createToken, createSealedToken, type Token } from '@openora/core/contracts'; import { ModuleRegistryImpl } from '../module-registry.js'; -const TOKEN = createToken('svc'); -const SEALED = createSealedToken('audit-log-writer'); -const catalog = { TOKEN, SEALED } satisfies TokenCatalog; - function newRegistry() { - const container = createContainer(catalog); + const container = new Container(); return { container, reg: new ModuleRegistryImpl(container) }; } describe('ModuleRegistryImpl', () => { it('provide() binds to the container (last-wins)', () => { const { container, reg } = newRegistry(); + const TOKEN = createToken('svc'); reg.provide(TOKEN, () => 'a'); reg.provide(TOKEN, () => 'b'); expect(container.get(TOKEN)).toBe('b'); @@ -22,21 +19,22 @@ describe('ModuleRegistryImpl', () => { it('provide() refuses to bind a sealed token', () => { const { reg } = newRegistry(); - expect(() => - reg.provide(SEALED as never, () => { - throw new Error('unreachable'); - }), - ).toThrow(/sealed token/i); + const SEALED = createSealedToken('rg-enforcement'); + expect(() => reg.provide(SEALED as unknown as Token, () => 'x')).toThrow( + /sealed token/i, + ); }); it('provideSealed() binds a sealed token exactly once', () => { const { container, reg } = newRegistry(); + const SEALED = createSealedToken('audit-log-writer'); reg.provideSealed(SEALED, () => 'canonical'); expect(container.get(SEALED)).toBe('canonical'); }); it('provideSealed() rejects a second bind of the same sealed token', () => { const { reg } = newRegistry(); + const SEALED = createSealedToken('audit-log-writer'); reg.provideSealed(SEALED, () => 'canonical'); expect(() => reg.provideSealed(SEALED, () => 'overlay-attempt')).toThrow(/already bound/i); }); diff --git a/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts b/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts index 836a834f..b394a0f2 100644 --- a/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts @@ -1,13 +1,12 @@ import { describe, it, expect } from 'vitest'; -import { createContainer } from '../../kernel/index.js'; -import { createToken, type TokenCatalog } from '@openora/core/contracts'; +import { Container } from '../../kernel/index.js'; +import { createToken } from '@openora/core/contracts'; import { assertRequiredPorts } from '../load-plugins.js'; import type { Plugin } from '../define-plugin.js'; const WALLET_COMMANDS = createToken<{ debit: () => void }>('WALLET_COMMANDS'); -const catalog = { WALLET_COMMANDS } satisfies TokenCatalog; -const consumer: Plugin = { +const consumer: Plugin = { id: 'gaming', requiresPorts: [WALLET_COMMANDS], register: () => {}, @@ -15,19 +14,19 @@ const consumer: Plugin = { describe('assertRequiredPorts (ADR-0024 boot fail-fast)', () => { it('passes when every required port is bound', () => { - const container = createContainer(catalog); + const container = new Container(); container.register(WALLET_COMMANDS, () => ({ debit: () => {} })); expect(() => assertRequiredPorts([consumer], container)).not.toThrow(); }); it('throws an actionable error naming the plugin and the unbound port', () => { - const container = createContainer(catalog); + const container = new Container(); expect(() => assertRequiredPorts([consumer], container)).toThrow(/gaming.*WALLET_COMMANDS/s); }); it('is a no-op for plugins that declare no required ports', () => { - const container = createContainer(catalog); - const plain: Plugin = { id: 'audit', register: () => {} }; + const container = new Container(); + const plain: Plugin = { id: 'audit', register: () => {} }; expect(() => assertRequiredPorts([plain], container)).not.toThrow(); }); }); diff --git a/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts b/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts index ea7fd859..c5a6ffd0 100644 --- a/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createToken, type TokenCatalog } from '@openora/core/contracts'; -import { Container, createContainer } from '../../kernel/index.js'; -import { defineExtensions, definePluginWithCatalog, ModuleRegistryImpl } from '../index.js'; +import { Container } from '../../kernel/index.js'; +import { defineExtensions, definePlugin, ModuleRegistryImpl } from '../index.js'; const COUNT = createToken('COUNT'); const SEED = createToken('SEED'); @@ -20,7 +20,7 @@ const typedCatalogWrongValue = { void typedCatalogWrongValue; function assertTypedContainer() { - const typedContainer = createContainer(catalog); + const typedContainer = new Container(); const typedCount: number = typedContainer.get(COUNT); void typedCount; @@ -39,12 +39,7 @@ function assertTypedContainer() { void assertTypedContainer; -// @ts-expect-error Containers are created from an explicit token catalog. -const untypedContainer = new Container(); - -void untypedContainer; - -const typedPlugin = definePluginWithCatalog()({ +const typedPlugin = definePlugin()({ id: 'typed-plugin', dependsOn: ['foundation'], register(ctx) { @@ -60,7 +55,7 @@ const typedPlugin = definePluginWithCatalog()({ }, }); -definePluginWithCatalog()({ +definePlugin()({ id: 'invalid-typed-plugin', register(ctx) { // @ts-expect-error A provider must return the catalog token value. @@ -81,6 +76,21 @@ definePluginWithCatalog()({ const typedPluginId: 'typed-plugin' = typedPlugin.id; const typedDependency: readonly ['foundation'] = typedPlugin.dependsOn ?? ['foundation']; +// The original, single-call, uncatalogued form still works and still infers +// literal id/dependsOn types - definePlugin() was never renamed. +const uncataloguedPlugin = definePlugin({ + id: 'uncatalogued-plugin', + dependsOn: ['foundation'], + register(ctx) { + ctx.provide(OTHER, () => 'anything goes without a catalog'); + }, +}); + +const uncataloguedPluginId: 'uncatalogued-plugin' = uncataloguedPlugin.id; +const uncataloguedDependency: readonly ['foundation'] = uncataloguedPlugin.dependsOn ?? [ + 'foundation', +]; + const validGraph = defineExtensions([ { id: 'foundation', path: './foundation.js' }, { id: 'feature', path: './feature.js', dependsOn: ['foundation'] }, @@ -97,7 +107,7 @@ defineExtensions([ describe('typed plugin surface', () => { it('keeps literal plugin metadata and resolves catalog services', () => { - const container = createContainer(catalog); + const container = new Container(); const registry = new ModuleRegistryImpl(container); container.register(SEED, () => 41); @@ -109,8 +119,13 @@ describe('typed plugin surface', () => { expect(container.get(COUNT)).toBe(42); }); + it('keeps the uncatalogued single-call form working', () => { + expect(uncataloguedPluginId).toBe('uncatalogued-plugin'); + expect(uncataloguedDependency).toEqual(['foundation']); + }); + it('keeps router factories on the catalogued container view', () => { - const container = createContainer(catalog); + const container = new Container(); const registry = new ModuleRegistryImpl(container); container.register(SEED, () => 7); diff --git a/packages/core/src/server/plugin-host/define-plugin.ts b/packages/core/src/server/plugin-host/define-plugin.ts index 2209ad87..f626e513 100644 --- a/packages/core/src/server/plugin-host/define-plugin.ts +++ b/packages/core/src/server/plugin-host/define-plugin.ts @@ -1,3 +1,4 @@ +import type { Container } from '../kernel/index.js'; import type { EventEnvelope, SealedToken, @@ -15,7 +16,7 @@ export type McpToolDefinition = { }; // Runs once at boot, after every plugin has registered its providers, so adapter overrides (last registration wins) are in effect. -export type RouterFactory = (c: TypedContainer) => unknown; +export type RouterFactory = (c: ContainerView) => unknown; export type TypedContainer = { get(token: T): TokenValue; @@ -25,18 +26,37 @@ export type TypedContainer = { export type EventHandler = (payload: unknown, envelope?: EventEnvelope) => void | Promise; -export type ModuleRegistry = { +// The container view a factory receives: the full Container when uncatalogued, or +// a view restricted to the catalog's own tokens otherwise. +export type ContainerView = [C] extends [never] + ? Container + : TypedContainer; + +// The token shape provide()/provideSealed() accept: any Token/SealedToken when +// uncatalogued, or a catalog-listed one when C is a real catalog. T is inferred +// directly from the token argument (never a keyof reverse lookup) - that's what +// makes TokenValue resolve to that one entry instead of a union of every +// catalog value. +type ProviderToken = [C] extends [never] + ? Token + : C[keyof C] & Token; + +type SealedProviderToken = [C] extends [never] + ? SealedToken + : C[keyof C] & SealedToken; + +export type ModuleRegistry = { // Last registration wins - an overlay loaded after a module can rebind its adapter token. - provide>( + provide>( token: T, - factory: (container: TypedContainer) => TokenValue, + factory: (container: ContainerView) => TokenValue, ): void; // Bind-once, owner-only. The ONLY legitimate way to bind a SealedToken - provide() // rejects sealed tokens outright. A second call for the same token (an overlay // trying to override a regulator-mandated service) throws instead of rebinding. - provideSealed>( + provideSealed>( token: T, - factory: (container: TypedContainer) => TokenValue, + factory: (container: ContainerView) => TokenValue, ): void; routers: { add(namespace: string, factory: RouterFactory): void; @@ -61,31 +81,57 @@ export type ModuleRegistry = { }; }; -export type PluginContext = ModuleRegistry; +export type PluginContext = ModuleRegistry; export type PluginDefinition< - C extends TokenCatalog, + C extends TokenCatalog = never, Id extends string = string, Dependencies extends readonly string[] = string[], > = { id: Id; dependsOn?: Dependencies; // Verified once after all plugins register - a missing port fails fast. See ADR-0024. - requiresPorts?: Array>; - register: (ctx: PluginContext) => void | Promise; + requiresPorts?: ProviderToken[]; + register(ctx: PluginContext): void | Promise; }; export type Plugin< - C extends TokenCatalog, + C extends TokenCatalog = never, Id extends string = string, Dependencies extends readonly string[] = string[], > = PluginDefinition; -export function definePluginWithCatalog() { - return function defineCataloguedPlugin< - const Id extends string, - const Dependencies extends readonly string[] = [], - >(definition: PluginDefinition): Plugin { - return definition; - }; +// `requiresPorts` is widened to a plain Token[] here only: TS can't prove the +// deferred `ProviderToken` conditional (unresolved for a generic C) is +// assignable against the never-catalog overload's resolved branch, even though +// every real instantiation of C does resolve safely - a checker limitation on +// this one field, not a hole in the constraint itself. +type LooseDefinition = Omit, 'requiresPorts'> & { + requiresPorts?: Token[]; +}; + +// Uncatalogued: definePlugin({ id, register }) - the plugin host's original, +// single-call form. Unchanged for consumer overlays and scaffolded modules that +// don't need catalog-constrained container access. +export function definePlugin< + const Id extends string = string, + const Dependencies extends readonly string[] = [], +>(definition: PluginDefinition): Plugin; +// Catalogued: definePlugin()({ id, register }). C is fixed by the +// first (argument-less) call so the second call's Id/Dependencies still infer from +// the literal object - TypeScript won't infer a trailing `const` type parameter +// past one supplied explicitly in the same call. +export function definePlugin(): < + const Id extends string, + const Dependencies extends readonly string[] = [], +>( + definition: PluginDefinition, +) => Plugin; +export function definePlugin( + definition?: LooseDefinition, +): LooseDefinition | ((definition: LooseDefinition) => LooseDefinition) { + if (definition === undefined) { + return (inner: LooseDefinition) => inner; + } + return definition; } diff --git a/packages/core/src/server/plugin-host/index.ts b/packages/core/src/server/plugin-host/index.ts index 3ed2d72a..e4846dbf 100644 --- a/packages/core/src/server/plugin-host/index.ts +++ b/packages/core/src/server/plugin-host/index.ts @@ -1,4 +1,4 @@ -export { definePluginWithCatalog } from './define-plugin.js'; +export { definePlugin } from './define-plugin.js'; export type { Plugin, PluginDefinition, diff --git a/packages/core/src/server/plugin-host/load-plugins.ts b/packages/core/src/server/plugin-host/load-plugins.ts index 27c8fa9d..6fd61df9 100644 --- a/packages/core/src/server/plugin-host/load-plugins.ts +++ b/packages/core/src/server/plugin-host/load-plugins.ts @@ -134,7 +134,7 @@ export function topoSort(plugins: Plugin[]): Plugin( +export async function loadPlugins( entries: PluginEntry[], container: Container, ): Promise> { @@ -172,7 +172,7 @@ export async function loadPlugins( * registration and throw one actionable error naming the plugin, the port, and the * likely-missing package. */ -export function assertRequiredPorts( +export function assertRequiredPorts( plugins: Plugin[], container: Container, ): void { diff --git a/packages/core/src/server/plugin-host/module-registry.ts b/packages/core/src/server/plugin-host/module-registry.ts index eaf5ae0f..f3421656 100644 --- a/packages/core/src/server/plugin-host/module-registry.ts +++ b/packages/core/src/server/plugin-host/module-registry.ts @@ -10,11 +10,11 @@ import type { ModuleRegistry, McpToolDefinition, RouterFactory, - TypedContainer, + ContainerView, EventHandler, } from './define-plugin.js'; -export class ModuleRegistryImpl implements ModuleRegistry { +export class ModuleRegistryImpl implements ModuleRegistry { private _routers = new Map>(); private _slots = new Map(); private _events = new Map(); @@ -28,9 +28,9 @@ export class ModuleRegistryImpl implements ModuleRegistr // Sealed tokens (Symbol description prefixed `sealed:`) are rejected at runtime // even though the type system already blocks them - catches plain-JS callers and cast escapes. // Canonical sealed list lives in `@openora/core/compliance`. - provide = >( + provide = >( token: T, - factory: (container: TypedContainer) => TokenValue, + factory: (container: ContainerView) => TokenValue, ): void => { const desc = token.description ?? ''; if (desc.startsWith('sealed:')) { @@ -42,16 +42,16 @@ export class ModuleRegistryImpl implements ModuleRegistr `See @openora/core/compliance for the canonical list.`, ); } - this.container.register(token, factory); + this.container.registerUnsafe(token, factory); }; // Bind-once. The owning module calls this during its own register() to bind the // canonical implementation; a second call for the same token - an overlay trying // to slip past provide()'s rejection, or a duplicate registration - throws instead // of silently rebinding (there is no "last-wins" for a sealed token). - provideSealed = >( + provideSealed = >( token: T, - factory: (container: TypedContainer) => TokenValue, + factory: (container: ContainerView) => TokenValue, ): void => { if (this._sealedBound.has(token)) { throw new Error( @@ -60,7 +60,7 @@ export class ModuleRegistryImpl implements ModuleRegistr ); } this._sealedBound.add(token); - this.container.register(token, factory); + this.container.registerUnsafe(token, factory); }; routers = { diff --git a/packages/core/src/server/runtime/create-app.ts b/packages/core/src/server/runtime/create-app.ts index d254c018..9e25933a 100644 --- a/packages/core/src/server/runtime/create-app.ts +++ b/packages/core/src/server/runtime/create-app.ts @@ -10,7 +10,7 @@ import { serve, type ServerType } from '@hono/node-server'; import { resolve } from 'node:path'; import { generateOpenApiSpec } from './openapi.js'; import { - createContainer, + Container, BullMqJobQueue, RedisCache, RedisRateLimiter, @@ -22,7 +22,6 @@ import { setErrorReporter, EVENT_BUS, extractClientMeta, - type Container, type OssContext, } from '../kernel/index.js'; import { randomUUID } from 'node:crypto'; @@ -45,7 +44,7 @@ import { AdminGuard, ADMIN_GUARD, SessionResolver, AUTH_SESSION } from '../auth/ import { loadPlugins, type PluginEntry } from '../plugin-host/index.js'; import { assertDurableSeamsBound } from './assert-durable-seams.js'; import { loadPlatformConfig, resolvePlatformConfigPath } from '../kernel/platform-config-loader.js'; -import { CORE_TOKEN_CATALOG, type CoreTokenCatalog } from './core-token-catalog.js'; +import type { CoreTokenCatalog } from './core-token-catalog.js'; // Path prefixes safe to cache at the HTTP layer: public, non-personalized reads // only (lobby feeds, public CMS content, the game catalogue). NOTHING @@ -209,7 +208,7 @@ export async function createApp(config: CreateAppConfig): Promise { process.env['DATABASE_URL'] = config.databaseUrl; } - const container = createContainer(CORE_TOKEN_CATALOG); + const container = new Container(); container.register(DRIZZLE, () => { const svc = new DrizzleService(); container.onDispose(() => svc.dispose()); diff --git a/packages/core/src/server/runtime/index.ts b/packages/core/src/server/runtime/index.ts index dc06ab97..8e6ca203 100644 --- a/packages/core/src/server/runtime/index.ts +++ b/packages/core/src/server/runtime/index.ts @@ -1,10 +1,14 @@ export { createApp } from './create-app.js'; export type { CreateAppConfig, CreatedApp } from './create-app.js'; -export { CORE_TOKEN_CATALOG } from './core-token-catalog.js'; export type { CoreTokenCatalog } from './core-token-catalog.js'; export { generateOpenApiSpec } from './openapi.js'; export type { GenerateOpenApiSpecOptions } from './openapi.js'; -export { type Plugin, type PluginEntry, type ModuleRegistry } from '../plugin-host/index.js'; -export { Container, createContainer } from '../kernel/index.js'; +export { + definePlugin, + type Plugin, + type PluginEntry, + type ModuleRegistry, +} from '../plugin-host/index.js'; +export { Container } from '../kernel/index.js'; diff --git a/packages/core/src/wallet/plugin.ts b/packages/core/src/wallet/plugin.ts index 49e9e661..6ae61401 100644 --- a/packages/core/src/wallet/plugin.ts +++ b/packages/core/src/wallet/plugin.ts @@ -1,5 +1,5 @@ import { - definePluginWithCatalog, + definePlugin, ADMIN_GUARD, EVENT_BUS, DRIZZLE, @@ -28,7 +28,7 @@ import { createWalletRouter } from './router/index.js'; import { MockPaymentAdapter } from './adapters/mock/mock-payment-adapter.js'; import { HmacPaymentWebhookVerifier } from './adapters/hmac-payment-webhook-verifier.js'; -export default definePluginWithCatalog()({ +export default definePlugin()({ // NOT dependsOn 'tag': that would cycle (tag hard-depends on wallet's WALLET_READER). // wallet's use of tag's PLAYER_TAGS / TAG_EVALUATION_COMMANDS is optional and resolved // lazily in the router factory (`c.has(...)`), which runs after every plugin has diff --git a/packages/testing/src/__tests__/analytics.e2e.test.ts b/packages/testing/src/__tests__/analytics.e2e.test.ts index 9f8a8510..4d6ab17e 100644 --- a/packages/testing/src/__tests__/analytics.e2e.test.ts +++ b/packages/testing/src/__tests__/analytics.e2e.test.ts @@ -1,12 +1,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; -import { - loadExtensions, - DRIZZLE, - type Container, - type CoreTokenCatalog, -} from '@openora/core/server'; +import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; import { user } from '@openora/core/pam/schema/identity'; import { wallet, walletTransaction } from '@openora/core/wallet/schema'; import { @@ -42,7 +37,7 @@ async function registerPlayer(email: string) { return body.user.id; } -async function verifyEmail(container: Container, userId: string) { +async function verifyEmail(container: Container, userId: string) { await container .get(DRIZZLE) .db.update(user) @@ -57,10 +52,7 @@ async function deposit(client: TestClient, amount: string, currency = 'USD') { } } -async function walletIdFor( - container: Container, - userId: string, -): Promise { +async function walletIdFor(container: Container, userId: string): Promise { const [row] = await container .get(DRIZZLE) .db.select() @@ -73,7 +65,7 @@ async function walletIdFor( } async function insertTransaction( - container: Container, + container: Container, walletId: string, type: 'bonus' | 'bet' | 'win', amount: string, diff --git a/packages/testing/src/__tests__/fixtures/test-kyc-config-plugin.ts b/packages/testing/src/__tests__/fixtures/test-kyc-config-plugin.ts index 260f926f..8788ce3a 100644 --- a/packages/testing/src/__tests__/fixtures/test-kyc-config-plugin.ts +++ b/packages/testing/src/__tests__/fixtures/test-kyc-config-plugin.ts @@ -1,4 +1,4 @@ -import { definePluginWithCatalog, type CoreTokenCatalog } from '@openora/core/server'; +import { definePlugin } from '@openora/core/server'; import { PLATFORM_CONFIG, KYC_ADAPTER, @@ -43,7 +43,7 @@ class ControllablePendingKycAdapter implements KycAdapter { * `KYC_ADAPTER` for a controllable stub. Append last in a test's `plugins` array so both * bindings win over the defaults (last-registration-wins; see docs/standards/module-structure.md > ports). */ -export default definePluginWithCatalog()({ +export default definePlugin({ id: 'test-kyc-config', dependsOn: ['identity'], register(ctx) { diff --git a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts index c86baa68..3b733e88 100644 --- a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts +++ b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts @@ -1,9 +1,9 @@ -import { definePluginWithCatalog, type CoreTokenCatalog } from '@openora/core/server'; +import { definePlugin } from '@openora/core/server'; import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts'; // PLATFORM_CONFIG overlay for the daily-cap scenario: dailyCapCount 1 trips on the 2nd withdrawal, still below // the high_frequency heuristic (>= 3) so the cap gate is tested in isolation. Separate app since config is boot-once. -export default definePluginWithCatalog()({ +export default definePlugin({ id: 'test-wallet-auto-withdrawal-cap-config', dependsOn: ['identity'], register(ctx) { diff --git a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts index 3d8ef71b..a9c4f85a 100644 --- a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts +++ b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts @@ -1,10 +1,10 @@ -import { definePluginWithCatalog, type CoreTokenCatalog } from '@openora/core/server'; +import { definePlugin } from '@openora/core/server'; import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts'; // PLATFORM_CONFIG overlay for the auto-withdrawal e2e suite: autoWithdrawal enabled (threshold 2, // high_risk/bonus_abuser excluded, caps set high). kyc.gateWithdrawals stays false so the KYC-not-passing // scenario hits the auto-approval KYC gate, not the withdraw-time one. Append last so this binding wins. -export default definePluginWithCatalog()({ +export default definePlugin({ id: 'test-wallet-auto-withdrawal-config', dependsOn: ['identity'], register(ctx) { diff --git a/packages/testing/src/__tests__/gaming-stake-debit.e2e.test.ts b/packages/testing/src/__tests__/gaming-stake-debit.e2e.test.ts index c923d425..714aa733 100644 --- a/packages/testing/src/__tests__/gaming-stake-debit.e2e.test.ts +++ b/packages/testing/src/__tests__/gaming-stake-debit.e2e.test.ts @@ -1,12 +1,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; -import { - loadExtensions, - DRIZZLE, - type Container, - type CoreTokenCatalog, -} from '@openora/core/server'; +import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; import { game, gameRound } from '@openora/core/casino/schema/gaming'; import { wallet, walletTransaction } from '@openora/core/wallet/schema'; import { @@ -48,7 +43,7 @@ async function deposit(client: TestClient, amount: string, currency = 'USD') { } } -async function balanceOf(container: Container, userId: string): Promise { +async function balanceOf(container: Container, userId: string): Promise { const [row] = await container .get(DRIZZLE) .db.select() diff --git a/packages/testing/src/__tests__/kyc.e2e.test.ts b/packages/testing/src/__tests__/kyc.e2e.test.ts index cd66d632..af04e4b6 100644 --- a/packages/testing/src/__tests__/kyc.e2e.test.ts +++ b/packages/testing/src/__tests__/kyc.e2e.test.ts @@ -2,12 +2,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { createHmac, randomUUID } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { eq } from 'drizzle-orm'; -import { - loadExtensions, - DRIZZLE, - type Container, - type CoreTokenCatalog, -} from '@openora/core/server'; +import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; import { player } from '@openora/core/pam/schema/profile'; import { setupTestDb, @@ -76,7 +71,7 @@ async function registerAndMaterializePlayer(app: TestApp['app'], email: string) return { client, playerId: profile.id, userId: profile.userId }; } -async function seedLegacyVerifiedStatus(container: Container, userId: string) { +async function seedLegacyVerifiedStatus(container: Container, userId: string) { await container .get(DRIZZLE) .db.update(player) diff --git a/packages/testing/src/__tests__/rg.e2e.test.ts b/packages/testing/src/__tests__/rg.e2e.test.ts index 706624b6..061f6995 100644 --- a/packages/testing/src/__tests__/rg.e2e.test.ts +++ b/packages/testing/src/__tests__/rg.e2e.test.ts @@ -1,12 +1,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; -import { - loadExtensions, - DRIZZLE, - type Container, - type CoreTokenCatalog, -} from '@openora/core/server'; +import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; import { JOB_QUEUE, PLAY_ELIGIBILITY, queue } from '@openora/core/contracts'; import { rgExclusion } from '@openora/core/compliance/schema'; import { user } from '@openora/core/pam/schema/identity'; @@ -68,11 +63,11 @@ async function attemptLogin(email: string, password: string) { }); } -async function setRole(container: Container, userId: string, role: string) { +async function setRole(container: Container, userId: string, role: string) { await container.get(DRIZZLE).db.update(user).set({ role }).where(eq(user.id, userId)); } -async function expireExclusion(container: Container, exclusionId: string) { +async function expireExclusion(container: Container, exclusionId: string) { await container .get(DRIZZLE) .db.update(rgExclusion) @@ -80,7 +75,7 @@ async function expireExclusion(container: Container, exclusion .where(eq(rgExclusion.id, exclusionId)); } -async function exclusionStatus(container: Container, exclusionId: string) { +async function exclusionStatus(container: Container, exclusionId: string) { const [row] = await container .get(DRIZZLE) .db.select({ status: rgExclusion.status }) @@ -89,7 +84,7 @@ async function exclusionStatus(container: Container, exclusion return row?.status; } -async function triggerRgMonitorSweep(container: Container) { +async function triggerRgMonitorSweep(container: Container) { await container.get(JOB_QUEUE).enqueue(queue('rg-monitor'), {}); } diff --git a/packages/testing/src/__tests__/tag-bf317.e2e.test.ts b/packages/testing/src/__tests__/tag-bf317.e2e.test.ts index 97feaa23..c53d70fe 100644 --- a/packages/testing/src/__tests__/tag-bf317.e2e.test.ts +++ b/packages/testing/src/__tests__/tag-bf317.e2e.test.ts @@ -1,12 +1,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; -import { - loadExtensions, - DRIZZLE, - type Container, - type CoreTokenCatalog, -} from '@openora/core/server'; +import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; import { rgExclusion } from '@openora/core/compliance/schema'; import { setupTestDb, @@ -89,11 +84,7 @@ async function activeTagKeys(admin: TestClient, playerId: string): Promise t.key); } -async function backdateExclusionExpiry( - container: Container, - exclusionId: string, - daysAgo: number, -) { +async function backdateExclusionExpiry(container: Container, exclusionId: string, daysAgo: number) { const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); await container .get(DRIZZLE) diff --git a/packages/testing/src/__tests__/tag.e2e.test.ts b/packages/testing/src/__tests__/tag.e2e.test.ts index 86368ebb..23d37462 100644 --- a/packages/testing/src/__tests__/tag.e2e.test.ts +++ b/packages/testing/src/__tests__/tag.e2e.test.ts @@ -1,13 +1,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; -import { - loadExtensions, - DRIZZLE, - EVENT_BUS, - type Container, - type CoreTokenCatalog, -} from '@openora/core/server'; +import { loadExtensions, DRIZZLE, EVENT_BUS, type Container } from '@openora/core/server'; import { JOB_QUEUE, queue } from '@openora/core/contracts'; import { session } from '@openora/core/pam/schema/identity'; import { walletTransaction } from '@openora/core/wallet/schema'; @@ -57,11 +51,7 @@ async function registerAndMaterializePlayer(honoApp: TestApp['app'], email: stri return { client, playerId: profile.id, userId: profile.userId }; } -async function backdateSessions( - container: Container, - userId: string, - daysAgo: number, -) { +async function backdateSessions(container: Container, userId: string, daysAgo: number) { const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); await container .get(DRIZZLE) @@ -70,11 +60,7 @@ async function backdateSessions( .where(eq(session.userId, userId)); } -async function backdateTransaction( - container: Container, - transactionId: string, - daysAgo: number, -) { +async function backdateTransaction(container: Container, transactionId: string, daysAgo: number) { const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); await container .get(DRIZZLE) @@ -110,7 +96,7 @@ async function assignTagManually(admin: TestClient, playerId: string, tagKey: st return readJson(res); } -async function runDailySweep(container: Container) { +async function runDailySweep(container: Container) { await container.get(JOB_QUEUE).enqueue(queue('tag.daily-evaluation'), {}); } diff --git a/packages/testing/src/__tests__/wallet-ledger-auto-withdrawal.e2e.test.ts b/packages/testing/src/__tests__/wallet-ledger-auto-withdrawal.e2e.test.ts index 72c3904b..932b54fa 100644 --- a/packages/testing/src/__tests__/wallet-ledger-auto-withdrawal.e2e.test.ts +++ b/packages/testing/src/__tests__/wallet-ledger-auto-withdrawal.e2e.test.ts @@ -2,12 +2,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { eq } from 'drizzle-orm'; -import { - loadExtensions, - DRIZZLE, - type Container, - type CoreTokenCatalog, -} from '@openora/core/server'; +import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; import { user } from '@openora/core/pam/schema/identity'; import { setupTestDb, @@ -57,7 +52,7 @@ async function registerAndMaterializePlayer(app: TestApp['app'], email: string) return { client, playerId: profile.id, userId: profile.userId }; } -async function setRole(container: Container, userId: string, role: string) { +async function setRole(container: Container, userId: string, role: string) { await container.get(DRIZZLE).db.update(user).set({ role }).where(eq(user.id, userId)); } diff --git a/packages/testing/src/app.ts b/packages/testing/src/app.ts index dc1a77b7..810c026b 100644 --- a/packages/testing/src/app.ts +++ b/packages/testing/src/app.ts @@ -8,7 +8,6 @@ import { RedisStreamsBroker, type CreateAppConfig, type Container, - type CoreTokenCatalog, } from '@openora/core/server'; import { MESSAGE_BROKER, @@ -27,7 +26,7 @@ export type TestApp = { /** The Hono app - drive it directly with `app.request(path, init)`. */ app: Hono; /** The composition container, for resolving services/tokens in assertions. */ - container: Container; + container: Container; /** Dispose the container (closes the DB pool, drains workers, frees the Redis db). */ close(): Promise; }; @@ -69,7 +68,7 @@ export async function bootTestApp(config: BootTestAppConfig): Promise { databaseUrl: config.databaseUrl, authSchema: { user, session, account, verification, twoFactor }, openapi: { enabled: false }, - configure(container) { + configure(container: Container) { const redis = createRedisClient(redisDatabase.url); container.onDispose(() => redis.close()); diff --git a/packages/testing/src/seed.ts b/packages/testing/src/seed.ts index 3eed9eb6..2876b83d 100644 --- a/packages/testing/src/seed.ts +++ b/packages/testing/src/seed.ts @@ -1,13 +1,7 @@ import { Pool } from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; import { seedDemoData, type SeedResult } from './seed-demo-data.js'; -import { - createAuth, - DRIZZLE, - type CoreTokenCatalog, - type DrizzleDb, - type Container, -} from '@openora/core/server'; +import { createAuth, DRIZZLE, type DrizzleDb, Container } from '@openora/core/server'; import { seedIam } from '@openora/core/iam/seed'; import { seedTag } from '@openora/core/pam/tag/seed'; import { user, session, account, verification } from '@openora/core/pam/schema/identity'; @@ -29,7 +23,7 @@ export type SeedMinimalOptions = { * for the direct table inserts (players, wallets, ...). */ export async function seedMinimal( - container: Container, + container: Container, options: SeedMinimalOptions = {}, ): Promise { const drizzleSvc = container.get(DRIZZLE); From b1f6e38705d6fca3bad04a6cd41a531c26a73396 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Tue, 4 Aug 2026 20:28:26 +0200 Subject: [PATCH 4/8] docs(contracts): convert adapter seam comments to jsdoc Every top-of-file seam rationale and inline field-level explanation in contracts/adapters was a plain // comment. Convert them to /** */ JSDoc directly above the file/type/field they describe, so editors surface them on hover; no content removed, only reformatted. --- .../adapters/admin-game-reporting.ts | 20 ++- .../contracts/adapters/admin-permission.ts | 16 +- .../adapters/admin-player-activity.ts | 38 +++-- .../adapters/admin-user-directory.ts | 44 +++--- .../adapters/admin-wallet-reporting.ts | 10 +- .../core/src/contracts/adapters/aggregator.ts | 20 ++- packages/core/src/contracts/adapters/audit.ts | 28 ++-- .../core/src/contracts/adapters/broker.ts | 42 ++--- packages/core/src/contracts/adapters/cache.ts | 14 +- .../contracts/adapters/chat-block-writer.ts | 6 +- .../src/contracts/adapters/email-template.ts | 14 +- packages/core/src/contracts/adapters/email.ts | 12 +- packages/core/src/contracts/adapters/game.ts | 6 +- .../core/src/contracts/adapters/geo-ip.ts | 6 +- .../core/src/contracts/adapters/identity.ts | 6 +- packages/core/src/contracts/adapters/index.ts | 10 +- .../core/src/contracts/adapters/job-queue.ts | 101 +++++++----- .../contracts/adapters/login-enforcement.ts | 14 +- .../src/contracts/adapters/notification.ts | 12 +- .../core/src/contracts/adapters/outbox.ts | 26 ++-- .../core/src/contracts/adapters/payment.ts | 8 +- .../src/contracts/adapters/player-tags.ts | 6 +- .../core/src/contracts/adapters/rate-limit.ts | 28 ++-- .../core/src/contracts/adapters/realtime.ts | 144 ++++++++++-------- packages/core/src/contracts/adapters/rng.ts | 20 +-- packages/core/src/contracts/adapters/sms.ts | 10 +- .../adapters/tag-evaluation-commands.ts | 18 ++- packages/core/src/contracts/adapters/token.ts | 28 ++-- .../src/contracts/adapters/wallet-commands.ts | 6 +- 29 files changed, 422 insertions(+), 291 deletions(-) diff --git a/packages/core/src/contracts/adapters/admin-game-reporting.ts b/packages/core/src/contracts/adapters/admin-game-reporting.ts index 642a2a83..fa59921a 100644 --- a/packages/core/src/contracts/adapters/admin-game-reporting.ts +++ b/packages/core/src/contracts/adapters/admin-game-reporting.ts @@ -1,10 +1,12 @@ import { createToken, type Token } from './token.js'; import type { GameType } from '../schemas/game.js'; -// Admin/back-office reporting over game performance. Owned + bound by the -// casino/gaming module (it owns the `game`/`gameRound` tables); the back-office -// depends only on this port, never on the gaming schema. A query port like -// ADMIN_WALLET_REPORTING. See ADR-0017/0025. +/** + * Admin/back-office reporting over game performance. Owned + bound by the + * casino/gaming module (it owns the `game`/`gameRound` tables); the back-office + * depends only on this port, never on the gaming schema. A query port like + * ADMIN_WALLET_REPORTING. See ADR-0017/0025. + */ export const GAME_PERFORMANCE_SORT_FIELDS = [ 'name', @@ -25,10 +27,12 @@ export type GamePerformanceFilter = { sortDir?: 'asc' | 'desc'; }; -// volume/revenue = SUM(gameRound.betAmount) / SUM(betAmount) - SUM(winAmount) over -// status='completed' rounds in range; revenue (GGR) can be negative. uniquePlayers/ -// roundsPlayed are 0 for a game with no completed rounds in range - games are never -// omitted just because they had no activity in the requested window. +/** + * volume/revenue = SUM(gameRound.betAmount) / SUM(betAmount) - SUM(winAmount) over + * status='completed' rounds in range; revenue (GGR) can be negative. uniquePlayers/ + * roundsPlayed are 0 for a game with no completed rounds in range - games are never + * omitted just because they had no activity in the requested window. + */ export type GamePerformanceRow = { gameId: string; name: string; diff --git a/packages/core/src/contracts/adapters/admin-permission.ts b/packages/core/src/contracts/adapters/admin-permission.ts index fdd3ece2..2085ac50 100644 --- a/packages/core/src/contracts/adapters/admin-permission.ts +++ b/packages/core/src/contracts/adapters/admin-permission.ts @@ -1,14 +1,18 @@ -// Port for DB-backed admin RBAC. A backoffice iam module binds a concrete -// resolver that reads role assignments + grants from its own tables; the -// AdminGuard (in @openora/core/server) depends only on this interface so the platform -// keeps working - falling back to the static roles - when no resolver is bound. +/** + * Port for DB-backed admin RBAC. A backoffice iam module binds a concrete + * resolver that reads role assignments + grants from its own tables; the + * AdminGuard (in @openora/core/server) depends only on this interface so the platform + * keeps working - falling back to the static roles - when no resolver is bound. + */ import { createToken, type Token } from './token.js'; export type AdminGrant = { resource: string; action: string }; export type AdminPermissionResolver = { - // Returns the effective grants for an admin user, or null if the user has no - // DB-backed role assignment (caller should fall back to static roles). + /** + * Returns the effective grants for an admin user, or null if the user has no + * DB-backed role assignment (caller should fall back to static roles). + */ getGrants(userId: string): Promise; }; diff --git a/packages/core/src/contracts/adapters/admin-player-activity.ts b/packages/core/src/contracts/adapters/admin-player-activity.ts index 2526c101..7a902649 100644 --- a/packages/core/src/contracts/adapters/admin-player-activity.ts +++ b/packages/core/src/contracts/adapters/admin-player-activity.ts @@ -1,24 +1,26 @@ import { createToken, type Token } from './token.js'; -// Admin/back-office reporting over player registration + engagement activity. -// Owned + bound by iam (see iam/AGENTS.md for why - it centralizes admin reporting -// concerns even though the underlying `user`/`session` tables belong to identity, -// read via that module's read-only /schema subpath); the back-office depends only -// on this port. A query port like ADMIN_WALLET_REPORTING/ADMIN_GAME_REPORTING. -// See ADR-0017/0025. +/** + * Admin/back-office reporting over player registration + engagement activity. + * Owned + bound by iam (see iam/AGENTS.md for why - it centralizes admin reporting + * concerns even though the underlying `user`/`session` tables belong to identity, + * read via that module's read-only /schema subpath); the back-office depends only + * on this port. A query port like ADMIN_WALLET_REPORTING/ADMIN_GAME_REPORTING. + * See ADR-0017/0025. + */ export type PlayerActivityFilter = { dateFrom?: Date; dateTo?: Date; }; -// One point per day (UTC, `YYYY-MM-DD`) within the requested range. +/** One point per day (UTC, `YYYY-MM-DD`) within the requested range. */ export type RegistrationsOverTimePoint = { date: string; registrations: number; }; -// DAU/WAU/MAU for the trailing 1/7/30-day window ending on `date` (inclusive). +/** DAU/WAU/MAU for the trailing 1/7/30-day window ending on `date` (inclusive). */ export type ActiveUsersTrendPoint = { date: string; dau: number; @@ -26,11 +28,13 @@ export type ActiveUsersTrendPoint = { mau: number; }; -// One row per registration-day cohort (UTC day `user.createdAt` truncates to). -// `returnRate` = returned / cohortSize (0 when cohortSize is 0). `isComplete` is -// false while the cohort's [registration, registration + windowDays] window has -// not fully elapsed yet - such a row's low/zero rate is not yet meaningful and -// must be flagged rather than read as real churn. +/** + * One row per registration-day cohort (UTC day `user.createdAt` truncates to). + * `returnRate` = returned / cohortSize (0 when cohortSize is 0). `isComplete` is + * false while the cohort's [registration, registration + windowDays] window has + * not fully elapsed yet - such a row's low/zero rate is not yet meaningful and + * must be flagged rather than read as real churn. + */ export type RetentionCohortRow = { cohortDate: string; cohortSize: number; @@ -42,9 +46,11 @@ export type RetentionCohortRow = { export type AdminPlayerActivity = { getRegistrationsOverTime(filter: PlayerActivityFilter): Promise; getActiveUsersTrend(filter: PlayerActivityFilter): Promise; - // windowDays is always 7 or 30 (the two cohorts the ticket asks for); a plain - // number keeps the port from hard-coding a union it would need to duplicate again - // the moment a third window is added. + /** + * windowDays is always 7 or 30 (the two cohorts the ticket asks for); a plain + * number keeps the port from hard-coding a union it would need to duplicate again + * the moment a third window is added. + */ getRetentionCohorts( filter: PlayerActivityFilter, windowDays: 7 | 30, diff --git a/packages/core/src/contracts/adapters/admin-user-directory.ts b/packages/core/src/contracts/adapters/admin-user-directory.ts index 529fa9de..2997428a 100644 --- a/packages/core/src/contracts/adapters/admin-user-directory.ts +++ b/packages/core/src/contracts/adapters/admin-user-directory.ts @@ -4,10 +4,12 @@ import type { UserRole } from '../schemas/iam.js'; import type { ClientMeta } from '../schemas/common.js'; import type { SortOrder } from '../kit.js'; -// Admin/back-office view of the user directory. Owned + bound by the identity -// module (it owns the `user` table); the back-office (admin-console) depends only -// on this port, never on the identity schema, so it stays a clean, extractable -// module. A query/command port like WALLET_COMMANDS. See ADR-0017/0025. +/** + * Admin/back-office view of the user directory. Owned + bound by the identity + * module (it owns the `user` table); the back-office (admin-console) depends only + * on this port, never on the identity schema, so it stays a clean, extractable + * module. A query/command port like WALLET_COMMANDS. See ADR-0017/0025. + */ export type AdminUserRow = { id: string; @@ -38,8 +40,10 @@ export type AdminUserListOptions = { sortOrder?: SortOrder; }; -// Player-facing back-office enrichment (username + KYC). Lets a back-office -// consumer label a player row without reaching into the player/profile tables. +/** + * Player-facing back-office enrichment (username + KYC). Lets a back-office + * consumer label a player row without reaching into the player/profile tables. + */ export type AdminPlayerSummary = { userId: string; username: string; @@ -56,25 +60,31 @@ export type AdminUserDirectory = { count(): Promise; list(opts: AdminUserListOptions): Promise<{ rows: AdminUserRow[]; total: number }>; get(id: string): Promise; - // actorId = the admin performing the change (for audit attribution on an isActive flip). + /** actorId = the admin performing the change (for audit attribution on an isActive flip). */ update( id: string, patch: { isActive?: boolean; role?: UserRole }, actorId: string, meta?: ClientMeta, ): Promise; - // Batch enrichment for back-office lists (eg the withdrawal queue). Returns one - // entry per resolvable id; unknown ids are omitted. + /** + * Batch enrichment for back-office lists (eg the withdrawal queue). Returns one + * entry per resolvable id; unknown ids are omitted. + */ lookupPlayers(userIds: readonly string[]): Promise; - // Resolves a free-text player filter to a capped set of userIds, matched against - // email (user table) OR username/displayName (player table). Empty = no match. - // limit caps both sub-queries and the merged set; defaults to 1000 (the implementation cap). + /** + * Resolves a free-text player filter to a capped set of userIds, matched against + * email (user table) OR username/displayName (player table). Empty = no match. + * limit caps both sub-queries and the merged set; defaults to 1000 (the implementation cap). + */ findPlayerIds(query: string, limit?: number): Promise; - // Exact-match resolution by display name (case-insensitive) - for callers that - // already have a complete, known username (not a partial search term), eg chat - // commands' /donate, /block, /ignore. Distinct from findPlayerIds' capped fuzzy - // substring search: a short/common username can substring-collide with more than - // the 20-row cap of unrelated accounts and silently drop the real exact match. + /** + * Exact-match resolution by display name (case-insensitive) - for callers that + * already have a complete, known username (not a partial search term), eg chat + * commands' /donate, /block, /ignore. Distinct from findPlayerIds' capped fuzzy + * substring search: a short/common username can substring-collide with more than + * the 20-row cap of unrelated accounts and silently drop the real exact match. + */ getPlayerByUsername(username: string): Promise; }; diff --git a/packages/core/src/contracts/adapters/admin-wallet-reporting.ts b/packages/core/src/contracts/adapters/admin-wallet-reporting.ts index 93d4c28b..0b4543bb 100644 --- a/packages/core/src/contracts/adapters/admin-wallet-reporting.ts +++ b/packages/core/src/contracts/adapters/admin-wallet-reporting.ts @@ -6,9 +6,11 @@ import type { } from '../schemas/wallet-tx.js'; import type { SortOrder } from '../kit.js'; -// Admin/back-office reporting over wallet money movement. Owned + bound by the -// wallet module; the back-office depends only on this port, never on the wallet -// schema. A query port like WALLET_COMMANDS. See ADR-0017/0025. +/** + * Admin/back-office reporting over wallet money movement. Owned + bound by the + * wallet module; the back-office depends only on this port, never on the wallet + * schema. A query port like WALLET_COMMANDS. See ADR-0017/0025. + */ export type AdminTxRow = { id: string; @@ -24,7 +26,7 @@ export type AdminTxRow = { export type AdminTxDetail = AdminTxRow & { providerRefId: string | null; providerName: string | null; - // UUID of the admin who reviewed the transaction (matches wallet.reviewedBy column). + /** UUID of the admin who reviewed the transaction (matches wallet.reviewedBy column). */ reviewedBy: string | null; reviewedAt: Date | null; reviewReason: string | null; diff --git a/packages/core/src/contracts/adapters/aggregator.ts b/packages/core/src/contracts/adapters/aggregator.ts index a569e790..e7e4cdce 100644 --- a/packages/core/src/contracts/adapters/aggregator.ts +++ b/packages/core/src/contracts/adapters/aggregator.ts @@ -1,6 +1,8 @@ -// Igaming-aggregator seam. An aggregator fans out to many game providers' -// catalogues; bind a concrete adapter to AGGREGATOR_ADAPTER in the -// igaming-aggregator module's plugin.ts. +/** + * Igaming-aggregator seam. An aggregator fans out to many game providers' + * catalogues; bind a concrete adapter to AGGREGATOR_ADAPTER in the + * igaming-aggregator module's plugin.ts. + */ import { createToken, type Token } from './token.js'; export type AggregatorGame = { @@ -19,11 +21,13 @@ export type AggregatorAdapter = { export const AGGREGATOR_ADAPTER: Token = createToken('AGGREGATOR_ADAPTER'); -// Verifies the public M2M callback is genuinely from the aggregator. The default -// impl recomputes an HMAC-SHA256 over the raw request body and constant-time -// compares it against the `x-aggregator-signature` header; a vendor overlay can -// rebind it to match that vendor's scheme. Fails closed when the secret is unset, -// the signature header is absent, or the raw body was not captured. +/** + * Verifies the public M2M callback is genuinely from the aggregator. The default + * impl recomputes an HMAC-SHA256 over the raw request body and constant-time + * compares it against the `x-aggregator-signature` header; a vendor overlay can + * rebind it to match that vendor's scheme. Fails closed when the secret is unset, + * the signature header is absent, or the raw body was not captured. + */ export type AggregatorWebhookVerifier = { verify(rawBody: string, signature: string | null): boolean; }; diff --git a/packages/core/src/contracts/adapters/audit.ts b/packages/core/src/contracts/adapters/audit.ts index c75ff7c0..d9e020b5 100644 --- a/packages/core/src/contracts/adapters/audit.ts +++ b/packages/core/src/contracts/adapters/audit.ts @@ -1,14 +1,18 @@ -// Audit-write seam. AML/SAR audit writes are a regulator-mandated invariant under -// MGA/UKGC - the audit module binds the sole implementation via ctx.provideSealed() -// (bind-once, owner-only); no overlay can rebind it (ctx.provide() rejects any -// sealed token outright, and provideSealed() itself refuses a second bind). Other -// modules resolve this port read-only (container.get(AUDIT_WRITER)) to call it. +/** + * Audit-write seam. AML/SAR audit writes are a regulator-mandated invariant under + * MGA/UKGC - the audit module binds the sole implementation via ctx.provideSealed() + * (bind-once, owner-only); no overlay can rebind it (ctx.provide() rejects any + * sealed token outright, and provideSealed() itself refuses a second bind). Other + * modules resolve this port read-only (container.get(AUDIT_WRITER)) to call it. + */ import type { DomainEventName } from '../schemas/events.js'; import type { ClientMeta } from '../schemas/common.js'; import { createSealedToken, type SealedToken } from './token.js'; -// Admin actions that are recorded directly (not via a domain-event subscription) -// and so have no entry in `domainEventSchemas`. Add new direct actions here. +/** + * Admin actions that are recorded directly (not via a domain-event subscription) + * and so have no entry in `domainEventSchemas`. Add new direct actions here. + */ export type DirectAuditAction = | 'admin.user.updated' | 'admin.player.updated' @@ -19,10 +23,12 @@ export type DirectAuditAction = | 'wallet.auto_withdrawal_rule.set' | 'wallet.auto_withdrawal_rule.deleted'; -// Every value the audit `action` column legitimately holds: a cross-module domain -// event topic (recorded by the audit plugin's subscriptions) or a direct admin -// action. The `string & {}` arm keeps literal autocomplete while still accepting -// overlay-defined actions, so it constrains nothing at runtime - it just guides. +/** + * Every value the audit `action` column legitimately holds: a cross-module domain + * event topic (recorded by the audit plugin's subscriptions) or a direct admin + * action. The `string & {}` arm keeps literal autocomplete while still accepting + * overlay-defined actions, so it constrains nothing at runtime - it just guides. + */ export type AuditAction = DomainEventName | DirectAuditAction | (string & {}); export type AuditWritePort = { diff --git a/packages/core/src/contracts/adapters/broker.ts b/packages/core/src/contracts/adapters/broker.ts index c51c7944..be7d5b01 100644 --- a/packages/core/src/contracts/adapters/broker.ts +++ b/packages/core/src/contracts/adapters/broker.ts @@ -1,37 +1,41 @@ -// Message-broker seam. The EventBus (@openora/core/server) publishes/subscribes through this -// adapter, so the inter-module transport is swappable: the default binding is an -// in-process broker; a downstream operator binds a durable driver (RabbitMQ, -// Redpanda / Kafka API, NATS JetStream) to MESSAGE_BROKER in an overlay and every -// event flows through it - no module change. Delivery is at-least-once once a real -// broker is bound, so consumers must be idempotent. See ADR-0010 and ADR-0016. +/** + * Message-broker seam. The EventBus (@openora/core/server) publishes/subscribes through this + * adapter, so the inter-module transport is swappable: the default binding is an + * in-process broker; a downstream operator binds a durable driver (RabbitMQ, + * Redpanda / Kafka API, NATS JetStream) to MESSAGE_BROKER in an overlay and every + * event flows through it - no module change. Delivery is at-least-once once a real + * broker is bound, so consumers must be idempotent. See ADR-0010 and ADR-0016. + */ import { createToken, type Token } from './token.js'; -// On-the-wire envelope that every remote adapter serializes. The EventBus -// builds this internally; module code never sees it (only the typed payload). -// Fields: -// eventId - consumer-side idempotency/dedup key (UUID per emission) -// topic - domain event name (eg "wallet.deposit.completed") -// payload - the validated event payload (matches domainEventSchemas) -// occurredAt - ISO-8601 timestamp of the emission -// schemaVersion - monotonic integer for forward-compat payload evolution -// orderingKey - optional; maps to Kafka partition key / RabbitMQ routing key -// for per-user ordering guarantees -// traceId - optional; distributed trace correlation ID +/** + * On-the-wire envelope that every remote adapter serializes. The EventBus + * builds this internally; module code never sees it (only the typed payload). + */ export type EventEnvelope = { + /** Consumer-side idempotency/dedup key (UUID per emission). */ eventId: string; + /** Domain event name (eg "wallet.deposit.completed"). */ topic: string; + /** The validated event payload (matches domainEventSchemas). */ payload: T; + /** ISO-8601 timestamp of the emission. */ occurredAt: string; + /** Monotonic integer for forward-compat payload evolution. */ schemaVersion: number; + /** Maps to Kafka partition key / RabbitMQ routing key for per-user ordering guarantees. */ orderingKey?: string; + /** Distributed trace correlation ID. */ traceId?: string; }; export type BrokerHandler = (envelope: EventEnvelope) => void | Promise; -// Forward hint for grouped consumers: Kafka consumer group / RabbitMQ queue group. -// When omitted, the adapter creates an exclusive per-process queue (fan-out). export type SubscribeOptions = { + /** + * Forward hint for grouped consumers: Kafka consumer group / RabbitMQ queue group. + * When omitted, the adapter creates an exclusive per-process queue (fan-out). + */ consumerGroup?: string; }; diff --git a/packages/core/src/contracts/adapters/cache.ts b/packages/core/src/contracts/adapters/cache.ts index 393e38a7..eab99092 100644 --- a/packages/core/src/contracts/adapters/cache.ts +++ b/packages/core/src/contracts/adapters/cache.ts @@ -1,9 +1,11 @@ -// Cache seam. Hot, non-personalized reads (public catalogue/content feeds) load -// through this adapter so the backend is swappable: the default binding is an -// in-process TTL cache (zero deps), process-local so invalidation does NOT -// propagate across replicas. Set REDIS_URL to bind the shipped Redis reference -// adapter (cross-replica invalidation) with zero consumer code; rebind CACHE via -// an overlay for any other backend. +/** + * Cache seam. Hot, non-personalized reads (public catalogue/content feeds) load + * through this adapter so the backend is swappable: the default binding is an + * in-process TTL cache (zero deps), process-local so invalidation does NOT + * propagate across replicas. Set REDIS_URL to bind the shipped Redis reference + * adapter (cross-replica invalidation) with zero consumer code; rebind CACHE via + * an overlay for any other backend. + */ import { createToken, type Token } from './token.js'; export type CacheAdapter = { diff --git a/packages/core/src/contracts/adapters/chat-block-writer.ts b/packages/core/src/contracts/adapters/chat-block-writer.ts index 0e98c799..fbed1dec 100644 --- a/packages/core/src/contracts/adapters/chat-block-writer.ts +++ b/packages/core/src/contracts/adapters/chat-block-writer.ts @@ -3,8 +3,10 @@ import { createToken, type Token } from './token.js'; export type ChatBlockWriter = { blockUser(blockerId: string, blockedId: string): Promise; ignoreUser(ignorerId: string, ignoredId: string): Promise; - // Union of blocked + ignored ids for a viewer - chat-commands uses this to keep - // blocked/ignored players out of player search results. + /** + * Union of blocked + ignored ids for a viewer - chat-commands uses this to keep + * blocked/ignored players out of player search results. + */ getExcludedUserIds(viewerId: string): Promise; }; diff --git a/packages/core/src/contracts/adapters/email-template.ts b/packages/core/src/contracts/adapters/email-template.ts index 64c6a8ad..d0ea2120 100644 --- a/packages/core/src/contracts/adapters/email-template.ts +++ b/packages/core/src/contracts/adapters/email-template.ts @@ -39,12 +39,14 @@ export type EmailTemplateRenderer = { export const EMAIL_TEMPLATE_RENDERER: Token = createToken('EMAIL_TEMPLATE_RENDERER'); -// Production-safe English-only copy, shared by `DefaultEmailTemplateRenderer` -// (packages/core/src/pam/identity/adapters/) and the SessionResolver-only createAuth() -// fallback in server/auth/auth.ts - single source of truth so the two never drift. -// Openora core intentionally ships English-only; overlays that need other languages -// replace the renderer via ctx.provide(EMAIL_TEMPLATE_RENDERER, () => new MyRenderer()) - -// `render`'s `locale` param (sourced from IdentityService.resolveUserLanguage) exists for exactly that seam. +/** + * Production-safe English-only copy, shared by `DefaultEmailTemplateRenderer` + * (packages/core/src/pam/identity/adapters/) and the SessionResolver-only createAuth() + * fallback in server/auth/auth.ts - single source of truth so the two never drift. + * Openora core intentionally ships English-only; overlays that need other languages + * replace the renderer via ctx.provide(EMAIL_TEMPLATE_RENDERER, () => new MyRenderer()) - + * `render`'s `locale` param (sourced from IdentityService.resolveUserLanguage) exists for exactly that seam. + */ export const DEFAULT_EMAIL_TEMPLATES: { [K in EmailTemplateKey]: (data: EmailTemplateData[K]) => { subject: string; body: string }; } = { diff --git a/packages/core/src/contracts/adapters/email.ts b/packages/core/src/contracts/adapters/email.ts index 25a9ec54..18411d0e 100644 --- a/packages/core/src/contracts/adapters/email.ts +++ b/packages/core/src/contracts/adapters/email.ts @@ -1,8 +1,10 @@ -// Email-send seam. The identity module uses this to deliver password-reset and -// verification emails. The default implementation delegates to -// NOTIFICATION_DELIVERY_ADAPTER (log-to-stdout in dev); operators swap it via -// an overlay: ctx.provide(SEND_EMAIL, () => new MyEmailAdapter()) -// Load your overlay AFTER the identity plugin in extensions.config.ts. +/** + * Email-send seam. The identity module uses this to deliver password-reset and + * verification emails. The default implementation delegates to + * NOTIFICATION_DELIVERY_ADAPTER (log-to-stdout in dev); operators swap it via + * an overlay: ctx.provide(SEND_EMAIL, () => new MyEmailAdapter()) + * Load your overlay AFTER the identity plugin in extensions.config.ts. + */ import { createToken, type Token } from './token.js'; export type SendEmailPort = { diff --git a/packages/core/src/contracts/adapters/game.ts b/packages/core/src/contracts/adapters/game.ts index 0a96e1b9..08695748 100644 --- a/packages/core/src/contracts/adapters/game.ts +++ b/packages/core/src/contracts/adapters/game.ts @@ -1,5 +1,7 @@ -// Gaming integration seam. A game studio/RGS implements GameAdapter; bind a -// concrete adapter to GAME_ADAPTER in the module's plugin.ts. +/** + * Gaming integration seam. A game studio/RGS implements GameAdapter; bind a + * concrete adapter to GAME_ADAPTER in the module's plugin.ts. + */ import { createToken, type Token } from './token.js'; export type GameAdapter = { diff --git a/packages/core/src/contracts/adapters/geo-ip.ts b/packages/core/src/contracts/adapters/geo-ip.ts index ec698fb2..71100fa2 100644 --- a/packages/core/src/contracts/adapters/geo-ip.ts +++ b/packages/core/src/contracts/adapters/geo-ip.ts @@ -1,5 +1,7 @@ -// Geo-IP seam. A vendor (eg MaxMind) implements GeoIpAdapter; bind a concrete -// adapter to GEO_IP_ADAPTER in the compliance module's plugin.ts. +/** + * Geo-IP seam. A vendor (eg MaxMind) implements GeoIpAdapter; bind a concrete + * adapter to GEO_IP_ADAPTER in the compliance module's plugin.ts. + */ import { createToken, type Token } from './token.js'; export type GeoIpAdapter = { diff --git a/packages/core/src/contracts/adapters/identity.ts b/packages/core/src/contracts/adapters/identity.ts index b67004b5..a1a6b86e 100644 --- a/packages/core/src/contracts/adapters/identity.ts +++ b/packages/core/src/contracts/adapters/identity.ts @@ -1,5 +1,7 @@ -// Identity options token. Downstream operators can provide this to configure -// identity behaviors such as login rate-limiting/lockouts. +/** + * Identity options token. Downstream operators can provide this to configure + * identity behaviors such as login rate-limiting/lockouts. + */ import { createToken, type Token } from './token.js'; export type IdentityLockoutOptions = { diff --git a/packages/core/src/contracts/adapters/index.ts b/packages/core/src/contracts/adapters/index.ts index 2a57f771..fc125d4e 100644 --- a/packages/core/src/contracts/adapters/index.ts +++ b/packages/core/src/contracts/adapters/index.ts @@ -1,7 +1,9 @@ -// @openora/core/contracts - the single home for vendor adapter interfaces (the swap -// seams). A module's service depends on an adapter interface; an operator binds -// a concrete implementation to its DI token in the module's plugin.ts. One file -// per service category. See AGENTS.md "third-party integration" in the decision tree. +/** + * @openora/core/contracts - the single home for vendor adapter interfaces (the swap + * seams). A module's service depends on an adapter interface; an operator binds + * a concrete implementation to its DI token in the module's plugin.ts. One file + * per service category. See AGENTS.md "third-party integration" in the decision tree. + */ export type { AnyToken, diff --git a/packages/core/src/contracts/adapters/job-queue.ts b/packages/core/src/contracts/adapters/job-queue.ts index 87313d03..b2f7ca3f 100644 --- a/packages/core/src/contracts/adapters/job-queue.ts +++ b/packages/core/src/contracts/adapters/job-queue.ts @@ -1,87 +1,110 @@ -// Background-job seam. Modules enqueue durable, retryable, schedulable work -// through this adapter, so the queue driver is swappable: the default binding is -// an in-process queue (zero dependencies - good for `pnpm dev`, seed and tests); -// a downstream operator binds a durable driver (BullMQ/Redis is the reference -// overlay) to JOB_QUEUE without touching modules. Delivery is at-least-once, so -// handlers MUST be idempotent (use `idempotencyKey` + a DB guard for money jobs). -// Distinct from MESSAGE_BROKER: the broker is "something happened" fan-out (the -// EventBus facade); JOB_QUEUE is "execute this unit of work later, with delivery -// and retry control". See ADR-0014. +/** + * Background-job seam. Modules enqueue durable, retryable, schedulable work + * through this adapter, so the queue driver is swappable: the default binding is + * an in-process queue (zero dependencies - good for `pnpm dev`, seed and tests); + * a downstream operator binds a durable driver (BullMQ/Redis is the reference + * overlay) to JOB_QUEUE without touching modules. Delivery is at-least-once, so + * handlers MUST be idempotent (use `idempotencyKey` + a DB guard for money jobs). + * Distinct from MESSAGE_BROKER: the broker is "something happened" fan-out (the + * EventBus facade); JOB_QUEUE is "execute this unit of work later, with delivery + * and retry control". See ADR-0014. + */ import { createToken, type Token } from './token.js'; -// A queue name is a branded string so registrations and enqueues line up and a -// bare string can't be passed by accident. +/** + * A queue name is a branded string so registrations and enqueues line up and a + * bare string can't be passed by accident. + */ export type QueueName = string & { readonly __brand: 'QueueName' }; export const queue = (name: string): QueueName => name as QueueName; export type BackoffStrategy = { type: 'fixed' | 'exponential'; delayMs: number }; export type EnqueueOptions = { - // Stable key -> at most one active job with this key (dedupe / idempotency). - // Drivers map this to BullMQ's jobId. Required-by-convention for money jobs; - // the handler must STILL guard duplicate execution with a DB unique constraint - // (queue dedupe alone is insufficient across a partial commit + retry). + /** + * Stable key -> at most one active job with this key (dedupe / idempotency). + * Drivers map this to BullMQ's jobId. Required-by-convention for money jobs; + * the handler must STILL guard duplicate execution with a DB unique constraint + * (queue dedupe alone is insufficient across a partial commit + retry). + */ idempotencyKey?: string; - // Fan-in ordering: jobs sharing a key run in order, never concurrently - // (per-wallet, per-bet). Cross-key jobs still parallelise. + /** + * Fan-in ordering: jobs sharing a key run in order, never concurrently + * (per-wallet, per-bet). Cross-key jobs still parallelise. + */ orderingKey?: string; delayMs?: number; - attempts?: number; // total tries incl. the first (driver default if omitted) + /** Total tries incl. the first (driver default if omitted). */ + attempts?: number; backoff?: BackoffStrategy; - priority?: number; // lower = sooner; a driver may ignore it - ttlMs?: number; // drop the job if not started within this window - // Tracing/correlation metadata carried verbatim onto JobContext.meta. + /** Lower = sooner; a driver may ignore it. */ + priority?: number; + /** Drop the job if not started within this window. */ + ttlMs?: number; + /** Tracing/correlation metadata carried verbatim onto JobContext.meta. */ meta?: Record; }; export type RepeatOptions = { - cron?: string; // eg '0 * * * *' (durable drivers only) - everyMs?: number; // OR a fixed interval + /** eg '0 * * * *' (durable drivers only). */ + cron?: string; + /** OR a fixed interval. */ + everyMs?: number; timezone?: string; }; -// Context handed to a worker handler. `payload` has already been validated by -// the registration's schema before the handler runs. +/** + * Context handed to a worker handler. `payload` has already been validated by + * the registration's schema before the handler runs. + */ export type JobContext = { id: string; name: QueueName; payload: T; - attempt: number; // 1-based + /** 1-based. */ + attempt: number; enqueuedAt: Date; - // Carried metadata (correlationId, idempotencyKey) for tracing. + /** Carried metadata (correlationId, idempotencyKey) for tracing. */ meta: Record; }; export type JobHandler = (ctx: JobContext) => void | Promise; export type WorkerOptions = { - concurrency?: number; // per-worker parallelism - // Strict per-orderingKey serialization even when concurrency > 1. + /** Per-worker parallelism. */ + concurrency?: number; + /** Strict per-orderingKey serialization even when concurrency > 1. */ serializeByOrderingKey?: boolean; }; -// A minimal structural validator. A Zod schema (`ZodType`) satisfies this, so -// callers pass their schema directly - but @openora/core/contracts stays zod-free (every -// other seam here imports nothing but ./token). +/** + * A minimal structural validator. A Zod schema (`ZodType`) satisfies this, so + * callers pass their schema directly - but @openora/core/contracts stays zod-free (every + * other seam here imports nothing but ./token). + */ export type PayloadSchema = { parse(data: unknown): T; }; -// What an overlay registers to start consuming a queue. The schema is the -// payload contract - validated before the handler runs, no vendor type leaks. +/** + * What an overlay registers to start consuming a queue. The schema is the + * payload contract - validated before the handler runs, no vendor type leaks. + */ export type WorkerRegistration = { queue: QueueName; schema: PayloadSchema; handler: JobHandler; options?: WorkerOptions; - // Invoked after attempts are exhausted (post-dead-letter hook): alert, persist - // the poison job, or trigger a compensating action. Never throws into the queue. + /** + * Invoked after attempts are exhausted (post-dead-letter hook): alert, persist + * the poison job, or trigger a compensating action. Never throws into the queue. + */ onDeadLetter?: (ctx: JobContext, error: Error) => void | Promise; }; export type JobQueueAdapter = { enqueue(queue: QueueName, payload: T, opts?: EnqueueOptions): Promise<{ id: string }>; - // Idempotent registration of a recurring schedule (keyed by queue + scheduleId). + /** Idempotent registration of a recurring schedule (keyed by queue + scheduleId). */ schedule( queue: QueueName, scheduleId: string, @@ -89,9 +112,9 @@ export type JobQueueAdapter = { repeat: RepeatOptions, ): Promise; unschedule(queue: QueueName, scheduleId: string): Promise; - // Start consuming a queue. Called once per worker overlay during boot. + /** Start consuming a queue. Called once per worker overlay during boot. */ registerWorker(registration: WorkerRegistration): void; - // Graceful drain: stop accepting, finish in-flight, close connections. + /** Graceful drain: stop accepting, finish in-flight, close connections. */ close(): Promise; }; diff --git a/packages/core/src/contracts/adapters/login-enforcement.ts b/packages/core/src/contracts/adapters/login-enforcement.ts index f9745a66..7ac589e2 100644 --- a/packages/core/src/contracts/adapters/login-enforcement.ts +++ b/packages/core/src/contracts/adapters/login-enforcement.ts @@ -1,9 +1,11 @@ -// Push port for Responsible-Gambling login enforcement. Owned + bound by the identity -// module (it owns the `user` table); compliance depends only on this port to block or -// unblock a player's login, never on the identity schema. `block` also revokes all of -// the player's active sessions (the RG session-termination requirement). `until: null` -// means an indefinite block (self-exclusion / permanent); a Date is the cooling-off -// expiry the login gate auto-clears once elapsed. See ADR-0017. +/** + * Push port for Responsible-Gambling login enforcement. Owned + bound by the identity + * module (it owns the `user` table); compliance depends only on this port to block or + * unblock a player's login, never on the identity schema. `block` also revokes all of + * the player's active sessions (the RG session-termination requirement). `until: null` + * means an indefinite block (self-exclusion / permanent); a Date is the cooling-off + * expiry the login gate auto-clears once elapsed. See ADR-0017. + */ import { createToken, type Token } from './token.js'; export type LoginEnforcementPort = { diff --git a/packages/core/src/contracts/adapters/notification.ts b/packages/core/src/contracts/adapters/notification.ts index b9abdcc0..9ed9d389 100644 --- a/packages/core/src/contracts/adapters/notification.ts +++ b/packages/core/src/contracts/adapters/notification.ts @@ -1,8 +1,10 @@ -// Notification-delivery seam. Custom implementation expected (no prescribed vendor). -// The notifications module ships MockNotificationDeliveryAdapter (logs to stdout) as default. -// Override via overlay: ctx.provide(NOTIFICATION_DELIVERY_ADAPTER, () => new MyAdapter()) -// Load your overlay AFTER the notifications plugin in extensions.config.ts (last registration wins). -// See docs/adapters/notification.md for the full binding guide. +/** + * Notification-delivery seam. Custom implementation expected (no prescribed vendor). + * The notifications module ships MockNotificationDeliveryAdapter (logs to stdout) as default. + * Override via overlay: ctx.provide(NOTIFICATION_DELIVERY_ADAPTER, () => new MyAdapter()) + * Load your overlay AFTER the notifications plugin in extensions.config.ts (last registration wins). + * See docs/adapters/notification.md for the full binding guide. + */ import { createToken, type Token } from './token.js'; export type NotificationDeliveryAdapter = { diff --git a/packages/core/src/contracts/adapters/outbox.ts b/packages/core/src/contracts/adapters/outbox.ts index 14495fb7..ea8a1ffe 100644 --- a/packages/core/src/contracts/adapters/outbox.ts +++ b/packages/core/src/contracts/adapters/outbox.ts @@ -1,15 +1,17 @@ -// Transactional-outbox seam. A money/critical service, INSIDE its db.transaction, -// records the event through this port; the row commits atomically with the state -// change. A relay then publishes it to the MESSAGE_BROKER after commit, closing -// the gap where a crash between "state committed" and "event published" would -// otherwise lose the event. This is the primitive that makes cross-service, -// at-least-once domain events reliable - and the reason a module can be extracted -// to its own process without dropping events. See ADR-0016. -// -// `tx` is the active transaction handle, typed `unknown` here to keep @openora/core/contracts -// ORM-free; the Drizzle implementation in @openora/core/server narrows it. Services never touch -// this port directly - they call EventBus.emitInTransaction(tx, ...), which builds -// the envelope and delegates here. +/** + * Transactional-outbox seam. A money/critical service, INSIDE its db.transaction, + * records the event through this port; the row commits atomically with the state + * change. A relay then publishes it to the MESSAGE_BROKER after commit, closing + * the gap where a crash between "state committed" and "event published" would + * otherwise lose the event. This is the primitive that makes cross-service, + * at-least-once domain events reliable - and the reason a module can be extracted + * to its own process without dropping events. See ADR-0016. + * + * `tx` is the active transaction handle, typed `unknown` here to keep @openora/core/contracts + * ORM-free; the Drizzle implementation in @openora/core/server narrows it. Services never touch + * this port directly - they call EventBus.emitInTransaction(tx, ...), which builds + * the envelope and delegates here. + */ import { createToken, type Token } from './token.js'; import type { EventEnvelope } from './broker.js'; diff --git a/packages/core/src/contracts/adapters/payment.ts b/packages/core/src/contracts/adapters/payment.ts index 54e83858..504cf82d 100644 --- a/packages/core/src/contracts/adapters/payment.ts +++ b/packages/core/src/contracts/adapters/payment.ts @@ -1,6 +1,8 @@ -// Payment seam. A PSP (card/bank/e-wallet) or a custody/address-issuing crypto vendor -// implements PaymentAdapter; bind a concrete adapter to PAYMENT_ADAPTER in the wallet -// module's plugin.ts. See docs/adapters/payment.md for the full binding guide. +/** + * Payment seam. A PSP (card/bank/e-wallet) or a custody/address-issuing crypto vendor + * implements PaymentAdapter; bind a concrete adapter to PAYMENT_ADAPTER in the wallet + * module's plugin.ts. See docs/adapters/payment.md for the full binding guide. + */ import { createToken, type Token } from './token.js'; /** diff --git a/packages/core/src/contracts/adapters/player-tags.ts b/packages/core/src/contracts/adapters/player-tags.ts index 9496b83e..8eea09d7 100644 --- a/packages/core/src/contracts/adapters/player-tags.ts +++ b/packages/core/src/contracts/adapters/player-tags.ts @@ -1,8 +1,10 @@ import { createToken, type Token } from './token.js'; import type { TagKey } from '../schemas/tag.js'; -// Read-only seam letting the wallet auto-withdrawal evaluator query a player's tags -// without importing the tag module's schema (module isolation). Absent from the map == no active tags. +/** + * Read-only seam letting the wallet auto-withdrawal evaluator query a player's tags + * without importing the tag module's schema (module isolation). Absent from the map == no active tags. + */ export type PlayerTags = { getActiveTagKeys(userIds: readonly string[]): Promise>; }; diff --git a/packages/core/src/contracts/adapters/rate-limit.ts b/packages/core/src/contracts/adapters/rate-limit.ts index b7f2c230..de9f5e2e 100644 --- a/packages/core/src/contracts/adapters/rate-limit.ts +++ b/packages/core/src/contracts/adapters/rate-limit.ts @@ -1,9 +1,11 @@ -// Rate-limiter seam. Abuse-prone routes (auth flows, money mutations) consume -// from this adapter so the throttling backend is swappable: the default binding -// is an in-process fixed-window limiter (zero deps - good for `pnpm dev`, seed -// and tests), process-local so it does NOT coordinate across replicas. Set -// REDIS_URL to bind the shipped Redis reference adapter (distributed fixed-window) -// with zero consumer code; rebind RATE_LIMITER via an overlay for any other backend. +/** + * Rate-limiter seam. Abuse-prone routes (auth flows, money mutations) consume + * from this adapter so the throttling backend is swappable: the default binding + * is an in-process fixed-window limiter (zero deps - good for `pnpm dev`, seed + * and tests), process-local so it does NOT coordinate across replicas. Set + * REDIS_URL to bind the shipped Redis reference adapter (distributed fixed-window) + * with zero consumer code; rebind RATE_LIMITER via an overlay for any other backend. + */ import { createToken, type Token } from './token.js'; export const RATE_LIMIT_KEYS = { @@ -32,19 +34,21 @@ export function makeRateLimitKey(prefix: RateLimitKeyPrefix, id: string): RateLi } export type RateLimitOptions = { - // Max allowed consumptions per window per key. + /** Max allowed consumptions per window per key. */ limit: number; windowMs: number; - // What to do when the backing store is unreachable: 'allow' keeps availability - // (throttling pauses during an outage); 'deny' fails closed for keys where an - // unthrottled window is worse than a 429 (credential guessing). Default 'allow'. - // The in-process default is never unavailable, so it ignores this. + /** + * What to do when the backing store is unreachable: 'allow' keeps availability + * (throttling pauses during an outage); 'deny' fails closed for keys where an + * unthrottled window is worse than a 429 (credential guessing). Default 'allow'. + * The in-process default is never unavailable, so it ignores this. + */ onUnavailable?: 'allow' | 'deny'; }; export type RateLimitResult = { allowed: boolean; - // Milliseconds until the window resets. 0 when allowed. + /** Milliseconds until the window resets. 0 when allowed. */ retryAfterMs: number; }; diff --git a/packages/core/src/contracts/adapters/realtime.ts b/packages/core/src/contracts/adapters/realtime.ts index 7fada7f3..bbdcb113 100644 --- a/packages/core/src/contracts/adapters/realtime.ts +++ b/packages/core/src/contracts/adapters/realtime.ts @@ -1,96 +1,120 @@ -// Realtime-transport seam. Client-facing push (live chat messages, PvP round -// state, live odds, big-win/jackpot feeds) flows through this adapter, so the -// transport is swappable: the default binding is a first-party in-process -// fan-out (served to clients as oRPC event-iterators over SSE); a downstream -// operator binds a managed transport (Ably / GetStream / PubNub) to -// REALTIME_TRANSPORT without touching modules. This realizes ADR-0007's -// `ChatTransportPort` as a generic primitive shared across modules - it is NOT -// the inter-module MESSAGE_BROKER (ADR-0010 #4 keeps client push separate from -// the event broker, which has delivery guarantees/acks). Money and moderation -// stay first-party domain logic; they are never delegated to the transport. -// -// CHAT_REALTIME_TRANSPORT / CHAT_REALTIME_CLIENT_AUTHORIZER are the SAME two -// port shapes, scoped to chat only. The chat module defaults them to whatever -// REALTIME_TRANSPORT/REALTIME_CLIENT_AUTHORIZER resolve to (so out of the box -// chat behaves identically to every other realtime consumer), but an operator -// can rebind just the CHAT_-prefixed tokens to run chat on a managed vendor -// (eg Ably) while KYC status updates and everything else stay first-party SSE -// - the two seams are independently swappable. `chat-commands` shares the SAME -// chat channels (gift claims, rain), so it consumes CHAT_REALTIME_TRANSPORT -// too, never the generic token. +/** + * Realtime-transport seam. Client-facing push (live chat messages, PvP round + * state, live odds, big-win/jackpot feeds) flows through this adapter, so the + * transport is swappable: the default binding is a first-party in-process + * fan-out (served to clients as oRPC event-iterators over SSE); a downstream + * operator binds a managed transport (Ably / GetStream / PubNub) to + * REALTIME_TRANSPORT without touching modules. This realizes ADR-0007's + * `ChatTransportPort` as a generic primitive shared across modules - it is NOT + * the inter-module MESSAGE_BROKER (ADR-0010 #4 keeps client push separate from + * the event broker, which has delivery guarantees/acks). Money and moderation + * stay first-party domain logic; they are never delegated to the transport. + * + * CHAT_REALTIME_TRANSPORT / CHAT_REALTIME_CLIENT_AUTHORIZER are the SAME two + * port shapes, scoped to chat only. The chat module defaults them to whatever + * REALTIME_TRANSPORT/REALTIME_CLIENT_AUTHORIZER resolve to (so out of the box + * chat behaves identically to every other realtime consumer), but an operator + * can rebind just the CHAT_-prefixed tokens to run chat on a managed vendor + * (eg Ably) while KYC status updates and everything else stay first-party SSE + * - the two seams are independently swappable. `chat-commands` shares the SAME + * chat channels (gift claims, rain), so it consumes CHAT_REALTIME_TRANSPORT + * too, never the generic token. + */ import { createToken, type Token } from './token.js'; -// Optional presence capability. A first-party transport can offer a simple -// connected-member count; managed vendors provide richer presence. Kept optional -// (a capability flag, per ADR-0007) so the base port is the common denominator. +/** + * Optional presence capability. A first-party transport can offer a simple + * connected-member count; managed vendors provide richer presence. Kept optional + * (a capability flag, per ADR-0007) so the base port is the common denominator. + */ export type RealtimePresence = { - // `connectionId` distinguishes concurrent tabs for the same member. Counts remain - // per member, so one tab leaving never marks a user offline while another is active. + /** + * `connectionId` distinguishes concurrent tabs for the same member. Counts remain + * per member, so one tab leaving never marks a user offline while another is active. + */ join(channel: string, memberId: string, connectionId: string): void; leave(channel: string, memberId: string, connectionId: string): void; count(channel: string): number | Promise; }; export type RealtimeTransport = { - // Fan a message out to every subscriber of `channel`. Best-effort, at-most-once - // for late joiners (the transport is not a system of record - persist first). + /** + * Fan a message out to every subscriber of `channel`. Best-effort, at-most-once + * for late joiners (the transport is not a system of record - persist first). + */ publish(channel: string, event: T): void | Promise; - // Subscribe a handler to a channel. Returns an unsubscribe fn the caller MUST - // invoke on teardown (eg an SSE handler on request abort). + /** + * Subscribe a handler to a channel. Returns an unsubscribe fn the caller MUST + * invoke on teardown (eg an SSE handler on request abort). + */ subscribe(channel: string, handler: (event: T) => void): () => void; - // Revoke managed-provider credentials for a client after access is removed. + /** Revoke managed-provider credentials for a client after access is removed. */ revokeClient?: (clientId: string) => void | Promise; presence?: RealtimePresence; - // Returns the set of authenticated user IDs currently online in `channel`. - // Anonymous connections (memberIds beginning with 'anonymous:') are excluded. + /** + * Returns the set of authenticated user IDs currently online in `channel`. + * Anonymous connections (memberIds beginning with 'anonymous:') are excluded. + */ getOnlineUserIds(channel: string): Promise; }; export const REALTIME_TRANSPORT: Token = createToken('REALTIME_TRANSPORT'); -// Chat-scoped realtime transport - see the module header comment above. +/** Chat-scoped realtime transport - see the module header comment above. */ export const CHAT_REALTIME_TRANSPORT: Token = createToken('CHAT_REALTIME_TRANSPORT'); -// Client connection provisioning - the complement to REALTIME_TRANSPORT. -// -// REALTIME_TRANSPORT is how the SERVER fans a message out. This seam is how a -// CLIENT learns to CONNECT and receive. The two models differ by provider: -// - first-party (default): the client pulls from our own API over SSE, so the -// "grant" is just the stream path - no secret, the session cookie authorizes. -// - managed vendor (Ably/GetStream/PubNub): the client connects DIRECTLY to the -// vendor edge, so the backend must mint a per-player, capability-scoped token -// (never ship the vendor API key to the browser). The grant carries that token. -// A downstream operator binds this token to its provider's authorizer without any -// module change; the chat module just hands the grant to the caller. See ADR-0007. +/** + * Client connection provisioning - the complement to REALTIME_TRANSPORT. + * + * REALTIME_TRANSPORT is how the SERVER fans a message out. This seam is how a + * CLIENT learns to CONNECT and receive. The two models differ by provider: + * - first-party (default): the client pulls from our own API over SSE, so the + * "grant" is just the stream path - no secret, the session cookie authorizes. + * - managed vendor (Ably/GetStream/PubNub): the client connects DIRECTLY to the + * vendor edge, so the backend must mint a per-player, capability-scoped token + * (never ship the vendor API key to the browser). The grant carries that token. + * A downstream operator binds this token to its provider's authorizer without any + * module change; the chat module just hands the grant to the caller. See ADR-0007. + */ -// What the client needs to start receiving, tagged by `provider` so a pluggable -// client-side adapter can pick the right transport. Open union - a new vendor adds -// its own variant without editing core. +/** + * What the client needs to start receiving, tagged by `provider` so a pluggable + * client-side adapter can pick the right transport. Open union - a new vendor adds + * its own variant without editing core. + */ export type RealtimeConnectionGrant = - // First-party SSE: subscribe by opening this event-iterator path on our API. + /** First-party SSE: subscribe by opening this event-iterator path on our API. */ | { provider: 'sse'; streamPath: string; channels: string[] } - // Ably: a signed TokenRequest the browser exchanges for a scoped token, plus the - // channels the player may subscribe to. `tokenRequest` is opaque here (the Ably - // SDK shape lives in the consumer, not in vendor-neutral core). + /** + * Ably: a signed TokenRequest the browser exchanges for a scoped token, plus the + * channels the player may subscribe to. `tokenRequest` is opaque here (the Ably + * SDK shape lives in the consumer, not in vendor-neutral core). + */ | { provider: 'ably'; tokenRequest: unknown; channels: string[] } - // Escape hatch for any other vendor (GetStream, PubNub, ...). + /** Escape hatch for any other vendor (GetStream, PubNub, ...). */ | { provider: string; channels: string[]; [key: string]: unknown }; export type RealtimeClientAuthorizerInput = { - // The authenticated caller; becomes the vendor `clientId` so presence/identity - // is bound server-side and cannot be spoofed by the browser. + /** + * The authenticated caller; becomes the vendor `clientId` so presence/identity + * is bound server-side and cannot be spoofed by the browser. + */ userId: string; - // A stable per-connection id from the client (defaults to userId when absent). + /** A stable per-connection id from the client (defaults to userId when absent). */ clientId: string; - // The channels the caller is allowed to subscribe to (the module computes these - // from the player's access - eg the global channel plus their rooms). + /** + * The channels the caller is allowed to subscribe to (the module computes these + * from the player's access - eg the global channel plus their rooms). + */ channels: string[]; }; export type RealtimeClientAuthorizer = { - // Mint a connection grant for an authenticated caller. Async because a managed - // vendor signs a token request over its SDK. + /** + * Mint a connection grant for an authenticated caller. Async because a managed + * vendor signs a token request over its SDK. + */ issueGrant( input: RealtimeClientAuthorizerInput, ): RealtimeConnectionGrant | Promise; @@ -100,7 +124,7 @@ export const REALTIME_CLIENT_AUTHORIZER: Token = creat 'REALTIME_CLIENT_AUTHORIZER', ); -// Chat-scoped client authorizer - see the module header comment above. +/** Chat-scoped client authorizer - see the module header comment above. */ export const CHAT_REALTIME_CLIENT_AUTHORIZER: Token = createToken( 'CHAT_REALTIME_CLIENT_AUTHORIZER', ); diff --git a/packages/core/src/contracts/adapters/rng.ts b/packages/core/src/contracts/adapters/rng.ts index ad11b318..4dec91f7 100644 --- a/packages/core/src/contracts/adapters/rng.ts +++ b/packages/core/src/contracts/adapters/rng.ts @@ -1,12 +1,14 @@ -// Random number generator seam. For dev / demo / non-certified game contexts. -// -// IMPORTANT: production game outcomes are governed by the SEALED -// `GAME_OUTCOME_AUTHORITY` token in `@openora/core/compliance` (regulator -// mandate - GLI / eCOGRA / BMM / iTechLabs). `RNG_ADAPTER` is NOT a substitute -// for a lab-certified RGS. Use this seam to swap deterministic mocks in tests, -// to plug in a seedable provider for replay debugging, or to back demo games -// that never resolve real money. Real-money RTP must flow through the sealed -// authority. +/** + * Random number generator seam. For dev / demo / non-certified game contexts. + * + * IMPORTANT: production game outcomes are governed by the SEALED + * `GAME_OUTCOME_AUTHORITY` token in `@openora/core/compliance` (regulator + * mandate - GLI / eCOGRA / BMM / iTechLabs). `RNG_ADAPTER` is NOT a substitute + * for a lab-certified RGS. Use this seam to swap deterministic mocks in tests, + * to plug in a seedable provider for replay debugging, or to back demo games + * that never resolve real money. Real-money RTP must flow through the sealed + * authority. + */ import { createToken, type Token } from './token.js'; export type RngAdapter = { diff --git a/packages/core/src/contracts/adapters/sms.ts b/packages/core/src/contracts/adapters/sms.ts index 3c863b35..0c8a05bf 100644 --- a/packages/core/src/contracts/adapters/sms.ts +++ b/packages/core/src/contracts/adapters/sms.ts @@ -1,7 +1,9 @@ -// SMS delivery seam. The phone-login OTP flow sends codes through this adapter so the -// transport is swappable: the platform default (bound in identity/plugin.ts) is a -// log-to-stdout mock, safe for dev/stage. A consumer overlay rebinds SMS_ADAPTER to a -// real vendor (Twilio, AWS SNS) in production. See AGENTS.md "third-party integration". +/** + * SMS delivery seam. The phone-login OTP flow sends codes through this adapter so the + * transport is swappable: the platform default (bound in identity/plugin.ts) is a + * log-to-stdout mock, safe for dev/stage. A consumer overlay rebinds SMS_ADAPTER to a + * real vendor (Twilio, AWS SNS) in production. See AGENTS.md "third-party integration". + */ import { createToken, type Token } from './token.js'; export type SmsAdapter = { diff --git a/packages/core/src/contracts/adapters/tag-evaluation-commands.ts b/packages/core/src/contracts/adapters/tag-evaluation-commands.ts index f92c30fd..64e08647 100644 --- a/packages/core/src/contracts/adapters/tag-evaluation-commands.ts +++ b/packages/core/src/contracts/adapters/tag-evaluation-commands.ts @@ -1,11 +1,13 @@ -// Tag-evaluation command port: wallet's withdraw() calls this synchronously, on its own -// transaction handle, so a withdrawal_review assignment is guaranteed to commit before -// maybeAutoApprove reads risk tags - closing the race an unawaited EventBus emit alone -// leaves open (`wallet.withdrawal.requested` is still fired for other consumers, but -// auto-approval's money decision must never depend on that fire-and-forget fan-out; see -// messaging-and-microservices "money never flows over events"). Mirrors the WALLET_COMMANDS -// command-port idiom (ADR-0017): `tx: unknown` so the caller's transaction handle threads -// through without either side importing the other's DB module. +/** + * Tag-evaluation command port: wallet's withdraw() calls this synchronously, on its own + * transaction handle, so a withdrawal_review assignment is guaranteed to commit before + * maybeAutoApprove reads risk tags - closing the race an unawaited EventBus emit alone + * leaves open (`wallet.withdrawal.requested` is still fired for other consumers, but + * auto-approval's money decision must never depend on that fire-and-forget fan-out; see + * messaging-and-microservices "money never flows over events"). Mirrors the WALLET_COMMANDS + * command-port idiom (ADR-0017): `tx: unknown` so the caller's transaction handle threads + * through without either side importing the other's DB module. + */ import { createToken, type Token } from './token.js'; export type TagEvaluationWithdrawalRequestedArgs = { diff --git a/packages/core/src/contracts/adapters/token.ts b/packages/core/src/contracts/adapters/token.ts index 0c19f308..4661ec05 100644 --- a/packages/core/src/contracts/adapters/token.ts +++ b/packages/core/src/contracts/adapters/token.ts @@ -1,11 +1,15 @@ -// Typed DI token. A token is a plain Symbol at runtime; the phantom `__token` -// field only carries the resolved type so the composition container can infer -// what `container.get(TOKEN)` returns. No decorators, no reflection - the type -// travels with the symbol. See @openora/core/server `Container`. +/** + * Typed DI token. A token is a plain Symbol at runtime; the phantom `__token` + * field only carries the resolved type so the composition container can infer + * what `container.get(TOKEN)` returns. No decorators, no reflection - the type + * travels with the symbol. See @openora/core/server `Container`. + */ -// Common shape both Token and SealedToken share - what Container itself accepts. -// Container is a type-erased-at-runtime symbol map; it doesn't care whether a -// token is sealed, only ModuleRegistry.provide()/provideSealed() do. +/** + * Common shape both Token and SealedToken share - what Container itself accepts. + * Container is a type-erased-at-runtime symbol map; it doesn't care whether a + * token is sealed, only ModuleRegistry.provide()/provideSealed() do. + */ export type AnyToken = symbol & { readonly __token?: T; readonly __key?: K; @@ -19,10 +23,12 @@ export type TokenCatalog export type TokenValue = T extends AnyToken ? Value : never; -// The `__sealed?: never` brand makes Token structurally incompatible with -// SealedToken (which has `__sealed: true`). That mismatch is what lets the -// `provide(token: Token, ...)` signature reject sealed tokens at the -// call site - see SealedToken below. +/** + * The `__sealed?: never` brand makes Token structurally incompatible with + * SealedToken (which has `__sealed: true`). That mismatch is what lets the + * `provide(token: Token, ...)` signature reject sealed tokens at the + * call site - see SealedToken below. + */ export type Token = AnyToken & { readonly __sealed?: never; }; diff --git a/packages/core/src/contracts/adapters/wallet-commands.ts b/packages/core/src/contracts/adapters/wallet-commands.ts index 359b13a4..e2b04233 100644 --- a/packages/core/src/contracts/adapters/wallet-commands.ts +++ b/packages/core/src/contracts/adapters/wallet-commands.ts @@ -1,5 +1,7 @@ -// Wallet command port: another module moves money on the caller's own `tx`, so the move is -// atomic with the caller's writes yet the modules stay decoupled and independently extractable. ADR-0017. +/** + * Wallet command port: another module moves money on the caller's own `tx`, so the move is + * atomic with the caller's writes yet the modules stay decoupled and independently extractable. ADR-0017. + */ import type { WalletTransactionType } from '../schemas/wallet-tx.js'; import { createToken, type Token } from './token.js'; From 47aeec991fb8fdcc353a149f4659aa1e8be61d24 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Tue, 4 Aug 2026 22:11:20 +0200 Subject: [PATCH 5/8] feat(server): type plugin container access --- packages/core/src/admin-console/plugin.ts | 4 +- packages/core/src/analytics/plugin.ts | 4 +- packages/core/src/audit/plugin.ts | 4 +- packages/core/src/casino/gaming/plugin.ts | 4 +- packages/core/src/casino/lobby/plugin.ts | 4 +- packages/core/src/cms/plugin.ts | 4 +- packages/core/src/compliance/plugin.ts | 4 +- .../src/engagement/chat-commands/plugin.ts | 4 +- packages/core/src/engagement/chat/plugin.ts | 4 +- .../src/engagement/notifications/plugin.ts | 4 +- packages/core/src/iam/plugin.ts | 4 +- packages/core/src/pam/identity/plugin.ts | 4 +- .../core/src/pam/player-management/plugin.ts | 4 +- packages/core/src/pam/player-note/plugin.ts | 4 +- packages/core/src/pam/profile/plugin.ts | 4 +- packages/core/src/pam/tag/plugin.ts | 3 +- packages/core/src/server/kernel/container.ts | 16 +-- .../__tests__/load-plugins.test.ts | 3 +- .../__tests__/plugin-graph.test.ts | 32 ++++++ .../__tests__/required-ports.test.ts | 13 ++- .../__tests__/typed-plugin.test.ts | 28 +---- .../src/server/plugin-host/core-plugins.ts | 64 ++++++++--- .../src/server/plugin-host/define-plugin.ts | 103 +++++++----------- .../src/server/plugin-host/load-plugins.ts | 70 +++--------- .../src/server/plugin-host/module-registry.ts | 8 +- .../src/server/plugin-host/plugin-graph.ts | 74 +++++++++++++ packages/core/src/server/runtime/index.ts | 1 + packages/core/src/wallet/plugin.ts | 4 +- .../src/__tests__/analytics.e2e.test.ts | 16 ++- .../fixtures/test-kyc-config-plugin.ts | 4 +- ...allet-auto-withdrawal-cap-config-plugin.ts | 4 +- ...st-wallet-auto-withdrawal-config-plugin.ts | 4 +- .../__tests__/gaming-stake-debit.e2e.test.ts | 9 +- .../testing/src/__tests__/kyc.e2e.test.ts | 9 +- packages/testing/src/__tests__/rg.e2e.test.ts | 15 ++- .../src/__tests__/tag-bf317.e2e.test.ts | 13 ++- .../testing/src/__tests__/tag.e2e.test.ts | 22 +++- .../wallet-ledger-auto-withdrawal.e2e.test.ts | 9 +- packages/testing/src/app.ts | 5 +- packages/testing/src/seed.ts | 10 +- 40 files changed, 355 insertions(+), 244 deletions(-) create mode 100644 packages/core/src/server/plugin-host/__tests__/plugin-graph.test.ts create mode 100644 packages/core/src/server/plugin-host/plugin-graph.ts diff --git a/packages/core/src/admin-console/plugin.ts b/packages/core/src/admin-console/plugin.ts index ae4ef3c7..049dd4ad 100644 --- a/packages/core/src/admin-console/plugin.ts +++ b/packages/core/src/admin-console/plugin.ts @@ -5,11 +5,11 @@ import { ADMIN_WALLET_REPORTING, AUDIT_WRITER, } from '@openora/core/contracts'; -import { definePlugin, ADMIN_GUARD, type CoreTokenCatalog } from '@openora/core/server'; +import { definePlugin, ADMIN_GUARD, CORE_TOKEN_CATALOG } from '@openora/core/server'; import { BackofficeService } from './service/backoffice.service.js'; import { createBackofficeRouter } from './router/index.js'; -export default definePlugin()({ +export default definePlugin(CORE_TOKEN_CATALOG, { id: 'admin-console', dependsOn: ['identity', 'wallet', 'audit', 'gaming', 'iam'], register(ctx) { diff --git a/packages/core/src/analytics/plugin.ts b/packages/core/src/analytics/plugin.ts index 02de8348..8462be89 100644 --- a/packages/core/src/analytics/plugin.ts +++ b/packages/core/src/analytics/plugin.ts @@ -1,10 +1,10 @@ import { CACHE } from '@openora/core/contracts'; -import { definePlugin, ADMIN_GUARD, DRIZZLE, type CoreTokenCatalog } from '@openora/core/server'; +import { definePlugin, ADMIN_GUARD, DRIZZLE, CORE_TOKEN_CATALOG } from '@openora/core/server'; import { FinancialAnalyticsService } from './service/financial-analytics.service.js'; import { FunnelAnalyticsService } from './service/funnel-analytics.service.js'; import { createAnalyticsRouter } from './router/index.js'; -export default definePlugin()({ +export default definePlugin(CORE_TOKEN_CATALOG, { id: 'analytics', dependsOn: ['wallet', 'identity', 'profile', 'gaming'], register(ctx) { diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index 1f23b6dc..6c6a11aa 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -4,7 +4,7 @@ import { DRIZZLE, ADMIN_GUARD, createLogger, - type CoreTokenCatalog, + CORE_TOKEN_CATALOG, } from '@openora/core/server'; import { AUDIT_WRITER, type DomainEventName } from '@openora/core/contracts'; import { AuditService, type RecordInput } from './service/audit.service.js'; @@ -565,7 +565,7 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [ 'player.level.changed', ] as const; -export default definePlugin()({ +export default definePlugin(CORE_TOKEN_CATALOG, { id: 'audit', register(ctx) { const logger = createLogger('audit'); diff --git a/packages/core/src/casino/gaming/plugin.ts b/packages/core/src/casino/gaming/plugin.ts index a3b0e9eb..e3bed6e6 100644 --- a/packages/core/src/casino/gaming/plugin.ts +++ b/packages/core/src/casino/gaming/plugin.ts @@ -1,4 +1,4 @@ -import { definePlugin, EVENT_BUS, DRIZZLE, type CoreTokenCatalog } from '@openora/core/server'; +import { definePlugin, EVENT_BUS, DRIZZLE, CORE_TOKEN_CATALOG } from '@openora/core/server'; import { ADMIN_GAME_REPORTING, GAME_ADAPTER, @@ -12,7 +12,7 @@ import { MockGameAdapter } from './adapters/mock/mock-game-adapter.js'; import { MockRngAdapter } from './adapters/mock/mock-rng-adapter.js'; import { DrizzleAdminGameReporting } from './admin-reporting.js'; -export default definePlugin()({ +export default definePlugin(CORE_TOKEN_CATALOG, { id: 'gaming', requiresPorts: [PLAY_ELIGIBILITY], dependsOn: ['wallet'], diff --git a/packages/core/src/casino/lobby/plugin.ts b/packages/core/src/casino/lobby/plugin.ts index a1b7be15..0774fc47 100644 --- a/packages/core/src/casino/lobby/plugin.ts +++ b/packages/core/src/casino/lobby/plugin.ts @@ -1,9 +1,9 @@ -import { definePlugin, DRIZZLE, type CoreTokenCatalog } from '@openora/core/server'; +import { definePlugin, DRIZZLE, CORE_TOKEN_CATALOG } from '@openora/core/server'; import { CACHE } from '@openora/core/contracts'; import { LobbyService } from './service/lobby.service.js'; import { createLobbyRouter } from './router/index.js'; -export default definePlugin()({ +export default definePlugin(CORE_TOKEN_CATALOG, { id: 'lobby', register(ctx) { ctx.routers.add('lobby', (c) => diff --git a/packages/core/src/cms/plugin.ts b/packages/core/src/cms/plugin.ts index 3c538c4a..b850029d 100644 --- a/packages/core/src/cms/plugin.ts +++ b/packages/core/src/cms/plugin.ts @@ -3,13 +3,13 @@ import { EVENT_BUS, DRIZZLE, ADMIN_GUARD, - type CoreTokenCatalog, + CORE_TOKEN_CATALOG, } from '@openora/core/server'; import { CACHE } from '@openora/core/contracts'; import { CmsService } from './service/cms.service.js'; import { createCmsRouter } from './router/index.js'; -export default definePlugin()({ +export default definePlugin(CORE_TOKEN_CATALOG, { id: 'cms', register(ctx) { ctx.routers.add('cms', (c) => diff --git a/packages/core/src/compliance/plugin.ts b/packages/core/src/compliance/plugin.ts index 2c8e3764..bb718775 100644 --- a/packages/core/src/compliance/plugin.ts +++ b/packages/core/src/compliance/plugin.ts @@ -4,7 +4,7 @@ import { DRIZZLE, ADMIN_GUARD, createLogger, - type CoreTokenCatalog, + CORE_TOKEN_CATALOG, } from '@openora/core/server'; import * as z from 'zod'; import { @@ -58,7 +58,7 @@ const KycDecisionSyncJobSchema = z.object({ receivedAt: z.iso.datetime(), }); -export default definePlugin()({ +export default definePlugin(CORE_TOKEN_CATALOG, { id: 'compliance', dependsOn: ['player-management', 'identity', 'wallet', 'gaming', 'audit'], requiresPorts: [LOGIN_ENFORCEMENT], diff --git a/packages/core/src/engagement/chat-commands/plugin.ts b/packages/core/src/engagement/chat-commands/plugin.ts index 738b58c2..7e152b71 100644 --- a/packages/core/src/engagement/chat-commands/plugin.ts +++ b/packages/core/src/engagement/chat-commands/plugin.ts @@ -3,7 +3,7 @@ import { DRIZZLE, EVENT_BUS, ADMIN_GUARD, - type CoreTokenCatalog, + CORE_TOKEN_CATALOG, } from '@openora/core/server'; import { WALLET_COMMANDS, @@ -19,7 +19,7 @@ import { import { ChatCommandsService } from './service/chat-commands.service.js'; import { createChatCommandsRouter } from './router/index.js'; -export default definePlugin()({ +export default definePlugin(CORE_TOKEN_CATALOG, { id: 'chat-commands', dependsOn: ['chat', 'wallet', 'iam', 'audit', 'gaming'], register(ctx) { diff --git a/packages/core/src/engagement/chat/plugin.ts b/packages/core/src/engagement/chat/plugin.ts index 6596a467..89654472 100644 --- a/packages/core/src/engagement/chat/plugin.ts +++ b/packages/core/src/engagement/chat/plugin.ts @@ -3,7 +3,7 @@ import { EVENT_BUS, DRIZZLE, ADMIN_GUARD, - type CoreTokenCatalog, + CORE_TOKEN_CATALOG, } from '@openora/core/server'; import { CHAT_REALTIME_TRANSPORT, @@ -22,7 +22,7 @@ import { createChatRouter } from './router/index.js'; const CHAT_SERVICE = createToken('_ChatService'); -export default definePlugin()({ +export default definePlugin(CORE_TOKEN_CATALOG, { id: 'chat', dependsOn: ['identity'], register(ctx) { diff --git a/packages/core/src/engagement/notifications/plugin.ts b/packages/core/src/engagement/notifications/plugin.ts index 089a8b27..4e67229f 100644 --- a/packages/core/src/engagement/notifications/plugin.ts +++ b/packages/core/src/engagement/notifications/plugin.ts @@ -15,7 +15,7 @@ import { definePlugin, EVENT_BUS, DRIZZLE, - type CoreTokenCatalog, + CORE_TOKEN_CATALOG, } from '@openora/core/server'; import { MockNotificationDeliveryAdapter } from './adapters/mock/mock-notification-adapter.js'; import { createNotificationsRouter } from './router/index.js'; @@ -28,7 +28,7 @@ const KycResubmissionNotifyJobSchema = z.object({ reason: z.string().nullable(), }); -export default definePlugin()({ +export default definePlugin(CORE_TOKEN_CATALOG, { id: 'notifications', // ADMIN_USER_DIRECTORY (owned by identity) resolves the player's email for the // withdrawal delivery emails; pin load order so a split still finds the port. See ADR-0017. diff --git a/packages/core/src/iam/plugin.ts b/packages/core/src/iam/plugin.ts index 18459292..5b6aa7a9 100644 --- a/packages/core/src/iam/plugin.ts +++ b/packages/core/src/iam/plugin.ts @@ -4,7 +4,7 @@ import { DRIZZLE, ADMIN_GUARD, createLogger, - type CoreTokenCatalog, + CORE_TOKEN_CATALOG, } from '@openora/core/server'; import { ADMIN_PERMISSION_RESOLVER, @@ -20,7 +20,7 @@ import { DrizzleAdminPlayerActivity } from './adapters/admin-player-activity.js' const logger = createLogger('iam'); -export default definePlugin()({ +export default definePlugin(CORE_TOKEN_CATALOG, { id: 'iam', dependsOn: ['identity'], register(ctx) { diff --git a/packages/core/src/pam/identity/plugin.ts b/packages/core/src/pam/identity/plugin.ts index fe8a7284..980c8307 100644 --- a/packages/core/src/pam/identity/plugin.ts +++ b/packages/core/src/pam/identity/plugin.ts @@ -20,7 +20,7 @@ import { EVENT_BUS, DRIZZLE, AUTH_SESSION, - type CoreTokenCatalog, + CORE_TOKEN_CATALOG, } from '@openora/core/server'; import { MockKycAdapter } from './adapters/mock/mock-kyc-adapter.js'; import { MockSmsAdapter } from './adapters/mock/mock-sms-adapter.js'; @@ -34,7 +34,7 @@ import { SessionService } from './service/session.service.js'; import { LoginEnforcementService } from './service/login-enforcement.service.js'; import { PlayEligibilityService } from './service/play-eligibility.service.js'; -export default definePlugin()({ +export default definePlugin(CORE_TOKEN_CATALOG, { id: 'identity', register(ctx) { ctx.provide(KYC_ADAPTER, () => new MockKycAdapter()); diff --git a/packages/core/src/pam/player-management/plugin.ts b/packages/core/src/pam/player-management/plugin.ts index 26e6c7d3..515cc36d 100644 --- a/packages/core/src/pam/player-management/plugin.ts +++ b/packages/core/src/pam/player-management/plugin.ts @@ -3,7 +3,7 @@ import { EVENT_BUS, DRIZZLE, ADMIN_GUARD, - type CoreTokenCatalog, + CORE_TOKEN_CATALOG, } from '@openora/core/server'; import { AUDIT_WRITER, KYC_STATUS_WRITER } from '@openora/core/contracts'; import { PlayerService } from './service/player.service.js'; @@ -12,7 +12,7 @@ import { createPlayerRouter } from './router/index.js'; // Owns the player table writes, so it binds the single KYC_STATUS_WRITER seam // (compliance + the admin override route consume it). Reads identity via /schema. See ADR-0020. -export default definePlugin()({ +export default definePlugin(CORE_TOKEN_CATALOG, { id: 'player-management', dependsOn: ['audit'], register(ctx) { diff --git a/packages/core/src/pam/player-note/plugin.ts b/packages/core/src/pam/player-note/plugin.ts index 53f503d9..56cd0141 100644 --- a/packages/core/src/pam/player-note/plugin.ts +++ b/packages/core/src/pam/player-note/plugin.ts @@ -1,8 +1,8 @@ -import { definePlugin, DRIZZLE, ADMIN_GUARD, type CoreTokenCatalog } from '@openora/core/server'; +import { definePlugin, DRIZZLE, ADMIN_GUARD, CORE_TOKEN_CATALOG } from '@openora/core/server'; import { PlayerNoteService } from './service/player-note.service.js'; import { createPlayerNoteRouter } from './router/index.js'; -export default definePlugin()({ +export default definePlugin(CORE_TOKEN_CATALOG, { id: 'player-note', register(ctx) { ctx.routers.add('player-note', (c) => diff --git a/packages/core/src/pam/profile/plugin.ts b/packages/core/src/pam/profile/plugin.ts index 37c868dc..78a9fd82 100644 --- a/packages/core/src/pam/profile/plugin.ts +++ b/packages/core/src/pam/profile/plugin.ts @@ -1,8 +1,8 @@ -import { definePlugin, DRIZZLE, type CoreTokenCatalog } from '@openora/core/server'; +import { definePlugin, DRIZZLE, CORE_TOKEN_CATALOG } from '@openora/core/server'; import { ProfileService } from './service/profile.service.js'; import { createProfileRouter } from './router/index.js'; -export default definePlugin()({ +export default definePlugin(CORE_TOKEN_CATALOG, { id: 'profile', register(ctx) { ctx.routers.add('profile', (c) => createProfileRouter(new ProfileService(c.get(DRIZZLE)))); diff --git a/packages/core/src/pam/tag/plugin.ts b/packages/core/src/pam/tag/plugin.ts index 157b8737..791cf9f0 100644 --- a/packages/core/src/pam/tag/plugin.ts +++ b/packages/core/src/pam/tag/plugin.ts @@ -4,6 +4,7 @@ import { DRIZZLE, ADMIN_GUARD, type TypedContainer, + CORE_TOKEN_CATALOG, type CoreTokenCatalog, } from '@openora/core/server'; import { @@ -21,7 +22,7 @@ import { TagRuleService } from './service/tag-rule.service.js'; import { TagEvaluationService } from './service/tag-evaluation.service.js'; import { createTagRouter } from './router/index.js'; -export default definePlugin()({ +export default definePlugin(CORE_TOKEN_CATALOG, { id: 'tag', dependsOn: ['wallet', 'identity'], register(ctx) { diff --git a/packages/core/src/server/kernel/container.ts b/packages/core/src/server/kernel/container.ts index 1617be97..e3273099 100644 --- a/packages/core/src/server/kernel/container.ts +++ b/packages/core/src/server/kernel/container.ts @@ -8,13 +8,7 @@ import type { AnyToken, TokenCatalog, TokenValue } from '@openora/core/contracts // sealed/overlay-rejection rules live one layer up, in ModuleRegistry's // provide()/provideSealed() (see plugin-host/module-registry.ts). -export type Factory = (c: Container) => T; - -// The token shape register()/has()/get() accept: any token when uncatalogued, or a -// catalog-listed one otherwise. T is inferred directly from the token argument -// (never a keyof reverse lookup), which is what makes TokenValue resolve to -// that one entry instead of a union of every catalog value. -type ContainerToken = [C] extends [never] ? AnyToken : C[keyof C]; +export type Factory = (c: Container) => T; /** * Functional DI container - no decorators, no reflection. `get()` resolves @@ -26,13 +20,13 @@ type ContainerToken = [C] extends [never] ? AnyToken { +export class Container { private readonly factories = new Map>(); private readonly instances = new Map(); private readonly resolving = new Set(); private readonly disposers: Array<() => void | Promise> = []; - register>( + register( token: T, factory: (container: Container) => TokenValue, ): void { @@ -44,11 +38,11 @@ export class Container { this.instances.delete(token); } - has>(token: T): boolean { + has(token: T): boolean { return this.factories.has(token); } - get>(token: T): TokenValue { + get(token: T): TokenValue { if (this.instances.has(token)) { return this.instances.get(token) as TokenValue; } diff --git a/packages/core/src/server/plugin-host/__tests__/load-plugins.test.ts b/packages/core/src/server/plugin-host/__tests__/load-plugins.test.ts index f441d741..81c7b45a 100644 --- a/packages/core/src/server/plugin-host/__tests__/load-plugins.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/load-plugins.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest'; +import type { TokenCatalog } from '@openora/core/contracts'; import type { Plugin } from '../define-plugin.js'; import { topoSort } from '../load-plugins.js'; -function plugin(id: string, dependsOn: string[] = []): Plugin { +function plugin(id: string, dependsOn: string[] = []): Plugin { return { id, dependsOn, diff --git a/packages/core/src/server/plugin-host/__tests__/plugin-graph.test.ts b/packages/core/src/server/plugin-host/__tests__/plugin-graph.test.ts new file mode 100644 index 00000000..55521ad5 --- /dev/null +++ b/packages/core/src/server/plugin-host/__tests__/plugin-graph.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import type { TokenCatalog } from '@openora/core/contracts'; +import type { Plugin } from '../define-plugin.js'; +import { assertEntryMatchesPlugin, type PluginEntry } from '../load-plugins.js'; +import { corePlugins } from '../core-plugins.js'; + +function entry(id: string): PluginEntry { + return { id, path: `./${id}.js` }; +} + +function plugin(id: string): Plugin { + return { id, register: () => {} }; +} + +describe('assertEntryMatchesPlugin', () => { + it('accepts a registry entry that resolves the same plugin id', () => { + expect(() => assertEntryMatchesPlugin(entry('wallet'), plugin('wallet'))).not.toThrow(); + }); + + it('throws when the loaded plugin has a different id', () => { + expect(() => assertEntryMatchesPlugin(entry('wallet'), plugin('tag'))).toThrow( + /does not match the plugin's own id "tag"/, + ); + }); +}); + +describe('corePlugins', () => { + it('leaves dependency metadata with each plugin declaration', () => { + const entries = corePlugins(); + expect(entries.every((entry) => entry.dependsOn === undefined)).toBe(false); + }); +}); diff --git a/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts b/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts index b394a0f2..9bc0906c 100644 --- a/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect } from 'vitest'; import { Container } from '../../kernel/index.js'; -import { createToken } from '@openora/core/contracts'; +import { createToken, type TokenCatalog } from '@openora/core/contracts'; import { assertRequiredPorts } from '../load-plugins.js'; import type { Plugin } from '../define-plugin.js'; const WALLET_COMMANDS = createToken<{ debit: () => void }>('WALLET_COMMANDS'); +const catalog = { WALLET_COMMANDS } satisfies TokenCatalog; -const consumer: Plugin = { +const consumer: Plugin = { id: 'gaming', requiresPorts: [WALLET_COMMANDS], register: () => {}, @@ -14,19 +15,19 @@ const consumer: Plugin = { describe('assertRequiredPorts (ADR-0024 boot fail-fast)', () => { it('passes when every required port is bound', () => { - const container = new Container(); + const container = new Container(); container.register(WALLET_COMMANDS, () => ({ debit: () => {} })); expect(() => assertRequiredPorts([consumer], container)).not.toThrow(); }); it('throws an actionable error naming the plugin and the unbound port', () => { - const container = new Container(); + const container = new Container(); expect(() => assertRequiredPorts([consumer], container)).toThrow(/gaming.*WALLET_COMMANDS/s); }); it('is a no-op for plugins that declare no required ports', () => { - const container = new Container(); - const plain: Plugin = { id: 'audit', register: () => {} }; + const container = new Container(); + const plain: Plugin = { id: 'audit', register: () => {} }; expect(() => assertRequiredPorts([plain], container)).not.toThrow(); }); }); diff --git a/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts b/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts index c5a6ffd0..66ed4e42 100644 --- a/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts @@ -39,7 +39,7 @@ function assertTypedContainer() { void assertTypedContainer; -const typedPlugin = definePlugin()({ +const typedPlugin = definePlugin(catalog, { id: 'typed-plugin', dependsOn: ['foundation'], register(ctx) { @@ -55,7 +55,7 @@ const typedPlugin = definePlugin()({ }, }); -definePlugin()({ +definePlugin(catalog, { id: 'invalid-typed-plugin', register(ctx) { // @ts-expect-error A provider must return the catalog token value. @@ -74,22 +74,7 @@ definePlugin()({ }); const typedPluginId: 'typed-plugin' = typedPlugin.id; -const typedDependency: readonly ['foundation'] = typedPlugin.dependsOn ?? ['foundation']; - -// The original, single-call, uncatalogued form still works and still infers -// literal id/dependsOn types - definePlugin() was never renamed. -const uncataloguedPlugin = definePlugin({ - id: 'uncatalogued-plugin', - dependsOn: ['foundation'], - register(ctx) { - ctx.provide(OTHER, () => 'anything goes without a catalog'); - }, -}); - -const uncataloguedPluginId: 'uncatalogued-plugin' = uncataloguedPlugin.id; -const uncataloguedDependency: readonly ['foundation'] = uncataloguedPlugin.dependsOn ?? [ - 'foundation', -]; +const typedDependency: readonly ['foundation'] = typedPlugin.dependsOn; const validGraph = defineExtensions([ { id: 'foundation', path: './foundation.js' }, @@ -106,7 +91,7 @@ defineExtensions([ ]); describe('typed plugin surface', () => { - it('keeps literal plugin metadata and resolves catalog services', () => { + it('resolves catalog services through a catalogued plugin', () => { const container = new Container(); const registry = new ModuleRegistryImpl(container); @@ -119,11 +104,6 @@ describe('typed plugin surface', () => { expect(container.get(COUNT)).toBe(42); }); - it('keeps the uncatalogued single-call form working', () => { - expect(uncataloguedPluginId).toBe('uncatalogued-plugin'); - expect(uncataloguedDependency).toEqual(['foundation']); - }); - it('keeps router factories on the catalogued container view', () => { const container = new Container(); const registry = new ModuleRegistryImpl(container); diff --git a/packages/core/src/server/plugin-host/core-plugins.ts b/packages/core/src/server/plugin-host/core-plugins.ts index 423f25d2..257e002b 100644 --- a/packages/core/src/server/plugin-host/core-plugins.ts +++ b/packages/core/src/server/plugin-host/core-plugins.ts @@ -1,26 +1,59 @@ import { createRequire } from 'node:module'; import type { PluginEntry } from './load-plugins.js'; +import type { PluginGraphError, PluginGraphNode } from './plugin-graph.js'; const nodeRequire = createRequire(import.meta.url); -const CORE_PLUGIN_MODULES: ReadonlyArray<{ id: string; specifier: string }> = [ +type CorePluginModule = PluginGraphNode & { specifier: string }; + +function defineCorePluginModules( + modules: Modules & PluginGraphError, +): Modules { + return modules; +} + +const CORE_PLUGIN_MODULES = defineCorePluginModules([ { id: 'audit', specifier: '@openora/core/audit/plugin' }, - { id: 'iam', specifier: '@openora/core/iam/plugin' }, { id: 'identity', specifier: '@openora/core/pam/plugins/identity' }, - { id: 'notifications', specifier: '@openora/core/engagement/plugins/notifications' }, - { id: 'compliance', specifier: '@openora/core/compliance/plugins/compliance' }, - { id: 'wallet', specifier: '@openora/core/wallet/plugins/wallet' }, - { id: 'gaming', specifier: '@openora/core/casino/plugins/gaming' }, + { id: 'iam', specifier: '@openora/core/iam/plugin', dependsOn: ['identity'] }, + { + id: 'notifications', + specifier: '@openora/core/engagement/plugins/notifications', + dependsOn: ['identity'], + }, + { + id: 'wallet', + specifier: '@openora/core/wallet/plugins/wallet', + dependsOn: ['identity', 'audit'], + }, + { id: 'gaming', specifier: '@openora/core/casino/plugins/gaming', dependsOn: ['wallet'] }, { id: 'lobby', specifier: '@openora/core/casino/plugins/lobby' }, - { id: 'chat', specifier: '@openora/core/engagement/plugins/chat' }, + { id: 'chat', specifier: '@openora/core/engagement/plugins/chat', dependsOn: ['identity'] }, { id: 'profile', specifier: '@openora/core/pam/plugins/profile' }, - { id: 'tag', specifier: '@openora/core/pam/plugins/tag' }, - { id: 'admin-console', specifier: '@openora/core/admin-console/plugin' }, - { id: 'analytics', specifier: '@openora/core/analytics/plugin' }, + { id: 'tag', specifier: '@openora/core/pam/plugins/tag', dependsOn: ['wallet', 'identity'] }, + { + id: 'player-management', + specifier: '@openora/core/pam/plugins/player-management', + dependsOn: ['audit'], + }, + { + id: 'compliance', + specifier: '@openora/core/compliance/plugins/compliance', + dependsOn: ['player-management', 'identity', 'wallet', 'gaming', 'audit'], + }, + { + id: 'admin-console', + specifier: '@openora/core/admin-console/plugin', + dependsOn: ['identity', 'wallet', 'audit', 'gaming', 'iam'], + }, + { + id: 'analytics', + specifier: '@openora/core/analytics/plugin', + dependsOn: ['wallet', 'identity', 'profile', 'gaming'], + }, { id: 'player-note', specifier: '@openora/core/pam/plugins/player-note' }, { id: 'cms', specifier: '@openora/core/cms/plugins/cms' }, - { id: 'player-management', specifier: '@openora/core/pam/plugins/player-management' }, -]; +]); /** * The full set of built-in platform plugins, as a ready-to-spread @@ -36,8 +69,9 @@ const CORE_PLUGIN_MODULES: ReadonlyArray<{ id: string; specifier: string }> = [ * out, filter by id: `corePlugins().filter((p) => p.id !== 'chat')`. */ export function corePlugins(): PluginEntry[] { - return CORE_PLUGIN_MODULES.map(({ id, specifier }) => ({ - id, - path: nodeRequire.resolve(specifier), + return CORE_PLUGIN_MODULES.map((module: CorePluginModule) => ({ + id: module.id, + path: nodeRequire.resolve(module.specifier), + ...(module.dependsOn ? { dependsOn: module.dependsOn } : {}), })); } diff --git a/packages/core/src/server/plugin-host/define-plugin.ts b/packages/core/src/server/plugin-host/define-plugin.ts index f626e513..267131cc 100644 --- a/packages/core/src/server/plugin-host/define-plugin.ts +++ b/packages/core/src/server/plugin-host/define-plugin.ts @@ -1,4 +1,3 @@ -import type { Container } from '../kernel/index.js'; import type { EventEnvelope, SealedToken, @@ -15,48 +14,38 @@ export type McpToolDefinition = { handler: (input: unknown) => unknown | Promise; }; -// Runs once at boot, after every plugin has registered its providers, so adapter overrides (last registration wins) are in effect. -export type RouterFactory = (c: ContainerView) => unknown; - +/** + * Container view a plugin's `provide()`/`provideSealed()` factory and router + * factories receive: read-only (`get`/`has`/`onDispose`) and catalog-constrained + * - never the full `Container`, so plugin code can't call `register()` directly + * and bypass ModuleRegistry's sealed-token rejection. + */ export type TypedContainer = { get(token: T): TokenValue; has(token: T): boolean; onDispose(fn: () => void | Promise): void; }; -export type EventHandler = (payload: unknown, envelope?: EventEnvelope) => void | Promise; - -// The container view a factory receives: the full Container when uncatalogued, or -// a view restricted to the catalog's own tokens otherwise. -export type ContainerView = [C] extends [never] - ? Container - : TypedContainer; - -// The token shape provide()/provideSealed() accept: any Token/SealedToken when -// uncatalogued, or a catalog-listed one when C is a real catalog. T is inferred -// directly from the token argument (never a keyof reverse lookup) - that's what -// makes TokenValue resolve to that one entry instead of a union of every -// catalog value. -type ProviderToken = [C] extends [never] - ? Token - : C[keyof C] & Token; +// Runs once at boot, after every plugin has registered its providers, so adapter overrides (last registration wins) are in effect. +export type RouterFactory = (c: TypedContainer) => unknown; -type SealedProviderToken = [C] extends [never] - ? SealedToken - : C[keyof C] & SealedToken; +export type EventHandler = (payload: unknown, envelope?: EventEnvelope) => void | Promise; -export type ModuleRegistry = { +export type ModuleRegistry = { // Last registration wins - an overlay loaded after a module can rebind its adapter token. - provide>( + // T is inferred directly from the token argument (never a keyof reverse lookup) - + // that's what makes TokenValue resolve to that one catalog entry instead of a + // union of every catalog value. + provide>( token: T, - factory: (container: ContainerView) => TokenValue, + factory: (container: TypedContainer) => TokenValue, ): void; // Bind-once, owner-only. The ONLY legitimate way to bind a SealedToken - provide() // rejects sealed tokens outright. A second call for the same token (an overlay // trying to override a regulator-mandated service) throws instead of rebinding. - provideSealed>( + provideSealed>( token: T, - factory: (container: ContainerView) => TokenValue, + factory: (container: TypedContainer) => TokenValue, ): void; routers: { add(namespace: string, factory: RouterFactory): void; @@ -81,57 +70,39 @@ export type ModuleRegistry = { }; }; -export type PluginContext = ModuleRegistry; +export type PluginContext = ModuleRegistry; export type PluginDefinition< - C extends TokenCatalog = never, + C extends TokenCatalog, Id extends string = string, - Dependencies extends readonly string[] = string[], + Dependencies extends readonly string[] = readonly string[], > = { id: Id; + // Kept as literals so extensions.config.ts can type-check the whole dependency + // graph (unknown ids + cycles) through defineExtensions. dependsOn?: Dependencies; // Verified once after all plugins register - a missing port fails fast. See ADR-0024. - requiresPorts?: ProviderToken[]; + requiresPorts?: Array>; register(ctx: PluginContext): void | Promise; }; export type Plugin< - C extends TokenCatalog = never, + C extends TokenCatalog, Id extends string = string, - Dependencies extends readonly string[] = string[], + Dependencies extends readonly string[] = readonly string[], > = PluginDefinition; -// `requiresPorts` is widened to a plain Token[] here only: TS can't prove the -// deferred `ProviderToken` conditional (unresolved for a generic C) is -// assignable against the never-catalog overload's resolved branch, even though -// every real instantiation of C does resolve safely - a checker limitation on -// this one field, not a hole in the constraint itself. -type LooseDefinition = Omit, 'requiresPorts'> & { - requiresPorts?: Token[]; -}; - -// Uncatalogued: definePlugin({ id, register }) - the plugin host's original, -// single-call form. Unchanged for consumer overlays and scaffolded modules that -// don't need catalog-constrained container access. -export function definePlugin< - const Id extends string = string, - const Dependencies extends readonly string[] = [], ->(definition: PluginDefinition): Plugin; -// Catalogued: definePlugin()({ id, register }). C is fixed by the -// first (argument-less) call so the second call's Id/Dependencies still infer from -// the literal object - TypeScript won't infer a trailing `const` type parameter -// past one supplied explicitly in the same call. -export function definePlugin(): < - const Id extends string, - const Dependencies extends readonly string[] = [], ->( - definition: PluginDefinition, -) => Plugin; -export function definePlugin( - definition?: LooseDefinition, -): LooseDefinition | ((definition: LooseDefinition) => LooseDefinition) { - if (definition === undefined) { - return (inner: LooseDefinition) => inner; - } +// Every plugin declares the token catalog it needs: definePlugin(CORE_TOKEN_CATALOG, { id, register }). +// The catalog is a VALUE argument, not a type argument, deliberately - TypeScript +// only fills in unspecified trailing type parameters from their declared defaults, +// never by inferring them from the call, so an explicit `definePlugin({...})` +// would silently widen `id`/`dependsOn` to `string`/`string[]`. Inferring C from a +// same-call value argument alongside the literal `definition` (via `const T`) lets +// both infer correctly together. +export function definePlugin>( + catalog: C, + definition: T, +): T { + void catalog; return definition; } diff --git a/packages/core/src/server/plugin-host/load-plugins.ts b/packages/core/src/server/plugin-host/load-plugins.ts index 6fd61df9..c4bd6a42 100644 --- a/packages/core/src/server/plugin-host/load-plugins.ts +++ b/packages/core/src/server/plugin-host/load-plugins.ts @@ -1,6 +1,7 @@ import type { Container } from '../kernel/index.js'; import type { TokenCatalog } from '@openora/core/contracts'; import type { Plugin } from './define-plugin.js'; +import type { PluginGraphError } from './plugin-graph.js'; import { ModuleRegistryImpl } from './module-registry.js'; export type PluginEntry< @@ -17,57 +18,6 @@ export type PluginEntry< kind?: 'module' | 'infra'; }; -type EntryIds = Entries[number]['id']; - -type EntryDependencies = Entry['dependsOn'] extends readonly string[] - ? Entry['dependsOn'][number] - : never; - -type MissingDependencies = - Entries[number] extends infer Entry - ? Entry extends PluginEntry - ? Exclude, EntryIds> - : never - : never; - -type EntryById< - Entries extends readonly PluginEntry[], - Id extends string, -> = Entries[number] extends infer Entry ? (Entry extends PluginEntry ? Entry : never) : never; - -type HasDependencyCycle< - Entries extends readonly PluginEntry[], - Id extends string, - Trail extends readonly string[] = [], -> = Id extends Trail[number] - ? true - : EntryById extends infer Entry - ? Entry extends PluginEntry - ? HasDependencyCycleFor, [...Trail, Id]> - : false - : false; - -type HasDependencyCycleFor< - Entries extends readonly PluginEntry[], - Ids extends string, - Trail extends readonly string[], -> = true extends (Ids extends string ? HasDependencyCycle : never) - ? true - : false; - -type GraphHasCycle< - Entries extends readonly PluginEntry[], - Ids extends string = EntryIds, -> = true extends (Ids extends string ? HasDependencyCycle : never) ? true : false; - -type PluginGraphError = [ - MissingDependencies, -] extends [never] - ? GraphHasCycle extends true - ? { readonly __pluginGraphError: 'circular dependency' } - : unknown - : { readonly __pluginGraphError: 'unknown dependency' }; - export function defineExtensions( entries: Entries & PluginGraphError, ): Entries { @@ -102,6 +52,19 @@ function validateEntries(entries: unknown): asserts entries is PluginEntry[] { }); } +/** Ensures a registry entry resolves the plugin it names. */ +export function assertEntryMatchesPlugin( + entry: PluginEntry, + plugin: Plugin, +): void { + if (entry.id !== plugin.id) { + throw new Error( + `Registry entry id "${entry.id}" does not match the plugin's own id "${plugin.id}" ` + + `(loaded from ${entry.path}).`, + ); + } +} + export function topoSort(plugins: Plugin[]): Plugin[] { const byId = new Map>(plugins.map((p) => [p.id, p])); const visited = new Set(); @@ -134,7 +97,7 @@ export function topoSort(plugins: Plugin[]): Plugin( +export async function loadPlugins( entries: PluginEntry[], container: Container, ): Promise> { @@ -147,6 +110,7 @@ export async function loadPlugins( if (!plugin || typeof plugin.register !== 'function') { throw new Error(`Plugin at "${entry.path}" does not export a valid definePlugin result`); } + assertEntryMatchesPlugin(entry, plugin); plugins.push(plugin); } @@ -172,7 +136,7 @@ export async function loadPlugins( * registration and throw one actionable error naming the plugin, the port, and the * likely-missing package. */ -export function assertRequiredPorts( +export function assertRequiredPorts( plugins: Plugin[], container: Container, ): void { diff --git a/packages/core/src/server/plugin-host/module-registry.ts b/packages/core/src/server/plugin-host/module-registry.ts index f3421656..2b52fe35 100644 --- a/packages/core/src/server/plugin-host/module-registry.ts +++ b/packages/core/src/server/plugin-host/module-registry.ts @@ -10,11 +10,11 @@ import type { ModuleRegistry, McpToolDefinition, RouterFactory, - ContainerView, + TypedContainer, EventHandler, } from './define-plugin.js'; -export class ModuleRegistryImpl implements ModuleRegistry { +export class ModuleRegistryImpl implements ModuleRegistry { private _routers = new Map>(); private _slots = new Map(); private _events = new Map(); @@ -30,7 +30,7 @@ export class ModuleRegistryImpl implements Modul // Canonical sealed list lives in `@openora/core/compliance`. provide = >( token: T, - factory: (container: ContainerView) => TokenValue, + factory: (container: TypedContainer) => TokenValue, ): void => { const desc = token.description ?? ''; if (desc.startsWith('sealed:')) { @@ -51,7 +51,7 @@ export class ModuleRegistryImpl implements Modul // of silently rebinding (there is no "last-wins" for a sealed token). provideSealed = >( token: T, - factory: (container: ContainerView) => TokenValue, + factory: (container: TypedContainer) => TokenValue, ): void => { if (this._sealedBound.has(token)) { throw new Error( diff --git a/packages/core/src/server/plugin-host/plugin-graph.ts b/packages/core/src/server/plugin-host/plugin-graph.ts new file mode 100644 index 00000000..5969648d --- /dev/null +++ b/packages/core/src/server/plugin-host/plugin-graph.ts @@ -0,0 +1,74 @@ +/** + * Compile-time dependency-graph checks shared by `defineExtensions` (a consumer's + * extensions.config.ts) and `corePlugins()` (the built-in module graph). + * + * The recursion walks each node's `dependsOn` depth-first, carrying the trail of + * ids already visited on that path - an id reappearing in its own trail is a + * cycle. TypeScript's recursion limit caps the reachable depth, so a graph deeper + * than ~45 hops degrades to the runtime topoSort check rather than a false positive. + */ +export type PluginGraphNode = { + id: string; + dependsOn?: readonly string[]; +}; + +type NodeIds = Nodes[number]['id']; + +type NodeDependencies = Node['dependsOn'] extends readonly string[] + ? Node['dependsOn'][number] + : never; + +type MissingDependencies = + Nodes[number] extends infer Node + ? Node extends PluginGraphNode + ? Exclude, NodeIds> + : never + : never; + +type NodeById< + Nodes extends readonly PluginGraphNode[], + Id extends string, +> = Nodes[number] extends infer Node + ? Node extends PluginGraphNode + ? Node['id'] extends Id + ? Node + : never + : never + : never; + +type HasCycleFrom< + Nodes extends readonly PluginGraphNode[], + Id extends string, + Trail extends readonly string[] = [], +> = Id extends Trail[number] + ? true + : NodeById extends infer Node + ? Node extends PluginGraphNode + ? HasCycleFromAny, [...Trail, Id]> + : false + : false; + +type HasCycleFromAny< + Nodes extends readonly PluginGraphNode[], + Ids extends string, + Trail extends readonly string[], +> = true extends (Ids extends string ? HasCycleFrom : never) ? true : false; + +type GraphHasCycle< + Nodes extends readonly PluginGraphNode[], + Ids extends string = NodeIds, +> = true extends (Ids extends string ? HasCycleFrom : never) ? true : false; + +/** + * Intersect with the argument type of a graph-taking function: it resolves to + * `unknown` (a no-op intersection) for a valid graph, and to a branded object the + * literal array cannot satisfy when a dependency is unknown or a cycle exists - + * so the call site fails to typecheck with the reason in the error text. + */ +export type PluginGraphError = [ + MissingDependencies, +] extends [never] + ? GraphHasCycle extends true + ? { readonly __pluginGraphError: 'circular dependency' } + : unknown + : { readonly __pluginGraphError: 'unknown dependency' }; diff --git a/packages/core/src/server/runtime/index.ts b/packages/core/src/server/runtime/index.ts index 8e6ca203..2635956b 100644 --- a/packages/core/src/server/runtime/index.ts +++ b/packages/core/src/server/runtime/index.ts @@ -1,5 +1,6 @@ export { createApp } from './create-app.js'; export type { CreateAppConfig, CreatedApp } from './create-app.js'; +export { CORE_TOKEN_CATALOG } from './core-token-catalog.js'; export type { CoreTokenCatalog } from './core-token-catalog.js'; export { generateOpenApiSpec } from './openapi.js'; diff --git a/packages/core/src/wallet/plugin.ts b/packages/core/src/wallet/plugin.ts index 6ae61401..8c3821de 100644 --- a/packages/core/src/wallet/plugin.ts +++ b/packages/core/src/wallet/plugin.ts @@ -3,7 +3,7 @@ import { ADMIN_GUARD, EVENT_BUS, DRIZZLE, - type CoreTokenCatalog, + CORE_TOKEN_CATALOG, } from '@openora/core/server'; import * as z from 'zod'; import { @@ -28,7 +28,7 @@ import { createWalletRouter } from './router/index.js'; import { MockPaymentAdapter } from './adapters/mock/mock-payment-adapter.js'; import { HmacPaymentWebhookVerifier } from './adapters/hmac-payment-webhook-verifier.js'; -export default definePlugin()({ +export default definePlugin(CORE_TOKEN_CATALOG, { // NOT dependsOn 'tag': that would cycle (tag hard-depends on wallet's WALLET_READER). // wallet's use of tag's PLAYER_TAGS / TAG_EVALUATION_COMMANDS is optional and resolved // lazily in the router factory (`c.has(...)`), which runs after every plugin has diff --git a/packages/testing/src/__tests__/analytics.e2e.test.ts b/packages/testing/src/__tests__/analytics.e2e.test.ts index 4d6ab17e..9f8a8510 100644 --- a/packages/testing/src/__tests__/analytics.e2e.test.ts +++ b/packages/testing/src/__tests__/analytics.e2e.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; -import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; +import { + loadExtensions, + DRIZZLE, + type Container, + type CoreTokenCatalog, +} from '@openora/core/server'; import { user } from '@openora/core/pam/schema/identity'; import { wallet, walletTransaction } from '@openora/core/wallet/schema'; import { @@ -37,7 +42,7 @@ async function registerPlayer(email: string) { return body.user.id; } -async function verifyEmail(container: Container, userId: string) { +async function verifyEmail(container: Container, userId: string) { await container .get(DRIZZLE) .db.update(user) @@ -52,7 +57,10 @@ async function deposit(client: TestClient, amount: string, currency = 'USD') { } } -async function walletIdFor(container: Container, userId: string): Promise { +async function walletIdFor( + container: Container, + userId: string, +): Promise { const [row] = await container .get(DRIZZLE) .db.select() @@ -65,7 +73,7 @@ async function walletIdFor(container: Container, userId: string): Promise, walletId: string, type: 'bonus' | 'bet' | 'win', amount: string, diff --git a/packages/testing/src/__tests__/fixtures/test-kyc-config-plugin.ts b/packages/testing/src/__tests__/fixtures/test-kyc-config-plugin.ts index 8788ce3a..8619be27 100644 --- a/packages/testing/src/__tests__/fixtures/test-kyc-config-plugin.ts +++ b/packages/testing/src/__tests__/fixtures/test-kyc-config-plugin.ts @@ -1,4 +1,4 @@ -import { definePlugin } from '@openora/core/server'; +import { definePlugin, CORE_TOKEN_CATALOG } from '@openora/core/server'; import { PLATFORM_CONFIG, KYC_ADAPTER, @@ -43,7 +43,7 @@ class ControllablePendingKycAdapter implements KycAdapter { * `KYC_ADAPTER` for a controllable stub. Append last in a test's `plugins` array so both * bindings win over the defaults (last-registration-wins; see docs/standards/module-structure.md > ports). */ -export default definePlugin({ +export default definePlugin(CORE_TOKEN_CATALOG, { id: 'test-kyc-config', dependsOn: ['identity'], register(ctx) { diff --git a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts index 3b733e88..f6609c04 100644 --- a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts +++ b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts @@ -1,9 +1,9 @@ -import { definePlugin } from '@openora/core/server'; +import { definePlugin, CORE_TOKEN_CATALOG } from '@openora/core/server'; import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts'; // PLATFORM_CONFIG overlay for the daily-cap scenario: dailyCapCount 1 trips on the 2nd withdrawal, still below // the high_frequency heuristic (>= 3) so the cap gate is tested in isolation. Separate app since config is boot-once. -export default definePlugin({ +export default definePlugin(CORE_TOKEN_CATALOG, { id: 'test-wallet-auto-withdrawal-cap-config', dependsOn: ['identity'], register(ctx) { diff --git a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts index a9c4f85a..205b946c 100644 --- a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts +++ b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts @@ -1,10 +1,10 @@ -import { definePlugin } from '@openora/core/server'; +import { definePlugin, CORE_TOKEN_CATALOG } from '@openora/core/server'; import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts'; // PLATFORM_CONFIG overlay for the auto-withdrawal e2e suite: autoWithdrawal enabled (threshold 2, // high_risk/bonus_abuser excluded, caps set high). kyc.gateWithdrawals stays false so the KYC-not-passing // scenario hits the auto-approval KYC gate, not the withdraw-time one. Append last so this binding wins. -export default definePlugin({ +export default definePlugin(CORE_TOKEN_CATALOG, { id: 'test-wallet-auto-withdrawal-config', dependsOn: ['identity'], register(ctx) { diff --git a/packages/testing/src/__tests__/gaming-stake-debit.e2e.test.ts b/packages/testing/src/__tests__/gaming-stake-debit.e2e.test.ts index 714aa733..c923d425 100644 --- a/packages/testing/src/__tests__/gaming-stake-debit.e2e.test.ts +++ b/packages/testing/src/__tests__/gaming-stake-debit.e2e.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; -import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; +import { + loadExtensions, + DRIZZLE, + type Container, + type CoreTokenCatalog, +} from '@openora/core/server'; import { game, gameRound } from '@openora/core/casino/schema/gaming'; import { wallet, walletTransaction } from '@openora/core/wallet/schema'; import { @@ -43,7 +48,7 @@ async function deposit(client: TestClient, amount: string, currency = 'USD') { } } -async function balanceOf(container: Container, userId: string): Promise { +async function balanceOf(container: Container, userId: string): Promise { const [row] = await container .get(DRIZZLE) .db.select() diff --git a/packages/testing/src/__tests__/kyc.e2e.test.ts b/packages/testing/src/__tests__/kyc.e2e.test.ts index af04e4b6..cd66d632 100644 --- a/packages/testing/src/__tests__/kyc.e2e.test.ts +++ b/packages/testing/src/__tests__/kyc.e2e.test.ts @@ -2,7 +2,12 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { createHmac, randomUUID } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { eq } from 'drizzle-orm'; -import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; +import { + loadExtensions, + DRIZZLE, + type Container, + type CoreTokenCatalog, +} from '@openora/core/server'; import { player } from '@openora/core/pam/schema/profile'; import { setupTestDb, @@ -71,7 +76,7 @@ async function registerAndMaterializePlayer(app: TestApp['app'], email: string) return { client, playerId: profile.id, userId: profile.userId }; } -async function seedLegacyVerifiedStatus(container: Container, userId: string) { +async function seedLegacyVerifiedStatus(container: Container, userId: string) { await container .get(DRIZZLE) .db.update(player) diff --git a/packages/testing/src/__tests__/rg.e2e.test.ts b/packages/testing/src/__tests__/rg.e2e.test.ts index 061f6995..706624b6 100644 --- a/packages/testing/src/__tests__/rg.e2e.test.ts +++ b/packages/testing/src/__tests__/rg.e2e.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; -import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; +import { + loadExtensions, + DRIZZLE, + type Container, + type CoreTokenCatalog, +} from '@openora/core/server'; import { JOB_QUEUE, PLAY_ELIGIBILITY, queue } from '@openora/core/contracts'; import { rgExclusion } from '@openora/core/compliance/schema'; import { user } from '@openora/core/pam/schema/identity'; @@ -63,11 +68,11 @@ async function attemptLogin(email: string, password: string) { }); } -async function setRole(container: Container, userId: string, role: string) { +async function setRole(container: Container, userId: string, role: string) { await container.get(DRIZZLE).db.update(user).set({ role }).where(eq(user.id, userId)); } -async function expireExclusion(container: Container, exclusionId: string) { +async function expireExclusion(container: Container, exclusionId: string) { await container .get(DRIZZLE) .db.update(rgExclusion) @@ -75,7 +80,7 @@ async function expireExclusion(container: Container, exclusionId: string) { .where(eq(rgExclusion.id, exclusionId)); } -async function exclusionStatus(container: Container, exclusionId: string) { +async function exclusionStatus(container: Container, exclusionId: string) { const [row] = await container .get(DRIZZLE) .db.select({ status: rgExclusion.status }) @@ -84,7 +89,7 @@ async function exclusionStatus(container: Container, exclusionId: string) { return row?.status; } -async function triggerRgMonitorSweep(container: Container) { +async function triggerRgMonitorSweep(container: Container) { await container.get(JOB_QUEUE).enqueue(queue('rg-monitor'), {}); } diff --git a/packages/testing/src/__tests__/tag-bf317.e2e.test.ts b/packages/testing/src/__tests__/tag-bf317.e2e.test.ts index c53d70fe..97feaa23 100644 --- a/packages/testing/src/__tests__/tag-bf317.e2e.test.ts +++ b/packages/testing/src/__tests__/tag-bf317.e2e.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; -import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; +import { + loadExtensions, + DRIZZLE, + type Container, + type CoreTokenCatalog, +} from '@openora/core/server'; import { rgExclusion } from '@openora/core/compliance/schema'; import { setupTestDb, @@ -84,7 +89,11 @@ async function activeTagKeys(admin: TestClient, playerId: string): Promise t.key); } -async function backdateExclusionExpiry(container: Container, exclusionId: string, daysAgo: number) { +async function backdateExclusionExpiry( + container: Container, + exclusionId: string, + daysAgo: number, +) { const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); await container .get(DRIZZLE) diff --git a/packages/testing/src/__tests__/tag.e2e.test.ts b/packages/testing/src/__tests__/tag.e2e.test.ts index 23d37462..86368ebb 100644 --- a/packages/testing/src/__tests__/tag.e2e.test.ts +++ b/packages/testing/src/__tests__/tag.e2e.test.ts @@ -1,7 +1,13 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; -import { loadExtensions, DRIZZLE, EVENT_BUS, type Container } from '@openora/core/server'; +import { + loadExtensions, + DRIZZLE, + EVENT_BUS, + type Container, + type CoreTokenCatalog, +} from '@openora/core/server'; import { JOB_QUEUE, queue } from '@openora/core/contracts'; import { session } from '@openora/core/pam/schema/identity'; import { walletTransaction } from '@openora/core/wallet/schema'; @@ -51,7 +57,11 @@ async function registerAndMaterializePlayer(honoApp: TestApp['app'], email: stri return { client, playerId: profile.id, userId: profile.userId }; } -async function backdateSessions(container: Container, userId: string, daysAgo: number) { +async function backdateSessions( + container: Container, + userId: string, + daysAgo: number, +) { const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); await container .get(DRIZZLE) @@ -60,7 +70,11 @@ async function backdateSessions(container: Container, userId: string, daysAgo: n .where(eq(session.userId, userId)); } -async function backdateTransaction(container: Container, transactionId: string, daysAgo: number) { +async function backdateTransaction( + container: Container, + transactionId: string, + daysAgo: number, +) { const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); await container .get(DRIZZLE) @@ -96,7 +110,7 @@ async function assignTagManually(admin: TestClient, playerId: string, tagKey: st return readJson(res); } -async function runDailySweep(container: Container) { +async function runDailySweep(container: Container) { await container.get(JOB_QUEUE).enqueue(queue('tag.daily-evaluation'), {}); } diff --git a/packages/testing/src/__tests__/wallet-ledger-auto-withdrawal.e2e.test.ts b/packages/testing/src/__tests__/wallet-ledger-auto-withdrawal.e2e.test.ts index 932b54fa..72c3904b 100644 --- a/packages/testing/src/__tests__/wallet-ledger-auto-withdrawal.e2e.test.ts +++ b/packages/testing/src/__tests__/wallet-ledger-auto-withdrawal.e2e.test.ts @@ -2,7 +2,12 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { eq } from 'drizzle-orm'; -import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; +import { + loadExtensions, + DRIZZLE, + type Container, + type CoreTokenCatalog, +} from '@openora/core/server'; import { user } from '@openora/core/pam/schema/identity'; import { setupTestDb, @@ -52,7 +57,7 @@ async function registerAndMaterializePlayer(app: TestApp['app'], email: string) return { client, playerId: profile.id, userId: profile.userId }; } -async function setRole(container: Container, userId: string, role: string) { +async function setRole(container: Container, userId: string, role: string) { await container.get(DRIZZLE).db.update(user).set({ role }).where(eq(user.id, userId)); } diff --git a/packages/testing/src/app.ts b/packages/testing/src/app.ts index 810c026b..ca622ef1 100644 --- a/packages/testing/src/app.ts +++ b/packages/testing/src/app.ts @@ -8,6 +8,7 @@ import { RedisStreamsBroker, type CreateAppConfig, type Container, + type CoreTokenCatalog, } from '@openora/core/server'; import { MESSAGE_BROKER, @@ -26,7 +27,7 @@ export type TestApp = { /** The Hono app - drive it directly with `app.request(path, init)`. */ app: Hono; /** The composition container, for resolving services/tokens in assertions. */ - container: Container; + container: Container; /** Dispose the container (closes the DB pool, drains workers, frees the Redis db). */ close(): Promise; }; @@ -68,7 +69,7 @@ export async function bootTestApp(config: BootTestAppConfig): Promise { databaseUrl: config.databaseUrl, authSchema: { user, session, account, verification, twoFactor }, openapi: { enabled: false }, - configure(container: Container) { + configure(container: Container) { const redis = createRedisClient(redisDatabase.url); container.onDispose(() => redis.close()); diff --git a/packages/testing/src/seed.ts b/packages/testing/src/seed.ts index 2876b83d..6ac9540d 100644 --- a/packages/testing/src/seed.ts +++ b/packages/testing/src/seed.ts @@ -1,7 +1,13 @@ import { Pool } from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; import { seedDemoData, type SeedResult } from './seed-demo-data.js'; -import { createAuth, DRIZZLE, type DrizzleDb, Container } from '@openora/core/server'; +import { + createAuth, + DRIZZLE, + type DrizzleDb, + Container, + type CoreTokenCatalog, +} from '@openora/core/server'; import { seedIam } from '@openora/core/iam/seed'; import { seedTag } from '@openora/core/pam/tag/seed'; import { user, session, account, verification } from '@openora/core/pam/schema/identity'; @@ -23,7 +29,7 @@ export type SeedMinimalOptions = { * for the direct table inserts (players, wallets, ...). */ export async function seedMinimal( - container: Container, + container: Container, options: SeedMinimalOptions = {}, ): Promise { const drizzleSvc = container.get(DRIZZLE); From d41a4c037e1597155b3d02efe19aa7a11477a2be Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Tue, 4 Aug 2026 22:43:35 +0200 Subject: [PATCH 6/8] refactor(server): remove dead plugin host surfaces --- docs/catalog.json | 3 +- .../__tests__/load-plugins.test.ts | 16 +++- .../__tests__/plugin-graph.test.ts | 32 -------- .../__tests__/typed-plugin.test.ts | 17 +---- .../src/server/plugin-host/core-plugins.ts | 60 ++++----------- .../src/server/plugin-host/define-plugin.ts | 17 +---- packages/core/src/server/plugin-host/index.ts | 2 +- .../src/server/plugin-host/load-plugins.ts | 15 +--- .../src/server/plugin-host/module-registry.ts | 8 -- .../src/server/plugin-host/plugin-graph.ts | 74 ------------------- packages/mcp/src/main.ts | 50 +++---------- 11 files changed, 46 insertions(+), 248 deletions(-) delete mode 100644 packages/core/src/server/plugin-host/__tests__/plugin-graph.test.ts delete mode 100644 packages/core/src/server/plugin-host/plugin-graph.ts diff --git a/docs/catalog.json b/docs/catalog.json index 181f99bc..66b58311 100644 --- a/docs/catalog.json +++ b/docs/catalog.json @@ -1776,8 +1776,7 @@ "events", "jobs", "mcp", - "routers", - "slots" + "routers" ], "httpRoutes": [ "DELETE /backoffice/chat/rooms/{id}", diff --git a/packages/core/src/server/plugin-host/__tests__/load-plugins.test.ts b/packages/core/src/server/plugin-host/__tests__/load-plugins.test.ts index 81c7b45a..5c12bb78 100644 --- a/packages/core/src/server/plugin-host/__tests__/load-plugins.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/load-plugins.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { TokenCatalog } from '@openora/core/contracts'; import type { Plugin } from '../define-plugin.js'; -import { topoSort } from '../load-plugins.js'; +import { assertEntryMatchesPlugin, topoSort, type PluginEntry } from '../load-plugins.js'; function plugin(id: string, dependsOn: string[] = []): Plugin { return { @@ -30,3 +30,17 @@ describe('topoSort', () => { ); }); }); + +describe('assertEntryMatchesPlugin', () => { + const entry = (id: string): PluginEntry => ({ id, path: `./${id}.js` }); + + it('accepts a registry entry that resolves the same plugin id', () => { + expect(() => assertEntryMatchesPlugin(entry('wallet'), plugin('wallet'))).not.toThrow(); + }); + + it('rejects a registry entry that resolves a different plugin id', () => { + expect(() => assertEntryMatchesPlugin(entry('wallet'), plugin('tag'))).toThrow( + /does not match the plugin's own id "tag"/, + ); + }); +}); diff --git a/packages/core/src/server/plugin-host/__tests__/plugin-graph.test.ts b/packages/core/src/server/plugin-host/__tests__/plugin-graph.test.ts deleted file mode 100644 index 55521ad5..00000000 --- a/packages/core/src/server/plugin-host/__tests__/plugin-graph.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { TokenCatalog } from '@openora/core/contracts'; -import type { Plugin } from '../define-plugin.js'; -import { assertEntryMatchesPlugin, type PluginEntry } from '../load-plugins.js'; -import { corePlugins } from '../core-plugins.js'; - -function entry(id: string): PluginEntry { - return { id, path: `./${id}.js` }; -} - -function plugin(id: string): Plugin { - return { id, register: () => {} }; -} - -describe('assertEntryMatchesPlugin', () => { - it('accepts a registry entry that resolves the same plugin id', () => { - expect(() => assertEntryMatchesPlugin(entry('wallet'), plugin('wallet'))).not.toThrow(); - }); - - it('throws when the loaded plugin has a different id', () => { - expect(() => assertEntryMatchesPlugin(entry('wallet'), plugin('tag'))).toThrow( - /does not match the plugin's own id "tag"/, - ); - }); -}); - -describe('corePlugins', () => { - it('leaves dependency metadata with each plugin declaration', () => { - const entries = corePlugins(); - expect(entries.every((entry) => entry.dependsOn === undefined)).toBe(false); - }); -}); diff --git a/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts b/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts index 66ed4e42..cd1e02e8 100644 --- a/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createToken, type TokenCatalog } from '@openora/core/contracts'; import { Container } from '../../kernel/index.js'; -import { defineExtensions, definePlugin, ModuleRegistryImpl } from '../index.js'; +import { definePlugin, ModuleRegistryImpl } from '../index.js'; const COUNT = createToken('COUNT'); const SEED = createToken('SEED'); @@ -76,20 +76,6 @@ definePlugin(catalog, { const typedPluginId: 'typed-plugin' = typedPlugin.id; const typedDependency: readonly ['foundation'] = typedPlugin.dependsOn; -const validGraph = defineExtensions([ - { id: 'foundation', path: './foundation.js' }, - { id: 'feature', path: './feature.js', dependsOn: ['foundation'] }, -]); - -// @ts-expect-error The registry must reject an unknown dependency. -defineExtensions([{ id: 'feature', path: './feature.js', dependsOn: ['missing'] }]); - -// @ts-expect-error The registry must reject a dependency cycle. -defineExtensions([ - { id: 'a', path: './a.js', dependsOn: ['b'] }, - { id: 'b', path: './b.js', dependsOn: ['a'] }, -]); - describe('typed plugin surface', () => { it('resolves catalog services through a catalogued plugin', () => { const container = new Container(); @@ -100,7 +86,6 @@ describe('typed plugin surface', () => { expect(typedPluginId).toBe('typed-plugin'); expect(typedDependency).toEqual(['foundation']); - expect(validGraph[1]?.dependsOn).toEqual(['foundation']); expect(container.get(COUNT)).toBe(42); }); diff --git a/packages/core/src/server/plugin-host/core-plugins.ts b/packages/core/src/server/plugin-host/core-plugins.ts index 257e002b..4b8e6e69 100644 --- a/packages/core/src/server/plugin-host/core-plugins.ts +++ b/packages/core/src/server/plugin-host/core-plugins.ts @@ -1,59 +1,26 @@ import { createRequire } from 'node:module'; import type { PluginEntry } from './load-plugins.js'; -import type { PluginGraphError, PluginGraphNode } from './plugin-graph.js'; const nodeRequire = createRequire(import.meta.url); -type CorePluginModule = PluginGraphNode & { specifier: string }; - -function defineCorePluginModules( - modules: Modules & PluginGraphError, -): Modules { - return modules; -} - -const CORE_PLUGIN_MODULES = defineCorePluginModules([ +const CORE_PLUGIN_MODULES = [ { id: 'audit', specifier: '@openora/core/audit/plugin' }, { id: 'identity', specifier: '@openora/core/pam/plugins/identity' }, - { id: 'iam', specifier: '@openora/core/iam/plugin', dependsOn: ['identity'] }, - { - id: 'notifications', - specifier: '@openora/core/engagement/plugins/notifications', - dependsOn: ['identity'], - }, - { - id: 'wallet', - specifier: '@openora/core/wallet/plugins/wallet', - dependsOn: ['identity', 'audit'], - }, - { id: 'gaming', specifier: '@openora/core/casino/plugins/gaming', dependsOn: ['wallet'] }, + { id: 'iam', specifier: '@openora/core/iam/plugin' }, + { id: 'notifications', specifier: '@openora/core/engagement/plugins/notifications' }, + { id: 'wallet', specifier: '@openora/core/wallet/plugins/wallet' }, + { id: 'gaming', specifier: '@openora/core/casino/plugins/gaming' }, { id: 'lobby', specifier: '@openora/core/casino/plugins/lobby' }, - { id: 'chat', specifier: '@openora/core/engagement/plugins/chat', dependsOn: ['identity'] }, + { id: 'chat', specifier: '@openora/core/engagement/plugins/chat' }, { id: 'profile', specifier: '@openora/core/pam/plugins/profile' }, - { id: 'tag', specifier: '@openora/core/pam/plugins/tag', dependsOn: ['wallet', 'identity'] }, - { - id: 'player-management', - specifier: '@openora/core/pam/plugins/player-management', - dependsOn: ['audit'], - }, - { - id: 'compliance', - specifier: '@openora/core/compliance/plugins/compliance', - dependsOn: ['player-management', 'identity', 'wallet', 'gaming', 'audit'], - }, - { - id: 'admin-console', - specifier: '@openora/core/admin-console/plugin', - dependsOn: ['identity', 'wallet', 'audit', 'gaming', 'iam'], - }, - { - id: 'analytics', - specifier: '@openora/core/analytics/plugin', - dependsOn: ['wallet', 'identity', 'profile', 'gaming'], - }, + { id: 'tag', specifier: '@openora/core/pam/plugins/tag' }, + { id: 'player-management', specifier: '@openora/core/pam/plugins/player-management' }, + { id: 'compliance', specifier: '@openora/core/compliance/plugins/compliance' }, + { id: 'admin-console', specifier: '@openora/core/admin-console/plugin' }, + { id: 'analytics', specifier: '@openora/core/analytics/plugin' }, { id: 'player-note', specifier: '@openora/core/pam/plugins/player-note' }, { id: 'cms', specifier: '@openora/core/cms/plugins/cms' }, -]); +] as const; /** * The full set of built-in platform plugins, as a ready-to-spread @@ -69,9 +36,8 @@ const CORE_PLUGIN_MODULES = defineCorePluginModules([ * out, filter by id: `corePlugins().filter((p) => p.id !== 'chat')`. */ export function corePlugins(): PluginEntry[] { - return CORE_PLUGIN_MODULES.map((module: CorePluginModule) => ({ + return CORE_PLUGIN_MODULES.map((module) => ({ id: module.id, path: nodeRequire.resolve(module.specifier), - ...(module.dependsOn ? { dependsOn: module.dependsOn } : {}), })); } diff --git a/packages/core/src/server/plugin-host/define-plugin.ts b/packages/core/src/server/plugin-host/define-plugin.ts index 267131cc..4b484522 100644 --- a/packages/core/src/server/plugin-host/define-plugin.ts +++ b/packages/core/src/server/plugin-host/define-plugin.ts @@ -51,10 +51,6 @@ export type ModuleRegistry = { add(namespace: string, factory: RouterFactory): void; getAll(): Map>; }; - slots: { - fill(slotName: string, component: unknown): void; - getAll(): Map; - }; events: { on(event: string, handler: EventHandler): void; getAll(): Map; @@ -78,8 +74,6 @@ export type PluginDefinition< Dependencies extends readonly string[] = readonly string[], > = { id: Id; - // Kept as literals so extensions.config.ts can type-check the whole dependency - // graph (unknown ids + cycles) through defineExtensions. dependsOn?: Dependencies; // Verified once after all plugins register - a missing port fails fast. See ADR-0024. requiresPorts?: Array>; @@ -92,13 +86,10 @@ export type Plugin< Dependencies extends readonly string[] = readonly string[], > = PluginDefinition; -// Every plugin declares the token catalog it needs: definePlugin(CORE_TOKEN_CATALOG, { id, register }). -// The catalog is a VALUE argument, not a type argument, deliberately - TypeScript -// only fills in unspecified trailing type parameters from their declared defaults, -// never by inferring them from the call, so an explicit `definePlugin({...})` -// would silently widen `id`/`dependsOn` to `string`/`string[]`. Inferring C from a -// same-call value argument alongside the literal `definition` (via `const T`) lets -// both infer correctly together. +export function definePlugin>( + catalog: C, + definition: T, +): T; export function definePlugin>( catalog: C, definition: T, diff --git a/packages/core/src/server/plugin-host/index.ts b/packages/core/src/server/plugin-host/index.ts index e4846dbf..d2ebe83c 100644 --- a/packages/core/src/server/plugin-host/index.ts +++ b/packages/core/src/server/plugin-host/index.ts @@ -10,7 +10,7 @@ export type { TypedContainer, } from './define-plugin.js'; export { ModuleRegistryImpl } from './module-registry.js'; -export { defineExtensions, loadPlugins, topoSort } from './load-plugins.js'; +export { loadPlugins, topoSort } from './load-plugins.js'; export type { PluginEntry } from './load-plugins.js'; export { applyServiceManifest, parseServiceManifest } from './service-manifest.js'; export { loadExtensions } from './load-extensions.js'; diff --git a/packages/core/src/server/plugin-host/load-plugins.ts b/packages/core/src/server/plugin-host/load-plugins.ts index c4bd6a42..c9aa8d78 100644 --- a/packages/core/src/server/plugin-host/load-plugins.ts +++ b/packages/core/src/server/plugin-host/load-plugins.ts @@ -1,16 +1,11 @@ import type { Container } from '../kernel/index.js'; import type { TokenCatalog } from '@openora/core/contracts'; import type { Plugin } from './define-plugin.js'; -import type { PluginGraphError } from './plugin-graph.js'; import { ModuleRegistryImpl } from './module-registry.js'; -export type PluginEntry< - Id extends string = string, - Dependencies extends readonly string[] = readonly string[], -> = { - id: Id; +export type PluginEntry = { + id: string; path: string; - dependsOn?: Dependencies; // 'module' (default) = a domain module, selectable by a service manifest. // 'infra' = a broker/queue driver overlay that always loads, even for a // single-module service, because a standalone process still needs its @@ -18,12 +13,6 @@ export type PluginEntry< kind?: 'module' | 'infra'; }; -export function defineExtensions( - entries: Entries & PluginGraphError, -): Entries { - return entries; -} - function validateEntries(entries: unknown): asserts entries is PluginEntry[] { if (!Array.isArray(entries)) { throw new Error( diff --git a/packages/core/src/server/plugin-host/module-registry.ts b/packages/core/src/server/plugin-host/module-registry.ts index 2b52fe35..eb9b6d54 100644 --- a/packages/core/src/server/plugin-host/module-registry.ts +++ b/packages/core/src/server/plugin-host/module-registry.ts @@ -16,7 +16,6 @@ import type { export class ModuleRegistryImpl implements ModuleRegistry { private _routers = new Map>(); - private _slots = new Map(); private _events = new Map(); private _jobs: WorkerRegistration[] = []; private _mcpTools: McpToolDefinition[] = []; @@ -73,13 +72,6 @@ export class ModuleRegistryImpl implements ModuleRegistr getAll: () => this._routers, }; - slots = { - fill: (slotName: string, component: unknown) => { - this._slots.set(slotName, component); - }, - getAll: () => this._slots, - }; - events = { on: (event: string, handler: EventHandler) => { const handlers = this._events.get(event) ?? []; diff --git a/packages/core/src/server/plugin-host/plugin-graph.ts b/packages/core/src/server/plugin-host/plugin-graph.ts deleted file mode 100644 index 5969648d..00000000 --- a/packages/core/src/server/plugin-host/plugin-graph.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Compile-time dependency-graph checks shared by `defineExtensions` (a consumer's - * extensions.config.ts) and `corePlugins()` (the built-in module graph). - * - * The recursion walks each node's `dependsOn` depth-first, carrying the trail of - * ids already visited on that path - an id reappearing in its own trail is a - * cycle. TypeScript's recursion limit caps the reachable depth, so a graph deeper - * than ~45 hops degrades to the runtime topoSort check rather than a false positive. - */ -export type PluginGraphNode = { - id: string; - dependsOn?: readonly string[]; -}; - -type NodeIds = Nodes[number]['id']; - -type NodeDependencies = Node['dependsOn'] extends readonly string[] - ? Node['dependsOn'][number] - : never; - -type MissingDependencies = - Nodes[number] extends infer Node - ? Node extends PluginGraphNode - ? Exclude, NodeIds> - : never - : never; - -type NodeById< - Nodes extends readonly PluginGraphNode[], - Id extends string, -> = Nodes[number] extends infer Node - ? Node extends PluginGraphNode - ? Node['id'] extends Id - ? Node - : never - : never - : never; - -type HasCycleFrom< - Nodes extends readonly PluginGraphNode[], - Id extends string, - Trail extends readonly string[] = [], -> = Id extends Trail[number] - ? true - : NodeById extends infer Node - ? Node extends PluginGraphNode - ? HasCycleFromAny, [...Trail, Id]> - : false - : false; - -type HasCycleFromAny< - Nodes extends readonly PluginGraphNode[], - Ids extends string, - Trail extends readonly string[], -> = true extends (Ids extends string ? HasCycleFrom : never) ? true : false; - -type GraphHasCycle< - Nodes extends readonly PluginGraphNode[], - Ids extends string = NodeIds, -> = true extends (Ids extends string ? HasCycleFrom : never) ? true : false; - -/** - * Intersect with the argument type of a graph-taking function: it resolves to - * `unknown` (a no-op intersection) for a valid graph, and to a branded object the - * literal array cannot satisfy when a dependency is unknown or a cycle exists - - * so the call site fails to typecheck with the reason in the error text. - */ -export type PluginGraphError = [ - MissingDependencies, -] extends [never] - ? GraphHasCycle extends true - ? { readonly __pluginGraphError: 'circular dependency' } - : unknown - : { readonly __pluginGraphError: 'unknown dependency' }; diff --git a/packages/mcp/src/main.ts b/packages/mcp/src/main.ts index f253bbf8..edb6dad9 100644 --- a/packages/mcp/src/main.ts +++ b/packages/mcp/src/main.ts @@ -23,11 +23,6 @@ type CatalogAdapter = { boundIn: string[]; }; -type CatalogUiSlot = { - name: string; - description: string; -}; - type CatalogSchema = { name: string; file: string; @@ -48,7 +43,6 @@ type Catalog = { modules: CatalogModule[]; adapters: CatalogAdapter[]; events: string[]; - uiSlots: CatalogUiSlot[]; schemas: CatalogSchema[]; config: CatalogConfig; pluginContract: string[]; @@ -120,14 +114,14 @@ server.registerTool( 'catalog-overview', { description: - 'START HERE. A concise "what can I extend" summary of the OSS igaming platform you are consuming: counts of modules/events/slots/schemas, the adapter swap-seam table (category/interface/token/status), and the igaming-config fields. Read this first to orient before drilling into the other tools.', + 'START HERE. A concise "what can I extend" summary of the OSS igaming platform you are consuming: counts of modules/events/schemas, the adapter swap-seam table (category/interface/token/status), and the igaming-config fields. Read this first to orient before drilling into the other tools.', inputSchema: {}, }, withCatalog((c) => { const lines: string[] = ['=== OSS igaming platform catalog ===']; lines.push( `modules: ${c.modules.length} adapters: ${c.adapters.length} events: ${c.events.length} ` + - `uiSlots: ${c.uiSlots.length} schemas: ${c.schemas.length} ` + + `schemas: ${c.schemas.length} ` + `httpRoutes: ${c.httpRoutes.length}`, ); @@ -142,7 +136,7 @@ server.registerTool( } lines.push( - '\nNext: list-adapters | list-routes | list-events | list-slots | describe-module | schema-get | get-config-schema', + '\nNext: list-adapters | list-routes | list-events | describe-module | schema-get | get-config-schema', ); return lines.join('\n'); }), @@ -229,25 +223,6 @@ server.registerTool( }), ); -server.registerTool( - 'list-slots', - { - description: - 'List the named UI slots you can fill from a client-side UI plugin (ctx..add(...)) to extend the backoffice without forking: nav items, table columns, dashboard tiles, detail sections. Includes each slot description and subject type.', - inputSchema: {}, - }, - withCatalog((c) => { - if (c.uiSlots.length === 0) { - return 'No UI slots in the catalog.'; - } - const lines: string[] = ['=== UI slots ===']; - for (const s of c.uiSlots) { - lines.push(`- ${s.name}${s.description ? ` # ${s.description}` : ''}`); - } - return lines.join('\n'); - }), -); - server.registerTool( 'describe-module', { @@ -364,7 +339,7 @@ function classifyIntent(ask: string): IntentKind { function buildConsumerPlaybook( kind: IntentKind, - ctx: { modules: string[]; tokens: string[]; slots: string[] }, + ctx: { modules: string[]; tokens: string[] }, ): string { const moduleList = ctx.modules.length ? ctx.modules.map((m) => `- ${m}`).join('\n') @@ -373,7 +348,7 @@ function buildConsumerPlaybook( case 'feature': return [ '## Where it goes', - 'New behavior in a consumer repo -> an overlay plugin (`pnpm gen plugin`). It can add routes, subscribe to events, rebind adapters, and fill UI slots without touching OSS core.', + 'New behavior in a consumer repo -> an overlay plugin (`pnpm gen plugin`). It can add routes, subscribe to events, and rebind adapters without touching OSS core.', 'If this is a new business domain that should be in the OSS platform itself, open an issue on the OSS repo instead.', '', '## Existing platform modules you can hook into', @@ -381,7 +356,7 @@ function buildConsumerPlaybook( '', '## Playbook', '1. Spawn the `expert` agent (Task tool) to turn this ask into requirements + acceptance criteria (player journey, jurisdiction rules, edge cases). Skip only for a trivial change.', - '2. Spawn the `builder` agent to implement: `pnpm gen plugin`, then in `register(ctx)` add routes (`ctx.routers.add`), subscribe to events (`ctx.events.on`), fill slots (`ctx.slots.fill`).', + '2. Spawn the `builder` agent to implement: `pnpm gen plugin`, then in `register(ctx)` add routes (`ctx.routers.add`) and subscribe to events (`ctx.events.on`).', '3. Register the plugin in `extensions.config.ts` at the repo root.', '4. Spawn the `qa` agent to write/run a Playwright E2E test for the acceptance criteria.', '5. Run `pnpm check:types && pnpm check:lint`.', @@ -414,15 +389,10 @@ function buildConsumerPlaybook( case 'ui-page': return [ '## Where it goes', - 'The platform is headless - pages live in your own frontend repo and consume the api via `@openora/core/react`. Fill named UI slots from a client-side UI plugin.', - '', - '## Named UI slots you can fill (via defineUIPlugin)', - ctx.slots.length - ? ctx.slots.map((s) => `- ${s}`).join('\n') - : '- (run list-slots for details)', + 'The platform is headless - pages live in your own frontend repo and consume the api via `@openora/core/react`.', '', '## Playbook', - '1. Spawn the `builder` agent to implement the page in your frontend repo, or extend an existing surface via `defineUIPlugin` into a slot above.', + '1. Spawn the `builder` agent to implement the page in your frontend repo.', '2. Spawn the `qa` agent to verify the page renders and behaves in a browser.', '3. Run `pnpm check:types`.', '', @@ -498,7 +468,7 @@ server.registerTool( 'enhance-intent', { description: - 'Turn a fuzzy "I want to build X" ask into a grounded, consumer-context brief. Uses the platform catalog (live module/adapter/slot list) to return a classified intent, a requirements checklist to collect from the user, a step-by-step playbook using the consumer pnpm gen commands, and an acceptance-criteria stub.', + 'Turn a fuzzy "I want to build X" ask into a grounded, consumer-context brief. Uses the platform catalog (live module and adapter list) to return a classified intent, a requirements checklist to collect from the user, a step-by-step playbook using the consumer pnpm gen commands, and an acceptance-criteria stub.', inputSchema: { ask: z .string() @@ -516,7 +486,6 @@ server.registerTool( const ctx = { modules: catalog?.modules.map((m) => `${m.group}/${m.id}`) ?? [], tokens: catalog?.adapters.map((a) => a.token) ?? [], - slots: catalog?.uiSlots.map((s) => s.name) ?? [], }; const detected = kind && kind !== 'unsure' ? '' : ' (auto-detected)'; const text = [ @@ -573,7 +542,6 @@ server.registerTool( const playbook = buildConsumerPlaybook(resolved, { modules, tokens, - slots: catalog?.uiSlots.map((s) => s.name) ?? [], }); return { content: [ From 5a0e9080dfcbd6d7ad1684da2416369393eb3824 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Wed, 5 Aug 2026 00:59:24 +0200 Subject: [PATCH 7/8] refactor: simplify plugin host --- .rulesync/commands/release-plugin.md | 4 +-- .../rules/messaging-and-microservices.md | 2 +- .rulesync/rules/overview.md | 4 +-- .rulesync/subagents/module-author.md | 2 +- .rulesync/subagents/plugin-author.md | 2 +- CONTRIBUTING.md | 2 +- GEMINI.md | 4 +-- README.md | 10 +++--- docs/adapters/error-tracking.md | 6 ++-- docs/adapters/kyc.md | 12 +++---- docs/adapters/notification.md | 6 ++-- docs/adapters/payment.md | 6 ++-- docs/architecture.md | 6 ++-- docs/core-concepts.md | 9 ++--- docs/downstream-consumer.md | 4 +-- docs/glossary.md | 2 +- docs/introduction.md | 2 +- docs/system-design.md | 2 +- packages/core/README.md | 20 +++++------ .../core/generators/src/templates/adapter.hbs | 6 ++-- .../generators/src/templates/job-worker.hbs | 6 ++-- .../src/templates/module/plugin.hbs | 7 ++-- .../core/generators/src/templates/plugin.hbs | 6 ++-- packages/core/src/admin-console/plugin.ts | 7 ++-- packages/core/src/analytics/plugin.ts | 7 ++-- packages/core/src/audit/plugin.ts | 14 +++----- packages/core/src/casino/gaming/plugin.ts | 7 ++-- packages/core/src/casino/lobby/plugin.ts | 7 ++-- packages/core/src/cms/plugin.ts | 13 +++---- packages/core/src/compliance/plugin.ts | 14 +++----- .../src/engagement/chat-commands/plugin.ts | 13 +++---- packages/core/src/engagement/chat/plugin.ts | 13 +++---- .../src/engagement/notifications/plugin.ts | 13 +++---- packages/core/src/iam/plugin.ts | 14 +++----- packages/core/src/pam/identity/plugin.ts | 14 +++----- .../core/src/pam/player-management/plugin.ts | 13 +++---- packages/core/src/pam/player-note/plugin.ts | 7 ++-- packages/core/src/pam/profile/plugin.ts | 7 ++-- packages/core/src/pam/tag/plugin.ts | 7 ++-- .../__tests__/core-plugins.test.ts | 2 +- .../__tests__/typed-plugin.test.ts | 17 ++++++---- .../src/server/plugin-host/core-plugins.ts | 34 +++++++++---------- .../src/server/plugin-host/define-plugin.ts | 30 +++------------- packages/core/src/server/plugin-host/index.ts | 2 -- .../src/server/plugin-host/load-plugins.ts | 2 +- .../runtime/__tests__/create-app.test.ts | 13 ++++--- .../src/server/runtime/core-token-catalog.ts | 6 ++-- .../core/src/server/runtime/create-app.ts | 30 +++------------- packages/core/src/server/runtime/index.ts | 8 +---- packages/core/src/server/runtime/openapi.ts | 3 +- packages/core/src/wallet/plugin.ts | 13 +++---- .../fixtures/test-kyc-config-plugin.ts | 6 ++-- ...allet-auto-withdrawal-cap-config-plugin.ts | 6 ++-- ...st-wallet-auto-withdrawal-config-plugin.ts | 6 ++-- packages/testing/src/app.ts | 19 ++++++----- .../__dot__rulesync/subagents/builder.md | 6 ++-- 56 files changed, 203 insertions(+), 300 deletions(-) diff --git a/.rulesync/commands/release-plugin.md b/.rulesync/commands/release-plugin.md index 8bcdb984..b71ed418 100644 --- a/.rulesync/commands/release-plugin.md +++ b/.rulesync/commands/release-plugin.md @@ -1,12 +1,12 @@ --- targets: - '*' -description: 'Validate an overlay plugin against the definePlugin contract, build it, and optionally publish to npm. Arg: path to the plugin directory (eg extensions/my-plugin).' +description: 'Validate an overlay plugin object, build it, and optionally publish to npm. Arg: path to the plugin directory (eg extensions/my-plugin).' --- Given the plugin path from $ARGUMENTS: -1. Read `plugin.ts` - it must export `definePlugin({ id, register })`. +1. Read `plugin.ts` - it must default-export `{ id, register } satisfies Plugin`. 2. `pnpm verify --filter ` - types and tests pass. 3. Check `AGENTS.md` exists and is filled in (not the template). 4. `pnpm -F build`. diff --git a/.rulesync/rules/messaging-and-microservices.md b/.rulesync/rules/messaging-and-microservices.md index e09b4a0b..83519022 100644 --- a/.rulesync/rules/messaging-and-microservices.md +++ b/.rulesync/rules/messaging-and-microservices.md @@ -72,7 +72,7 @@ The `EventBus` wraps every emission at the broker boundary; module code never bu - `orderingKey` - Kafka partition key / RabbitMQ routing for per-user ordering. - `schemaVersion` - forward-compatible payload evolution. `traceId` - correlation. -Because the envelope isolates transport from domain logic, binding a durable broker is an overlay swap (a `definePlugin` re-providing `MESSAGE_BROKER`) and extracting a module needs no module edits. Migration path: Redis Streams (default, `REDIS_URL`) -> RabbitMQ/Kafka (a consumer overlay implementing `MessageBrokerAdapter`); `topic` maps to routing key/topic, `orderingKey` to partition key, `consumerGroup` to durable queue/consumer group, `eventId` to dedup. Swap when you need what Streams lacks: partitioned ordering (`orderingKey` is not honoured), unbounded retention (Streams trim at `STREAM_MAXLEN`), or a dead-letter queue. `AMQP_URL`/`RABBITMQ_URL` do NOT bind a broker - core ships no AMQP driver; they only enable the transactional outbox, same as `OUTBOX_ENABLED`. +Because the envelope isolates transport from domain logic, binding a durable broker is an overlay swap (a plugin object re-providing `MESSAGE_BROKER`) and extracting a module needs no module edits. Migration path: Redis Streams (default, `REDIS_URL`) -> RabbitMQ/Kafka (a consumer overlay implementing `MessageBrokerAdapter`); `topic` maps to routing key/topic, `orderingKey` to partition key, `consumerGroup` to durable queue/consumer group, `eventId` to dedup. Swap when you need what Streams lacks: partitioned ordering (`orderingKey` is not honoured), unbounded retention (Streams trim at `STREAM_MAXLEN`), or a dead-letter queue. `AMQP_URL`/`RABBITMQ_URL` do NOT bind a broker - core ships no AMQP driver; they only enable the transactional outbox, same as `OUTBOX_ENABLED`. ## Deployable topology - the service manifest (ADR-0017) diff --git a/.rulesync/rules/overview.md b/.rulesync/rules/overview.md index 1a63b41f..ca9481b7 100644 --- a/.rulesync/rules/overview.md +++ b/.rulesync/rules/overview.md @@ -24,7 +24,7 @@ Before acting on any non-trivial request - and before delegating - run the `enha 1. **Zod-first contracts.** Every shape is a Zod schema; types are `z.infer`'d, never hand-written. Cross-cutting schemas in `packages/core/src/contracts/schemas/`; each module OWNS its route contract + req/res schemas + `z.infer`'d types in its `contract/` dir - the single source of wire truth, nothing else re-declares a wire shape. `composeContract` (`@openora/core/contracts`) owns only `health`; the composition root (`tools/gen/build-contract.ts` here, the consumer's entry when deployed) composes each enabled module's `/contract` slice into the one runtime contract the SDK links against. ADR-0021/0025. 2. **oRPC + Hono.** oRPC owns route definition + Zod validation + OpenAPI emit; its `OpenAPIHandler` mounts on a Hono server. DI is a functional `Container` (`@openora/core/server`) - typed-token factories, no decorators, no `reflect-metadata`. ADR-0009. -3. **Plugin host.** `definePlugin({ id, dependsOn, register })` is the only way new functionality enters. Everything (core modules included) loads through `extensions.config.ts`. +3. **Plugin host.** Typed plugin objects are the only way new functionality enters. Everything (core modules included) loads through `extensions.config.ts`. 4. **Headless.** Backend modules + contracts + SDK surface only. UI lives in the consumer, which imports `@openora/core/react` (hooks, typed client, auth, realtime). No UI packages here. 5. **Explicit > magic.** No auto-discovery, no decorators. Everything greppable; every wiring point a typed call. 6. **AI-first.** Every module has an `AGENTS.md`; every scaffold a command; contracts queryable via the `oss-dev` MCP server + generated `docs/catalog.json`. @@ -42,7 +42,7 @@ packages/ core/ # @openora/core - THE single published package (ADR-0025). Subpaths: src/contracts/ # isomorphic: composeContract + healthContract, base zod schemas (schemas/), adapter interfaces + DI tokens (adapters/) src/react/ # domain-agnostic SDK: createClient, typed client, auth, realtime. No UI. - src/server/ # node engine: kernel (logger, EventBus + EVENT_BUS, Container), plugin-host (definePlugin, ModuleRegistry, loader), db (DrizzleService), auth (better-auth + AdminGuard), runtime (createApp - domain-agnostic, single-tenant). Subpaths: /orm, /migrate + src/server/ # node engine: kernel (logger, EventBus + EVENT_BUS, Container), plugin-host (Plugin, ModuleRegistry, loader), db (DrizzleService), auth (better-auth + AdminGuard), runtime (createApp - domain-agnostic, single-tenant). Subpaths: /orm, /migrate src/compliance/ # sealed-token list + assertSealedServicesBound (engine); also the compliance domain (/contracts, /schema, /plugins) src// # 9 folded domains (casino, cms, compliance, engagement, pam, wallet, iam, audit, admin-console), exposed as @openora/core//{contracts,schema,plugins,server,react}. The BARE root (@openora/core/) is the public consumer surface: an isomorphic contract barrel (schemas, enum triples, z.infer types; multi-slice domains namespace per slice, eg `import { chat } from '@openora/core/engagement'`) - never server code. Services/routers/plugin live under /server; tables under /schema. A domain imports engine zones + a sibling's read-only /schema only - never a sibling's internals. src///drizzle/ # each module owns its drizzle.config.ts + migrations/ history (ADR-0027); scripts/generate-all.mjs runs them all diff --git a/.rulesync/subagents/module-author.md b/.rulesync/subagents/module-author.md index d37522a1..7cc651dd 100644 --- a/.rulesync/subagents/module-author.md +++ b/.rulesync/subagents/module-author.md @@ -46,7 +46,7 @@ Creates the module as a standalone package with all required files and registers | `service/.service.ts` | Business logic as plain async methods. No HTTP concepts. Inject `DrizzleService` + `EventBus`. | | `adapters//` | Impls of any adapter ports (port + token in `packages/core/src/contracts/adapters/`). | | `router/index.ts` | Thin oRPC wiring; admin routes call `await adminGuard.assert(context)` first. | -| `plugin.ts` | `definePlugin` - DI wiring only. | +| `plugin.ts` | `Plugin` object - DI wiring only. | | `AGENTS.md` | ONLY what code can't say: invariants, rationale, gotchas, extension seams. No route/table/layout listings - they duplicate `contract/`/`schema/` and drift. | Headless repo: build no UI. After filling in: `pnpm regen` (migration + OpenAPI + catalog), then `pnpm verify` and fix everything. diff --git a/.rulesync/subagents/plugin-author.md b/.rulesync/subagents/plugin-author.md index 8ac7b974..86768691 100644 --- a/.rulesync/subagents/plugin-author.md +++ b/.rulesync/subagents/plugin-author.md @@ -43,7 +43,7 @@ ctx.jobs.worker({ queue, schema, handler, onDeadLetter }); // process JOB_QUEUE ctx.mcp.tool(definition); // expose a new MCP tool ``` -No decorators, no controllers - `definePlugin({ id, dependsOn, register })` wired by the functional Container (ADR-0009). DB tables: a `pgTable` in the plugin's own `schema/index.ts`, then `pnpm regen`. +No decorators, no controllers - `{ id, dependsOn, register } satisfies Plugin` wired by the functional Container (ADR-0009). DB tables: a `pgTable` in the plugin's own `schema/index.ts`, then `pnpm regen`. ## Swapping a vendor adapter diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28bfec89..de65c01b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -149,7 +149,7 @@ generator and fails on an uncommitted diff. So if you touched schemas or routes, - Anything that touches the database is tested against real Postgres (`createTestDb` from `@openora/core/testing`), never a faked query builder. Only external vendors and cross-module ports are doubled. -- New functionality enters only via `definePlugin`. No auto-discovery, no magic. +- New functionality enters only via a plugin object. No auto-discovery, no magic. - ASCII only in code. Short dashes (-) only. - Don't hand-edit generated files: drizzle migrations, `docs/openapi.json`, `docs/catalog.json`, and the rulesync-generated agent files (`AGENTS.md`, `CLAUDE.md`, `.codex/config.toml`, diff --git a/GEMINI.md b/GEMINI.md index f0cbbcaf..220501b4 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -33,7 +33,7 @@ Before acting on any non-trivial request - and before delegating to an agent - r 1. **Zod-first contracts.** Every shape is a Zod schema; types are `z.infer`'d, never hand-written. Cross-cutting schemas in `packages/core/src/contracts/schemas/`; each module OWNS its route contract + req/res schemas + `z.infer`'d types in its `contract/` dir - the single source of wire truth, nothing else re-declares a wire shape. `composeContract` (`@openora/core/contracts`) owns only `health`; the composition root (`tools/gen/build-contract.ts` here, the consumer's entry when deployed) composes each enabled module's `/contract` slice into the one runtime contract the SDK links against. ADR-0021/0025. 2. **oRPC + Hono.** oRPC owns route definition + Zod validation + OpenAPI emit; its `OpenAPIHandler` mounts on a Hono server. DI is a functional `Container` (`@openora/core/server`) - typed-token factories, no decorators, no `reflect-metadata`. ADR-0009. -3. **Plugin host.** `definePlugin({ id, dependsOn, register })` is the only way new functionality enters. Everything (core modules included) loads through `extensions.config.ts`. +3. **Plugin host.** Typed plugin objects are the only way new functionality enters. Everything (core modules included) loads through `extensions.config.ts`. 4. **Headless.** Backend modules + contracts + SDK surface only. UI lives in the consumer, which imports `@openora/core/react` (hooks, typed client, auth, realtime). No UI packages here. 5. **Explicit > magic.** No auto-discovery, no decorators. Everything greppable; every wiring point a typed call. 6. **AI-friendly.** Every module has an `AGENTS.md`; every scaffold a command; contracts queryable via the `oss-dev` MCP server + generated `docs/catalog.json`. @@ -52,7 +52,7 @@ packages/ core/ # @openora/core - THE single published package (ADR-0025). Subpaths: src/contracts/ # isomorphic: composeContract + healthContract, base zod schemas (schemas/), adapter interfaces + DI tokens (adapters/) src/react/ # domain-agnostic SDK: createClient, typed client, auth, realtime. No UI. - src/server/ # node engine: kernel (logger, EventBus + EVENT_BUS, Container), plugin-host (definePlugin, ModuleRegistry, loader), db (DrizzleService), auth (better-auth + AdminGuard), runtime (createApp - domain-agnostic, single-tenant). Subpaths: /orm, /migrate + src/server/ # node engine: kernel (logger, EventBus + EVENT_BUS, Container), plugin-host (Plugin, ModuleRegistry, loader), db (DrizzleService), auth (better-auth + AdminGuard), runtime (createApp - domain-agnostic, single-tenant). Subpaths: /orm, /migrate src/compliance/ # sealed-token list + assertSealedServicesBound (engine); also the compliance domain (/contracts, /schema, /plugins) src// # 9 folded domains (casino, cms, compliance, engagement, pam, wallet, iam, audit, admin-console), exposed as @openora/core//{contracts,schema,plugins,server,react}. The BARE root (@openora/core/) is the public consumer surface: an isomorphic contract barrel (schemas, enum triples, z.infer types; multi-slice domains namespace per slice, eg `import { chat } from '@openora/core/engagement'`) - never server code. Services/routers/plugin live under /server; tables under /schema. A domain imports engine zones + a sibling's read-only /schema only - never a sibling's internals. src///drizzle/ # each module owns its drizzle.config.ts + migrations/ history (ADR-0027); scripts/generate-all.mjs runs them all diff --git a/README.md b/README.md index 67b9a5cf..f03acb8a 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The platform ships the backend surface (auth, wallet, player management, complia ## Highlights - **Headless by design** - backend modules, contracts, and an SDK consumption surface only. No UI ships here; you own the frontend. -- **Plugin host** - `definePlugin({ id, dependsOn, register })` is the single way new functionality enters the system. Overlay a folder or install an npm package; same contract. +- **Plugin host** - typed plugin objects are the single way new functionality enters the system. Overlay a folder or install an npm package; same contract. - **Zod-first contracts** - every shape is a Zod schema; types are inferred, never hand-written. Routes are oRPC on Hono with OpenAPI emitted at build time. - **Explicit wiring** - a small functional DI container with typed tokens. No decorators, no auto-discovery; everything is greppable. - **Swappable vendor seams** - PSP, KYC, aggregator, chat, realtime transport, job queue, and message broker are ports with default in-process drivers and adapter overrides. @@ -108,13 +108,13 @@ Generates the module under `packages/core/src///`, wires its domai ### Add an extension (overlay plugin) -Drop a folder under `extensions//` or point to an npm package. Both use the same `definePlugin` contract: +Drop a folder under `extensions//` or point to an npm package. Both use the same plugin-object contract: ```typescript // extensions/my-feature/plugin.ts -import { definePlugin } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; -export default definePlugin({ +export default { id: 'my-feature', dependsOn: ['identity', 'wallet'], // optional load-order hint register(ctx) { @@ -123,7 +123,7 @@ export default definePlugin({ ctx.events.on('wallet.deposit.completed', handler); ctx.mcp.tool({ name: 'my-tool', description: '...', handler }); }, -}); +} as const satisfies Plugin; ``` Then register it in `extensions.config.ts`. diff --git a/docs/adapters/error-tracking.md b/docs/adapters/error-tracking.md index 829d0698..5be5dbb9 100644 --- a/docs/adapters/error-tracking.md +++ b/docs/adapters/error-tracking.md @@ -53,10 +53,10 @@ provide it; loading the overlay last makes its binding win. ```ts // extensions/sentry/plugin.ts (consumer) import { ERROR_TRACKING } from '@openora/core/contracts'; -import { definePlugin } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import * as Sentry from '@sentry/node'; -export default definePlugin({ +export default { id: 'sentry', register(ctx) { const dsn = process.env['SENTRY_DSN']; @@ -74,7 +74,7 @@ export default definePlugin({ }, })); }, -}); +} as const satisfies Plugin; ``` Register it last in `extensions.config.ts` (a `kind: 'infra'` overlay). A PostHog/Rollbar shop diff --git a/docs/adapters/kyc.md b/docs/adapters/kyc.md index 0f489bbf..90ada3f7 100644 --- a/docs/adapters/kyc.md +++ b/docs/adapters/kyc.md @@ -98,16 +98,16 @@ export class SumsubKycAdapter implements KycAdapter { ```ts // extensions/sumsub-kyc/plugin.ts import { KYC_ADAPTER } from '@openora/core/contracts'; -import { definePlugin } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { SumsubKycAdapter } from './src/sumsub-kyc-adapter.js'; -export default definePlugin({ +export default { id: 'sumsub-kyc', dependsOn: ['identity'], register(ctx) { ctx.provide(KYC_ADAPTER, () => new SumsubKycAdapter()); }, -}); +} as const satisfies Plugin; ``` 4. Register in `extensions.config.ts` **after** the `identity` entry. @@ -182,11 +182,11 @@ export class HostedKycAdapter implements KycAdapter { ```ts // extensions/hosted-kyc/plugin.ts import { KYC_ADAPTER, KYC_WEBHOOK_VERIFIER } from '@openora/core/contracts'; -import { definePlugin } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { HostedKycAdapter } from './src/hosted-kyc-adapter.js'; import { HostedKycWebhookVerifier } from './src/hosted-kyc-webhook-verifier.js'; -export default definePlugin({ +export default { id: 'hosted-kyc', dependsOn: ['identity'], register(ctx) { @@ -196,7 +196,7 @@ export default definePlugin({ () => new HostedKycWebhookVerifier(process.env.HOSTED_KYC_WEBHOOK_SECRET), ); }, -}); +} as const satisfies Plugin; ``` 4. Register in `extensions.config.ts` **after** the `identity` entry. The consumer's diff --git a/docs/adapters/notification.md b/docs/adapters/notification.md index 53ed11a9..2bc79035 100644 --- a/docs/adapters/notification.md +++ b/docs/adapters/notification.md @@ -37,16 +37,16 @@ export class MyEmailAdapter implements NotificationDeliveryAdapter { ```ts // apps/api/src/extensions/email-delivery/plugin.ts import { NOTIFICATION_DELIVERY_ADAPTER } from '@openora/core/contracts'; -import { definePlugin } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { MyEmailAdapter } from './src/my-email-adapter.js'; -export default definePlugin({ +export default { id: 'email-delivery', dependsOn: ['notifications'], register(ctx) { ctx.provide(NOTIFICATION_DELIVERY_ADAPTER, () => new MyEmailAdapter()); }, -}); +} as const satisfies Plugin; ``` 4. Register in `extensions.config.ts` **after** the `notifications` entry. diff --git a/docs/adapters/payment.md b/docs/adapters/payment.md index ee2e2bdb..468bf9ce 100644 --- a/docs/adapters/payment.md +++ b/docs/adapters/payment.md @@ -95,17 +95,17 @@ export class CustodyPaymentAdapter implements PaymentAdapter { ```ts // extensions/custody-payment/plugin.ts import { PAYMENT_ADAPTER, PAYMENT_WEBHOOK_VERIFIER } from '@openora/core/contracts'; -import { definePlugin } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { CustodyPaymentAdapter } from './src/custody-payment-adapter.js'; -export default definePlugin({ +export default { id: 'custody-payment', dependsOn: ['wallet'], register(ctx) { ctx.provide(PAYMENT_ADAPTER, () => new CustodyPaymentAdapter()); // Omit this line to keep the default HmacPaymentWebhookVerifier (PAYMENT_WEBHOOK_SECRET env var). }, -}); +} as const satisfies Plugin; ``` 4. Register in `extensions.config.ts` **after** the `wallet` entry. diff --git a/docs/architecture.md b/docs/architecture.md index e9e54d11..08010ef0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,7 +31,7 @@ flowchart TB subgraph runtime["API runtime (consumer's createApp() entry)"] cfg["extensions.config.ts
plugin registry"] - host["plugin-host
definePlugin / ModuleRegistry"] + host["plugin-host
Plugin / ModuleRegistry"] container["Container
functional composition (tokens -> factories)"] hono["Hono + oRPC OpenAPIHandler
validation, OpenAPI emit"] cfg --> host @@ -93,7 +93,7 @@ Solid arrows are runtime/build dependencies; dashed arrows are **adapter seams** **API runtime** - **extensions.config.ts** - the one list of enabled plugins (modules + overlays). The only place wiring is turned on. -- **plugin-host** - `definePlugin({ id, dependsOn, register })` + `ModuleRegistry`. In `register(ctx)` a plugin binds providers (`ctx.provide(token, factory)`), mounts routers (`ctx.routers.add(namespace, (c) => router)`), subscribes to events, and registers MCP tools. Overlays add their own `pgTable` in their module's `schema/index.ts`. ADR-0002. +- **plugin-host** - `Plugin` + `ModuleRegistry`. In `register(ctx)` a plugin binds providers (`ctx.provide(token, factory)`), mounts routers (`ctx.routers.add(namespace, (c) => router)`), subscribes to events, and registers MCP tools. Overlays add their own `pgTable` in their module's `schema/index.ts`. ADR-0002. - **Hono + oRPC** - oRPC defines routes and validates I/O against the Zod contract; its `OpenAPIHandler` is mounted on a Hono server and emits `docs/openapi.json`. Dependency wiring is a small **functional composition `Container`** (`@openora/core/server`): typed-token factories, lazy + last-wins, no decorators. Downstream consumers call `createApp()` from `@openora/core/server` to boot their API entry. ADR-0009. **Engine** (`@openora/core/server`) - the node runtime, all under one subpath: `db` (Drizzle client, drizzle-kit migrations, `DrizzleService`, the framework-free `@openora/core/server/orm` re-export), `auth` (better-auth + the shared `AdminGuard`), `kernel` (logger, typed `EventBus`, composition `Container`), `plugin-host` (the plugin loader), and `createApp()` - which is domain-agnostic (the consumer injects the PAM identity schema, ADR-0025/0026: single-tenant, no resolveTenant). @@ -123,7 +123,7 @@ These are the swap points - the reason the platform is "headless" and extensible | Seam | Interface side | Implementation side | Swap to... | | -------------- | ------------------------------------- | ------------------------------------------------- | ------------------------------------------------ | -| Plugin host | `definePlugin` contract | a module or overlay folder | add/remove features without touching core | +| Plugin host | `Plugin` object contract | a module or overlay folder | add/remove features without touching core | | Vendor adapter | `@openora/core/contracts` (interface) | impl under `//adapters//` | a different PSP, KYC, or aggregator | | Consumer link | `createApp()` + `@openora/core` | the consumer's own `apps/api` entry | publish to npm and bump the tag (no code change) | diff --git a/docs/core-concepts.md b/docs/core-concepts.md index bfd0efe1..8e4f1aa9 100644 --- a/docs/core-concepts.md +++ b/docs/core-concepts.md @@ -73,17 +73,18 @@ export function createWalletRouter(wallet: WalletService) { ## Plugins -`definePlugin` is the only way new functionality enters the system. In `register(ctx)` you bind +Typed plugin objects are the only way new functionality enters the system. In `register(ctx)` you bind adapters, add routers, subscribe to events, and register MCP tools. ```ts -import { definePlugin, EVENT_BUS, DRIZZLE } from '@openora/core/server'; +import { EVENT_BUS, DRIZZLE } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { PAYMENT_ADAPTER } from '@openora/core/contracts'; import { WalletService } from './service/wallet.service.js'; import { createWalletRouter } from './router/index.js'; import { MockPaymentAdapter } from './adapters/mock/mock-payment-adapter.js'; -export default definePlugin({ +export default { id: 'wallet', dependsOn: [], // optional load-order hints register(ctx) { @@ -94,7 +95,7 @@ export default definePlugin({ ), ); }, -}); +} as const satisfies Plugin; ``` ## Ports & adapters diff --git a/docs/downstream-consumer.md b/docs/downstream-consumer.md index 76e0f9a6..adb12b9d 100644 --- a/docs/downstream-consumer.md +++ b/docs/downstream-consumer.md @@ -55,17 +55,15 @@ import { extensions } from './extensions.config.js'; // their own plugin list // Compose only the modules you enable (composeContract adds `health` itself). const contract = composeContract({ identity: identityContract, wallet: walletContract }); -const { listen, emitOpenApiSpec } = await createApp({ +const { listen } = await createApp({ plugins: extensions, contract, authSchema: { user, session, account, verification, twoFactor }, port: 3001, cors: { origins: ['https://my-igaming.example'] }, - openapi: { info: { title: 'my-igaming API', version: '1.0.0' } }, }); await listen(); -await emitOpenApiSpec(); ``` Downstream consumers create their own thin entrypoint that calls `createApp` and bring diff --git a/docs/glossary.md b/docs/glossary.md index dd7d5659..291e895e 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -19,7 +19,7 @@ Shared vocabulary for this repo: the **roles** (who's who), the **platform/archi | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | **Headless** | The platform ships backend only - modules, contracts, API, and SDK consumption surface. The operator supplies the entire frontend (pages, components, styling, theme) in their own repo. | `packages/core/src//*`, `@openora/core/react` | | **Module** | A business domain packaged as an independently loadable unit (auth, wallet, gaming...). Never imports another module. | `packages/core/src///` | -| **Plugin** / **extension** / **overlay** | A drop-in unit that adds or overrides behavior via `definePlugin({ id, register })`. The only way new functionality enters the system. | `extensions/*`, consumer plugins | +| **Plugin** / **extension** / **overlay** | A drop-in object that adds or overrides behavior via `{ id, register } satisfies Plugin`. The only way new functionality enters the system. | `extensions/*`, consumer plugins | | **Adapter** | The vendor-agnostic interface a module depends on (e.g. `KycAdapter`, `PaymentAdapter`) plus its per-vendor implementations. The swap seam: a module declares the interface + DI token in `@openora/core/contracts`; an operator binds a concrete impl. | `@openora/core/contracts` + `//adapters//` | | **Contract** | The composed oRPC router. Drives request validation, the typed client, and the emitted OpenAPI spec. | `@openora/core/contracts` | | **Domain schema** | A Zod schema - the single source of truth for a shape. Types are `z.infer`'d, never hand-written. | `@openora/core/contracts`, module `schemas/` | diff --git a/docs/introduction.md b/docs/introduction.md index 364b3bf3..ab694366 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -12,7 +12,7 @@ repo and talks to the API over HTTP. - **Headless** - backend modules, contracts, and an SDK only. Bring your own UI. - **Contract-first** - every shape is a Zod schema; types are inferred, never hand-written. oRPC turns a schema into a validated route plus OpenAPI. -- **Plugin host** - new functionality enters through `definePlugin`. No forking, no decorator +- **Plugin host** - new functionality enters through typed plugin objects. No forking, no decorator magic; every wiring point is an explicit, typed function call. - **Swappable seams** - payments, KYC, messaging, realtime, and jobs are ports you bind to any vendor. diff --git a/docs/system-design.md b/docs/system-design.md index bff4b199..4072d40d 100644 --- a/docs/system-design.md +++ b/docs/system-design.md @@ -60,7 +60,7 @@ flowchart TB %% ============ ENGINE (@openora/core/server) ============ subgraph RT["@openora/core/server · createApp() (node engine)"] - PH["plugin-host
definePlugin · ModuleRegistry · applyServiceManifest"] + PH["plugin-host
Plugin · ModuleRegistry · applyServiceManifest"] DI["Container
tokens to factories (last-wins overlay)"] HONO["Hono + oRPC OpenAPIHandler
validation · OpenAPI emit"] GATE["SERVICE_MANIFEST module filter"] diff --git a/packages/core/README.md b/packages/core/README.md index 5556a986..7cb71fc3 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -21,16 +21,16 @@ The rolling `canary` dev channel (`@openora/core@canary`) publishes on every `de ## What's inside (subpath exports) -| Subpath | Surface | -| -------------------------------- | ---------------------------------------------------------------------------------------------- | -| `@openora/core/contracts` | Isomorphic Zod schemas, contract composition, adapter ports + DI tokens | -| `@openora/core/server` | The engine: `createApp`, plugin host (`definePlugin`), DI container, EventBus, Drizzle service | -| `@openora/core/react` | Typed client, auth, realtime hooks for your frontend (no UI components) | -| `@openora/core/` | A domain's public contract surface, eg `wallet`, `compliance`, `engagement` | -| `@openora/core//schema` | Read-only Drizzle tables for cross-module reads | -| `@openora/core//plugins` | The domain's plugin entry for your composition root | - -Everything enters through plugins - `definePlugin({ id, dependsOn, register })` - wired explicitly in your app's `extensions.config.ts`. No decorators, no auto-discovery. +| Subpath | Surface | +| -------------------------------- | ---------------------------------------------------------------------------------------- | +| `@openora/core/contracts` | Isomorphic Zod schemas, contract composition, adapter ports + DI tokens | +| `@openora/core/server` | The engine: `createApp`, plugin host (`Plugin`), DI container, EventBus, Drizzle service | +| `@openora/core/react` | Typed client, auth, realtime hooks for your frontend (no UI components) | +| `@openora/core/` | A domain's public contract surface, eg `wallet`, `compliance`, `engagement` | +| `@openora/core//schema` | Read-only Drizzle tables for cross-module reads | +| `@openora/core//plugins` | The domain's plugin entry for your composition root | + +Everything enters through typed plugin objects - `{ id, dependsOn, register } satisfies Plugin` - wired explicitly in your app's `extensions.config.ts`. No decorators, no auto-discovery. ## Principles diff --git a/packages/core/generators/src/templates/adapter.hbs b/packages/core/generators/src/templates/adapter.hbs index 1ce542d2..f5abadbb 100644 --- a/packages/core/generators/src/templates/adapter.hbs +++ b/packages/core/generators/src/templates/adapter.hbs @@ -1,4 +1,4 @@ -import { definePlugin } from '@openora/plugin-host'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { {{token}}, type {{pascalCase token}} } from '@openora/adapters'; // Overlay that swaps the {{token}} binding. Registered AFTER `{{dependsOn}}` (which @@ -10,10 +10,10 @@ function create{{pascalCase name}}Adapter(): {{pascalCase token}} { throw new Error('{{kebabCase name}} adapter is not implemented yet'); } -export default definePlugin({ +export default { id: '{{kebabCase name}}', dependsOn: ['{{dependsOn}}'], register(ctx) { ctx.provide({{token}}, () => create{{pascalCase name}}Adapter()); }, -}); +} as const satisfies Plugin; diff --git a/packages/core/generators/src/templates/job-worker.hbs b/packages/core/generators/src/templates/job-worker.hbs index 7a0f8579..a82003e2 100644 --- a/packages/core/generators/src/templates/job-worker.hbs +++ b/packages/core/generators/src/templates/job-worker.hbs @@ -1,4 +1,4 @@ -import { definePlugin } from '@openora/plugin-host'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { queue } from '@openora/adapters'; import * as z from 'zod'; @@ -13,7 +13,7 @@ const {{camelCase name}}Payload = z.object({ id: z.string(), }); -export default definePlugin({ +export default { id: '{{kebabCase name}}-worker', register(ctx) { ctx.jobs.worker({ @@ -31,4 +31,4 @@ export default definePlugin({ }, }); }, -}); +} as const satisfies Plugin; diff --git a/packages/core/generators/src/templates/module/plugin.hbs b/packages/core/generators/src/templates/module/plugin.hbs index 658ac6f4..bcc0f07f 100644 --- a/packages/core/generators/src/templates/module/plugin.hbs +++ b/packages/core/generators/src/templates/module/plugin.hbs @@ -1,11 +1,12 @@ -import { definePlugin, EVENT_BUS, DRIZZLE } from '@openora/core/server'; +import { EVENT_BUS, DRIZZLE } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { {{pascalCase name}}Service } from './service/{{kebabCase name}}.service.js'; import { create{{pascalCase name}}Router } from './router/index.js'; // DI wiring only - no business logic here. The router factory resolves deps from // the container at boot (after every plugin registered), so an overlay can rebind // an adapter token (last registration wins) without a fork. -export default definePlugin({ +export default { id: '{{kebabCase name}}', // dependsOn: ['identity'], // declare deps so the loader boots them first register(ctx) { @@ -15,4 +16,4 @@ export default definePlugin({ create{{pascalCase name}}Router(new {{pascalCase name}}Service(c.get(DRIZZLE), c.get(EVENT_BUS))), ); }, -}); +} as const satisfies Plugin; diff --git a/packages/core/generators/src/templates/plugin.hbs b/packages/core/generators/src/templates/plugin.hbs index 3138bf97..eddb1c5e 100644 --- a/packages/core/generators/src/templates/plugin.hbs +++ b/packages/core/generators/src/templates/plugin.hbs @@ -1,4 +1,4 @@ -import { definePlugin } from '@openora/plugin-host'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; // Overlay plugin. Registered in extensions.config.ts (auto-wired in the OSS repo; // in a consumer repo add it to your apps/api/src/extensions.config.ts). @@ -10,11 +10,11 @@ import { definePlugin } from '@openora/plugin-host'; // ctx.jobs.worker({ queue, schema, handler }) - register a background-job worker // ctx.slots.fill(slotName, component) - contribute a UI slot // ctx.mcp.tool({ name, description, inputSchema, handler }) - expose an MCP tool -export default definePlugin({ +export default { id: '{{kebabCase name}}', // dependsOn: ['wallet'], // load after the plugin that owns a token you rebind register(ctx) { void ctx; // AGENT: implement here }, -}); +} as const satisfies Plugin; diff --git a/packages/core/src/admin-console/plugin.ts b/packages/core/src/admin-console/plugin.ts index 049dd4ad..de47f6a3 100644 --- a/packages/core/src/admin-console/plugin.ts +++ b/packages/core/src/admin-console/plugin.ts @@ -5,11 +5,12 @@ import { ADMIN_WALLET_REPORTING, AUDIT_WRITER, } from '@openora/core/contracts'; -import { definePlugin, ADMIN_GUARD, CORE_TOKEN_CATALOG } from '@openora/core/server'; +import { ADMIN_GUARD } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { BackofficeService } from './service/backoffice.service.js'; import { createBackofficeRouter } from './router/index.js'; -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { id: 'admin-console', dependsOn: ['identity', 'wallet', 'audit', 'gaming', 'iam'], register(ctx) { @@ -26,4 +27,4 @@ export default definePlugin(CORE_TOKEN_CATALOG, { ), ); }, -}); +} as const satisfies Plugin; diff --git a/packages/core/src/analytics/plugin.ts b/packages/core/src/analytics/plugin.ts index 8462be89..54dd8740 100644 --- a/packages/core/src/analytics/plugin.ts +++ b/packages/core/src/analytics/plugin.ts @@ -1,10 +1,11 @@ import { CACHE } from '@openora/core/contracts'; -import { definePlugin, ADMIN_GUARD, DRIZZLE, CORE_TOKEN_CATALOG } from '@openora/core/server'; +import { ADMIN_GUARD, DRIZZLE } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { FinancialAnalyticsService } from './service/financial-analytics.service.js'; import { FunnelAnalyticsService } from './service/funnel-analytics.service.js'; import { createAnalyticsRouter } from './router/index.js'; -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { id: 'analytics', dependsOn: ['wallet', 'identity', 'profile', 'gaming'], register(ctx) { @@ -16,4 +17,4 @@ export default definePlugin(CORE_TOKEN_CATALOG, { ), ); }, -}); +} as const satisfies Plugin; diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index 6c6a11aa..0609f172 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -1,11 +1,5 @@ -import { - definePlugin, - EVENT_BUS, - DRIZZLE, - ADMIN_GUARD, - createLogger, - CORE_TOKEN_CATALOG, -} from '@openora/core/server'; +import { EVENT_BUS, DRIZZLE, ADMIN_GUARD, createLogger } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { AUDIT_WRITER, type DomainEventName } from '@openora/core/contracts'; import { AuditService, type RecordInput } from './service/audit.service.js'; import { createAuditRouter } from './router/index.js'; @@ -565,7 +559,7 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [ 'player.level.changed', ] as const; -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { id: 'audit', register(ctx) { const logger = createLogger('audit'); @@ -600,4 +594,4 @@ export default definePlugin(CORE_TOKEN_CATALOG, { return createAuditRouter(svc, c.get(ADMIN_GUARD)); }); }, -}); +} as const satisfies Plugin; diff --git a/packages/core/src/casino/gaming/plugin.ts b/packages/core/src/casino/gaming/plugin.ts index e3bed6e6..07592703 100644 --- a/packages/core/src/casino/gaming/plugin.ts +++ b/packages/core/src/casino/gaming/plugin.ts @@ -1,4 +1,5 @@ -import { definePlugin, EVENT_BUS, DRIZZLE, CORE_TOKEN_CATALOG } from '@openora/core/server'; +import { EVENT_BUS, DRIZZLE } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { ADMIN_GAME_REPORTING, GAME_ADAPTER, @@ -12,7 +13,7 @@ import { MockGameAdapter } from './adapters/mock/mock-game-adapter.js'; import { MockRngAdapter } from './adapters/mock/mock-rng-adapter.js'; import { DrizzleAdminGameReporting } from './admin-reporting.js'; -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { id: 'gaming', requiresPorts: [PLAY_ELIGIBILITY], dependsOn: ['wallet'], @@ -32,4 +33,4 @@ export default definePlugin(CORE_TOKEN_CATALOG, { ), ); }, -}); +} as const satisfies Plugin; diff --git a/packages/core/src/casino/lobby/plugin.ts b/packages/core/src/casino/lobby/plugin.ts index 0774fc47..2f448e9c 100644 --- a/packages/core/src/casino/lobby/plugin.ts +++ b/packages/core/src/casino/lobby/plugin.ts @@ -1,13 +1,14 @@ -import { definePlugin, DRIZZLE, CORE_TOKEN_CATALOG } from '@openora/core/server'; +import { DRIZZLE } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { CACHE } from '@openora/core/contracts'; import { LobbyService } from './service/lobby.service.js'; import { createLobbyRouter } from './router/index.js'; -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { id: 'lobby', register(ctx) { ctx.routers.add('lobby', (c) => createLobbyRouter(new LobbyService(c.get(DRIZZLE), c.get(CACHE))), ); }, -}); +} as const satisfies Plugin; diff --git a/packages/core/src/cms/plugin.ts b/packages/core/src/cms/plugin.ts index b850029d..1e8ffb0e 100644 --- a/packages/core/src/cms/plugin.ts +++ b/packages/core/src/cms/plugin.ts @@ -1,15 +1,10 @@ -import { - definePlugin, - EVENT_BUS, - DRIZZLE, - ADMIN_GUARD, - CORE_TOKEN_CATALOG, -} from '@openora/core/server'; +import { EVENT_BUS, DRIZZLE, ADMIN_GUARD } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { CACHE } from '@openora/core/contracts'; import { CmsService } from './service/cms.service.js'; import { createCmsRouter } from './router/index.js'; -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { id: 'cms', register(ctx) { ctx.routers.add('cms', (c) => @@ -19,4 +14,4 @@ export default definePlugin(CORE_TOKEN_CATALOG, { ), ); }, -}); +} as const satisfies Plugin; diff --git a/packages/core/src/compliance/plugin.ts b/packages/core/src/compliance/plugin.ts index bb718775..169cf9f4 100644 --- a/packages/core/src/compliance/plugin.ts +++ b/packages/core/src/compliance/plugin.ts @@ -1,11 +1,5 @@ -import { - definePlugin, - EVENT_BUS, - DRIZZLE, - ADMIN_GUARD, - createLogger, - CORE_TOKEN_CATALOG, -} from '@openora/core/server'; +import { EVENT_BUS, DRIZZLE, ADMIN_GUARD, createLogger } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import * as z from 'zod'; import { ADMIN_USER_DIRECTORY, @@ -58,7 +52,7 @@ const KycDecisionSyncJobSchema = z.object({ receivedAt: z.iso.datetime(), }); -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { id: 'compliance', dependsOn: ['player-management', 'identity', 'wallet', 'gaming', 'audit'], requiresPorts: [LOGIN_ENFORCEMENT], @@ -232,4 +226,4 @@ export default definePlugin(CORE_TOKEN_CATALOG, { }); }); }, -}); +} as const satisfies Plugin; diff --git a/packages/core/src/engagement/chat-commands/plugin.ts b/packages/core/src/engagement/chat-commands/plugin.ts index 7e152b71..45bf38e0 100644 --- a/packages/core/src/engagement/chat-commands/plugin.ts +++ b/packages/core/src/engagement/chat-commands/plugin.ts @@ -1,10 +1,5 @@ -import { - definePlugin, - DRIZZLE, - EVENT_BUS, - ADMIN_GUARD, - CORE_TOKEN_CATALOG, -} from '@openora/core/server'; +import { DRIZZLE, EVENT_BUS, ADMIN_GUARD } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { WALLET_COMMANDS, ADMIN_USER_DIRECTORY, @@ -19,7 +14,7 @@ import { import { ChatCommandsService } from './service/chat-commands.service.js'; import { createChatCommandsRouter } from './router/index.js'; -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { id: 'chat-commands', dependsOn: ['chat', 'wallet', 'iam', 'audit', 'gaming'], register(ctx) { @@ -40,4 +35,4 @@ export default definePlugin(CORE_TOKEN_CATALOG, { return createChatCommandsRouter(svc, c.get(ADMIN_GUARD)); }); }, -}); +} as const satisfies Plugin; diff --git a/packages/core/src/engagement/chat/plugin.ts b/packages/core/src/engagement/chat/plugin.ts index 89654472..7debf876 100644 --- a/packages/core/src/engagement/chat/plugin.ts +++ b/packages/core/src/engagement/chat/plugin.ts @@ -1,10 +1,5 @@ -import { - definePlugin, - EVENT_BUS, - DRIZZLE, - ADMIN_GUARD, - CORE_TOKEN_CATALOG, -} from '@openora/core/server'; +import { EVENT_BUS, DRIZZLE, ADMIN_GUARD } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { CHAT_REALTIME_TRANSPORT, CHAT_REALTIME_CLIENT_AUTHORIZER, @@ -22,7 +17,7 @@ import { createChatRouter } from './router/index.js'; const CHAT_SERVICE = createToken('_ChatService'); -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { id: 'chat', dependsOn: ['identity'], register(ctx) { @@ -55,4 +50,4 @@ export default definePlugin(CORE_TOKEN_CATALOG, { }), ); }, -}); +} as const satisfies Plugin; diff --git a/packages/core/src/engagement/notifications/plugin.ts b/packages/core/src/engagement/notifications/plugin.ts index 4e67229f..c54946a1 100644 --- a/packages/core/src/engagement/notifications/plugin.ts +++ b/packages/core/src/engagement/notifications/plugin.ts @@ -10,13 +10,8 @@ import { type JobQueueAdapter, type NotificationDeliveryAdapter, } from '@openora/core/contracts'; -import { - createLogger, - definePlugin, - EVENT_BUS, - DRIZZLE, - CORE_TOKEN_CATALOG, -} from '@openora/core/server'; +import { createLogger, EVENT_BUS, DRIZZLE } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { MockNotificationDeliveryAdapter } from './adapters/mock/mock-notification-adapter.js'; import { createNotificationsRouter } from './router/index.js'; import { NotificationsService } from './service/notifications.service.js'; @@ -28,7 +23,7 @@ const KycResubmissionNotifyJobSchema = z.object({ reason: z.string().nullable(), }); -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { id: 'notifications', // ADMIN_USER_DIRECTORY (owned by identity) resolves the player's email for the // withdrawal delivery emails; pin load order so a split still finds the port. See ADR-0017. @@ -140,4 +135,4 @@ export default definePlugin(CORE_TOKEN_CATALOG, { return createNotificationsRouter(svc); }); }, -}); +} as const satisfies Plugin; diff --git a/packages/core/src/iam/plugin.ts b/packages/core/src/iam/plugin.ts index 5b6aa7a9..7beda8ff 100644 --- a/packages/core/src/iam/plugin.ts +++ b/packages/core/src/iam/plugin.ts @@ -1,11 +1,5 @@ -import { - definePlugin, - EVENT_BUS, - DRIZZLE, - ADMIN_GUARD, - createLogger, - CORE_TOKEN_CATALOG, -} from '@openora/core/server'; +import { EVENT_BUS, DRIZZLE, ADMIN_GUARD, createLogger } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { ADMIN_PERMISSION_RESOLVER, ADMIN_PLAYER_ACTIVITY, @@ -20,7 +14,7 @@ import { DrizzleAdminPlayerActivity } from './adapters/admin-player-activity.js' const logger = createLogger('iam'); -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { id: 'iam', dependsOn: ['identity'], register(ctx) { @@ -77,4 +71,4 @@ export default definePlugin(CORE_TOKEN_CATALOG, { ), ); }, -}); +} as const satisfies Plugin; diff --git a/packages/core/src/pam/identity/plugin.ts b/packages/core/src/pam/identity/plugin.ts index 980c8307..ec26f620 100644 --- a/packages/core/src/pam/identity/plugin.ts +++ b/packages/core/src/pam/identity/plugin.ts @@ -14,14 +14,8 @@ import { SESSION_COMMANDS, SMS_ADAPTER, } from '@openora/core/contracts'; -import { - definePlugin, - ADMIN_GUARD, - EVENT_BUS, - DRIZZLE, - AUTH_SESSION, - CORE_TOKEN_CATALOG, -} from '@openora/core/server'; +import { ADMIN_GUARD, EVENT_BUS, DRIZZLE, AUTH_SESSION } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { MockKycAdapter } from './adapters/mock/mock-kyc-adapter.js'; import { MockSmsAdapter } from './adapters/mock/mock-sms-adapter.js'; import { PhoneLoginService } from './service/phone-login.service.js'; @@ -34,7 +28,7 @@ import { SessionService } from './service/session.service.js'; import { LoginEnforcementService } from './service/login-enforcement.service.js'; import { PlayEligibilityService } from './service/play-eligibility.service.js'; -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { id: 'identity', register(ctx) { ctx.provide(KYC_ADAPTER, () => new MockKycAdapter()); @@ -96,4 +90,4 @@ export default definePlugin(CORE_TOKEN_CATALOG, { ), ); }, -}); +} as const satisfies Plugin; diff --git a/packages/core/src/pam/player-management/plugin.ts b/packages/core/src/pam/player-management/plugin.ts index 515cc36d..059020f4 100644 --- a/packages/core/src/pam/player-management/plugin.ts +++ b/packages/core/src/pam/player-management/plugin.ts @@ -1,10 +1,5 @@ -import { - definePlugin, - EVENT_BUS, - DRIZZLE, - ADMIN_GUARD, - CORE_TOKEN_CATALOG, -} from '@openora/core/server'; +import { EVENT_BUS, DRIZZLE, ADMIN_GUARD } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { AUDIT_WRITER, KYC_STATUS_WRITER } from '@openora/core/contracts'; import { PlayerService } from './service/player.service.js'; import { PlayerKycStatusWriter } from './service/kyc-status-writer.js'; @@ -12,7 +7,7 @@ import { createPlayerRouter } from './router/index.js'; // Owns the player table writes, so it binds the single KYC_STATUS_WRITER seam // (compliance + the admin override route consume it). Reads identity via /schema. See ADR-0020. -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { id: 'player-management', dependsOn: ['audit'], register(ctx) { @@ -28,4 +23,4 @@ export default definePlugin(CORE_TOKEN_CATALOG, { ), ); }, -}); +} as const satisfies Plugin; diff --git a/packages/core/src/pam/player-note/plugin.ts b/packages/core/src/pam/player-note/plugin.ts index 56cd0141..50041007 100644 --- a/packages/core/src/pam/player-note/plugin.ts +++ b/packages/core/src/pam/player-note/plugin.ts @@ -1,12 +1,13 @@ -import { definePlugin, DRIZZLE, ADMIN_GUARD, CORE_TOKEN_CATALOG } from '@openora/core/server'; +import { DRIZZLE, ADMIN_GUARD } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { PlayerNoteService } from './service/player-note.service.js'; import { createPlayerNoteRouter } from './router/index.js'; -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { id: 'player-note', register(ctx) { ctx.routers.add('player-note', (c) => createPlayerNoteRouter(new PlayerNoteService(c.get(DRIZZLE)), c.get(ADMIN_GUARD)), ); }, -}); +} as const satisfies Plugin; diff --git a/packages/core/src/pam/profile/plugin.ts b/packages/core/src/pam/profile/plugin.ts index 78a9fd82..5941d63f 100644 --- a/packages/core/src/pam/profile/plugin.ts +++ b/packages/core/src/pam/profile/plugin.ts @@ -1,10 +1,11 @@ -import { definePlugin, DRIZZLE, CORE_TOKEN_CATALOG } from '@openora/core/server'; +import { DRIZZLE } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { ProfileService } from './service/profile.service.js'; import { createProfileRouter } from './router/index.js'; -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { id: 'profile', register(ctx) { ctx.routers.add('profile', (c) => createProfileRouter(new ProfileService(c.get(DRIZZLE)))); }, -}); +} as const satisfies Plugin; diff --git a/packages/core/src/pam/tag/plugin.ts b/packages/core/src/pam/tag/plugin.ts index 791cf9f0..c5ea9f9b 100644 --- a/packages/core/src/pam/tag/plugin.ts +++ b/packages/core/src/pam/tag/plugin.ts @@ -1,10 +1,9 @@ import { - definePlugin, EVENT_BUS, DRIZZLE, ADMIN_GUARD, + type Plugin, type TypedContainer, - CORE_TOKEN_CATALOG, type CoreTokenCatalog, } from '@openora/core/server'; import { @@ -22,7 +21,7 @@ import { TagRuleService } from './service/tag-rule.service.js'; import { TagEvaluationService } from './service/tag-evaluation.service.js'; import { createTagRouter } from './router/index.js'; -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { id: 'tag', dependsOn: ['wallet', 'identity'], register(ctx) { @@ -103,4 +102,4 @@ export default definePlugin(CORE_TOKEN_CATALOG, { return createTagRouter(tagSvc, ruleSvcForRouter, c.get(ADMIN_GUARD)); }); }, -}); +} as const satisfies Plugin; diff --git a/packages/core/src/server/plugin-host/__tests__/core-plugins.test.ts b/packages/core/src/server/plugin-host/__tests__/core-plugins.test.ts index 9c7df9ad..34606264 100644 --- a/packages/core/src/server/plugin-host/__tests__/core-plugins.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/core-plugins.test.ts @@ -18,7 +18,7 @@ describe('corePlugins', () => { it('resolves every entry to an importable plugin whose id matches the entry id', async () => { const loaded = await loadDefaults(); for (const { entry, plugin } of loaded) { - expect(plugin.id, `entry ${entry.id} resolves to a definePlugin`).toBe(entry.id); + expect(plugin.id, `entry ${entry.id} resolves to a plugin`).toBe(entry.id); } }, 20000); diff --git a/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts b/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts index cd1e02e8..4357a9e8 100644 --- a/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/typed-plugin.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createToken, type TokenCatalog } from '@openora/core/contracts'; import { Container } from '../../kernel/index.js'; -import { definePlugin, ModuleRegistryImpl } from '../index.js'; +import { ModuleRegistryImpl, type Plugin } from '../index.js'; const COUNT = createToken('COUNT'); const SEED = createToken('SEED'); @@ -39,9 +39,10 @@ function assertTypedContainer() { void assertTypedContainer; -const typedPlugin = definePlugin(catalog, { +const typedPlugin = { id: 'typed-plugin', - dependsOn: ['foundation'], + dependsOn: ['foundation'] as const, + requiresPorts: [SEED] as const, register(ctx) { ctx.provide(COUNT, (container) => container.get(SEED) + 1); @@ -53,9 +54,9 @@ const typedPlugin = definePlugin(catalog, { // @ts-expect-error A token outside the catalog is not a valid provider. ctx.provide(OTHER, () => 'not-registered'); }, -}); +} as const satisfies Plugin; -definePlugin(catalog, { +const invalidTypedPlugin = { id: 'invalid-typed-plugin', register(ctx) { // @ts-expect-error A provider must return the catalog token value. @@ -71,10 +72,13 @@ definePlugin(catalog, { return undefined; }); }, -}); +} as const satisfies Plugin; + +void invalidTypedPlugin; const typedPluginId: 'typed-plugin' = typedPlugin.id; const typedDependency: readonly ['foundation'] = typedPlugin.dependsOn; +const typedRequiredPort: typeof SEED = typedPlugin.requiresPorts[0]; describe('typed plugin surface', () => { it('resolves catalog services through a catalogued plugin', () => { @@ -86,6 +90,7 @@ describe('typed plugin surface', () => { expect(typedPluginId).toBe('typed-plugin'); expect(typedDependency).toEqual(['foundation']); + expect(typedRequiredPort).toBe(SEED); expect(container.get(COUNT)).toBe(42); }); diff --git a/packages/core/src/server/plugin-host/core-plugins.ts b/packages/core/src/server/plugin-host/core-plugins.ts index 4b8e6e69..19b843c4 100644 --- a/packages/core/src/server/plugin-host/core-plugins.ts +++ b/packages/core/src/server/plugin-host/core-plugins.ts @@ -4,22 +4,22 @@ import type { PluginEntry } from './load-plugins.js'; const nodeRequire = createRequire(import.meta.url); const CORE_PLUGIN_MODULES = [ - { id: 'audit', specifier: '@openora/core/audit/plugin' }, - { id: 'identity', specifier: '@openora/core/pam/plugins/identity' }, - { id: 'iam', specifier: '@openora/core/iam/plugin' }, - { id: 'notifications', specifier: '@openora/core/engagement/plugins/notifications' }, - { id: 'wallet', specifier: '@openora/core/wallet/plugins/wallet' }, - { id: 'gaming', specifier: '@openora/core/casino/plugins/gaming' }, - { id: 'lobby', specifier: '@openora/core/casino/plugins/lobby' }, - { id: 'chat', specifier: '@openora/core/engagement/plugins/chat' }, - { id: 'profile', specifier: '@openora/core/pam/plugins/profile' }, - { id: 'tag', specifier: '@openora/core/pam/plugins/tag' }, - { id: 'player-management', specifier: '@openora/core/pam/plugins/player-management' }, - { id: 'compliance', specifier: '@openora/core/compliance/plugins/compliance' }, - { id: 'admin-console', specifier: '@openora/core/admin-console/plugin' }, - { id: 'analytics', specifier: '@openora/core/analytics/plugin' }, - { id: 'player-note', specifier: '@openora/core/pam/plugins/player-note' }, - { id: 'cms', specifier: '@openora/core/cms/plugins/cms' }, + { id: 'audit', path: '@openora/core/audit/plugin' }, + { id: 'identity', path: '@openora/core/pam/plugins/identity' }, + { id: 'iam', path: '@openora/core/iam/plugin' }, + { id: 'notifications', path: '@openora/core/engagement/plugins/notifications' }, + { id: 'wallet', path: '@openora/core/wallet/plugins/wallet' }, + { id: 'gaming', path: '@openora/core/casino/plugins/gaming' }, + { id: 'lobby', path: '@openora/core/casino/plugins/lobby' }, + { id: 'chat', path: '@openora/core/engagement/plugins/chat' }, + { id: 'profile', path: '@openora/core/pam/plugins/profile' }, + { id: 'tag', path: '@openora/core/pam/plugins/tag' }, + { id: 'player-management', path: '@openora/core/pam/plugins/player-management' }, + { id: 'compliance', path: '@openora/core/compliance/plugins/compliance' }, + { id: 'admin-console', path: '@openora/core/admin-console/plugin' }, + { id: 'analytics', path: '@openora/core/analytics/plugin' }, + { id: 'player-note', path: '@openora/core/pam/plugins/player-note' }, + { id: 'cms', path: '@openora/core/cms/plugins/cms' }, ] as const; /** @@ -38,6 +38,6 @@ const CORE_PLUGIN_MODULES = [ export function corePlugins(): PluginEntry[] { return CORE_PLUGIN_MODULES.map((module) => ({ id: module.id, - path: nodeRequire.resolve(module.specifier), + path: nodeRequire.resolve(module.path), })); } diff --git a/packages/core/src/server/plugin-host/define-plugin.ts b/packages/core/src/server/plugin-host/define-plugin.ts index 4b484522..fa7c3f73 100644 --- a/packages/core/src/server/plugin-host/define-plugin.ts +++ b/packages/core/src/server/plugin-host/define-plugin.ts @@ -68,32 +68,10 @@ export type ModuleRegistry = { export type PluginContext = ModuleRegistry; -export type PluginDefinition< - C extends TokenCatalog, - Id extends string = string, - Dependencies extends readonly string[] = readonly string[], -> = { - id: Id; - dependsOn?: Dependencies; +export type Plugin = { + id: string; + dependsOn?: readonly string[]; // Verified once after all plugins register - a missing port fails fast. See ADR-0024. - requiresPorts?: Array>; + requiresPorts?: readonly (C[keyof C] & Token)[]; register(ctx: PluginContext): void | Promise; }; - -export type Plugin< - C extends TokenCatalog, - Id extends string = string, - Dependencies extends readonly string[] = readonly string[], -> = PluginDefinition; - -export function definePlugin>( - catalog: C, - definition: T, -): T; -export function definePlugin>( - catalog: C, - definition: T, -): T { - void catalog; - return definition; -} diff --git a/packages/core/src/server/plugin-host/index.ts b/packages/core/src/server/plugin-host/index.ts index d2ebe83c..15c0d2cf 100644 --- a/packages/core/src/server/plugin-host/index.ts +++ b/packages/core/src/server/plugin-host/index.ts @@ -1,7 +1,5 @@ -export { definePlugin } from './define-plugin.js'; export type { Plugin, - PluginDefinition, ModuleRegistry, PluginContext, RouterFactory, diff --git a/packages/core/src/server/plugin-host/load-plugins.ts b/packages/core/src/server/plugin-host/load-plugins.ts index c9aa8d78..c0b2bce6 100644 --- a/packages/core/src/server/plugin-host/load-plugins.ts +++ b/packages/core/src/server/plugin-host/load-plugins.ts @@ -97,7 +97,7 @@ export async function loadPlugins( const mod = (await import(entry.path)) as { default?: Plugin }; const plugin = mod.default; if (!plugin || typeof plugin.register !== 'function') { - throw new Error(`Plugin at "${entry.path}" does not export a valid definePlugin result`); + throw new Error(`Plugin at "${entry.path}" does not default-export a valid plugin`); } assertEntryMatchesPlugin(entry, plugin); plugins.push(plugin); diff --git a/packages/core/src/server/runtime/__tests__/create-app.test.ts b/packages/core/src/server/runtime/__tests__/create-app.test.ts index cf4c7c64..62b3b849 100644 --- a/packages/core/src/server/runtime/__tests__/create-app.test.ts +++ b/packages/core/src/server/runtime/__tests__/create-app.test.ts @@ -11,9 +11,9 @@ const DUMMY_DATABASE_URL = 'postgres://test:test@127.0.0.1:1/create_app_test'; describe('createApp - distributed-only durable seams (ADR-0030)', () => { it('throws a clear, actionable error when no durable seam is bound', async () => { - await expect( - createApp({ plugins: [], databaseUrl: DUMMY_DATABASE_URL, openapi: { enabled: false } }), - ).rejects.toThrow(/MESSAGE_BROKER.*JOB_QUEUE.*CACHE.*RATE_LIMITER/s); + await expect(createApp({ plugins: [], databaseUrl: DUMMY_DATABASE_URL })).rejects.toThrow( + /MESSAGE_BROKER.*JOB_QUEUE.*CACHE.*RATE_LIMITER/s, + ); }); it('boots and serves once REDIS_URL auto-binds all four seams', async () => { @@ -23,7 +23,6 @@ describe('createApp - distributed-only durable seams (ADR-0030)', () => { const created = await createApp({ plugins: [], databaseUrl: DUMMY_DATABASE_URL, - openapi: { enabled: false }, }); for (const token of [MESSAGE_BROKER, JOB_QUEUE, CACHE, RATE_LIMITER]) { @@ -70,8 +69,8 @@ describe('createApp - service name for the Redis Streams consumer group', () => process.env['SERVICE_MANIFEST'] = 'wallet,iam'; delete process.env['SERVICE_NAME']; - await expect( - createApp({ plugins: [], databaseUrl: DUMMY_DATABASE_URL, openapi: { enabled: false } }), - ).rejects.toThrow(/SERVICE_MANIFEST is set but SERVICE_NAME is not/); + await expect(createApp({ plugins: [], databaseUrl: DUMMY_DATABASE_URL })).rejects.toThrow( + /SERVICE_MANIFEST is set but SERVICE_NAME is not/, + ); }); }); diff --git a/packages/core/src/server/runtime/core-token-catalog.ts b/packages/core/src/server/runtime/core-token-catalog.ts index 9c2f0b07..b641f348 100644 --- a/packages/core/src/server/runtime/core-token-catalog.ts +++ b/packages/core/src/server/runtime/core-token-catalog.ts @@ -94,8 +94,6 @@ const coreTokenCatalog = { TAG_EVALUATION_COMMANDS, WALLET_COMMANDS, WALLET_READER, -}; +} satisfies TokenCatalog; -export const CORE_TOKEN_CATALOG = coreTokenCatalog satisfies TokenCatalog; - -export type CoreTokenCatalog = typeof CORE_TOKEN_CATALOG; +export type CoreTokenCatalog = typeof coreTokenCatalog; diff --git a/packages/core/src/server/runtime/create-app.ts b/packages/core/src/server/runtime/create-app.ts index 9e25933a..17041a9e 100644 --- a/packages/core/src/server/runtime/create-app.ts +++ b/packages/core/src/server/runtime/create-app.ts @@ -7,8 +7,6 @@ import { cors } from 'hono/cors'; import { etag } from 'hono/etag'; import { HTTPException } from 'hono/http-exception'; import { serve, type ServerType } from '@hono/node-server'; -import { resolve } from 'node:path'; -import { generateOpenApiSpec } from './openapi.js'; import { Container, BullMqJobQueue, @@ -33,7 +31,6 @@ import { RATE_LIMITER, CACHE, ERROR_TRACKING, - composeContract, healthContract, IGAMING_CONFIG, type IgamingConfig, @@ -109,20 +106,12 @@ export type CreateAppConfig = { // oxlint-disable-next-line typescript/no-explicit-any contract?: ContractRouter; - openapi?: { - enabled?: boolean; - info?: { title?: string; version?: string }; - outputPath?: string; - }; - igaming?: IgamingConfig; // GET-only, path-prefix-matched Cache-Control on PUBLIC_HTTP_CACHE_PATHS (or a // supplied override). `false` disables HTTP response caching entirely. httpCache?: { paths?: string[]; maxAgeSeconds?: number } | false; - configure?: (container: Container) => void | Promise; - disableHealthModule?: boolean; }; @@ -131,7 +120,6 @@ export type CreatedApp = { container: Container; port: number; listen(): Promise; - emitOpenApiSpec(): Promise; close(): Promise; }; @@ -203,7 +191,10 @@ async function captureRawBody(req: Request): Promise { * before the DB closes. A router namespace registered by more than one plugin * throws at boot, not at request time. */ -export async function createApp(config: CreateAppConfig): Promise { +export async function createApp( + config: CreateAppConfig, + configure?: (container: Container) => void | Promise, +): Promise { if (config.databaseUrl) { process.env['DATABASE_URL'] = config.databaseUrl; } @@ -286,7 +277,7 @@ export async function createApp(config: CreateAppConfig): Promise { container.register(PLATFORM_CONFIG, () => loadPlatformConfig(resolvePlatformConfigPath())); const registry = await loadPlugins(config.plugins, container); - await config.configure?.(container); + await configure?.(container); assertDurableSeamsBound(container); @@ -460,17 +451,6 @@ export async function createApp(config: CreateAppConfig): Promise { server = serve({ fetch: app.fetch, port }); process.stdout.write(`API listening on :${port}\n`); }, - async emitOpenApiSpec() { - if (config.openapi?.enabled === false) { - return null; - } - const outPath = await generateOpenApiSpec(config.contract ?? composeContract({}), { - info: config.openapi?.info, - outputPath: config.openapi?.outputPath ?? resolve(process.cwd(), 'docs/openapi.json'), - }); - process.stdout.write(`OpenAPI spec written to ${outPath}\n`); - return outPath; - }, async close() { server?.close(); await container.dispose(); diff --git a/packages/core/src/server/runtime/index.ts b/packages/core/src/server/runtime/index.ts index 2635956b..977b6211 100644 --- a/packages/core/src/server/runtime/index.ts +++ b/packages/core/src/server/runtime/index.ts @@ -1,15 +1,9 @@ export { createApp } from './create-app.js'; export type { CreateAppConfig, CreatedApp } from './create-app.js'; -export { CORE_TOKEN_CATALOG } from './core-token-catalog.js'; export type { CoreTokenCatalog } from './core-token-catalog.js'; export { generateOpenApiSpec } from './openapi.js'; export type { GenerateOpenApiSpecOptions } from './openapi.js'; -export { - definePlugin, - type Plugin, - type PluginEntry, - type ModuleRegistry, -} from '../plugin-host/index.js'; +export { type Plugin, type PluginEntry, type ModuleRegistry } from '../plugin-host/index.js'; export { Container } from '../kernel/index.js'; diff --git a/packages/core/src/server/runtime/openapi.ts b/packages/core/src/server/runtime/openapi.ts index 1e5f2cba..063b7028 100644 --- a/packages/core/src/server/runtime/openapi.ts +++ b/packages/core/src/server/runtime/openapi.ts @@ -11,8 +11,7 @@ export type GenerateOpenApiSpecOptions = { /** * Generate the OpenAPI spec from a contract and write it to disk. - * Pure codegen - no server boot, no DB. Used by `createApp().emitOpenApiSpec()` - * and by the standalone `codegen` script (`pnpm regen`). + * Pure codegen - no server boot, no DB. Used by the standalone `codegen` script (`pnpm regen`). */ export async function generateOpenApiSpec( // oxlint-disable-next-line typescript/no-explicit-any diff --git a/packages/core/src/wallet/plugin.ts b/packages/core/src/wallet/plugin.ts index 8c3821de..b89f8870 100644 --- a/packages/core/src/wallet/plugin.ts +++ b/packages/core/src/wallet/plugin.ts @@ -1,10 +1,5 @@ -import { - definePlugin, - ADMIN_GUARD, - EVENT_BUS, - DRIZZLE, - CORE_TOKEN_CATALOG, -} from '@openora/core/server'; +import { ADMIN_GUARD, EVENT_BUS, DRIZZLE } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import * as z from 'zod'; import { ADMIN_USER_DIRECTORY, @@ -28,7 +23,7 @@ import { createWalletRouter } from './router/index.js'; import { MockPaymentAdapter } from './adapters/mock/mock-payment-adapter.js'; import { HmacPaymentWebhookVerifier } from './adapters/hmac-payment-webhook-verifier.js'; -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { // NOT dependsOn 'tag': that would cycle (tag hard-depends on wallet's WALLET_READER). // wallet's use of tag's PLAYER_TAGS / TAG_EVALUATION_COMMANDS is optional and resolved // lazily in the router factory (`c.has(...)`), which runs after every plugin has @@ -72,4 +67,4 @@ export default definePlugin(CORE_TOKEN_CATALOG, { ), ); }, -}); +} as const satisfies Plugin; diff --git a/packages/testing/src/__tests__/fixtures/test-kyc-config-plugin.ts b/packages/testing/src/__tests__/fixtures/test-kyc-config-plugin.ts index 8619be27..4dec1ad5 100644 --- a/packages/testing/src/__tests__/fixtures/test-kyc-config-plugin.ts +++ b/packages/testing/src/__tests__/fixtures/test-kyc-config-plugin.ts @@ -1,4 +1,4 @@ -import { definePlugin, CORE_TOKEN_CATALOG } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { PLATFORM_CONFIG, KYC_ADAPTER, @@ -43,7 +43,7 @@ class ControllablePendingKycAdapter implements KycAdapter { * `KYC_ADAPTER` for a controllable stub. Append last in a test's `plugins` array so both * bindings win over the defaults (last-registration-wins; see docs/standards/module-structure.md > ports). */ -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { id: 'test-kyc-config', dependsOn: ['identity'], register(ctx) { @@ -57,4 +57,4 @@ export default definePlugin(CORE_TOKEN_CATALOG, { ); ctx.provide(KYC_ADAPTER, () => new ControllablePendingKycAdapter()); }, -}); +} as const satisfies Plugin; diff --git a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts index f6609c04..c9c2774f 100644 --- a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts +++ b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts @@ -1,9 +1,9 @@ -import { definePlugin, CORE_TOKEN_CATALOG } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts'; // PLATFORM_CONFIG overlay for the daily-cap scenario: dailyCapCount 1 trips on the 2nd withdrawal, still below // the high_frequency heuristic (>= 3) so the cap gate is tested in isolation. Separate app since config is boot-once. -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { id: 'test-wallet-auto-withdrawal-cap-config', dependsOn: ['identity'], register(ctx) { @@ -13,4 +13,4 @@ export default definePlugin(CORE_TOKEN_CATALOG, { }), ); }, -}); +} as const satisfies Plugin; diff --git a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts index 205b946c..0dd28133 100644 --- a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts +++ b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts @@ -1,10 +1,10 @@ -import { definePlugin, CORE_TOKEN_CATALOG } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts'; // PLATFORM_CONFIG overlay for the auto-withdrawal e2e suite: autoWithdrawal enabled (threshold 2, // high_risk/bonus_abuser excluded, caps set high). kyc.gateWithdrawals stays false so the KYC-not-passing // scenario hits the auto-approval KYC gate, not the withdraw-time one. Append last so this binding wins. -export default definePlugin(CORE_TOKEN_CATALOG, { +export default { id: 'test-wallet-auto-withdrawal-config', dependsOn: ['identity'], register(ctx) { @@ -21,4 +21,4 @@ export default definePlugin(CORE_TOKEN_CATALOG, { }), ); }, -}); +} as const satisfies Plugin; diff --git a/packages/testing/src/app.ts b/packages/testing/src/app.ts index ca622ef1..755ee897 100644 --- a/packages/testing/src/app.ts +++ b/packages/testing/src/app.ts @@ -62,14 +62,15 @@ export async function bootTestApp(config: BootTestAppConfig): Promise { const redisDatabase = await acquireTestRedisDatabase(); const serviceName = `test-${randomUUID()}`; - const created = await createApp({ - plugins: config.plugins, - ...(config.contract ? { contract: config.contract } : {}), - ...(config.igaming ? { igaming: config.igaming } : {}), - databaseUrl: config.databaseUrl, - authSchema: { user, session, account, verification, twoFactor }, - openapi: { enabled: false }, - configure(container: Container) { + const created = await createApp( + { + plugins: config.plugins, + ...(config.contract ? { contract: config.contract } : {}), + ...(config.igaming ? { igaming: config.igaming } : {}), + databaseUrl: config.databaseUrl, + authSchema: { user, session, account, verification, twoFactor }, + }, + (container: Container) => { const redis = createRedisClient(redisDatabase.url); container.onDispose(() => redis.close()); @@ -93,7 +94,7 @@ export async function bootTestApp(config: BootTestAppConfig): Promise { container.register(REALTIME_CLIENT_AUTHORIZER, () => new SseClientAuthorizer()); } }, - }); + ); return { app: created.app, diff --git a/tools/templates/consumer/__dot__rulesync/subagents/builder.md b/tools/templates/consumer/__dot__rulesync/subagents/builder.md index 4d82880c..c021e9ac 100644 --- a/tools/templates/consumer/__dot__rulesync/subagents/builder.md +++ b/tools/templates/consumer/__dot__rulesync/subagents/builder.md @@ -46,17 +46,17 @@ my-igaming/ 2. Create `apps/api/src/extensions//plugin.ts`: ```ts - import { definePlugin } from '@openora/plugin-host'; + import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { KYC_ADAPTER } from '@openora/adapters'; import { MyKycAdapter } from './src/my-kyc-adapter.js'; - export default definePlugin({ + export default { id: 'my-kyc', dependsOn: ['identity'], // always load after the default-binding module register(ctx) { ctx.provide(KYC_ADAPTER, () => new MyKycAdapter()); }, - }); + } as const satisfies Plugin; ``` 3. Register it in `extensions.config.ts` AFTER the module that owns the default binding. From 1431d46879e4fcd77ec9a0abc91c9cee650254a2 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Wed, 5 Aug 2026 20:36:25 +0200 Subject: [PATCH 8/8] fix(testing): update BF-211/BF-319 fixtures for typed plugin API dev added QA fixtures using the old definePlugin() factory after this branch removed it in favor of the typed Plugin object pattern. --- .../qa-bf211-auto-withdrawal-config-plugin.ts | 6 +++--- .../qa-bf319-exclude-risk-flags-plugin.ts | 6 +++--- ...f211-wallet-auto-withdrawal-config.e2e.test.ts | 15 ++++++++++++--- ...auto-withdrawal-exclude-risk-flags.e2e.test.ts | 13 +++++++++++-- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/testing/src/__tests__/fixtures/qa-bf211-auto-withdrawal-config-plugin.ts b/packages/testing/src/__tests__/fixtures/qa-bf211-auto-withdrawal-config-plugin.ts index 5f07b418..28fc5b45 100644 --- a/packages/testing/src/__tests__/fixtures/qa-bf211-auto-withdrawal-config-plugin.ts +++ b/packages/testing/src/__tests__/fixtures/qa-bf211-auto-withdrawal-config-plugin.ts @@ -1,4 +1,4 @@ -import { definePlugin } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts'; // PLATFORM_CONFIG overlay for the BF-211 QA suite: autoWithdrawal enabled with NO @@ -8,7 +8,7 @@ import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts'; // the DB row's excludeRiskFlags column, seeded via seedAutoWithdrawalConfig's migration // default, drives exclusion now. kyc.gateWithdrawals stays false so KYC-not-passing // scenarios exercise the auto-approval KYC gate, not the withdraw-time one. -export default definePlugin({ +export default { id: 'qa-bf211-auto-withdrawal-config', dependsOn: ['identity'], register(ctx) { @@ -23,4 +23,4 @@ export default definePlugin({ }), ); }, -}); +} as const satisfies Plugin; diff --git a/packages/testing/src/__tests__/fixtures/qa-bf319-exclude-risk-flags-plugin.ts b/packages/testing/src/__tests__/fixtures/qa-bf319-exclude-risk-flags-plugin.ts index 8d33d8ad..4c967e50 100644 --- a/packages/testing/src/__tests__/fixtures/qa-bf319-exclude-risk-flags-plugin.ts +++ b/packages/testing/src/__tests__/fixtures/qa-bf319-exclude-risk-flags-plugin.ts @@ -1,4 +1,4 @@ -import { definePlugin } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts'; // PLATFORM_CONFIG overlay for the BF-319 QA suite (excludeRiskFlags moved off static @@ -8,7 +8,7 @@ import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts'; // BF-319 moves the exclusion list). Caps set high so they never interfere with the // tag-exclusion gate under test. kyc.gateWithdrawals stays false so a not-yet-verified // player hits the auto-approval KYC gate, not the withdraw-time one. -export default definePlugin({ +export default { id: 'qa-bf319-exclude-risk-flags', dependsOn: ['identity'], register(ctx) { @@ -23,4 +23,4 @@ export default definePlugin({ }), ); }, -}); +} as const satisfies Plugin; diff --git a/packages/testing/src/__tests__/qa-bf211-wallet-auto-withdrawal-config.e2e.test.ts b/packages/testing/src/__tests__/qa-bf211-wallet-auto-withdrawal-config.e2e.test.ts index 29f28b94..e4502ec5 100644 --- a/packages/testing/src/__tests__/qa-bf211-wallet-auto-withdrawal-config.e2e.test.ts +++ b/packages/testing/src/__tests__/qa-bf211-wallet-auto-withdrawal-config.e2e.test.ts @@ -3,7 +3,12 @@ import { randomUUID } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { Client } from 'pg'; import { eq } from 'drizzle-orm'; -import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; +import { + loadExtensions, + DRIZZLE, + type Container, + type CoreTokenCatalog, +} from '@openora/core/server'; import { user } from '@openora/core/pam/schema/identity'; import { adminRole, adminRoleAssignment } from '@openora/core/iam/schema'; import { walletAutoWithdrawalConfig } from '@openora/core/wallet/schema'; @@ -89,7 +94,11 @@ async function verifyKyc(admin: TestClient, userId: string) { * So a DB-backed role (super-admin, payments-manager, ...) needs BOTH the static * `user.role = 'admin'` coarse flag AND the specific admin_role_assignment row. */ -async function assignIamRoleByKey(container: Container, userId: string, roleKey: string) { +async function assignIamRoleByKey( + container: Container, + userId: string, + roleKey: string, +) { const drizzle = container.get(DRIZZLE).db; await drizzle.update(user).set({ role: 'admin' }).where(eq(user.id, userId)); const [role] = await drizzle.select().from(adminRole).where(eq(adminRole.key, roleKey)); @@ -102,7 +111,7 @@ async function assignIamRoleByKey(container: Container, userId: string, roleKey: .onConflictDoNothing(); } -async function setStaticRole(container: Container, userId: string, role: string) { +async function setStaticRole(container: Container, userId: string, role: string) { await container.get(DRIZZLE).db.update(user).set({ role }).where(eq(user.id, userId)); } diff --git a/packages/testing/src/__tests__/qa-bf319-wallet-auto-withdrawal-exclude-risk-flags.e2e.test.ts b/packages/testing/src/__tests__/qa-bf319-wallet-auto-withdrawal-exclude-risk-flags.e2e.test.ts index b89190e2..cb22fc09 100644 --- a/packages/testing/src/__tests__/qa-bf319-wallet-auto-withdrawal-exclude-risk-flags.e2e.test.ts +++ b/packages/testing/src/__tests__/qa-bf319-wallet-auto-withdrawal-exclude-risk-flags.e2e.test.ts @@ -2,7 +2,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { randomUUID } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { eq } from 'drizzle-orm'; -import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; +import { + loadExtensions, + DRIZZLE, + type Container, + type CoreTokenCatalog, +} from '@openora/core/server'; import { user } from '@openora/core/pam/schema/identity'; import { adminRole, adminRoleAssignment } from '@openora/core/iam/schema'; import { walletAutoWithdrawalConfig } from '@openora/core/wallet/schema'; @@ -85,7 +90,11 @@ async function assignTag(admin: TestClient, playerId: string, tagKey: string) { // user.role='admin' coarse gate, then attach the real DB-backed super-admin role // assignment - both are required before the DB-backed permission resolver becomes // authoritative (see BF-211 suite's own doc comment on AdminGuard.assert's two-stage gate). -async function makeSuperAdmin(app: TestApp['app'], container: Container, email: string) { +async function makeSuperAdmin( + app: TestApp['app'], + container: Container, + email: string, +) { const { client, userId } = await registerAndMaterializePlayer(app, email); const drizzle = container.get(DRIZZLE).db; await drizzle.update(user).set({ role: 'admin' }).where(eq(user.id, userId));