From b92ad59265a4f781d0b0f389375be784b6e9e446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Mon, 28 Sep 2026 07:47:32 +0200 Subject: [PATCH 01/14] feat(core): add the mcp kernel with agent tool and action-type registries Modules register agent tools with ctx.mcp.tool(defineMcpTool(...), factory) and action types with ctx.actions.register(defineActionType(...), factory). Consumers reach both only through MCP_KERNEL, which validates input shape at registration, checks IAM per call through AdminGuard.assertUser, returns only allow-listed output, maps exceptions to declared codes and audits every call. - RunContext, PlatformConfig.agents, trigger catalog, agent IAM resources - first tools: player.summary, wallet.activity, kyc.status, ggr.summary - first action types: add_tag, add_note, hold_withdrawal, send_to_manual_review, request_enhanced_kyc - withdrawals can be held; admins approve or reject a held withdrawal - catalog and @openora/mcp list the agent surface; ADR-0041 --- .changeset/mcp-kernel.md | 24 + .rulesync/commands/scaffold-plugin.md | 7 +- .rulesync/rules/conventions.md | 1 + .rulesync/rules/overview.md | 2 +- .rulesync/subagents/plugin-author.md | 5 +- AGENTS.md | 3 +- README.md | 3 +- .../0002-defineplugin-and-overlay-pattern.md | 2 + ...registries-behind-one-enforcing-wrapper.md | 131 ++++ docs/guides/downstream-consumer.md | 2 +- docs/guides/mcp-setup.md | 3 +- docs/modules/wallet.md | 12 +- docs/platform/architecture.md | 4 +- docs/platform/core-concepts.md | 3 +- docs/platform/glossary.md | 38 +- docs/standards/audit.md | 1 + docs/standards/module-structure.md | 2 + docs/standards/money.md | 1 + .../core/generators/src/templates/plugin.hbs | 3 +- .../analytics-agent-registration.test.ts | 15 + .../__tests__/ggr-summary.contract.test.ts | 90 +++ .../__tests__/ggr-summary.int.test.ts | 181 +++++ .../src/analytics/contract/agent-tools.ts | 80 ++ packages/core/src/analytics/contract/index.ts | 1 + packages/core/src/analytics/plugin.ts | 14 +- .../service/financial-analytics.service.ts | 23 +- .../compliance-agent-registration.test.ts | 17 + .../__tests__/kyc-agent-tools.int.test.ts | 382 ++++++++++ .../src/compliance/contract/agent-tools.ts | 80 ++ .../core/src/compliance/contract/enums.ts | 6 + .../core/src/compliance/contract/index.ts | 7 +- packages/core/src/compliance/plugin.ts | 98 ++- .../src/compliance/service/kyc.service.ts | 177 ++++- .../contracts/adapters/__tests__/mcp.test.ts | 102 +++ packages/core/src/contracts/adapters/audit.ts | 7 +- .../src/contracts/adapters/identity-reader.ts | 20 + packages/core/src/contracts/adapters/index.ts | 56 ++ packages/core/src/contracts/adapters/mcp.ts | 281 +++++++ .../schemas/__tests__/agents-config.test.ts | 95 +++ packages/core/src/contracts/schemas/agents.ts | 110 +++ packages/core/src/contracts/schemas/iam.ts | 3 + packages/core/src/contracts/schemas/index.ts | 1 + .../src/contracts/schemas/platform-config.ts | 3 + .../identity-reader.service.int.test.ts | 42 ++ .../adapters/identity-reader.service.ts | 9 + .../player-management/contract/agent-tools.ts | 60 ++ .../pam/player-management/contract/index.ts | 1 + .../core/src/pam/player-management/plugin.ts | 15 +- .../player-note.agent-actions.int.test.ts | 248 +++++++ .../__tests__/player-note.router.int.test.ts | 2 +- .../pam/player-note/contract/agent-tools.ts | 19 + .../src/pam/player-note/contract/index.ts | 5 +- .../contract/player-note-content.ts | 1 + packages/core/src/pam/player-note/plugin.ts | 41 +- .../service/player-note.service.ts | 93 ++- .../__tests__/get-active-tag-keys.int.test.ts | 6 +- .../tag-evaluation.service.int.test.ts | 3 +- .../__tests__/tag.agent-actions.int.test.ts | 408 ++++++++++ .../pam/tag/__tests__/tag.router.int.test.ts | 6 +- .../pam/tag/__tests__/tag.service.int.test.ts | 6 +- .../core/src/pam/tag/contract/agent-tools.ts | 48 ++ packages/core/src/pam/tag/contract/index.ts | 1 + packages/core/src/pam/tag/plugin.ts | 84 ++- .../core/src/pam/tag/service/tag.service.ts | 121 +++ .../auth/__tests__/admin-guard.int.test.ts | 80 ++ .../server/auth/__tests__/permissions.test.ts | 15 + packages/core/src/server/auth/admin-guard.ts | 109 ++- packages/core/src/server/auth/permissions.ts | 3 + .../server/mcp/__tests__/authorize.test.ts | 45 ++ .../mcp/__tests__/canonical-json.test.ts | 38 + .../src/server/mcp/__tests__/kernel.test.ts | 631 ++++++++++++++++ packages/core/src/server/mcp/authorize.ts | 26 + .../core/src/server/mcp/canonical-json.ts | 44 ++ .../src/server/mcp/contract-validation.ts | 547 ++++++++++++++ packages/core/src/server/mcp/index.ts | 2 + packages/core/src/server/mcp/kernel.ts | 524 +++++++++++++ .../fixtures/agent-surface-plugin.ts | 43 ++ .../__tests__/load-plugins.test.ts | 20 +- .../__tests__/module-registry.test.ts | 345 ++++++++- .../src/server/plugin-host/define-plugin.ts | 42 ++ packages/core/src/server/plugin-host/index.ts | 4 + .../src/server/plugin-host/load-plugins.ts | 2 + .../src/server/plugin-host/module-registry.ts | 43 +- .../src/server/runtime/core-token-catalog.ts | 2 + .../core/src/server/runtime/create-app.ts | 18 + packages/core/src/testing/index.ts | 8 + .../wallet-agent-registration.test.ts | 17 + .../__tests__/wallet-agent-tools.int.test.ts | 429 +++++++++++ .../wallet-withdrawal-hold.int.test.ts | 488 ++++++++++++ .../core/src/wallet/contract/agent-tools.ts | 89 +++ packages/core/src/wallet/contract/index.ts | 2 + packages/core/src/wallet/plugin.ts | 124 +++- .../core/src/wallet/service/wallet.service.ts | 194 ++++- packages/mcp/README.md | 21 +- packages/mcp/src/main.ts | 88 ++- .../src/__tests__/mcp-kernel.e2e.test.ts | 702 ++++++++++++++++++ packages/testing/src/db.ts | 2 + .../gen-catalog-agent-surface.test.mjs | 166 +++++ tools/gen/gen-catalog.ts | 357 ++++++++- .../commands/scaffold-plugin.md | 13 +- .../__dot__rulesync/rules/overview.md | 3 +- 101 files changed, 8386 insertions(+), 215 deletions(-) create mode 100644 .changeset/mcp-kernel.md create mode 100644 docs/adr/0041-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md create mode 100644 packages/core/src/analytics/__tests__/analytics-agent-registration.test.ts create mode 100644 packages/core/src/analytics/__tests__/ggr-summary.contract.test.ts create mode 100644 packages/core/src/analytics/__tests__/ggr-summary.int.test.ts create mode 100644 packages/core/src/analytics/contract/agent-tools.ts create mode 100644 packages/core/src/compliance/__tests__/compliance-agent-registration.test.ts create mode 100644 packages/core/src/compliance/__tests__/kyc-agent-tools.int.test.ts create mode 100644 packages/core/src/compliance/contract/agent-tools.ts create mode 100644 packages/core/src/contracts/adapters/__tests__/mcp.test.ts create mode 100644 packages/core/src/contracts/adapters/mcp.ts create mode 100644 packages/core/src/contracts/schemas/__tests__/agents-config.test.ts create mode 100644 packages/core/src/contracts/schemas/agents.ts create mode 100644 packages/core/src/pam/identity/__tests__/identity-reader.service.int.test.ts create mode 100644 packages/core/src/pam/player-management/contract/agent-tools.ts create mode 100644 packages/core/src/pam/player-note/__tests__/player-note.agent-actions.int.test.ts create mode 100644 packages/core/src/pam/player-note/contract/agent-tools.ts create mode 100644 packages/core/src/pam/player-note/contract/player-note-content.ts create mode 100644 packages/core/src/pam/tag/__tests__/tag.agent-actions.int.test.ts create mode 100644 packages/core/src/pam/tag/contract/agent-tools.ts create mode 100644 packages/core/src/server/mcp/__tests__/authorize.test.ts create mode 100644 packages/core/src/server/mcp/__tests__/canonical-json.test.ts create mode 100644 packages/core/src/server/mcp/__tests__/kernel.test.ts create mode 100644 packages/core/src/server/mcp/authorize.ts create mode 100644 packages/core/src/server/mcp/canonical-json.ts create mode 100644 packages/core/src/server/mcp/contract-validation.ts create mode 100644 packages/core/src/server/mcp/index.ts create mode 100644 packages/core/src/server/mcp/kernel.ts create mode 100644 packages/core/src/server/plugin-host/__tests__/fixtures/agent-surface-plugin.ts create mode 100644 packages/core/src/wallet/__tests__/wallet-agent-registration.test.ts create mode 100644 packages/core/src/wallet/__tests__/wallet-agent-tools.int.test.ts create mode 100644 packages/core/src/wallet/__tests__/wallet-withdrawal-hold.int.test.ts create mode 100644 packages/core/src/wallet/contract/agent-tools.ts create mode 100644 packages/testing/src/__tests__/mcp-kernel.e2e.test.ts create mode 100644 tools/__tests__/gen-catalog-agent-surface.test.mjs diff --git a/.changeset/mcp-kernel.md b/.changeset/mcp-kernel.md new file mode 100644 index 000000000..fd98bb9e9 --- /dev/null +++ b/.changeset/mcp-kernel.md @@ -0,0 +1,24 @@ +--- +'@openora/core': minor +'@openora/mcp': minor +--- + +MCP kernel. A module now registers agent **tools** with `ctx.mcp.tool(defineMcpTool({...}), factory)` and agent **action types** with `ctx.actions.register(defineActionType({...}), factory)` in its own `plugin.ts`. An agent runtime or an MCP transport reaches both only through the `MCP_KERNEL` token, never by importing a module. The kernel wrapper: + +- rejects at boot a tool input or action payload that is not a top-level `z.object`, has a string without a maximum length (enums, UUIDs and ISO dates aside), a number without both bounds or without `z.coerce`, an array without a maximum, or a free-form map, and an action type without an IAM resource; +- checks the IAM grant of the admin behind the run's actor (`AdminGuard.assertUser`, the same enforcement and denial audit as an admin route) before a handler or executor runs; +- returns only the output keys on the tool's allow-list, maps any exception to a declared error code or `internal_error` (never the exception text), and writes one audit record per call (`mcp.tool.invoked` / `mcp.tool.failed` / `mcp.action.executed` / `mcp.action.failed`, with SHA-256 hashes of input and output). A call whose audit record cannot be written fails with `audit_unavailable`; an action execution retries the write first, because its effect is already committed. + +An action executor is replay-safe on the state it writes: a replay while its effect still holds performs nothing and answers `already_applied`. Core stores no proposals, so after someone else changes that state a replay may apply again or answer a declared refusal. Whatever stores proposals must not re-execute one it recorded as executed. + +Also new: the shared `RunContext` type; `PlatformConfig.agents` (run limits, retention, and a model allow-list of `//` ids with step timeout, fallbacks, capability flags and deprecation date, where the gateway must be listed in `agents.modelGateways`, `['openrouter']` by default); a trigger catalog derived from the domain events; and the IAM resources `agent` (`view`, `create`, `update`, `publish`, `run`), `agent-proposal` (`view`, `approve`, `reject`) and `agent-config` (`view`, `update`). + +Who holds the new IAM resources after an upgrade: a Super Admin gets them at once, because a super-admin role's grants are derived from the permission statement at runtime, and so does the built-in static `admin` role. No other seeded or custom role is granted them. Grant `agent`, `agent-proposal` or `agent-config` to the roles that should run agents, review proposals or edit agent settings (`iam.setRolePermissions`) before a screen is gated on them. The kernel checks each tool's and action type's own resource, so an agent acting for an admin can only read and execute what that admin could through the Backoffice. + +First registrations: tools `player.summary`, `wallet.activity`, `kyc.status`, `ggr.summary`; action types `add_tag`, `add_note`, `hold_withdrawal`, `send_to_manual_review`, `request_enhanced_kyc`. Tools return data as the module has it and declare which output keys carry personal data (`redact.personal`); pseudonymising it before a model sees it is the consumer's job. `IdentityReader` gains an optional `getUserIdByPlayerId`, which the wallet and compliance registrations need: an operator who binds their own `IDENTITY_READER` without it gets a boot warning, and those four registrations answer `internal_error` until it is added. + +A withdrawal held by `hold_withdrawal` (`on_hold`) can now be approved or rejected by an admin through the existing routes; before, both refused anything but `pending`. Auto-approval still only ever takes a `pending` withdrawal. + +`@openora/mcp` lists the agent surface: `list-agent-tools`, counts in `catalog-overview`, and a module's tools and action types in `describe-module`. + +**Breaking (types):** a hand-written `ModuleRegistry` implementation, a test double for example, needs the new `actions` member, `mcp.getTools`, and the two-argument `mcp.tool` overload. A TypeScript `PlatformConfig` object literal needs `agents`; config files parse unchanged, because the section defaults. The earlier untyped `ctx.mcp.tool({ name, description, inputSchema, handler })` still registers, but the kernel does not serve it; move such a tool to the contract form. `createMcpKernel` is exported only from `@openora/core/testing`; production code resolves `MCP_KERNEL`. diff --git a/.rulesync/commands/scaffold-plugin.md b/.rulesync/commands/scaffold-plugin.md index 0dc52b07f..1d1550888 100644 --- a/.rulesync/commands/scaffold-plugin.md +++ b/.rulesync/commands/scaffold-plugin.md @@ -1,7 +1,7 @@ --- targets: - '*' -description: 'Generate a new overlay extension plugin under extensions//. Adds routes, providers, event handlers, job workers, and MCP tools without modifying core modules.' +description: 'Generate a new overlay extension plugin under extensions//. Adds routes, providers, event handlers, job workers, agent tools, and agent action types without modifying core modules.' --- Run `pnpm gen plugin $ARGUMENTS` in the repo root. Variants: `pnpm gen adapter ` for a vendor-adapter swap; `pnpm gen job-worker ` for a background-job worker. @@ -12,7 +12,10 @@ The generator creates `extensions//plugin.ts` and registers it in `extensi - `ctx.routers.add(namespace, (c) => router)` - mount oRPC routes. - `ctx.events.on(topic, handler)` - subscribe to the typed `EventBus`. - `ctx.jobs.worker({ queue, schema, handler, onDeadLetter? })` - process jobs off the `JOB_QUEUE` seam (ADR-0014). Enqueue from a service via `enqueue(queue('name'), payload, { idempotencyKey, delayMs, attempts, backoff })`. Handlers must be idempotent (at-least-once). -- `ctx.mcp.tool(definition)` - expose a new MCP tool. +- `ctx.mcp.tool(defineMcpTool({...}), (c) => async (input, run) => output)` - register an agent tool, a read a model makes during a run. +- `ctx.actions.register(defineActionType({...}), (c) => ({ precondition, execute }))` - register an agent action type, a change a model may only propose. `execute` performs nothing on a replay while its effect still holds, and answers `already_applied`. + +Both are served only through the `MCP_KERNEL` token, which checks IAM, validates input, applies the output allow-list and audits every tool call and action execution; a shape it cannot serve fails boot with the fix (ADR-0041). The one-argument `ctx.mcp.tool(definition)` still registers but the kernel does not serve it. Tables go in the plugin's own `schema/index.ts` (Drizzle `pgTable`), then `pnpm regen`. diff --git a/.rulesync/rules/conventions.md b/.rulesync/rules/conventions.md index c789abf25..7f859c986 100644 --- a/.rulesync/rules/conventions.md +++ b/.rulesync/rules/conventions.md @@ -30,6 +30,7 @@ Use pure, composable functions and explicit typed wiring. Match local naming and | SQL, Drizzle, migration, seed, DB tool | `docs/standards/database.md` | | function, service method, constructor | `docs/standards/functions.md` | | module, DI wiring, integration, cross-module dependency | `docs/standards/module-structure.md` | +| agent tool or action type | `docs/standards/module-structure.md` | | error class or catch | `docs/standards/errors.md` | | money movement or payment settlement | `docs/standards/money.md` | | wallet module surface or ledger invariant | `docs/modules/wallet.md` | diff --git a/.rulesync/rules/overview.md b/.rulesync/rules/overview.md index 4f8d575f6..424a8550e 100644 --- a/.rulesync/rules/overview.md +++ b/.rulesync/rules/overview.md @@ -27,7 +27,7 @@ This is real-money regulated gambling. A defect here moves a player's money or b ## Orient before reading files -- `docs/catalog.json` is the generated surface: every module, table, route, event, adapter port, config field. Read it, or call the `oss-dev` MCP tools (`catalog-overview`, `list-modules`, `describe-module`, `list-routes`, `list-adapters`, `schema-get`, `docs-search`) instead of grepping. +- `docs/catalog.json` is the generated surface: every module, table, route, event, adapter port, config field, agent tool and action type. Read it, or call the `oss-dev` MCP tools (`catalog-overview`, `list-modules`, `describe-module`, `list-routes`, `list-adapters`, `schema-get`, `docs-search`) instead of grepping. - `pnpm setup` boots infra, migrates, and prints a summary. `pnpm dev` runs it. `pnpm verify` is the gate CI runs; it ends with `pnpm check:drift`. ## Load the right owner diff --git a/.rulesync/subagents/plugin-author.md b/.rulesync/subagents/plugin-author.md index 58e5c38be..9ec0d7ccd 100644 --- a/.rulesync/subagents/plugin-author.md +++ b/.rulesync/subagents/plugin-author.md @@ -40,9 +40,12 @@ ctx.provide(TOKEN, factory); // bind/override a typed DI token (last registratio ctx.routers.add(namespace, (c) => router); // mount oRPC routes ctx.events.on(topic, handler); // subscribe to the typed EventBus ctx.jobs.worker({ queue, schema, handler, onDeadLetter }); // process JOB_QUEUE jobs (idempotent - at-least-once) -ctx.mcp.tool(definition); // expose a new MCP tool +ctx.mcp.tool(defineMcpTool({...}), (c) => async (input, run) => output); // agent tool: a read a model makes during a run +ctx.actions.register(defineActionType({...}), (c) => ({ precondition, execute })); // action type: a change a model may only propose ``` +Agent tools and action types are served only through the `MCP_KERNEL` token, which checks the IAM grant of the admin behind the run, validates input, returns only the allow-listed output keys and declared error codes, and audits every tool call and action execution. A shape it cannot serve (eg a tool input that is not a top-level `z.object`, or an unbounded string) fails boot with the fix. `execute` must perform nothing on a replay while its effect still holds, and answer `already_applied`. The one-argument `ctx.mcp.tool(definition)` still registers but the kernel does not serve it. Contract types: `packages/core/src/contracts/adapters/mcp.ts`; why: ADR-0041. + 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/AGENTS.md b/AGENTS.md index 9e6d8cad0..5ca75e0e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ This is real-money regulated gambling. A defect here moves a player's money or b ## Orient before reading files -- `docs/catalog.json` is the generated surface: every module, table, route, event, adapter port, config field. Read it, or call the `oss-dev` MCP tools (`catalog-overview`, `list-modules`, `describe-module`, `list-routes`, `list-adapters`, `schema-get`, `docs-search`) instead of grepping. +- `docs/catalog.json` is the generated surface: every module, table, route, event, adapter port, config field, agent tool and action type. Read it, or call the `oss-dev` MCP tools (`catalog-overview`, `list-modules`, `describe-module`, `list-routes`, `list-adapters`, `schema-get`, `docs-search`) instead of grepping. - `pnpm setup` boots infra, migrates, and prints a summary. `pnpm dev` runs it. `pnpm verify` is the gate CI runs; it ends with `pnpm check:drift`. ## Load the right owner @@ -90,6 +90,7 @@ Use pure, composable functions and explicit typed wiring. Match local naming and | SQL, Drizzle, migration, seed, DB tool | `docs/standards/database.md` | | function, service method, constructor | `docs/standards/functions.md` | | module, DI wiring, integration, cross-module dependency | `docs/standards/module-structure.md` | +| agent tool or action type | `docs/standards/module-structure.md` | | error class or catch | `docs/standards/errors.md` | | money movement or payment settlement | `docs/standards/money.md` | | wallet module surface or ledger invariant | `docs/modules/wallet.md` | diff --git a/README.md b/README.md index fbf74dffd..2dfd6fa36 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,8 @@ export default { ctx.provide(MY_ADAPTER, () => new MyAdapter()); // bind a vendor seam ctx.routers.add('myFeature', (c) => createMyRouter(c)); // oRPC router ctx.events.on('wallet.deposit.completed', handler); - ctx.mcp.tool({ name: 'my-tool', description: '...', handler }); + ctx.mcp.tool(myTool, (c) => async (input, run) => output); // agent tool (defineMcpTool) + ctx.actions.register(myAction, (c) => ({ precondition, execute })); // agent action type }, } as const satisfies Plugin; ``` diff --git a/docs/adr/0002-defineplugin-and-overlay-pattern.md b/docs/adr/0002-defineplugin-and-overlay-pattern.md index 681c8247e..c4560da54 100644 --- a/docs/adr/0002-defineplugin-and-overlay-pattern.md +++ b/docs/adr/0002-defineplugin-and-overlay-pattern.md @@ -3,6 +3,8 @@ **Date**: 2026-05-18 **Status**: Accepted +> **Update (2026-09-25)**: `ctx.mcp.tool` now takes a contract declared with `defineMcpTool` and a factory, and a second registry, `ctx.actions.register`, takes agent action types declared with `defineActionType`. Both are served only through the `MCP_KERNEL` token, which `createApp` binds after every plugin has registered. The one-argument `ctx.mcp.tool(definition)` form still registers, but the kernel does not serve it. See [ADR-0041](./0041-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md). + ## Context The platform's primary value proposition is extensibility: operators clone the repo and add their own games, payment providers, custom pages, and business rules without forking core. We need a mechanism that: diff --git a/docs/adr/0041-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md b/docs/adr/0041-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md new file mode 100644 index 000000000..dbbe0a3ed --- /dev/null +++ b/docs/adr/0041-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md @@ -0,0 +1,131 @@ +# ADR-0041: MCP kernel - two registries behind one enforcing wrapper + +**Date**: 2026-09-25 +**Status**: Proposed + +## Context + +An agent hub (a premium package outside this repo) runs models that read platform data and +propose changes for an admin to approve. An MCP HTTP transport will let an admin's own MCP +client call the same reads. Both need to know what the modules offer without importing a +module, and both sit on a real-money path: whatever a model reads or triggers has to pass the +same authorization, audit and compliance rules as a Backoffice route. + +A spike ran the candidate runtimes against real models and a stub harness. Four findings shape +the kernel, because no runtime fixes them: + +- A tool input whose top level is a discriminated union is sent to the model as `oneOf` without + `type: "object"`. Four different models then sent money amounts as strings and did not + recover after validation feedback. With a top-level object every call was valid first time. +- Tool output and thrown errors reach the model verbatim in every runtime tested. A `pg` error + carrying the SQL text of the failing query went straight to the model. A framework's output + schema is not redaction: on a validation failure it forwards the raw output. +- A crash in the middle of an action followed by a framework restart executed the money step + twice. +- A model proposed a "loss-mitigation" bonus to a player who had lost most of their deposits. + Amount caps do not catch that; a domain rule evaluated before the proposal exists does. + +The hub's runtime and its transport authentication were decided downstream (the agent hub +runtime on a thin AI SDK adapter, downstream ADR 0006; MCP clients authenticating with a +Backoffice-issued token rather than OAuth, downstream ADR 0007). Neither decision may leak into +core: the kernel has to serve any runtime and any transport. + +## Decision + +Modules register two different things, in their own `plugin.ts`, through two registries: + +- `ctx.mcp.tool(contract, factory)` registers a **tool**: a call a model makes during a run. +- `ctx.actions.register(contract, factory)` registers an **action type**: a state change a model + may only propose. An executor runs it later, after approval, under the approver's identity. + +The contract is static data declared with `defineMcpTool` / `defineActionType` in the module's +`contract/` dir: id, title, description, schema version, the IAM `resource:action` it needs, +Zod schemas, the error codes it may return, and for a tool its class (`read | propose`) and +output allow-list, for an action type its reversible flag. The factory receives the container +once, when the kernel is built after every provider is bound, and returns the handler or the +`{ precondition, execute }` pair. The catalog generator reads the contract literals, so +`docs/catalog.json` and `@openora/mcp` list both registries without running anything. + +Consumers reach both registries only through `MCP_KERNEL`, which `createApp` binds after every +plugin and the composition root have registered, so no overlay can replace it. The kernel +wrapper, not the runtime, enforces: + +- **Shape at registration.** Tool inputs and action payloads are a top-level `z.object`; + conditional rules go in `.superRefine()`. Every number is `z.coerce.number()` with both bounds, + every string is bounded, every array has a maximum, free-form maps are refused, and schemas + must convert to JSON Schema. A violation fails boot with the field path and the fix. +- **IAM per call.** The run's actor (an admin, an agent acting for an admin, or an MCP token + owned by an admin) resolves to one admin, whose grants are checked by `AdminGuard.assertUser` - + the same enforcement point as every admin route, including the denial audit event. +- **Output allow-list.** The handler's output is parsed by its schema and only allow-listed keys + leave the kernel. A schema mismatch returns `output_invalid`, never the raw output. +- **Error codes, never messages.** A handler returns a declared code by throwing `McpToolError`; + any other exception becomes `internal_error`. Validation failures return field paths and + messages without input values. +- **An audit record per call**, with tool id, schema version, actor, correlation id and SHA-256 + hashes of the parsed input and the redacted output. If the record cannot be written the call + fails with `audit_unavailable` instead of returning data. An action execution retries the + write first, because by then the executor's effect is committed. +- **Preconditions before proposals.** Each action type declares a precondition the hub must pass + before a proposal is created. It is not re-run at execution, where the executor guards its + own state under its own locks. +- **Replay-safe execution.** An executor receives `(payload, proposalId, actor)`. A replay while + its effect still holds performs nothing and answers `already_applied`, so a retry after a crash + is safe. Core stores no proposals, so executors key on the resulting state: after someone else + changes that state (an admin removes the tag, resolves the held withdrawal), a replay may apply + again or answer a declared refusal. The store that keeps proposals must not re-execute one it + recorded as executed. + +`RunContext` (run id, actor, optional player pseudonym, catalog version, correlation id) is one +shared type, so the hub and the transport cannot disagree on it. `PlatformConfig.agents` holds +default run limits, retention and the model allow-list. A model id must be +`//` with the gateway in `agents.modelGateways`, which defaults to +`openrouter`: a bare id can be routed to a different gateway by the SDK, and core names no +gateway an operator cannot replace. The trigger catalog is derived from the domain event catalog, not registered separately. + +Core returns data as the module has it. Pseudonymising players, stripping personal data and +trimming logs before anything reaches a model is the hub's job; a tool declares which output +keys carry personal data (`redact.personal`) so the hub can do that without knowing the module. +Tool ids may be dotted (`player.summary`); the kernel also publishes a `modelName` with dots +replaced, because the major model APIs reject dots in function names. + +Rejected alternatives: + +- **One registry for reads and writes.** A write needs approval, an idempotent executor and a + reversible flag; a read needs none of them. One registry would let a model call a write as if + it were a read. +- **Enforcement in the runtime.** Every runtime tested forwarded output and errors verbatim, so + each runtime and each transport would re-implement the same rules, and one would get them + wrong. +- **A rebindable kernel token.** An overlay could swap the wrapper and bypass IAM and audit. + `createApp` binds `MCP_KERNEL` after every plugin, and the kernel factory is exported only + from `@openora/core/testing`. +- **Pseudonymisation in core.** It needs a mapping store and belongs at the model boundary, which + only the hub knows. + +## Consequences + +**Positive:** + +- The hub and the transport share one enforcement path, and a module owns its agent surface + exactly as it owns its routes. +- A tool that leaks a field, returns an unbounded input to a model, or skips IAM fails at boot or + in the kernel's own tests, not in production. +- Every model-facing call leaves an audit trail that proves what was read without storing it. + +**Negative / trade-offs:** + +- Every tool call writes an audit row. That is the price of auditing reads, and it grows with + agent traffic. +- The input rules constrain authors: a free-text field needs a `max`, a number needs coercion + and bounds, and a union must be flattened. +- Without a proposals table in core, replay safety is each executor's responsibility, keyed on + the state it writes. `add_note` recognises a replay by the same player, author and text, so a + second approved proposal carrying exactly the same note from the same approver adds nothing. +- The earlier untyped `ctx.mcp.tool(definition)` form still registers, for compatibility, but + the kernel does not serve it. + +**Neutral:** + +- A held withdrawal (`on_hold`) is now reachable through the `hold_withdrawal` action type, and + an admin resolves it with the existing approve and reject routes. diff --git a/docs/guides/downstream-consumer.md b/docs/guides/downstream-consumer.md index 72eb52a43..9c96c142e 100644 --- a/docs/guides/downstream-consumer.md +++ b/docs/guides/downstream-consumer.md @@ -5,7 +5,7 @@ forking core. The root `AGENTS.md` links here; this is the detail an agent loads actually wiring a consumer. See the generated `docs/catalog.json` (`pnpm gen:catalog`, also served by `@openora/mcp`) for the machine-readable surface (routes, schemas, adapter -tokens, events, config schema) an agent reads instead of grepping `node_modules`. +tokens, events, config schema, agent tools and action types) an agent reads instead of grepping `node_modules`. ## Fastest path: scaffold the repo diff --git a/docs/guides/mcp-setup.md b/docs/guides/mcp-setup.md index 948c09260..357c3b1af 100644 --- a/docs/guides/mcp-setup.md +++ b/docs/guides/mcp-setup.md @@ -113,7 +113,8 @@ pnpm --filter @openora/mcp build # rebuild after any source change Note: in a `create:app` consumer running in dev/link mode you don't need this - the generated `.mcp.json` already points at the full `oss-dev` server through the sibling -checkout, which is a superset of `@openora/mcp`. +checkout. The exception is the agent surface: `list-agent-tools` is served only by +`@openora/mcp`. ## Usage pattern for agents diff --git a/docs/modules/wallet.md b/docs/modules/wallet.md index 3977c57f2..dc4ef8f9b 100644 --- a/docs/modules/wallet.md +++ b/docs/modules/wallet.md @@ -16,6 +16,9 @@ does not repeat it. withdrawal. Which pairs exist is operator configuration, not code. - **The custody surface.** Issued deposit addresses, saved payout destinations, per-player vendor containers, the sweep, reconciliation findings, and the withdrawal approval path. +- **Its agent surface.** A read tool over a player's balances, recent money movement and + withdrawals still awaiting a decision, and an action type that holds a player's waiting + withdrawal for manual review. Both are served only through the MCP kernel (ADR-0041). ## Rules that are easy to get wrong here @@ -32,8 +35,13 @@ does not repeat it. - **The balance stream carries a signal, not an amount.** A dropped frame must not be able to leave a stale number on screen, so the client refetches. Every event that moves a settled balance publishes one, both legs of a player-to-player transfer included. +- **A held withdrawal waits for an admin.** Holding moves no money: the funds were taken when the + withdrawal was requested. An admin approves or rejects a held withdrawal the same way as one + still waiting, and auto-approval only ever takes a withdrawal nobody has held. Only a waiting + withdrawal can be held, and holding one already held changes nothing, so a replayed proposal + holds it once. - **Admin actions are guarded and audited.** The guard is the first line of the handler. A manual - adjustment, an approval, a rejection, a catalog edit and a resolved finding each write an audit - entry naming the actor and the reason. + adjustment, an approval, a rejection, a hold, a catalog edit and a resolved finding each write an + audit entry naming the actor and the reason. - **The webhook path resolves the verifier and the adapter from the same provider entry,** and fails closed when either is missing. diff --git a/docs/platform/architecture.md b/docs/platform/architecture.md index e13865c80..5a68c05ca 100644 --- a/docs/platform/architecture.md +++ b/docs/platform/architecture.md @@ -93,10 +93,10 @@ 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** - `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. +- **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 agent tools (`ctx.mcp.tool`) and agent action types (`ctx.actions.register`), which only the MCP kernel serves. 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 with a live API reference. 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). +**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), `mcp` (the MCP kernel behind `MCP_KERNEL`, the only gateway to agent tools and action types; `createApp()` binds it after every plugin has registered, so no overlay can replace it - ADR-0041), and `createApp()` - which is domain-agnostic (the consumer injects the PAM identity schema, ADR-0025/0026: single-tenant, no resolveTenant). **Domains** (`packages/core/src//*`) - folded into the single `@openora/core` package and exposed as subpaths (`@openora/core/`), not as one package per domain. A domain may import the engine zones (`contracts`, `server`, `react`) and a sibling's read-only `/schema` subpath, but **never another domain's internals** - cross-domain communication goes through events, command ports, or shared contracts. Vendor adapter interfaces come from `@openora/core/contracts`. ADR-0025. diff --git a/docs/platform/core-concepts.md b/docs/platform/core-concepts.md index 8e4f1aa94..fb67d1c39 100644 --- a/docs/platform/core-concepts.md +++ b/docs/platform/core-concepts.md @@ -74,7 +74,8 @@ export function createWalletRouter(wallet: WalletService) { ## Plugins 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. +adapters, add routers, subscribe to events, and register agent tools and action types - the reads +a model may make and the changes it may only propose, served only through the MCP kernel. ```ts import { EVENT_BUS, DRIZZLE } from '@openora/core/server'; diff --git a/docs/platform/glossary.md b/docs/platform/glossary.md index a62ab5539..140190d3e 100644 --- a/docs/platform/glossary.md +++ b/docs/platform/glossary.md @@ -15,23 +15,26 @@ Shared vocabulary for this repo: the **roles** (who's who), the **platform/archi ## Platform & architecture terms -| Term | Meaning | Maps to | -| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| **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 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 live OpenAPI reference. | `@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/` | -| **UI plugin** | A client-side extension that customizes the look/feel. Lives in the consumer frontend, not in this repo. The platform is headless backend only. | ADR-0013 (superseded) | -| **Slot** | A typed injection point in a UI surface that a plugin can fill. Owned by the consumer frontend. | ADR-0013 (superseded) | -| **Sealed token** | A `SealedToken` whose backing service operators may never override (RG enforcement, KYC writes, AML/SAR, ledger writes, RNG, etc.). Structurally incompatible with `Token` + runtime-rejected by plugin-host. | `@openora/core/compliance` | -| **Client page token** | `ClientPageToken

` - Tier 3 escape hatch for full client-side page replacement. | `@openora/core/contracts/token.ts` | -| **Brand scope** | Multi-brand operators define brands in `PlatformConfig.brands`; `` selects the active one; slot fills with `brandScope: [...]` render only when the active brand matches. | ADR-0013 T0.5 | -| **Platform config** | Operator-editable Zod-validated YAML/JSON consumed at boot. Controls feature flags, brands, RG defaults per geo. No admin UI in v1. | `@openora/core/contracts/platform-config.ts`, `@openora/core/server (loadPlatformConfig)` | -| **Scaffold** | A deterministic code-mod that stamps a module/plugin/route/component skeleton. | `tools/gen/gen.ts`, `/scaffold-*` | -| **MCP dev server** | A stdio tool server agents connect to for read-only inspection + scaffolding. | `apps/mcp-server-dev` | -| **RGS** (Remote Gaming Server) | The game engine that runs rounds, applies RTP, and settles bets. Can be third-party (a provider) or the platform's own multiplayer/provably-fair foundation. | `gaming` module + `GameAdapter` | +| Term | Meaning | Maps to | +| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| **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 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 live OpenAPI reference. | `@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/` | +| **UI plugin** | A client-side extension that customizes the look/feel. Lives in the consumer frontend, not in this repo. The platform is headless backend only. | ADR-0013 (superseded) | +| **Slot** | A typed injection point in a UI surface that a plugin can fill. Owned by the consumer frontend. | ADR-0013 (superseded) | +| **Sealed token** | A `SealedToken` whose backing service operators may never override (RG enforcement, KYC writes, AML/SAR, ledger writes, RNG, etc.). Structurally incompatible with `Token` + runtime-rejected by plugin-host. | `@openora/core/compliance` | +| **Client page token** | `ClientPageToken

` - Tier 3 escape hatch for full client-side page replacement. | `@openora/core/contracts/token.ts` | +| **Brand scope** | Multi-brand operators define brands in `PlatformConfig.brands`; `` selects the active one; slot fills with `brandScope: [...]` render only when the active brand matches. | ADR-0013 T0.5 | +| **Platform config** | Operator-editable Zod-validated YAML/JSON consumed at boot. Controls feature flags, brands, RG defaults per geo. No admin UI in v1. | `@openora/core/contracts/platform-config.ts`, `@openora/core/server (loadPlatformConfig)` | +| **Scaffold** | A deterministic code-mod that stamps a module/plugin/route/component skeleton. | `tools/gen/gen.ts`, `/scaffold-*` | +| **MCP dev server** | A stdio tool server agents connect to for read-only inspection + scaffolding. | `apps/mcp-server-dev` | +| **Agent tool** | A read a model makes during an agent run (eg a player's summary), declared by the module that owns the data. Registered in `plugin.ts`, reached only through the MCP kernel. | module `contract/`, `docs/catalog.json` | +| **Action type** | A state change a model may only propose. An admin approves the proposal; an executor then applies it, and a replay while the effect still holds changes nothing. Core declares the action types; the runtime that runs agents and stores proposals is not part of core. | module `contract/`, `docs/catalog.json` | +| **MCP kernel** | The only gateway to agent tools and action types: checks the IAM grant of the admin behind the run, validates input, returns only allow-listed output and declared error codes, and audits each tool call and execution. Not the MCP dev server. | `@openora/core/server` (`MCP_KERNEL`), ADR-0041 | +| **RGS** (Remote Gaming Server) | The game engine that runs rounds, applies RTP, and settles bets. Can be third-party (a provider) or the platform's own multiplayer/provably-fair foundation. | `gaming` module + `GameAdapter` | ## iGaming domain terms @@ -52,6 +55,7 @@ Shared vocabulary for this repo: the **roles** (who's who), the **platform/archi | **Wallet** | A player's balance(s) and the ledger of money movements. | `wallet` module | | **Balance** | Spendable funds, shown in real time; may differ from pending. | `wallet` | | **Deposit** / **withdrawal** | Money in / money out, in fiat or crypto. | `wallet` | +| **Held withdrawal** | A withdrawal set aside for manual review. No money moves when it is held; only an admin's approval or rejection moves it on, and auto-approval never takes it. | `wallet` | | **PSP** (Payment Service Provider) | A fiat payment vendor (card, bank, e-wallet) integrated behind an adapter. | `wallet` / `PaymentAdapter` | | **Fiat** / **crypto** | Government currency (USD, EUR...) vs cryptocurrency (BTC, ETH, USDT...). The platform supports multi-currency display. | `wallet` | | **Transaction history** | The filterable record of deposits, withdrawals, bets, wins, and transfers. | `wallet` | diff --git a/docs/standards/audit.md b/docs/standards/audit.md index 0c7576c26..3c1e30d2f 100644 --- a/docs/standards/audit.md +++ b/docs/standards/audit.md @@ -4,6 +4,7 @@ Read this before adding a state-changing action or changing audit storage, event - Every mutation that changes player, operator, money, KYC, permissions, or configuration state must produce an audit record with actor, resource, outcome, and meaningful before/after state. Use a declared domain event after commit, or write through `AUDIT_WRITER`; money-path records join the business transaction. - `AUDIT_WRITER` is sealed. Only the audit module binds it; overlays and domains must not replace it. +- Every agent tool call and action execution through the MCP kernel that names a valid actor leaves exactly one audit record, a read included, carrying hashes of the input and output rather than the data; a precondition check is not audited. A call whose record cannot be written fails instead of returning data, so never reach an agent tool or action type around the kernel. The kernel's record says that a call happened; an action executor still audits the state it changed, like any other mutation (ADR-0041). - Audit rows are append-only, enforced by a database trigger that denies UPDATE and DELETE on `audit_log`. Never add update or delete behavior, and never invent a topic outside `domainEventSchemas`. - Preserve the hash-chain protocol: serialize concurrent appends, include the full persisted record in the hash, and use a stable deep key order for JSON values. Insert the final hash with the row, never as a later update. - A denial audit signal must come from a real backend request; a client-side route guard's redirect never reaches the server, so it is never a substitute. Every authorization denial emits the same event regardless of where it is thrown - a service-level check that denies before ever reaching a shared guard (e.g. `AdminGuard.assert()`) still needs its own explicit emit, not just the guard's. diff --git a/docs/standards/module-structure.md b/docs/standards/module-structure.md index d58aec30a..5354289f4 100644 --- a/docs/standards/module-structure.md +++ b/docs/standards/module-structure.md @@ -15,6 +15,8 @@ Detail for the boundary lines in `conventions`. Read this when creating a module Service methods are data-in/data-out; side effects (DB writes, event emits, adapter calls) at the edges. +A module's agent surface keeps the same split: tool and action-type contracts (`defineMcpTool` / `defineActionType`) in `contract/agent-tools.ts`, the registration and its factory in `plugin.ts` (`ctx.mcp.tool`, `ctx.actions.register`), the logic in the service. The MCP kernel serves them and refuses at boot a shape it cannot serve, naming the fix. A handler signals a declared error code by throwing `McpToolError`; anything else reaches the caller as an internal error. The kernel does not re-run a precondition before execution, so an executor re-checks its own state under its own lock, and a replay while its effect still holds performs nothing and answers `already_applied`. Executors key on that state, not on the proposal id, so whatever stores proposals must not re-execute one it recorded as executed. ADR-0041. + ## Dependency injection (no decorators, no reflect-metadata) - Tokens are typed symbols: `createToken('NAME')`, declared with the port in `packages/core/src/contracts/adapters/`. diff --git a/docs/standards/money.md b/docs/standards/money.md index ec2dcc304..c1112a2d7 100644 --- a/docs/standards/money.md +++ b/docs/standards/money.md @@ -7,6 +7,7 @@ Read this before changing a balance, ledger, payment, wager settlement, or money - Debit, every corresponding credit, the business record, and its required audit entry commit or roll back together. Do not debit a remainder that is not credited somewhere. - Treat external payment calls as non-transactional. Persist a recoverable state first, make every settlement transition idempotent, and compensate a failed held withdrawal exactly once. - Auto-approval and any missing risk/KYC signal fail closed to manual review. A system decision records its actor and rationale before contacting the payment rail. +- A withdrawal held for manual review moves on only by an admin's approval or rejection. Auto-approval takes only a withdrawal nobody has held, and holding one moves no money: its funds were taken when it was requested. - Use the wallet command port for cross-domain transfers and pass the caller transaction. Never reach into wallet tables or rely on an event for a transfer that must be atomic. - A runtime-editable auto-approval config (a DB-backed threshold, cap, or exclusion set an admin can change without redeploy) must fail closed to manual review if the config row is unexpectedly missing - never silently default or create it outside its explicit admin write path. - A per-entity override narrows only the specific gate it targets. It must never implicitly bypass an independent gate (e.g. a risk or exclusion check) that the override was not designed to touch. diff --git a/packages/core/generators/src/templates/plugin.hbs b/packages/core/generators/src/templates/plugin.hbs index eddb1c5e7..4c807ed8c 100644 --- a/packages/core/generators/src/templates/plugin.hbs +++ b/packages/core/generators/src/templates/plugin.hbs @@ -9,7 +9,8 @@ import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; // ctx.events.on(topic, (payload) => ...) - subscribe to a domain event // 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 +// ctx.mcp.tool(defineMcpTool({...}), (c) => handler) - expose an agent tool (served only via MCP_KERNEL) +// ctx.actions.register(defineActionType({...}), (c) => ({ precondition, execute })) - an agent action type export default { id: '{{kebabCase name}}', // dependsOn: ['wallet'], // load after the plugin that owns a token you rebind diff --git a/packages/core/src/analytics/__tests__/analytics-agent-registration.test.ts b/packages/core/src/analytics/__tests__/analytics-agent-registration.test.ts new file mode 100644 index 000000000..608232d31 --- /dev/null +++ b/packages/core/src/analytics/__tests__/analytics-agent-registration.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; +import { Container, ModuleRegistryImpl, type CoreTokenCatalog } from '@openora/core/server'; +import analyticsPlugin from '../plugin.js'; + +describe('analytics agent surface registration', () => { + it('registers ggr.summary within the kernel contract rules', async () => { + const registry = new ModuleRegistryImpl(new Container()); + registry.setOwner('analytics'); + + await analyticsPlugin.register(registry); + + expect(registry.mcp.getTools().map((tool) => tool.contract.id)).toEqual(['ggr.summary']); + expect(registry.actions.getAll().map((action) => action.contract.id)).toEqual([]); + }); +}); diff --git a/packages/core/src/analytics/__tests__/ggr-summary.contract.test.ts b/packages/core/src/analytics/__tests__/ggr-summary.contract.test.ts new file mode 100644 index 000000000..3b90a7905 --- /dev/null +++ b/packages/core/src/analytics/__tests__/ggr-summary.contract.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import * as z from 'zod'; +import { + GGR_SUMMARY_MAX_DAYS, + GgrSummaryInputSchema, + resolveGgrSummaryRange, +} from '../contract/index.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const NOW = new Date('2026-03-15T12:00:00Z'); + +const dayOffset = (day: string, days: number) => + new Date(Date.parse(day) + days * DAY_MS).toISOString().slice(0, 10); +const today = () => new Date().toISOString().slice(0, 10); + +describe('resolveGgrSummaryRange', () => { + it('defaults to the 30 days ending on the day of now', () => { + expect(resolveGgrSummaryRange({}, NOW)).toEqual({ + dateFrom: '2026-02-14', + dateTo: '2026-03-15', + }); + }); + + it('ends a range with only a start on the day of now', () => { + expect(resolveGgrSummaryRange({ dateFrom: '2026-03-01' }, NOW)).toEqual({ + dateFrom: '2026-03-01', + dateTo: '2026-03-15', + }); + }); + + it('starts a range with only an end 30 days before it, across a leap day', () => { + expect(resolveGgrSummaryRange({ dateTo: '2024-03-10' }, NOW)).toEqual({ + dateFrom: '2024-02-10', + dateTo: '2024-03-10', + }); + }); + + it('keeps an explicit range as given', () => { + expect(resolveGgrSummaryRange({ dateFrom: '2025-01-01', dateTo: '2025-06-30' }, NOW)).toEqual({ + dateFrom: '2025-01-01', + dateTo: '2025-06-30', + }); + }); +}); + +describe('GgrSummaryInputSchema', () => { + const parse = (input: unknown) => z.safeParse(GgrSummaryInputSchema, input); + + it('defaults the granularity to week', () => { + expect(parse({})).toEqual({ success: true, data: { granularity: 'week' } }); + }); + + it(`accepts a range of exactly ${GGR_SUMMARY_MAX_DAYS} days`, () => { + const dateFrom = '2024-01-01'; + + expect(parse({ dateFrom, dateTo: dayOffset(dateFrom, GGR_SUMMARY_MAX_DAYS - 1) }).success).toBe( + true, + ); + }); + + it(`refuses a range longer than ${GGR_SUMMARY_MAX_DAYS} days`, () => { + const dateFrom = '2024-01-01'; + + const result = parse({ dateFrom, dateTo: dayOffset(dateFrom, GGR_SUMMARY_MAX_DAYS) }); + + expect(result.success).toBe(false); + expect(result.error?.issues.map((issue) => issue.path)).toEqual([['dateFrom']]); + }); + + it('refuses a start that alone reaches further back than the longest range', () => { + expect(parse({ dateFrom: dayOffset(today(), -GGR_SUMMARY_MAX_DAYS) }).success).toBe(false); + expect(parse({ dateFrom: dayOffset(today(), 1 - GGR_SUMMARY_MAX_DAYS) }).success).toBe(true); + }); + + it('refuses a range that ends before it starts', () => { + const result = parse({ dateFrom: '2026-03-15', dateTo: '2026-03-14' }); + + expect(result.success).toBe(false); + expect(result.error?.issues.map((issue) => issue.path)).toEqual([['dateFrom']]); + }); + + it('refuses a start in the future when the end defaults to today', () => { + expect(parse({ dateFrom: dayOffset(today(), 1) }).success).toBe(false); + }); + + it('refuses a date that does not exist and a currency that is not a ticker', () => { + expect(parse({ dateFrom: '2026-02-30' }).success).toBe(false); + expect(parse({ currency: 'US-DOLLAR' }).success).toBe(false); + }); +}); diff --git a/packages/core/src/analytics/__tests__/ggr-summary.int.test.ts b/packages/core/src/analytics/__tests__/ggr-summary.int.test.ts new file mode 100644 index 000000000..7789ac89f --- /dev/null +++ b/packages/core/src/analytics/__tests__/ggr-summary.int.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { sql } from 'drizzle-orm'; +import { + CACHE, + type RunContext, + type WalletTransactionStatus, + type WalletTransactionType, +} from '@openora/core/contracts'; +import { + Container, + DRIZZLE, + ModuleRegistryImpl, + findOneOrThrow, + type CoreTokenCatalog, +} from '@openora/core/server'; +import { createMcpKernel, createTestDb, type TestDb } from '@openora/core/testing'; +import { wallet, walletTransaction } from '@openora/core/wallet/schema'; +import { migrate as migrateWallet } from '@openora/core/wallet/migrate'; +import { makeAuditWriter, makeCache } from '../../testing/mock.js'; +import { FinancialAnalyticsService } from '../service/financial-analytics.service.js'; +import analyticsPlugin from '../plugin.js'; + +let db: TestDb; + +const FIRST_WEEK = '2026-03-02'; +const SECOND_WEEK = '2026-03-09'; +const LAST_DAY = '2026-03-15'; + +function bootKernel() { + const container = new Container(); + container.register(DRIZZLE, () => db.drizzle); + container.register(CACHE, () => makeCache()); + const registry = new ModuleRegistryImpl(container); + registry.setOwner('analytics'); + analyticsPlugin.register(registry); + return createMcpKernel({ + tools: registry.mcp.getTools(), + actions: registry.actions.getAll(), + container, + authorize: async () => 'allowed', + audit: makeAuditWriter(), + }); +} + +const runContext = (): RunContext => ({ + runId: randomUUID(), + actor: { kind: 'admin', adminId: randomUUID() }, + catalogVersion: 'test', + correlationId: 'ggr-summary', +}); + +async function seedRound( + type: WalletTransactionType, + amount: string, + createdAt: string, + { + currency = 'USD', + status = 'completed', + }: { currency?: string; status?: WalletTransactionStatus } = {}, +) { + const record = findOneOrThrow( + await db.drizzle.db.insert(wallet).values({ userId: randomUUID(), currency }).returning(), + new Error('seedRound: query returned no row'), + ); + await db.drizzle.db.insert(walletTransaction).values({ + walletId: record.id, + type, + amount, + currency, + status, + createdAt: new Date(createdAt), + }); +} + +beforeAll(async () => { + db = await createTestDb([migrateWallet]); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + await db.drizzle.db.execute( + sql`TRUNCATE ${walletTransaction}, ${wallet} RESTART IDENTITY CASCADE`, + ); + await seedRound('bet', '100', '2026-03-03T10:00:00Z'); + await seedRound('win', '30', '2026-03-04T10:00:00Z'); + await seedRound('bet_reversal', '10', '2026-03-05T10:00:00Z'); + await seedRound('bet', '50', '2026-03-10T10:00:00Z'); + await seedRound('win', '80', '2026-03-11T10:00:00Z'); + await seedRound('bet', '5', '2026-03-15T23:30:00Z'); + await seedRound('bet', '1000', '2026-03-12T10:00:00Z', { status: 'pending' }); + await seedRound('bet', '700', '2026-03-16T00:00:00Z'); + await seedRound('bet', '20', '2026-03-03T10:00:00Z', { currency: 'USDT' }); +}); + +describe('ggr.summary through the MCP kernel (real PG)', () => { + it('buckets completed GGR per currency over whole days, the last day included', async () => { + const result = await bootKernel().invokeTool( + 'ggr.summary', + { dateFrom: FIRST_WEEK, dateTo: LAST_DAY }, + runContext(), + ); + + expect(result).toEqual({ + ok: true, + output: { + dateFrom: FIRST_WEEK, + dateTo: LAST_DAY, + granularity: 'week', + series: [ + { + currency: 'USD', + points: [ + { bucket: FIRST_WEEK, ggr: '60.000000000000000000' }, + { bucket: SECOND_WEEK, ggr: '-25.000000000000000000' }, + ], + }, + { + currency: 'USDT', + points: [ + { bucket: FIRST_WEEK, ggr: '20.000000000000000000' }, + { bucket: SECOND_WEEK, ggr: '0' }, + ], + }, + ], + }, + }); + }); + + it('narrows to one currency whatever its case', async () => { + const result = await bootKernel().invokeTool( + 'ggr.summary', + { dateFrom: FIRST_WEEK, dateTo: LAST_DAY, currency: 'usdt', granularity: 'month' }, + runContext(), + ); + + expect(result).toMatchObject({ + ok: true, + output: { + granularity: 'month', + series: [ + { currency: 'USDT', points: [{ bucket: '2026-03-01', ggr: '20.000000000000000000' }] }, + ], + }, + }); + }); + + it('refuses a range that ends before it starts', async () => { + const result = await bootKernel().invokeTool( + 'ggr.summary', + { dateFrom: LAST_DAY, dateTo: FIRST_WEEK }, + runContext(), + ); + + expect(result).toMatchObject({ ok: false, error: 'invalid_input' }); + }); +}); + +describe('FinancialAnalyticsService.ggrSummary (real PG)', () => { + it('defaults to the 30 days ending on the day of now', async () => { + const service = new FinancialAnalyticsService(db.drizzle, makeCache()); + + const summary = await service.ggrSummary( + { granularity: 'week' }, + new Date(`${LAST_DAY}T12:00:00Z`), + ); + + expect(summary).toMatchObject({ + dateFrom: '2026-02-14', + dateTo: LAST_DAY, + granularity: 'week', + }); + expect(summary.series.find((s) => s.currency === 'USD')?.points.at(-1)).toEqual({ + bucket: SECOND_WEEK, + ggr: '-25.000000000000000000', + }); + }); +}); diff --git a/packages/core/src/analytics/contract/agent-tools.ts b/packages/core/src/analytics/contract/agent-tools.ts new file mode 100644 index 000000000..8f5652679 --- /dev/null +++ b/packages/core/src/analytics/contract/agent-tools.ts @@ -0,0 +1,80 @@ +import * as z from 'zod'; +import { CurrencyTickerSchema, GranularitySchema, defineMcpTool } from '@openora/core/contracts'; +import { GgrSeriesSchema } from './financial.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const GGR_SUMMARY_DEFAULT_DAYS = 30; +export const GGR_SUMMARY_MAX_DAYS = 366; + +const isoDay = (epochMs: number) => new Date(epochMs).toISOString().slice(0, 10); + +/** + * The whole UTC days a ggr.summary call covers, both ends inclusive: `dateTo` defaults to the + * day of `now`, and `dateFrom` to the 30 days ending on `dateTo`. + */ +export function resolveGgrSummaryRange( + { dateFrom, dateTo }: { dateFrom?: string; dateTo?: string }, + now = new Date(), +) { + const to = dateTo ?? isoDay(now.getTime()); + return { + dateFrom: dateFrom ?? isoDay(Date.parse(to) - (GGR_SUMMARY_DEFAULT_DAYS - 1) * DAY_MS), + dateTo: to, + }; +} + +function ggrSummaryRangeDays({ dateFrom, dateTo }: { dateFrom: string; dateTo: string }) { + return (Date.parse(dateTo) - Date.parse(dateFrom)) / DAY_MS + 1; +} + +export const GgrSummaryInputSchema = z + .object({ + dateFrom: z.iso.date().optional(), + dateTo: z.iso.date().optional(), + currency: CurrencyTickerSchema.max(10).optional(), + granularity: GranularitySchema.default('week'), + }) + .superRefine((input, ctx) => { + const range = resolveGgrSummaryRange(input); + if (range.dateFrom > range.dateTo) { + ctx.addIssue({ + code: 'custom', + path: ['dateFrom'], + message: 'dateFrom must be on or before dateTo', + }); + return; + } + if (ggrSummaryRangeDays(range) > GGR_SUMMARY_MAX_DAYS) { + ctx.addIssue({ + code: 'custom', + path: ['dateFrom'], + message: `the range spans at most ${GGR_SUMMARY_MAX_DAYS} days`, + }); + } + }); +export type GgrSummaryInput = z.infer; + +export const GgrSummaryOutputSchema = z.object({ + dateFrom: z.iso.date(), + dateTo: z.iso.date(), + granularity: GranularitySchema, + series: z.array(GgrSeriesSchema.extend({ currency: CurrencyTickerSchema })), +}); +export type GgrSummary = z.infer; + +export const ggrSummaryTool = defineMcpTool({ + id: 'ggr.summary', + title: 'GGR summary', + description: + 'Operator-wide gross gaming revenue - completed bets minus bet reversals minus wins - per ' + + 'currency, in day, week or month buckets over whole UTC days from dateFrom through dateTo. ' + + 'Defaults to the 30 days ending today; a range spans at most 366 days. Amounts are decimal ' + + 'strings and can be negative.', + class: 'read', + schemaVersion: 1, + iam: { resource: 'analytics', action: 'view' }, + inputSchema: GgrSummaryInputSchema, + outputSchema: GgrSummaryOutputSchema, + redact: { allow: ['dateFrom', 'dateTo', 'granularity', 'series'] }, + errors: [], +}); diff --git a/packages/core/src/analytics/contract/index.ts b/packages/core/src/analytics/contract/index.ts index 3bb5b010b..766fa7c8b 100644 --- a/packages/core/src/analytics/contract/index.ts +++ b/packages/core/src/analytics/contract/index.ts @@ -1,5 +1,6 @@ export * from './financial.js'; export * from './funnel.js'; +export * from './agent-tools.js'; import { financialContract } from './financial.js'; import { funnelContract } from './funnel.js'; diff --git a/packages/core/src/analytics/plugin.ts b/packages/core/src/analytics/plugin.ts index 54dd8740a..e99ce9486 100644 --- a/packages/core/src/analytics/plugin.ts +++ b/packages/core/src/analytics/plugin.ts @@ -1,6 +1,7 @@ import { CACHE } from '@openora/core/contracts'; import { ADMIN_GUARD, DRIZZLE } from '@openora/core/server'; -import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin, TypedContainer } from '@openora/core/server'; +import { ggrSummaryTool } from './contract/index.js'; import { FinancialAnalyticsService } from './service/financial-analytics.service.js'; import { FunnelAnalyticsService } from './service/funnel-analytics.service.js'; import { createAnalyticsRouter } from './router/index.js'; @@ -9,9 +10,18 @@ export default { id: 'analytics', dependsOn: ['wallet', 'identity', 'profile', 'gaming'], register(ctx) { + let financialRef: FinancialAnalyticsService | null = null; + const financialAnalytics = (c: TypedContainer) => + (financialRef ??= new FinancialAnalyticsService(c.get(DRIZZLE), c.get(CACHE))); + + ctx.mcp.tool(ggrSummaryTool, (c) => { + const financial = financialAnalytics(c); + return (input) => financial.ggrSummary(input); + }); + ctx.routers.add('analytics', (c) => createAnalyticsRouter( - new FinancialAnalyticsService(c.get(DRIZZLE), c.get(CACHE)), + financialAnalytics(c), new FunnelAnalyticsService(c.get(DRIZZLE), c.get(CACHE)), c.get(ADMIN_GUARD), ), diff --git a/packages/core/src/analytics/service/financial-analytics.service.ts b/packages/core/src/analytics/service/financial-analytics.service.ts index 02fa6a981..c078d949a 100644 --- a/packages/core/src/analytics/service/financial-analytics.service.ts +++ b/packages/core/src/analytics/service/financial-analytics.service.ts @@ -2,11 +2,13 @@ import { and, eq, gte, inArray, lte, sql } from 'drizzle-orm'; import type { CacheAdapter, Granularity } from '@openora/core/contracts'; import { DrizzleService } from '@openora/core/server'; import { walletTransaction } from '@openora/core/wallet/schema'; -import type { - FinancialGgrQuery, - FinancialSummary, - FinancialSummaryQuery, - GgrSeries, +import { + resolveGgrSummaryRange, + type FinancialGgrQuery, + type FinancialSummary, + type FinancialSummaryQuery, + type GgrSeries, + type GgrSummaryInput, } from '../contract/index.js'; const ANALYTICS_CACHE_TTL_MS = 60_000; @@ -60,6 +62,17 @@ export class FinancialAnalyticsService { return this.cached('financial.ggr', query, () => this.computeGgr(query)); } + async ggrSummary({ currency, granularity, ...dates }: GgrSummaryInput, now = new Date()) { + const range = resolveGgrSummaryRange(dates, now); + const series = await this.ggr({ + dateFrom: `${range.dateFrom}T00:00:00.000Z`, + dateTo: `${range.dateTo}T23:59:59.999Z`, + currency: currency?.toUpperCase(), + granularity, + }); + return { ...range, granularity, series }; + } + private async cached(scope: string, query: object, compute: () => Promise): Promise { const key = `analytics:${scope}:${JSON.stringify(query, Object.keys(query).sort())}`; const cached = await this.cache.get(key); diff --git a/packages/core/src/compliance/__tests__/compliance-agent-registration.test.ts b/packages/core/src/compliance/__tests__/compliance-agent-registration.test.ts new file mode 100644 index 000000000..ef15ed667 --- /dev/null +++ b/packages/core/src/compliance/__tests__/compliance-agent-registration.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { Container, ModuleRegistryImpl, type CoreTokenCatalog } from '@openora/core/server'; +import compliancePlugin from '../plugin.js'; + +describe('compliance agent surface registration', () => { + it('registers kyc.status and request_enhanced_kyc within the kernel contract rules', async () => { + const registry = new ModuleRegistryImpl(new Container()); + registry.setOwner('compliance'); + + await compliancePlugin.register(registry); + + expect(registry.mcp.getTools().map((tool) => tool.contract.id)).toEqual(['kyc.status']); + expect(registry.actions.getAll().map((action) => action.contract.id)).toEqual([ + 'request_enhanced_kyc', + ]); + }); +}); diff --git a/packages/core/src/compliance/__tests__/kyc-agent-tools.int.test.ts b/packages/core/src/compliance/__tests__/kyc-agent-tools.int.test.ts new file mode 100644 index 000000000..416aadd6f --- /dev/null +++ b/packages/core/src/compliance/__tests__/kyc-agent-tools.int.test.ts @@ -0,0 +1,382 @@ +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { and, eq, sql } from 'drizzle-orm'; +import { + Container, + DRIZZLE, + EVENT_BUS, + ModuleRegistryImpl, + findOneOrThrow, + withAdvisoryXactLock, + type CoreTokenCatalog, +} from '@openora/core/server'; +import { + AUDIT_WRITER, + IDENTITY_READER, + KYC_ADAPTER, + KYC_STATUS_WRITER, + type IdentityReader, + type KycAdapter, + type KycStatus, + type KycStatusWriter, + type KycTier, + type RunContext, +} from '@openora/core/contracts'; +import { createMcpKernel, createTestDb, type TestDb } from '@openora/core/testing'; +import { player } from '@openora/core/pam/schema/profile'; +import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; +import { makeAuditWriter, makeEventBus, makeIdentityReader, mock } from '../../testing/mock.js'; +import { migrate } from '../migrate.js'; +import { kycVerification } from '../schema/index.js'; +import compliancePlugin from '../plugin.js'; + +let db: TestDb; + +const REASON = 'deposits outgrew the declared income'; + +const userIdByPlayerId = new Map(); +const kycStatusByUserId = new Map(); + +function playerDirectory(): IdentityReader { + return { + ...makeIdentityReader(), + getUserIdByPlayerId: vi.fn(async (playerId: string) => userIdByPlayerId.get(playerId) ?? null), + getPlayerKycStatusByUserId: vi.fn( + async (userId: string) => kycStatusByUserId.get(userId) ?? null, + ), + }; +} + +function bootKernel() { + const container = new Container(); + const events = makeEventBus(); + container.register(DRIZZLE, () => db.drizzle); + container.register(EVENT_BUS, () => events); + container.register(AUDIT_WRITER, () => makeAuditWriter()); + container.register(IDENTITY_READER, () => playerDirectory()); + container.register(KYC_ADAPTER, () => mock({})); + container.register(KYC_STATUS_WRITER, () => + mock({ setStatus: vi.fn(async () => null) }), + ); + const registry = new ModuleRegistryImpl(container); + registry.setOwner('compliance'); + compliancePlugin.register(registry); + const kernel = createMcpKernel({ + tools: registry.mcp.getTools(), + actions: registry.actions.getAll(), + container, + authorize: async () => 'allowed', + audit: makeAuditWriter(), + }); + return { kernel, events }; +} + +const adminId = randomUUID(); + +const runContext = (): RunContext => ({ + runId: randomUUID(), + actor: { kind: 'agent', agentId: randomUUID(), agentVersion: 1, onBehalfOf: adminId }, + catalogVersion: 'test', + correlationId: 'kyc-agent-tools', +}); + +const execution = (proposalId: string = randomUUID()) => ({ + proposalId, + actor: runContext().actor, + correlationId: 'kyc-agent-tools', +}); + +async function seedPlayer(kycStatus: KycStatus = 'pending') { + const row = findOneOrThrow( + await db.drizzle.db.insert(player).values({ userId: randomUUID(), kycStatus }).returning(), + new Error('seedPlayer: query returned no row'), + ); + userIdByPlayerId.set(row.id, row.userId); + kycStatusByUserId.set(row.userId, kycStatus); + return row; +} + +async function seedVerification( + userId: string, + tier: KycTier, + status: KycStatus, + overrides: Partial = {}, +) { + return findOneOrThrow( + await db.drizzle.db + .insert(kycVerification) + .values({ + userId, + provider: 'vendor', + referenceId: `ref-${randomUUID()}`, + tier, + status, + documentTypes: ['passport'], + triggeredBy: 'submission', + ...overrides, + }) + .returning(), + new Error('seedVerification: query returned no row'), + ); +} + +async function advancedRows(userId: string) { + return db.drizzle.db + .select() + .from(kycVerification) + .where(and(eq(kycVerification.userId, userId), eq(kycVerification.tier, 'advanced'))); +} + +const daysAgo = (days: number) => new Date(Date.now() - days * 24 * 60 * 60 * 1000); + +beforeAll(async () => { + db = await createTestDb([migrate, migrateProfile]); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + userIdByPlayerId.clear(); + kycStatusByUserId.clear(); + await db.drizzle.db.execute(sql`TRUNCATE ${kycVerification}, ${player} RESTART IDENTITY CASCADE`); +}); + +describe('kyc.status through the MCP kernel (real PG)', () => { + it('reports the normalized basic status and the current verification of each tier', async () => { + const { kernel } = bootKernel(); + const row = await seedPlayer('verified'); + await seedVerification(row.userId, 'basic', 'rejected', { createdAt: daysAgo(3) }); + const basic = await seedVerification(row.userId, 'basic', 'verified', { + decisionReason: 'documents match', + decidedAt: daysAgo(1), + createdAt: daysAgo(2), + }); + const advanced = await seedVerification(row.userId, 'advanced', 'pending', { + documentTypes: ['national_id'], + riskSignals: { + vpnOrTorDetected: true, + dataCenterIpDetected: false, + duplicateDeviceDetected: false, + highRiskCountryDetected: false, + deviceFingerprints: ['fp-1'], + }, + }); + + const result = await kernel.invokeTool('kyc.status', { playerId: row.id }, runContext()); + + expect(result).toEqual({ + ok: true, + output: { + playerId: row.id, + kycStatus: 'approved', + basic: { + status: 'approved', + provider: 'vendor', + documentTypes: ['passport'], + triggeredBy: 'submission', + decidedAt: basic.decidedAt?.toISOString(), + createdAt: basic.createdAt.toISOString(), + }, + advanced: { + status: 'pending', + provider: 'vendor', + documentTypes: ['national_id'], + triggeredBy: 'submission', + decidedAt: null, + createdAt: advanced.createdAt.toISOString(), + }, + basicDecisionReason: 'documents match', + advancedDecisionReason: null, + }, + }); + }); + + it('reports no verification for a player who never started one', async () => { + const { kernel } = bootKernel(); + const row = await seedPlayer('pending'); + + const result = await kernel.invokeTool('kyc.status', { playerId: row.id }, runContext()); + + expect(result).toEqual({ + ok: true, + output: { + playerId: row.id, + kycStatus: 'pending', + basic: null, + advanced: null, + basicDecisionReason: null, + advancedDecisionReason: null, + }, + }); + }); + + it('answers player_not_found for an unknown player', async () => { + const { kernel } = bootKernel(); + + const result = await kernel.invokeTool('kyc.status', { playerId: randomUUID() }, runContext()); + + expect(result).toEqual({ ok: false, error: 'player_not_found' }); + }); +}); + +describe('request_enhanced_kyc through the MCP kernel (real PG)', () => { + const precondition = (kernel: ReturnType['kernel'], playerId: string) => + kernel.checkPrecondition('request_enhanced_kyc', { playerId, reason: REASON }, runContext()); + + it('passes the precondition for a player with no advanced verification in flight', async () => { + const { kernel } = bootKernel(); + const fresh = await seedPlayer(); + const decided = await seedPlayer('approved'); + await seedVerification(decided.userId, 'advanced', 'approved', { decidedAt: daysAgo(30) }); + + expect(await precondition(kernel, fresh.id)).toEqual({ ok: true }); + expect(await precondition(kernel, decided.id)).toEqual({ ok: true }); + }); + + it('refuses the precondition with the code for each blocked case', async () => { + const { kernel } = bootKernel(); + const requested = await seedPlayer(); + await seedVerification(requested.userId, 'advanced', 'resubmission_requested', { + provider: 'manual', + triggeredBy: 'manual', + }); + const verifying = await seedPlayer(); + await seedVerification(verifying.userId, 'advanced', 'pending'); + const opened = await seedPlayer(); + await seedVerification(opened.userId, 'advanced', 'not_started'); + + expect(await precondition(kernel, randomUUID())).toEqual({ + ok: false, + error: 'player_not_found', + }); + expect(await precondition(kernel, requested.id)).toEqual({ + ok: false, + error: 'already_requested', + }); + expect(await precondition(kernel, verifying.id)).toEqual({ + ok: false, + error: 'verification_in_progress', + }); + expect(await precondition(kernel, opened.id)).toEqual({ + ok: false, + error: 'verification_in_progress', + }); + }); + + it('requests once per proposal: a replay resolves already_applied with one advanced row', async () => { + const { kernel, events } = bootKernel(); + const row = await seedPlayer('approved'); + const proposal = execution(); + const payload = { playerId: row.id, reason: REASON }; + + const first = await kernel.executeAction('request_enhanced_kyc', payload, proposal); + const replay = await kernel.executeAction('request_enhanced_kyc', payload, proposal); + + expect(first).toEqual({ ok: true, outcome: 'applied' }); + expect(replay).toEqual({ ok: true, outcome: 'already_applied' }); + const rows = await advancedRows(row.userId); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + status: 'resubmission_requested', + provider: 'manual', + triggeredBy: 'manual', + decisionReason: REASON, + }); + const updates = events.emit.mock.calls.filter(([topic]) => topic === 'compliance.kyc.updated'); + expect(updates).toHaveLength(1); + expect(updates[0]?.[1]).toMatchObject({ + userId: row.userId, + actorId: adminId, + tier: 'advanced', + status: 'resubmission_requested', + source: 'manual', + }); + }); + + it('refuses at execution when a verification started after the proposal was checked', async () => { + const { kernel } = bootKernel(); + const row = await seedPlayer(); + expect(await precondition(kernel, row.id)).toEqual({ ok: true }); + await seedVerification(row.userId, 'advanced', 'pending'); + + const result = await kernel.executeAction( + 'request_enhanced_kyc', + { playerId: row.id, reason: REASON }, + execution(), + ); + + expect(result).toEqual({ ok: false, error: 'verification_in_progress' }); + expect((await advancedRows(row.userId)).map((r) => r.status)).toEqual(['pending']); + }); + + it('waits for an advanced submit holding the lock, then refuses the verification it started', async () => { + const { kernel } = bootKernel(); + const row = await seedPlayer(); + const submitLock = `kyc-submit:${row.userId}:advanced`; + let releaseSubmit = () => {}; + const submitMayCommit = new Promise((resolve) => { + releaseSubmit = resolve; + }); + let submitStarted = () => {}; + const submitHoldsLock = new Promise((resolve) => { + submitStarted = resolve; + }); + const submit = db.drizzle.db.transaction((trx) => + withAdvisoryXactLock(trx, submitLock, async () => { + await trx.insert(kycVerification).values({ + userId: row.userId, + provider: 'vendor', + referenceId: `ref-${randomUUID()}`, + tier: 'advanced', + status: 'pending', + documentTypes: ['passport'], + triggeredBy: 'submission', + }); + submitStarted(); + await submitMayCommit; + }), + ); + await submitHoldsLock; + + const request = kernel.executeAction( + 'request_enhanced_kyc', + { playerId: row.id, reason: REASON }, + execution(), + ); + await waitForAdvisoryLockWaiter(); + releaseSubmit(); + await submit; + + expect(await request).toEqual({ ok: false, error: 'verification_in_progress' }); + expect((await advancedRows(row.userId)).map((r) => r.status)).toEqual(['pending']); + }); + + it('answers player_not_found for an unknown player', async () => { + const { kernel } = bootKernel(); + + const result = await kernel.executeAction( + 'request_enhanced_kyc', + { playerId: randomUUID(), reason: REASON }, + execution(), + ); + + expect(result).toEqual({ ok: false, error: 'player_not_found' }); + }); +}); + +async function waitForAdvisoryLockWaiter() { + for (let attempt = 0; attempt < 250; attempt += 1) { + const { rows } = await db.drizzle.db.execute<{ waiting: number }>( + sql`select count(*)::int as waiting from pg_locks + where locktype = 'advisory' and not granted + and database = (select oid from pg_database where datname = current_database())`, + ); + if ((rows[0]?.waiting ?? 0) > 0) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error('no transaction started waiting on the advisory lock'); +} diff --git a/packages/core/src/compliance/contract/agent-tools.ts b/packages/core/src/compliance/contract/agent-tools.ts new file mode 100644 index 000000000..c373fd060 --- /dev/null +++ b/packages/core/src/compliance/contract/agent-tools.ts @@ -0,0 +1,80 @@ +import * as z from 'zod'; +import { + KycStatusSchema, + NonEmptyReasonSchema, + TimestampSchema, + UuidSchema, + defineActionType, + defineMcpTool, +} from '@openora/core/contracts'; +import { KycDocumentTypeSchema, KycTriggeredBySchema } from './enums.js'; + +export const KycStandingVerificationSchema = z.object({ + status: KycStatusSchema, + provider: z.string(), + documentTypes: z.array(KycDocumentTypeSchema), + triggeredBy: KycTriggeredBySchema, + decidedAt: TimestampSchema.nullable(), + createdAt: TimestampSchema, +}); +export type KycStandingVerification = z.infer; + +export const KycStandingInputSchema = z.object({ playerId: UuidSchema }); + +export const KycStandingOutputSchema = z.object({ + playerId: UuidSchema, + kycStatus: KycStatusSchema, + basic: KycStandingVerificationSchema.nullable(), + advanced: KycStandingVerificationSchema.nullable(), + basicDecisionReason: z.string().nullable(), + advancedDecisionReason: z.string().nullable(), +}); +export type KycStanding = z.infer; + +export const kycStatusTool = defineMcpTool({ + id: 'kyc.status', + title: 'KYC status', + description: + "A player's KYC standing: kycStatus is their basic-tier status; basic and advanced are " + + 'the current verification of each tier (advanced is enhanced KYC) with its status, ' + + 'provider, document types, what triggered it, and when it was created and decided, or ' + + "null when the player has none on that tier. The vendor's free-text decision reason for " + + 'each tier is returned separately.', + class: 'read', + schemaVersion: 1, + iam: { resource: 'compliance', action: 'view' }, + inputSchema: KycStandingInputSchema, + outputSchema: KycStandingOutputSchema, + redact: { + allow: [ + 'playerId', + 'kycStatus', + 'basic', + 'advanced', + 'basicDecisionReason', + 'advancedDecisionReason', + ], + personal: ['basicDecisionReason', 'advancedDecisionReason'], + }, + errors: ['player_not_found'], +}); + +export const RequestEnhancedKycPayloadSchema = z.object({ + playerId: UuidSchema, + reason: NonEmptyReasonSchema.max(500), +}); +export type RequestEnhancedKycPayload = z.infer; + +export const requestEnhancedKycAction = defineActionType({ + id: 'request_enhanced_kyc', + title: 'Request enhanced KYC', + description: + 'Asks a player to complete advanced-tier (enhanced) KYC verification, recorded with the ' + + 'reason. Refused when the player already has one requested, or has an advanced ' + + 'verification open or awaiting a decision. The request cannot be undone.', + schemaVersion: 1, + iam: { resource: 'compliance', action: 'override-limit' }, + reversible: false, + payloadSchema: RequestEnhancedKycPayloadSchema, + errors: ['player_not_found', 'already_requested', 'verification_in_progress'], +}); diff --git a/packages/core/src/compliance/contract/enums.ts b/packages/core/src/compliance/contract/enums.ts index 6f2b43857..c9ef8f277 100644 --- a/packages/core/src/compliance/contract/enums.ts +++ b/packages/core/src/compliance/contract/enums.ts @@ -1,3 +1,5 @@ +import * as z from 'zod'; + export const KYC_DOCUMENT_TYPES = ['passport', 'drivers_license', 'national_id'] as const; export const KYC_TRIGGERED_BY = [ 'submission', @@ -5,3 +7,7 @@ export const KYC_TRIGGERED_BY = [ 'manual', 'exemption', ] as const; + +export const KycDocumentTypeSchema = z.enum(KYC_DOCUMENT_TYPES); + +export const KycTriggeredBySchema = z.enum(KYC_TRIGGERED_BY); diff --git a/packages/core/src/compliance/contract/index.ts b/packages/core/src/compliance/contract/index.ts index 3753750ce..718289f94 100644 --- a/packages/core/src/compliance/contract/index.ts +++ b/packages/core/src/compliance/contract/index.ts @@ -12,13 +12,11 @@ import { PageQuerySchema, paginated, } from '@openora/core/contracts'; -import { KYC_DOCUMENT_TYPES, KYC_TRIGGERED_BY } from './enums.js'; +import { KycDocumentTypeSchema, KycTriggeredBySchema } from './enums.js'; import { LimitSchema, LimitViewSchema, UpsertLimitInputSchema } from './limits.js'; import { rgContract } from './rg.js'; -export const KycDocumentTypeSchema = z.enum(KYC_DOCUMENT_TYPES); - -export const KycTriggeredBySchema = z.enum(KYC_TRIGGERED_BY); +export { KycDocumentTypeSchema, KycTriggeredBySchema }; export const KycDocumentSchema = z.object({ type: KycDocumentTypeSchema, @@ -390,3 +388,4 @@ export const complianceContract = { export * from './limits.js'; export * from './rg.js'; +export * from './agent-tools.js'; diff --git a/packages/core/src/compliance/plugin.ts b/packages/core/src/compliance/plugin.ts index 8c3bbfc41..25370260a 100644 --- a/packages/core/src/compliance/plugin.ts +++ b/packages/core/src/compliance/plugin.ts @@ -19,6 +19,7 @@ import { KycTierSchema, LOGIN_ENFORCEMENT, MAIL_DISPATCH, + McpToolError, PLATFORM_CONFIG, REALTIME_TRANSPORT, RG_LIMITS, @@ -26,11 +27,19 @@ import { defaultResponsibleGamingConfig, domainEventSchemas, queue, + runActorAdminId, + userIdOfPlayer, type JobQueueAdapter, type RealtimeTransport, } from '@openora/core/contracts'; +import { kycStatusTool, requestEnhancedKycAction } from './contract/index.js'; import { ComplianceService } from './service/compliance.service.js'; -import { KycVerificationService } from './service/kyc.service.js'; +import { + KycResubmissionAlreadyRequestedError, + KycVerificationInProgressError, + KycVerificationService, + PlayerNotFoundError, +} from './service/kyc.service.js'; import { RgService } from './service/rg.service.js'; import { RgSelfServiceService } from './service/rg-self-service.service.js'; import { @@ -85,6 +94,19 @@ const KycExemptionJobSchema = z.object({ countryCode: z.string().length(2).nullable(), }); +function enhancedKycRefusalCode(err: unknown) { + if (err instanceof PlayerNotFoundError) { + return 'player_not_found'; + } + if (err instanceof KycResubmissionAlreadyRequestedError) { + return 'already_requested'; + } + if (err instanceof KycVerificationInProgressError) { + return 'verification_in_progress'; + } + throw err; +} + export default { id: 'compliance', dependsOn: ['player-management', 'identity', 'wallet', 'gaming', 'audit', 'exchange-rate'], @@ -117,9 +139,71 @@ export default { directory: c.has(ADMIN_USER_DIRECTORY) ? c.get(ADMIN_USER_DIRECTORY) : null, rates: c.get(EXCHANGE_RATE_READER), })); + // One instance backs the router, its event/job hooks and the agent tools; either factory + // may build it first. + const kycVerification = (c: TypedContainer) => + (kycRef ??= new KycVerificationService({ + drizzle: c.get(DRIZZLE), + events: c.get(EVENT_BUS), + audit: c.get(AUDIT_WRITER), + kycAdapter: c.get(KYC_ADAPTER), + statusWriter: c.get(KYC_STATUS_WRITER), + identityReader: c.get(IDENTITY_READER), + platformConfig: c.has(PLATFORM_CONFIG) ? c.get(PLATFORM_CONFIG) : undefined, + exchangeRateReader: c.has(EXCHANGE_RATE_READER) ? c.get(EXCHANGE_RATE_READER) : undefined, + })); let jobQueueRef: JobQueueAdapter | null = null; let realtimeTransport: RealtimeTransport | null = null; + ctx.mcp.tool(kycStatusTool, (c) => { + const identity = c.get(IDENTITY_READER); + if (!identity.getUserIdByPlayerId) { + logger.warn( + 'the bound IDENTITY_READER has no getUserIdByPlayerId - kyc.status and request_enhanced_kyc will answer internal_error', + ); + } + const kyc = kycVerification(c); + return async ({ playerId }) => { + const userId = await userIdOfPlayer(identity, playerId); + const standing = userId ? await kyc.getKycStanding(userId) : null; + if (!standing) { + throw new McpToolError('player_not_found'); + } + return { playerId, ...standing }; + }; + }); + + ctx.actions.register(requestEnhancedKycAction, (c) => { + const identity = c.get(IDENTITY_READER); + const kyc = kycVerification(c); + return { + precondition: async ({ playerId }) => { + const userId = await userIdOfPlayer(identity, playerId); + if (!userId) { + return { ok: false, code: 'player_not_found' }; + } + try { + await kyc.assertEnhancedKycRequestable(userId); + } catch (err) { + return { ok: false, code: enhancedKycRefusalCode(err) }; + } + return { ok: true }; + }, + execute: async ({ playerId, reason }, _proposalId, actor) => { + const userId = await userIdOfPlayer(identity, playerId); + if (!userId) { + throw new McpToolError('player_not_found'); + } + const { changed } = await kyc + .requestEnhancedKyc({ userId, reason, actorId: runActorAdminId(actor) }) + .catch((err: unknown) => { + throw new McpToolError(enhancedKycRefusalCode(err)); + }); + return { outcome: changed ? 'applied' : 'already_applied' }; + }, + }; + }); + ctx.events.on('compliance.kyc.updated', (payload, envelope) => { const parsed = domainEventSchemas['compliance.kyc.updated'].safeParse(payload); // kycStatusChannel is the basic-tier withdrawal-status stream; advanced-tier @@ -290,17 +374,7 @@ export default { } logger.warn(msg); } - const kyc = new KycVerificationService({ - drizzle: c.get(DRIZZLE), - events: c.get(EVENT_BUS), - audit: c.get(AUDIT_WRITER), - kycAdapter, - statusWriter: c.get(KYC_STATUS_WRITER), - identityReader: c.get(IDENTITY_READER), - platformConfig, - exchangeRateReader: c.has(EXCHANGE_RATE_READER) ? c.get(EXCHANGE_RATE_READER) : undefined, - }); - kycRef = kyc; + const kyc = kycVerification(c); const compliance = makeComplianceService(c); complianceRef = compliance; diff --git a/packages/core/src/compliance/service/kyc.service.ts b/packages/core/src/compliance/service/kyc.service.ts index 812222966..bce09b1ea 100644 --- a/packages/core/src/compliance/service/kyc.service.ts +++ b/packages/core/src/compliance/service/kyc.service.ts @@ -4,6 +4,7 @@ import { createDomainError, createLogger, findOneOrThrow, + makeConflictError, makeNotFoundError, mapConcurrent, reportError, @@ -69,6 +70,21 @@ export const PlayerNotFoundError = makeNotFoundError('Player'); export const KycVerificationNotFoundError = makeNotFoundError('KycVerification'); +export const KycResubmissionAlreadyRequestedError = makeConflictError( + 'KycResubmissionAlreadyRequestedError', + 'A resubmission is already requested for this tier', +); + +// A vendor session the player opened (not_started) is as live as one awaiting a decision +// (pending): submit() treats both as the session to continue, and a newer manual row would +// shadow it. +const ENHANCED_KYC_IN_PROGRESS: ReadonlySet = new Set(['pending', 'not_started']); + +export const KycVerificationInProgressError = makeConflictError( + 'KycVerificationInProgressError', + 'A verification of this tier is in progress', +); + // Fail-closed: a referenceId is supposed to belong to one player. If rows sharing one // ever don't, that's a vendor reference collision - refuse to touch any of them rather // than risk writing one player's decision onto another player's kyc_verification row. @@ -155,6 +171,19 @@ function toSummaryDto(dto: NonNullable): KycV }; } +// An allow-list, not a spread: vendor reference ids, risk signals and checks never reach an agent. +// The decision reason is the vendor's free text and travels separately, as personal data. +function toStandingVerification(dto: NonNullable) { + return { + status: normalizeKycStatus(dto.status), + provider: dto.provider, + documentTypes: dto.documentTypes, + triggeredBy: dto.triggeredBy, + decidedAt: dto.decidedAt, + createdAt: dto.createdAt, + }; +} + // Player-facing projection: strips riskSignals, checks, decisionReason, provider, and // referenceId - those are fraud-detection internals, admin-only via getPlayerKyc. export function toPlayerSummaryView(view: PlayerKycView): PlayerKycSummaryView { @@ -607,6 +636,27 @@ export class KycVerificationService { }; } + /** + * The player's KYC standing for an agent: the basic-tier status their profile holds and the + * current verification of each tier. Null when the player has no profile. + */ + async getKycStanding(userId: User['id']) { + const [kycStatus, view] = await Promise.all([ + this.identityReader.getPlayerKycStatusByUserId(userId), + this.getForPlayer(userId), + ]); + if (!kycStatus) { + return null; + } + return { + kycStatus: normalizeKycStatus(kycStatus), + basic: view.basic.current ? toStandingVerification(view.basic.current) : null, + advanced: view.advanced.current ? toStandingVerification(view.advanced.current) : null, + basicDecisionReason: view.basic.current?.decisionReason ?? null, + advancedDecisionReason: view.advanced.current?.decisionReason ?? null, + }; + } + // Names which of `rows`' currencies sumInPivot could not price, for the error log/report // when a re-KYC evaluation has to be skipped - sumInPivot itself only says "some row failed". private async unpricedCurrencies( @@ -806,48 +856,111 @@ export class KycVerificationService { reason: string, actorId: User['id'], ) { - const outcome = await this.drizzle.db.transaction(async (trx) => { - const current = await this.requirePlayerRowForUpdate(userId, trx); - const latest = await this.latestVerification(userId, tier, trx); - const currentStatus = tier === 'basic' ? current.kycStatus : latest?.status; - if (currentStatus === 'resubmission_requested') { - return { - row: latest ? toDto(latest) : null, - playerTransition: null, - previousStatus: currentStatus, - changed: false, - }; - } - const applied = await this.applyManualDecision(trx, { + const outcome = await this.drizzle.db.transaction((trx) => + this.applyResubmissionRequest(trx, { userId, tier, - status: 'resubmission_requested', reason, actorId, - referenceIdPrefix: 'manual-resubmit', - decidedAt: null, - }); - // compliance.kyc.updated is the audit-visible status-change event (docs/standards/ - // compliance.md): emitted here, inside the transaction, so it can never be dropped - // by a crash between commit and a post-commit emit. - await this.emitUpdated({ - userId, - tier, - status: 'resubmission_requested', - previousStatus: currentStatus ?? null, - playerTransition: applied.playerTransition, - actorId, - reason, - source: 'manual', - }); - return { ...applied, previousStatus: currentStatus ?? null, changed: true }; - }); + refuseInProgress: false, + }), + ); if (!outcome.row) { throw new KycVerificationNotFoundError(userId); } return outcome.row; } + async assertEnhancedKycRequestable(userId: User['id']) { + const latest = await this.latestVerification(userId, 'advanced'); + if (latest?.status === 'resubmission_requested') { + throw new KycResubmissionAlreadyRequestedError(); + } + if (latest && ENHANCED_KYC_IN_PROGRESS.has(latest.status)) { + throw new KycVerificationInProgressError(); + } + } + + /** + * Agent-proposed: asks the player for advanced-tier (enhanced) verification the way an admin's + * resubmission request does, and reports whether anything changed - a repeat is a no-op. Unlike + * the admin request it refuses while an advanced verification is pending, re-checked under the + * player's submit lock: the proposal was approved after its precondition ran, and the player + * may have started a verification since. + */ + async requestEnhancedKyc({ + userId, + reason, + actorId, + }: { + userId: User['id']; + reason: string; + actorId: User['id']; + }) { + const { changed } = await this.drizzle.db.transaction((trx) => + withAdvisoryXactLock(trx, `kyc-submit:${userId}:advanced`, () => + this.applyResubmissionRequest(trx, { + userId, + tier: 'advanced', + reason, + actorId, + refuseInProgress: true, + }), + ), + ); + return { changed }; + } + + private async applyResubmissionRequest( + trx: DrizzleTx, + { + userId, + tier, + reason, + actorId, + refuseInProgress, + }: { + userId: User['id']; + tier: KycTier; + reason: string; + actorId: User['id']; + refuseInProgress: boolean; + }, + ) { + const current = await this.requirePlayerRowForUpdate(userId, trx); + const latest = await this.latestVerification(userId, tier, trx); + const currentStatus = tier === 'basic' ? current.kycStatus : latest?.status; + if (currentStatus === 'resubmission_requested') { + return { row: latest ? toDto(latest) : null, changed: false }; + } + if (refuseInProgress && currentStatus && ENHANCED_KYC_IN_PROGRESS.has(currentStatus)) { + throw new KycVerificationInProgressError(); + } + const applied = await this.applyManualDecision(trx, { + userId, + tier, + status: 'resubmission_requested', + reason, + actorId, + referenceIdPrefix: 'manual-resubmit', + decidedAt: null, + }); + // compliance.kyc.updated is the audit-visible status-change event (docs/standards/ + // compliance.md): emitted here, inside the transaction, so it can never be dropped + // by a crash between commit and a post-commit emit. + await this.emitUpdated({ + userId, + tier, + status: 'resubmission_requested', + previousStatus: currentStatus ?? null, + playerTransition: applied.playerTransition, + actorId, + reason, + source: 'manual', + }); + return { row: applied.row, changed: true }; + } + /** * Admin-initiated: forces the player to an operator-chosen status, guarded by the * router's `compliance:override-limit`. `resolveManualStatus` remaps an `approved` diff --git a/packages/core/src/contracts/adapters/__tests__/mcp.test.ts b/packages/core/src/contracts/adapters/__tests__/mcp.test.ts new file mode 100644 index 000000000..6f443de6a --- /dev/null +++ b/packages/core/src/contracts/adapters/__tests__/mcp.test.ts @@ -0,0 +1,102 @@ +import { randomUUID } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { DOMAIN_EVENT_CATALOG, getEventVersion } from '../../schemas/events.js'; +import { + McpToolError, + RunContextSchema, + runActorAdminId, + triggerCatalog, + type RunActor, +} from '../mcp.js'; + +const runFields = () => ({ + runId: randomUUID(), + catalogVersion: 'catalog-1', + correlationId: 'correlation-1', +}); + +describe('RunContextSchema', () => { + it.each([ + ['an admin', { kind: 'admin', adminId: randomUUID() }], + [ + 'an agent acting for an admin', + { kind: 'agent', agentId: randomUUID(), agentVersion: 2, onBehalfOf: randomUUID() }, + ], + [ + 'an MCP token owned by an admin', + { kind: 'mcp_token', tokenId: randomUUID(), adminId: randomUUID() }, + ], + ])('accepts %s as the actor', (_label, actor) => { + expect(RunContextSchema.safeParse({ ...runFields(), actor }).success).toBe(true); + }); + + it('rejects an agent that does not name the admin it acts for', () => { + const result = RunContextSchema.safeParse({ + ...runFields(), + actor: { kind: 'agent', agentId: randomUUID(), agentVersion: 2 }, + }); + + expect(result.success).toBe(false); + expect(result.error?.issues.map((issue) => issue.path.join('.'))).toEqual(['actor.onBehalfOf']); + }); +}); + +describe('runActorAdminId', () => { + const adminId = randomUUID(); + + it.each<[string, RunActor]>([ + ['the admin itself', { kind: 'admin', adminId }], + [ + 'the admin an agent acts for', + { kind: 'agent', agentId: randomUUID(), agentVersion: 1, onBehalfOf: adminId }, + ], + ["the token's owner", { kind: 'mcp_token', tokenId: randomUUID(), adminId }], + ])('checks grants against %s', (_label, actor) => { + expect(runActorAdminId(actor)).toBe(adminId); + }); +}); + +describe('McpToolError', () => { + it('carries its code as the whole message', () => { + const error = new McpToolError('player_not_found'); + + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ + name: 'McpToolError', + code: 'player_not_found', + message: 'player_not_found', + }); + }); +}); + +describe('triggerCatalog', () => { + it('lists every domain event once, at its current schema version', () => { + const triggers = triggerCatalog(); + + expect(triggers.map((trigger) => trigger.topic)).toEqual(DOMAIN_EVENT_CATALOG); + for (const trigger of triggers) { + expect(trigger).toMatchObject({ + kind: 'event', + version: getEventVersion(trigger.topic), + domain: trigger.topic.slice(0, trigger.topic.indexOf('.')), + payloadJsonSchema: { $schema: 'http://json-schema.org/draft-07/schema#' }, + }); + } + }); + + it('carries a bumped event version and its payload shape', () => { + const kycUpdated = triggerCatalog().find( + (trigger) => trigger.topic === 'compliance.kyc.updated', + ); + + expect(kycUpdated).toMatchObject({ + domain: 'compliance', + version: 6, + payloadJsonSchema: { type: 'object', properties: { userId: { format: 'uuid' } } }, + }); + }); + + it('builds the catalog once and serves it afterwards', () => { + expect(triggerCatalog()).toBe(triggerCatalog()); + }); +}); diff --git a/packages/core/src/contracts/adapters/audit.ts b/packages/core/src/contracts/adapters/audit.ts index 2fb1bbf97..8f6554283 100644 --- a/packages/core/src/contracts/adapters/audit.ts +++ b/packages/core/src/contracts/adapters/audit.ts @@ -59,7 +59,12 @@ export type DirectAuditAction = | 'promo.weight_profile.created' | 'promo.weight_profile.set' | 'promo.rank_ladder.set' - | 'promo.rank_config.set'; + | 'promo.rank_config.set' + | 'mcp.tool.invoked' + | 'mcp.tool.failed' + | 'mcp.action.executed' + | 'mcp.action.failed' + | 'wallet.withdrawal.held'; /** * Every value the audit `action` column legitimately holds: a cross-module domain diff --git a/packages/core/src/contracts/adapters/identity-reader.ts b/packages/core/src/contracts/adapters/identity-reader.ts index 4c897d644..97e859608 100644 --- a/packages/core/src/contracts/adapters/identity-reader.ts +++ b/packages/core/src/contracts/adapters/identity-reader.ts @@ -23,6 +23,26 @@ export type IdentityReader = { * a missing implementation reads as opted out, which is the default state anyway. */ canReceiveLoginWithdrawalAlerts?(userId: User['id']): Promise; + /** + * Resolves a player profile id to its auth user id, or null when no such player exists. + * Optional so an operator's own implementation of this port keeps compiling; a caller that + * needs it must fail closed when it is missing rather than treat that as "no such player". + */ + getUserIdByPlayerId?(playerId: Player['id']): Promise; }; export const IDENTITY_READER = createToken('IDENTITY_READER'); + +/** + * `getUserIdByPlayerId` for a caller that cannot work without it: throws when the bound reader + * does not implement it, so the caller fails closed instead of reading "no such player". + */ +export async function userIdOfPlayer( + reader: IdentityReader, + playerId: Player['id'], +): Promise { + if (!reader.getUserIdByPlayerId) { + throw new Error('the bound IDENTITY_READER cannot resolve a player id to a user id'); + } + return reader.getUserIdByPlayerId(playerId); +} diff --git a/packages/core/src/contracts/adapters/index.ts b/packages/core/src/contracts/adapters/index.ts index 9afbdf469..884e3f48f 100644 --- a/packages/core/src/contracts/adapters/index.ts +++ b/packages/core/src/contracts/adapters/index.ts @@ -101,6 +101,7 @@ export { TAG_EVALUATION_COMMANDS } from './tag-evaluation-commands.js'; export type { UserCommands } from './identity.js'; export type { IdentityReader } from './identity-reader.js'; +export { userIdOfPlayer } from './identity-reader.js'; export { IDENTITY_READER } from './identity-reader.js'; export type { @@ -385,3 +386,58 @@ export { SESSION_IDLE_POLICY, type SessionIdlePolicy } from './session-idle-poli export type { SocialCommands, FriendshipDissolvedPayload } from './social-commands.js'; export { SOCIAL_COMMANDS } from './social-commands.js'; + +export type { + McpToolClass, + AgentApprovalLevel, + AgentProposalStatus, + TriggerKind, + McpCommonErrorCode, + RunActor, + RunContext, + ActionExecutionContext, + McpIamRequirement, + McpToolContract, + McpToolHandler, + ActionTypeContract, + ActionPreconditionOutcome, + ActionPrecondition, + ActionExecutionOutcome, + ActionExecutor, + ActionTypeImplementation, + McpToolDescriptor, + ActionTypeDescriptor, + TriggerDescriptor, + McpInputIssue, + McpFailure, + McpToolResult, + ActionPreconditionResult, + ActionExecutionResult, + McpKernel, +} from './mcp.js'; +export { + MCP_TOOL_CLASSES, + McpToolClassSchema, + AGENT_APPROVAL_LEVELS, + AgentApprovalLevelSchema, + AGENT_PROPOSAL_STATUSES, + AgentProposalStatusSchema, + TRIGGER_KINDS, + TriggerKindSchema, + MCP_COMMON_ERROR_CODES, + MCP_ERROR_CODE_PATTERN, + MCP_TOOL_ID_PATTERN, + MCP_ACTION_TYPE_ID_PATTERN, + McpToolError, + RunActorSchema, + runActorAdminId, + RunContextSchema, + ActionExecutionContextSchema, + defineMcpTool, + ActionPreconditionOutcomeSchema, + ACTION_EXECUTION_OUTCOMES, + ActionExecutionOutcomeSchema, + defineActionType, + triggerCatalog, + MCP_KERNEL, +} from './mcp.js'; diff --git a/packages/core/src/contracts/adapters/mcp.ts b/packages/core/src/contracts/adapters/mcp.ts new file mode 100644 index 000000000..65e672ab0 --- /dev/null +++ b/packages/core/src/contracts/adapters/mcp.ts @@ -0,0 +1,281 @@ +import * as z from 'zod'; +import { UuidSchema } from '../schemas/common.js'; +import type { AdminActionOf, AdminResource } from '../schemas/iam.js'; +import { + DOMAIN_EVENT_CATALOG, + domainEventSchemas, + getEventVersion, + type DomainEventName, +} from '../schemas/events.js'; +import { createToken, type Token } from './token.js'; + +export const MCP_TOOL_CLASSES = ['read', 'propose'] as const; +export const McpToolClassSchema = z.enum(MCP_TOOL_CLASSES); +export type McpToolClass = z.infer; + +export const AGENT_APPROVAL_LEVELS = ['read_only', 'requires_review', 'automatic'] as const; +export const AgentApprovalLevelSchema = z.enum(AGENT_APPROVAL_LEVELS); +export type AgentApprovalLevel = z.infer; + +export const AGENT_PROPOSAL_STATUSES = [ + 'open', + 'in_review', + 'approved', + 'rejected', + 'expired', + 'executed', + 'failed', +] as const; +export const AgentProposalStatusSchema = z.enum(AGENT_PROPOSAL_STATUSES); +export type AgentProposalStatus = z.infer; + +export const TRIGGER_KINDS = ['event', 'schedule', 'manual'] as const; +export const TriggerKindSchema = z.enum(TRIGGER_KINDS); +export type TriggerKind = z.infer; + +export const MCP_COMMON_ERROR_CODES = [ + 'invalid_input', + 'invalid_run_context', + 'forbidden', + 'unknown_tool', + 'unknown_action', + 'output_invalid', + 'audit_unavailable', + 'internal_error', +] as const; +export type McpCommonErrorCode = (typeof MCP_COMMON_ERROR_CODES)[number]; + +export const MCP_ERROR_CODE_PATTERN = /^[a-z][a-z0-9_]{1,63}$/; +/** Lowercase dotted segments (eg `player.summary`); an id is also capped at 64 characters. */ +export const MCP_TOOL_ID_PATTERN = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/; +export const MCP_ACTION_TYPE_ID_PATTERN = /^[a-z][a-z0-9_]{1,63}$/; + +/** + * Thrown by a handler, precondition or executor to return a declared error code. + * The message IS the code - never data. + */ +export class McpToolError extends Error { + constructor(readonly code: string) { + super(code); + this.name = 'McpToolError'; + } +} + +export const RunActorSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('admin'), adminId: UuidSchema }), + z.object({ + kind: z.literal('agent'), + agentId: UuidSchema, + agentVersion: z.number().int().positive(), + onBehalfOf: UuidSchema, + }), + z.object({ kind: z.literal('mcp_token'), tokenId: UuidSchema, adminId: UuidSchema }), +]); +export type RunActor = z.infer; + +/** + * The admin whose grants every call is checked against: the admin, the admin an agent + * acts for, or the token's owner. + */ +export function runActorAdminId(actor: RunActor): string { + switch (actor.kind) { + case 'admin': + return actor.adminId; + case 'agent': + return actor.onBehalfOf; + case 'mcp_token': + return actor.adminId; + } +} + +export const RunContextSchema = z.object({ + runId: UuidSchema, + actor: RunActorSchema, + playerPseudonym: z.string().min(1).max(128).optional(), + catalogVersion: z.string().min(1).max(128), + correlationId: z.string().min(1).max(128), +}); +export type RunContext = z.infer; + +export const ActionExecutionContextSchema = z.object({ + proposalId: UuidSchema, + actor: RunActorSchema, + correlationId: RunContextSchema.shape.correlationId, +}); +export type ActionExecutionContext = z.infer; + +export type McpIamRequirement = { + [R in AdminResource]: { resource: R; action: AdminActionOf }; +}[AdminResource]; + +export type McpToolContract< + I extends z.ZodObject = z.ZodObject, + O extends z.ZodObject = z.ZodObject, +> = { + id: string; + title: string; + description: string; + class: McpToolClass; + schemaVersion: number; + iam: McpIamRequirement; + inputSchema: I; + outputSchema: O; + /** + * allow: top-level output keys that may leave the kernel. personal: the subset carrying + * personal data a consumer must pseudonymise or drop before a model sees it. + */ + redact: { + allow: readonly (keyof z.output & string)[]; + personal?: readonly (keyof z.output & string)[]; + }; + /** Codes the handler may throw via McpToolError. Anything else becomes internal_error. */ + errors: readonly string[]; +}; + +export type McpToolHandler< + I extends z.ZodObject = z.ZodObject, + O extends z.ZodObject = z.ZodObject, +> = (input: z.output, run: RunContext) => Promise>; + +export function defineMcpTool( + contract: McpToolContract, +): McpToolContract { + return contract; +} + +export type ActionTypeContract

= { + id: string; + title: string; + description: string; + schemaVersion: number; + iam: McpIamRequirement; + reversible: boolean; + payloadSchema: P; + /** Codes the precondition may refuse with and the executor may fail with. */ + errors: readonly string[]; +}; + +export const ActionPreconditionOutcomeSchema = z.discriminatedUnion('ok', [ + z.object({ ok: z.literal(true) }), + z.object({ ok: z.literal(false), code: z.string() }), +]); +export type ActionPreconditionOutcome = z.infer; + +export type ActionPrecondition

= ( + payload: z.output

, + run: RunContext, +) => Promise; + +export const ACTION_EXECUTION_OUTCOMES = ['applied', 'already_applied'] as const; +export const ActionExecutionOutcomeSchema = z.object({ + outcome: z.enum(ACTION_EXECUTION_OUTCOMES), + detail: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(), +}); +export type ActionExecutionOutcome = z.infer; + +/** + * Runs an approved proposal. A replay while the effect still holds must perform nothing and + * resolve 'already_applied', which makes a retry after a crash safe. Executors key on the + * resulting state, not on a stored proposal id: after someone else changes that state, a replay + * may apply again or answer a declared refusal, so a proposal store must not re-execute a + * proposal it recorded as executed. + */ +export type ActionExecutor

= ( + payload: z.output

, + proposalId: string, + actor: RunActor, +) => Promise; + +export type ActionTypeImplementation

= { + precondition: ActionPrecondition

; + execute: ActionExecutor

; +}; + +export function defineActionType

( + contract: ActionTypeContract

, +): ActionTypeContract

{ + return contract; +} + +export type McpToolDescriptor = McpToolContract & { + owner: string; + /** + * The id with every dot replaced by an underscore. The major model APIs reject dots in + * function names, so a consumer sends this name to models and MCP clients and maps back. + */ + modelName: string; + inputJsonSchema: Record; + outputJsonSchema: Record; +}; + +export type ActionTypeDescriptor = ActionTypeContract & { + owner: string; + payloadJsonSchema: Record; +}; + +export type TriggerDescriptor = { + kind: 'event'; + topic: DomainEventName; + version: number; + domain: string; + payloadJsonSchema: Record; +}; + +let eventTriggers: readonly TriggerDescriptor[] | undefined; + +/** Every domain event as a trigger, derived from `domainEventSchemas` once and memoised. */ +export function triggerCatalog(): readonly TriggerDescriptor[] { + eventTriggers ??= DOMAIN_EVENT_CATALOG.map((topic) => { + const [domain = topic] = topic.split('.'); + return { + kind: 'event', + topic, + version: getEventVersion(topic), + domain, + payloadJsonSchema: z.toJSONSchema(domainEventSchemas[topic], { + target: 'draft-7', + io: 'output', + unrepresentable: 'any', + }), + }; + }); + return eventTriggers; +} + +export type McpInputIssue = { path: string; message: string }; +export type McpFailure = { ok: false; error: string; issues?: McpInputIssue[] }; +export type McpToolResult = { ok: true; output: Record } | McpFailure; +export type ActionPreconditionResult = { ok: true } | McpFailure; +export type ActionExecutionResult = ({ ok: true } & ActionExecutionOutcome) | McpFailure; + +/** + * The only way to run an agent-callable tool or action type. Every tool call and action + * execution is checked against the caller's IAM grant and the input schema, returns only + * allow-listed output and declared error codes (never an exception message), and leaves + * exactly one audit record. + */ +export type McpKernel = { + readonly catalogVersion: string; + listTools(): readonly McpToolDescriptor[]; + listActionTypes(): readonly ActionTypeDescriptor[]; + listTriggers(): readonly TriggerDescriptor[]; + invokeTool(toolId: string, input: unknown, run: RunContext): Promise; + /** Evaluation only: no IAM check and no audit record. */ + checkPrecondition( + actionTypeId: string, + payload: unknown, + run: RunContext, + ): Promise; + /** + * Does not re-run the precondition: an executor guards its own state under its own + * locks, and a replay while its effect still holds resolves 'already_applied'. The audit + * write is retried before the call fails, because the effect is already committed. + */ + executeAction( + actionTypeId: string, + payload: unknown, + context: ActionExecutionContext, + ): Promise; +}; + +export const MCP_KERNEL: Token = createToken('MCP_KERNEL'); diff --git a/packages/core/src/contracts/schemas/__tests__/agents-config.test.ts b/packages/core/src/contracts/schemas/__tests__/agents-config.test.ts new file mode 100644 index 000000000..c7eca6011 --- /dev/null +++ b/packages/core/src/contracts/schemas/__tests__/agents-config.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { AgentsConfigSchema } from '../agents.js'; +import { definePlatformConfig } from '../platform-config.js'; + +const SONNET = 'openrouter/anthropic/claude-sonnet-4.5'; +const LLAMA_FREE = 'openrouter/meta-llama/llama-3.3-70b-instruct:free'; + +const capabilities = { + tools: true, + structuredOutputs: true, + reasoning: false, + cacheMode: 'implicit', +} as const; + +const model = (id: string, fallbacks: string[] = []) => ({ + id, + stepTimeoutMs: 30_000, + fallbacks, + capabilities, +}); + +const issuePaths = (input: unknown) => { + const result = AgentsConfigSchema.safeParse(input); + return result.success ? [] : result.error.issues.map((issue) => issue.path.join('.')); +}; + +describe('AgentsConfigSchema model ids', () => { + it.each([SONNET, LLAMA_FREE])('accepts the gateway id %s', (id) => { + expect(issuePaths({ models: [model(id)] })).toEqual([]); + }); + + it.each(['anthropic/claude-sonnet-4.5', 'openrouter/anthropic', 'claude-sonnet-4.5'])( + 'rejects %s, which does not name //', + (id) => { + expect(issuePaths({ models: [model(id)] })).toEqual(['models.0.id']); + }, + ); + + it('rejects a gateway that is not configured, openrouter being the only default', () => { + expect(issuePaths({ models: [model('direct/anthropic/claude-sonnet-4.5')] })).toEqual([ + 'models.0.id', + ]); + }); + + it('accepts a gateway the operator added', () => { + expect( + issuePaths({ + modelGateways: ['openrouter', 'direct'], + models: [model('direct/anthropic/claude-sonnet-4.5')], + }), + ).toEqual([]); + }); +}); + +describe('AgentsConfigSchema fallbacks', () => { + it('accepts a fallback to another configured model', () => { + expect(issuePaths({ models: [model(SONNET, [LLAMA_FREE]), model(LLAMA_FREE)] })).toEqual([]); + }); + + it('rejects a fallback that is not a configured model', () => { + expect(issuePaths({ models: [model(SONNET, [LLAMA_FREE])] })).toEqual(['models.0.fallbacks.0']); + }); + + it('rejects a model that falls back to itself', () => { + expect(issuePaths({ models: [model(SONNET, [SONNET])] })).toEqual(['models.0.fallbacks.0']); + }); + + it('rejects a model id configured twice', () => { + expect(issuePaths({ models: [model(SONNET), model(SONNET)] })).toEqual(['models.1.id']); + }); +}); + +describe('PlatformConfig.agents', () => { + it('applies the run limits and retention defaults when agents is absent', () => { + expect(definePlatformConfig({}).agents).toEqual({ + limits: { + maxStepsPerRun: 10, + maxToolCallsPerRun: 20, + runTimeoutMs: 300_000, + maxRunsPerAgentPerDay: 500, + }, + retention: { runDays: 30, proposalDays: 365 }, + modelGateways: ['openrouter'], + models: [], + }); + }); + + it('names the offending path when a model id bypasses the gateway', () => { + expect(() => + definePlatformConfig({ + agents: { models: [{ id: 'anthropic/claude', stepTimeoutMs: 30_000, capabilities }] }, + }), + ).toThrow(/agents\.models\.0\.id: must be a \/\/ id/); + }); +}); diff --git a/packages/core/src/contracts/schemas/agents.ts b/packages/core/src/contracts/schemas/agents.ts new file mode 100644 index 000000000..2e8fd409c --- /dev/null +++ b/packages/core/src/contracts/schemas/agents.ts @@ -0,0 +1,110 @@ +import * as z from 'zod'; + +/** + * `//`. The gateway prefix is mandatory: an agent SDK can route a bare + * model id to a different gateway than the one the operator chose. + */ +export const AGENT_MODEL_ID_PATTERN = + /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._:-]*$/; + +export const DEFAULT_AGENT_MODEL_GATEWAYS = ['openrouter'] as const; + +export const AgentModelGatewaySchema = z + .string() + .max(64) + .regex(/^[a-z0-9][a-z0-9-]*$/, 'must be a lowercase gateway slug'); + +export const AGENT_MODEL_CACHE_MODES = ['none', 'implicit', 'explicit'] as const; +export const AgentModelCacheModeSchema = z.enum(AGENT_MODEL_CACHE_MODES); +export type AgentModelCacheMode = z.infer; + +export const AgentModelIdSchema = z + .string() + .max(128) + .regex(AGENT_MODEL_ID_PATTERN, 'must be a // id'); + +export const AgentModelCapabilitiesSchema = z + .object({ + tools: z.boolean(), + structuredOutputs: z.boolean(), + reasoning: z.boolean(), + cacheMode: AgentModelCacheModeSchema, + }) + .strict(); + +export const AgentModelSchema = z + .object({ + id: AgentModelIdSchema, + stepTimeoutMs: z.number().int().min(1_000).max(600_000), + fallbacks: z.array(AgentModelIdSchema).max(5).default([]), + capabilities: AgentModelCapabilitiesSchema, + deprecatedOn: z.iso.date().optional(), + }) + .strict(); + +export const AgentsConfigSchema = z + .object({ + limits: z + .object({ + maxStepsPerRun: z.number().int().min(1).max(50).default(10), + maxToolCallsPerRun: z.number().int().min(1).max(100).default(20), + runTimeoutMs: z.number().int().min(1_000).max(900_000).default(300_000), + maxRunsPerAgentPerDay: z.number().int().min(1).max(100_000).default(500), + }) + .strict() + .prefault({}), + retention: z + .object({ + runDays: z.number().int().min(1).max(3650).default(30), + proposalDays: z.number().int().min(1).max(3650).default(365), + }) + .strict() + .prefault({}), + /** Gateways a model id may name as its first segment. */ + modelGateways: z + .array(AgentModelGatewaySchema) + .min(1) + .max(10) + .default([...DEFAULT_AGENT_MODEL_GATEWAYS]), + models: z.array(AgentModelSchema).max(50).default([]), + }) + .strict() + .superRefine((config, ctx) => { + const gateways = new Set(config.modelGateways); + const configuredIds = new Set(config.models.map((model) => model.id)); + const seenIds = new Set(); + config.models.forEach((model, modelIndex) => { + if (seenIds.has(model.id)) { + ctx.addIssue({ + code: 'custom', + message: `model id "${model.id}" is configured more than once`, + path: ['models', modelIndex, 'id'], + }); + } + seenIds.add(model.id); + const [gateway = ''] = model.id.split('/'); + if (AGENT_MODEL_ID_PATTERN.test(model.id) && !gateways.has(gateway)) { + ctx.addIssue({ + code: 'custom', + message: `gateway "${gateway}" is not in agents.modelGateways`, + path: ['models', modelIndex, 'id'], + }); + } + model.fallbacks.forEach((fallback, fallbackIndex) => { + const path = ['models', modelIndex, 'fallbacks', fallbackIndex]; + if (fallback === model.id) { + ctx.addIssue({ code: 'custom', message: 'a model cannot fall back to itself', path }); + return; + } + if (!configuredIds.has(fallback)) { + ctx.addIssue({ + code: 'custom', + message: `fallback "${fallback}" is not a configured model`, + path, + }); + } + }); + }); + }); + +export type AgentsConfig = z.infer; diff --git a/packages/core/src/contracts/schemas/iam.ts b/packages/core/src/contracts/schemas/iam.ts index bdb1f3a89..3fdee9253 100644 --- a/packages/core/src/contracts/schemas/iam.ts +++ b/packages/core/src/contracts/schemas/iam.ts @@ -47,6 +47,9 @@ export const adminStatement = { 'swap-config': ['view', 'update'] as const, 'chat-command': ['view', 'update'] as const, 'chat-moderation': ['view', 'moderate'] as const, + agent: ['view', 'create', 'update', 'publish', 'run'] as const, + 'agent-proposal': ['view', 'approve', 'reject'] as const, + 'agent-config': ['view', 'update'] as const, } as const; export type AdminResource = keyof typeof adminStatement; diff --git a/packages/core/src/contracts/schemas/index.ts b/packages/core/src/contracts/schemas/index.ts index c9e14b440..097ee1eb4 100644 --- a/packages/core/src/contracts/schemas/index.ts +++ b/packages/core/src/contracts/schemas/index.ts @@ -18,3 +18,4 @@ export * from './chat-command.js'; export * from './chat-attachment.js'; export * from './mail.js'; export * from './bounded-json-params.js'; +export * from './agents.js'; diff --git a/packages/core/src/contracts/schemas/platform-config.ts b/packages/core/src/contracts/schemas/platform-config.ts index 482f29d93..7a0ea6f37 100644 --- a/packages/core/src/contracts/schemas/platform-config.ts +++ b/packages/core/src/contracts/schemas/platform-config.ts @@ -6,6 +6,7 @@ import { defaultResponsibleGamingConfig, } from './igaming-config.js'; import { CurrencyTickerInputSchema, MoneyAmountSchema } from './common.js'; +import { AgentsConfigSchema } from './agents.js'; import { createToken } from '../adapters/token.js'; /** @@ -410,6 +411,8 @@ export const PlatformConfigSchema = z cms: CmsConfigSchema.default({ allowedBannerImageHosts: [] }), /** How often the rank payout jobs tick. Absent = the built-in defaults. */ promo: PromoConfigSchema.prefault({}), + /** Agent run limits, retention and the model catalog. Absent = the schema defaults, no models. */ + agents: AgentsConfigSchema.prefault({}), }) .strict() .superRefine((cfg, ctx) => { diff --git a/packages/core/src/pam/identity/__tests__/identity-reader.service.int.test.ts b/packages/core/src/pam/identity/__tests__/identity-reader.service.int.test.ts new file mode 100644 index 000000000..0e15c3736 --- /dev/null +++ b/packages/core/src/pam/identity/__tests__/identity-reader.service.int.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { sql } from 'drizzle-orm'; +import { findOneOrThrow } from '@openora/core/server'; +import { createTestDb, type TestDb } from '@openora/core/testing'; +import { player } from '@openora/core/pam/schema/profile'; +import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; +import { migrate } from '../migrate.js'; +import { IdentityReaderService } from '../adapters/identity-reader.service.js'; + +let db: TestDb; + +beforeAll(async () => { + db = await createTestDb([migrate, migrateProfile]); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + await db.drizzle.db.execute(sql`TRUNCATE ${player} RESTART IDENTITY CASCADE`); +}); + +describe('IdentityReaderService.getUserIdByPlayerId (real PG)', () => { + it('resolves a player id to the user id that owns the profile', async () => { + const reader = new IdentityReaderService(db.drizzle); + const row = findOneOrThrow( + await db.drizzle.db.insert(player).values({ userId: randomUUID() }).returning(), + new Error('player insert returned no row'), + ); + await db.drizzle.db.insert(player).values({ userId: randomUUID() }); + + expect(await reader.getUserIdByPlayerId(row.id)).toBe(row.userId); + }); + + it('resolves an unknown player id to null', async () => { + const reader = new IdentityReaderService(db.drizzle); + + expect(await reader.getUserIdByPlayerId(randomUUID())).toBeNull(); + }); +}); diff --git a/packages/core/src/pam/identity/adapters/identity-reader.service.ts b/packages/core/src/pam/identity/adapters/identity-reader.service.ts index 961f64872..959a06795 100644 --- a/packages/core/src/pam/identity/adapters/identity-reader.service.ts +++ b/packages/core/src/pam/identity/adapters/identity-reader.service.ts @@ -54,6 +54,15 @@ export class IdentityReaderService implements IdentityReader { return row?.id ?? null; } + async getUserIdByPlayerId(playerId: Player['id']): Promise { + const [row] = await this.drizzle.db + .select({ userId: player.userId }) + .from(player) + .where(eq(player.id, playerId)) + .limit(1); + return row?.userId ?? null; + } + async getPlayerIdByUserIdSafe(userId: User['id']): Promise { try { return await this.getPlayerIdByUserId(userId); diff --git a/packages/core/src/pam/player-management/contract/agent-tools.ts b/packages/core/src/pam/player-management/contract/agent-tools.ts new file mode 100644 index 000000000..d2462935a --- /dev/null +++ b/packages/core/src/pam/player-management/contract/agent-tools.ts @@ -0,0 +1,60 @@ +import * as z from 'zod'; +import { PlayerSchema, TagKeySchema, UuidSchema, defineMcpTool } from '@openora/core/contracts'; + +export const PlayerSummaryToolOutputSchema = z.object({ + playerId: UuidSchema, + ...PlayerSchema.omit({ id: true }).shape, + tags: z.array(TagKeySchema), +}); + +export const playerSummaryTool = defineMcpTool({ + id: 'player.summary', + title: 'Player summary', + description: + "Returns one player's account record: username, email, name, date of birth, phone, country, " + + 'currency, status, KYC status, level, lifetime wagered and deposited totals, last-seen time, ' + + 'timezone and active tags. Use it to look a player up by player id before judging or ' + + 'proposing anything about them.', + class: 'read', + schemaVersion: 1, + iam: { resource: 'player', action: 'view' }, + inputSchema: z.object({ playerId: UuidSchema }), + outputSchema: PlayerSummaryToolOutputSchema, + redact: { + allow: [ + 'playerId', + 'userId', + 'username', + 'email', + 'firstName', + 'lastName', + 'dateOfBirth', + 'phone', + 'country', + 'currency', + 'status', + 'kycStatus', + 'level', + 'totalWagered', + 'totalDeposits', + 'lastSeenAt', + 'timezone', + 'timezoneUpdatedAt', + 'createdAt', + 'updatedAt', + 'tags', + ], + personal: [ + 'userId', + 'username', + 'email', + 'firstName', + 'lastName', + 'dateOfBirth', + 'phone', + 'timezone', + 'timezoneUpdatedAt', + ], + }, + errors: ['player_not_found'], +}); diff --git a/packages/core/src/pam/player-management/contract/index.ts b/packages/core/src/pam/player-management/contract/index.ts index f1564c315..53219fbec 100644 --- a/packages/core/src/pam/player-management/contract/index.ts +++ b/packages/core/src/pam/player-management/contract/index.ts @@ -14,6 +14,7 @@ import { import { paginated } from '@openora/core/contracts/kit'; export { PlayerSchema, PlayerStatusSchema, KycStatusSchema }; +export * from './agent-tools.js'; /** Player row enriched with their active tags - used only by the admin list endpoint. */ export const PlayerWithTagsSchema = PlayerSchema.extend({ diff --git a/packages/core/src/pam/player-management/plugin.ts b/packages/core/src/pam/player-management/plugin.ts index 83a32a8d6..7b32a3f32 100644 --- a/packages/core/src/pam/player-management/plugin.ts +++ b/packages/core/src/pam/player-management/plugin.ts @@ -8,11 +8,13 @@ import { SESSION_COMMANDS, USER_COMMANDS, PLAYER_ACTIVITY_TRACKER, + McpToolError, } from '@openora/core/contracts'; import type { CoreTokenCatalog, Plugin, TypedContainer } from '@openora/core/server'; -import { PlayerService } from './service/player.service.js'; +import { PlayerService, PlayerNotFoundError } from './service/player.service.js'; import { PlayerKycStatusWriter } from './service/kyc-status-writer.js'; import { createPlayerRouter } from './router/index.js'; +import { playerSummaryTool } from './contract/agent-tools.js'; function makePlayerService(c: TypedContainer) { return new PlayerService( @@ -35,5 +37,16 @@ export default { ctx.routers.add('player', (c) => createPlayerRouter(makePlayerService(c), c.get(ADMIN_GUARD), c.get(AUDIT_WRITER)), ); + ctx.mcp.tool(playerSummaryTool, (c) => { + const players = makePlayerService(c); + return async ({ playerId }) => { + try { + const { id, ...detail } = await players.get(playerId); + return { playerId: id, ...detail }; + } catch (err) { + throw err instanceof PlayerNotFoundError ? new McpToolError('player_not_found') : err; + } + }; + }); }, } as const satisfies Plugin; diff --git a/packages/core/src/pam/player-note/__tests__/player-note.agent-actions.int.test.ts b/packages/core/src/pam/player-note/__tests__/player-note.agent-actions.int.test.ts new file mode 100644 index 000000000..11b5bdf2f --- /dev/null +++ b/packages/core/src/pam/player-note/__tests__/player-note.agent-actions.int.test.ts @@ -0,0 +1,248 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { eq, sql } from 'drizzle-orm'; +import { + Container, + ModuleRegistryImpl, + DRIZZLE, + type CoreTokenCatalog, +} from '@openora/core/server'; +import { + AUDIT_WRITER, + type ActionExecutionContext, + type RunContext, +} from '@openora/core/contracts'; +import { createMcpKernel, createTestDb, type TestDb } from '@openora/core/testing'; +import { player } from '@openora/core/pam/schema/profile'; +import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; +import { makeAuditWriter } from '../../../testing/mock.js'; +import { migrate as migratePlayerNote } from '../migrate.js'; +import { playerNote } from '../schema/index.js'; +import { PlayerNoteService, PlayerNotFoundError } from '../service/player-note.service.js'; +import playerNotePlugin from '../plugin.js'; + +const ADMIN_ID = randomUUID(); + +let db: TestDb; + +beforeAll(async () => { + db = await createTestDb([migratePlayerNote, migrateProfile]); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + await db.drizzle.db.execute(sql`TRUNCATE ${playerNote}, ${player} RESTART IDENTITY CASCADE`); +}); + +async function seedPlayer() { + const [row] = await db.drizzle.db.insert(player).values({ userId: randomUUID() }).returning(); + return row!; +} + +function notesOf(playerId: string) { + return db.drizzle.db.select().from(playerNote).where(eq(playerNote.playerId, playerId)); +} + +function makeService() { + const audit = makeAuditWriter(); + return { svc: new PlayerNoteService(db.drizzle, audit), audit }; +} + +const proposalFor = (playerId: string, content = 'Player asked about a delayed withdrawal.') => ({ + playerId, + content, + actorId: ADMIN_ID, + proposalId: randomUUID(), +}); + +function bootKernel() { + const audit = makeAuditWriter(); + const container = new Container(); + container.register(DRIZZLE, () => db.drizzle); + container.register(AUDIT_WRITER, () => audit); + const registry = new ModuleRegistryImpl(container); + playerNotePlugin.register(registry); + const kernel = createMcpKernel({ + tools: registry.mcp.getTools(), + actions: registry.actions.getAll(), + container, + authorize: async () => 'allowed', + audit, + }); + return { kernel, audit }; +} + +const runContext = (): RunContext => ({ + runId: randomUUID(), + actor: { kind: 'admin', adminId: ADMIN_ID }, + catalogVersion: 'test', + correlationId: 'test', +}); + +const agentExecution = (): ActionExecutionContext => ({ + proposalId: randomUUID(), + actor: { kind: 'agent', agentId: randomUUID(), agentVersion: 1, onBehalfOf: ADMIN_ID }, + correlationId: 'test', +}); + +describe('PlayerNoteService.playerExists (real PG)', () => { + it('is true for a player row and false for an unknown id', async () => { + const { svc } = makeService(); + const target = await seedPlayer(); + + expect(await svc.playerExists(target.id)).toBe(true); + expect(await svc.playerExists(randomUUID())).toBe(false); + }); +}); + +describe('PlayerNoteService.createForProposal (real PG)', () => { + it('writes the note and records it in the audit log on the same transaction', async () => { + const { svc, audit } = makeService(); + const target = await seedPlayer(); + const proposal = proposalFor(target.id); + + const result = await svc.createForProposal(proposal); + + const rows = await notesOf(target.id); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ actorId: ADMIN_ID, content: proposal.content }); + expect(result).toMatchObject({ status: 'created', note: { id: rows[0]!.id } }); + expect(audit.recordInTransaction).toHaveBeenCalledTimes(1); + expect(audit.recordInTransaction).toHaveBeenCalledWith(expect.anything(), { + actorId: ADMIN_ID, + actorType: 'admin', + action: 'admin.player_note.created', + resourceType: 'player', + resourceId: target.id, + after: { noteId: rows[0]!.id, content: proposal.content, proposalId: proposal.proposalId }, + }); + expect(audit.record).not.toHaveBeenCalled(); + }); + + it('answers a replay of the same proposal with already_created and writes nothing more', async () => { + const { svc, audit } = makeService(); + const target = await seedPlayer(); + const proposal = proposalFor(target.id); + + await svc.createForProposal(proposal); + const replay = await svc.createForProposal(proposal); + + expect(replay).toEqual({ status: 'already_created' }); + expect(await notesOf(target.id)).toHaveLength(1); + expect(audit.recordInTransaction).toHaveBeenCalledTimes(1); + }); + + it('writes exactly one note when the same proposal executes twice concurrently', async () => { + const { svc, audit } = makeService(); + const target = await seedPlayer(); + const proposal = proposalFor(target.id); + + const results = await Promise.all([ + svc.createForProposal(proposal), + svc.createForProposal(proposal), + ]); + + expect(results.map((r) => r.status).sort()).toEqual(['already_created', 'created']); + expect(await notesOf(target.id)).toHaveLength(1); + expect(audit.recordInTransaction).toHaveBeenCalledTimes(1); + }); + + it('refuses an unknown player and writes neither a note nor an audit record', async () => { + const { svc, audit } = makeService(); + const proposal = proposalFor(randomUUID()); + + await expect(svc.createForProposal(proposal)).rejects.toBeInstanceOf(PlayerNotFoundError); + + expect(await notesOf(proposal.playerId)).toHaveLength(0); + expect(audit.recordInTransaction).not.toHaveBeenCalled(); + }); + + it('rolls the note back when its audit record cannot be written', async () => { + const { svc, audit } = makeService(); + audit.recordInTransaction.mockRejectedValueOnce(new Error('audit store unavailable')); + const target = await seedPlayer(); + + await expect(svc.createForProposal(proposalFor(target.id))).rejects.toThrow( + 'audit store unavailable', + ); + + expect(await notesOf(target.id)).toHaveLength(0); + }); +}); + +describe('add_note action type (plugin wiring through the MCP kernel)', () => { + it('refuses the precondition with player_not_found for an unknown player', async () => { + const { kernel } = bootKernel(); + + const result = await kernel.checkPrecondition( + 'add_note', + { playerId: randomUUID(), content: 'A note' }, + runContext(), + ); + + expect(result).toEqual({ ok: false, error: 'player_not_found' }); + }); + + it('passes the precondition for an existing player', async () => { + const { kernel } = bootKernel(); + const target = await seedPlayer(); + + const result = await kernel.checkPrecondition( + 'add_note', + { playerId: target.id, content: 'A note' }, + runContext(), + ); + + expect(result).toEqual({ ok: true }); + }); + + it('rejects content that is blank once trimmed', async () => { + const { kernel } = bootKernel(); + const target = await seedPlayer(); + + const result = await kernel.checkPrecondition( + 'add_note', + { playerId: target.id, content: ' ' }, + runContext(), + ); + + expect(result).toMatchObject({ ok: false, error: 'invalid_input' }); + }); + + it('adds the trimmed note in the name of the admin the agent acts for, once per proposal', async () => { + const { kernel, audit } = bootKernel(); + const target = await seedPlayer(); + const execution = agentExecution(); + const payload = { playerId: target.id, content: ' Player asked to close the account. ' }; + + const first = await kernel.executeAction('add_note', payload, execution); + const replay = await kernel.executeAction('add_note', payload, execution); + + const rows = await notesOf(target.id); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + actorId: ADMIN_ID, + content: 'Player asked to close the account.', + }); + expect(first).toEqual({ ok: true, outcome: 'applied', detail: { noteId: rows[0]!.id } }); + expect(replay).toEqual({ ok: true, outcome: 'already_applied' }); + expect(audit.recordInTransaction).toHaveBeenCalledTimes(1); + }); + + it('fails execution with player_not_found for an unknown player', async () => { + const { kernel } = bootKernel(); + const playerId = randomUUID(); + + const result = await kernel.executeAction( + 'add_note', + { playerId, content: 'A note' }, + agentExecution(), + ); + + expect(result).toEqual({ ok: false, error: 'player_not_found' }); + expect(await notesOf(playerId)).toHaveLength(0); + }); +}); diff --git a/packages/core/src/pam/player-note/__tests__/player-note.router.int.test.ts b/packages/core/src/pam/player-note/__tests__/player-note.router.int.test.ts index 46a023fa7..d02dbba94 100644 --- a/packages/core/src/pam/player-note/__tests__/player-note.router.int.test.ts +++ b/packages/core/src/pam/player-note/__tests__/player-note.router.int.test.ts @@ -33,7 +33,7 @@ const guardAllowing = (allow: readonly string[]) => function build(adminGuard: AdminGuard) { const audit = makeAuditWriter(); return { - router: createPlayerNoteRouter(new PlayerNoteService(db.drizzle), adminGuard, audit), + router: createPlayerNoteRouter(new PlayerNoteService(db.drizzle, audit), adminGuard, audit), audit, }; } diff --git a/packages/core/src/pam/player-note/contract/agent-tools.ts b/packages/core/src/pam/player-note/contract/agent-tools.ts new file mode 100644 index 000000000..0111171c8 --- /dev/null +++ b/packages/core/src/pam/player-note/contract/agent-tools.ts @@ -0,0 +1,19 @@ +import z from 'zod'; +import { UuidSchema, defineActionType } from '@openora/core/contracts'; +import { PLAYER_NOTE_CONTENT_MAX_LENGTH } from './player-note-content.js'; + +export const addNoteAction = defineActionType({ + id: 'add_note', + title: 'Add a player note', + description: + "Adds an internal note to a player's account that only admins can read. Use it to record a " + + 'finding or the reason behind another action; a note cannot be edited or deleted once added.', + schemaVersion: 1, + iam: { resource: 'player-note', action: 'create' }, + reversible: false, + payloadSchema: z.object({ + playerId: UuidSchema, + content: z.string().trim().min(1).max(PLAYER_NOTE_CONTENT_MAX_LENGTH), + }), + errors: ['player_not_found'], +}); diff --git a/packages/core/src/pam/player-note/contract/index.ts b/packages/core/src/pam/player-note/contract/index.ts index 3c7793cc2..ebf1b663a 100644 --- a/packages/core/src/pam/player-note/contract/index.ts +++ b/packages/core/src/pam/player-note/contract/index.ts @@ -2,6 +2,9 @@ import { oc } from '@orpc/contract'; import { UuidSchema, TimestampSchema } from '@openora/core/contracts'; import { PageQuerySchema, SortOrderSchema, paginated } from '@openora/core/contracts/kit'; import z from 'zod'; +import { PLAYER_NOTE_CONTENT_MAX_LENGTH } from './player-note-content.js'; + +export * from './agent-tools.js'; export const PLAYER_NOTE_SORT_BY_VALUES = ['createdAt', 'updatedAt'] as const; export const PlayerNoteSortBySchema = z.enum(PLAYER_NOTE_SORT_BY_VALUES).default('createdAt'); @@ -18,7 +21,7 @@ export const PlayerNoteSchema = z.object({ export const CreatePlayerNoteInputSchema = z.object({ playerId: UuidSchema, - content: z.string().min(1).max(5000), + content: z.string().min(1).max(PLAYER_NOTE_CONTENT_MAX_LENGTH), }); export type PlayerNoteItem = z.infer; diff --git a/packages/core/src/pam/player-note/contract/player-note-content.ts b/packages/core/src/pam/player-note/contract/player-note-content.ts new file mode 100644 index 000000000..d871caa10 --- /dev/null +++ b/packages/core/src/pam/player-note/contract/player-note-content.ts @@ -0,0 +1 @@ +export const PLAYER_NOTE_CONTENT_MAX_LENGTH = 5000; diff --git a/packages/core/src/pam/player-note/plugin.ts b/packages/core/src/pam/player-note/plugin.ts index aaead612b..d1df35c2f 100644 --- a/packages/core/src/pam/player-note/plugin.ts +++ b/packages/core/src/pam/player-note/plugin.ts @@ -1,19 +1,44 @@ import { DRIZZLE, ADMIN_GUARD } from '@openora/core/server'; -import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; -import { AUDIT_WRITER } from '@openora/core/contracts'; -import { PlayerNoteService } from './service/player-note.service.js'; +import type { CoreTokenCatalog, Plugin, TypedContainer } from '@openora/core/server'; +import { AUDIT_WRITER, McpToolError, runActorAdminId } from '@openora/core/contracts'; +import { PlayerNoteService, PlayerNotFoundError } from './service/player-note.service.js'; import { createPlayerNoteRouter } from './router/index.js'; +import { addNoteAction } from './contract/agent-tools.js'; + +function makePlayerNoteService(c: TypedContainer) { + return new PlayerNoteService(c.get(DRIZZLE), c.get(AUDIT_WRITER)); +} export default { id: 'player-note', dependsOn: ['audit'], register(ctx) { ctx.routers.add('player-note', (c) => - createPlayerNoteRouter( - new PlayerNoteService(c.get(DRIZZLE)), - c.get(ADMIN_GUARD), - c.get(AUDIT_WRITER), - ), + createPlayerNoteRouter(makePlayerNoteService(c), c.get(ADMIN_GUARD), c.get(AUDIT_WRITER)), ); + ctx.actions.register(addNoteAction, (c) => { + const notes = makePlayerNoteService(c); + return { + precondition: async ({ playerId }) => + (await notes.playerExists(playerId)) + ? { ok: true } + : { ok: false, code: 'player_not_found' }, + execute: async ({ playerId, content }, proposalId, actor) => { + try { + const result = await notes.createForProposal({ + playerId, + content, + actorId: runActorAdminId(actor), + proposalId, + }); + return result.status === 'created' + ? { outcome: 'applied', detail: { noteId: result.note.id } } + : { outcome: 'already_applied' }; + } catch (err) { + throw err instanceof PlayerNotFoundError ? new McpToolError('player_not_found') : err; + } + }, + }; + }); }, } as const satisfies Plugin; diff --git a/packages/core/src/pam/player-note/service/player-note.service.ts b/packages/core/src/pam/player-note/service/player-note.service.ts index f5cdd9496..18a70eae1 100644 --- a/packages/core/src/pam/player-note/service/player-note.service.ts +++ b/packages/core/src/pam/player-note/service/player-note.service.ts @@ -1,17 +1,35 @@ -import { DrizzleService, pageToOffset, serializeRow } from '@openora/core/server'; -import { asc, count, desc, eq } from 'drizzle-orm'; -import type { Player, User, PaginationOptions } from '@openora/core/contracts'; +import { + DrizzleService, + makeNotFoundError, + pageToOffset, + serializeRow, + withAdvisoryXactLock, +} from '@openora/core/server'; +import { and, asc, count, desc, eq } from 'drizzle-orm'; +import type { + AuditWritePort, + Player, + User, + PaginationOptions, + Uuid, +} from '@openora/core/contracts'; +import { player } from '@openora/core/pam/schema/profile'; import { playerNote } from '../schema/index.js'; import type { CreatePlayerNoteInput, PlayerNoteItem, PlayerNoteSortBy } from '../contract/index.js'; const DATE_FIELDS = ['createdAt', 'updatedAt'] as const; +export const PlayerNotFoundError = makeNotFoundError('Player'); + function toItem(row: typeof playerNote.$inferSelect): PlayerNoteItem { return serializeRow(row, { dateFields: DATE_FIELDS }); } export class PlayerNoteService { - constructor(private readonly drizzle: DrizzleService) {} + constructor( + private readonly drizzle: DrizzleService, + private readonly audit: AuditWritePort, + ) {} async list({ playerId, @@ -44,4 +62,71 @@ export class PlayerNoteService { .returning(); return toItem(created); } + + async playerExists(playerId: Player['id']) { + const [found] = await this.drizzle.db + .select({ id: player.id }) + .from(player) + .where(eq(player.id, playerId)) + .limit(1); + return found !== undefined; + } + + /** + * Adds the note an approved agent proposal carries, at most once per proposal. With no + * proposal column to key on, a replay is recognised by the note it already wrote (same + * player, author and content); the lock on the proposal id stops two concurrent executions + * from both missing it. + */ + async createForProposal({ + playerId, + content, + actorId, + proposalId, + }: { + playerId: Player['id']; + content: string; + actorId: User['id']; + proposalId: Uuid; + }) { + return this.drizzle.db.transaction((tx) => + withAdvisoryXactLock(tx, `agent-proposal:${proposalId}`, async () => { + const [existing] = await tx + .select({ id: playerNote.id }) + .from(playerNote) + .where( + and( + eq(playerNote.playerId, playerId), + eq(playerNote.actorId, actorId), + eq(playerNote.content, content), + ), + ) + .limit(1); + if (existing) { + return { status: 'already_created' as const }; + } + const [target] = await tx + .select({ id: player.id }) + .from(player) + .where(eq(player.id, playerId)) + .limit(1); + if (!target) { + throw new PlayerNotFoundError(playerId); + } + const [created] = await tx + .insert(playerNote) + .values({ playerId, actorId, content }) + .returning(); + await this.audit.recordInTransaction(tx, { + actorId, + actorType: 'admin', + action: 'admin.player_note.created', + resourceType: 'player', + resourceId: playerId, + after: { noteId: created.id, content: created.content, proposalId }, + }); + return { status: 'created' as const, note: toItem(created) }; + }), + ); + } } diff --git a/packages/core/src/pam/tag/__tests__/get-active-tag-keys.int.test.ts b/packages/core/src/pam/tag/__tests__/get-active-tag-keys.int.test.ts index ae6669ec5..fae241d93 100644 --- a/packages/core/src/pam/tag/__tests__/get-active-tag-keys.int.test.ts +++ b/packages/core/src/pam/tag/__tests__/get-active-tag-keys.int.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq, sql } from 'drizzle-orm'; -import type { TagKey } from '@openora/core/contracts'; +import type { PlayEligibilityPort, TagKey } from '@openora/core/contracts'; import { createTestDb, type TestDb } from '@openora/core/testing'; import { player } from '@openora/core/pam/schema/profile'; import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; -import { makeEventBus } from '../../../testing/mock.js'; +import { makeEventBus, mock } from '../../../testing/mock.js'; import { migrate } from '../migrate.js'; import { playerTag, tag } from '../schema/index.js'; import { TagService } from '../service/tag.service.js'; @@ -40,7 +40,7 @@ async function seedPlayerWithTags(activeKeys: TagKey[], removedKeys: TagKey[] = beforeAll(async () => { db = await createTestDb([migrate, migrateProfile]); - svc = new TagService(db.drizzle, makeEventBus()); + svc = new TagService(db.drizzle, makeEventBus(), mock({})); }); afterAll(async () => { diff --git a/packages/core/src/pam/tag/__tests__/tag-evaluation.service.int.test.ts b/packages/core/src/pam/tag/__tests__/tag-evaluation.service.int.test.ts index 03c5febd1..aac9bd230 100644 --- a/packages/core/src/pam/tag/__tests__/tag-evaluation.service.int.test.ts +++ b/packages/core/src/pam/tag/__tests__/tag-evaluation.service.int.test.ts @@ -5,6 +5,7 @@ import type { WalletReader, IdentityReader, AdminUserDirectory, + PlayEligibilityPort, TagKey, KycStatus, } from '@openora/core/contracts'; @@ -145,7 +146,7 @@ function makeServices( } = {}, ) { const events = makeEventBus(); - const tagService = new TagService(db.drizzle, events); + const tagService = new TagService(db.drizzle, events, mock({})); const ruleService = new TagRuleService(db.drizzle, events); const walletReader = mock({ getLifetimeDeposit: vi.fn().mockResolvedValue('0'), diff --git a/packages/core/src/pam/tag/__tests__/tag.agent-actions.int.test.ts b/packages/core/src/pam/tag/__tests__/tag.agent-actions.int.test.ts new file mode 100644 index 000000000..6c243416a --- /dev/null +++ b/packages/core/src/pam/tag/__tests__/tag.agent-actions.int.test.ts @@ -0,0 +1,408 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { and, eq, isNull, sql } from 'drizzle-orm'; +import { + Container, + ModuleRegistryImpl, + DRIZZLE, + EVENT_BUS, + type CoreTokenCatalog, +} from '@openora/core/server'; +import { + PLAY_ELIGIBILITY, + type ActionExecutionContext, + type PlayEligibilityPort, + type PlayerStatus, + type RunContext, + type TagKey, +} from '@openora/core/contracts'; +import { createMcpKernel, createTestDb, type TestDb } from '@openora/core/testing'; +import { player } from '@openora/core/pam/schema/profile'; +import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; +import { makeAuditWriter, makeEventBus, mock } from '../../../testing/mock.js'; +import { migrate } from '../migrate.js'; +import { playerTag, tag } from '../schema/index.js'; +import { + TagService, + TagNotFoundError, + PlayerNotFoundError, + PlayerNotEligibleForTagError, +} from '../service/tag.service.js'; +import tagPlugin from '../plugin.js'; + +const ADMIN_ID = randomUUID(); +const REASON = 'Flagged by the account review agent'; + +let db: TestDb; + +beforeAll(async () => { + db = await createTestDb([migrate, migrateProfile]); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + await db.drizzle.db.execute( + sql`TRUNCATE ${playerTag}, ${tag}, ${player} RESTART IDENTITY CASCADE`, + ); +}); + +async function seedTags(...keys: TagKey[]) { + await db.drizzle.db.insert(tag).values(keys.map((key) => ({ key, isSticky: true }))); +} + +async function seedPlayer(status: PlayerStatus = 'active') { + const [row] = await db.drizzle.db + .insert(player) + .values({ userId: randomUUID(), status }) + .returning(); + return row!; +} + +async function activeAssignments(playerId: string, tagKey: TagKey) { + return db.drizzle.db + .select({ row: playerTag }) + .from(playerTag) + .innerJoin(tag, eq(tag.id, playerTag.tagId)) + .where(and(eq(playerTag.playerId, playerId), eq(tag.key, tagKey), isNull(playerTag.removedAt))); +} + +function playEligibility(restricted = false) { + const isRestricted = vi.fn(async (_userId: string) => restricted); + return { port: mock({ isRestricted }), isRestricted }; +} + +function makeService({ restricted = false } = {}) { + const events = makeEventBus(); + const eligibility = playEligibility(restricted); + return { + svc: new TagService(db.drizzle, events, eligibility.port), + events, + isRestricted: eligibility.isRestricted, + }; +} + +const assignment = (playerId: string, tagKey: TagKey) => ({ + playerId, + tagKey, + assignReason: REASON, + assignActor: 'manual' as const, + assignActorUserId: ADMIN_ID, +}); + +describe('TagService.assignmentRefusal (real PG)', () => { + it('refuses an unknown player', async () => { + const { svc } = makeService(); + await seedTags('vip'); + + expect(await svc.assignmentRefusal(randomUUID(), 'vip')).toBe('player_not_found'); + }); + + it('refuses a tag missing from the catalog', async () => { + const { svc } = makeService(); + const target = await seedPlayer(); + + expect(await svc.assignmentRefusal(target.id, 'vip')).toBe('tag_not_found'); + }); + + it('refuses a tag the player already holds', async () => { + const { svc } = makeService(); + await seedTags('bonus_abuser'); + const target = await seedPlayer(); + await svc.assignPlayerTag(assignment(target.id, 'bonus_abuser')); + + expect(await svc.assignmentRefusal(target.id, 'bonus_abuser')).toBe('tag_already_active'); + }); + + it.each(['active', 'dormant'] as const)('allows vip for a %s player', async (status) => { + const { svc } = makeService(); + await seedTags('vip', 'self_excluded'); + const target = await seedPlayer(status); + + expect(await svc.assignmentRefusal(target.id, 'vip')).toBeNull(); + }); + + it.each(['self_excluded', 'suspended', 'closed'] as const)( + 'refuses vip for a %s player', + async (status) => { + const { svc } = makeService(); + await seedTags('vip'); + const target = await seedPlayer(status); + + expect(await svc.assignmentRefusal(target.id, 'vip')).toBe('player_not_eligible'); + }, + ); + + it('refuses vip for an active player carrying the self_excluded tag', async () => { + const { svc } = makeService(); + await seedTags('vip', 'self_excluded'); + const target = await seedPlayer(); + await svc.assignPlayerTag(assignment(target.id, 'self_excluded')); + + expect(await svc.assignmentRefusal(target.id, 'vip')).toBe('player_not_eligible'); + }); + + it('refuses vip for an active player whose RG restriction is in force', async () => { + const { svc, isRestricted } = makeService({ restricted: true }); + await seedTags('vip', 'self_excluded'); + const target = await seedPlayer(); + + expect(await svc.assignmentRefusal(target.id, 'vip')).toBe('player_not_eligible'); + expect(isRestricted).toHaveBeenCalledWith(target.userId); + }); + + it('applies the eligibility guard to vip only', async () => { + const { svc } = makeService({ restricted: true }); + await seedTags('bonus_abuser'); + const target = await seedPlayer('self_excluded'); + + expect(await svc.assignmentRefusal(target.id, 'bonus_abuser')).toBeNull(); + }); +}); + +describe('TagService.assignPlayerTagForProposal (real PG)', () => { + it('assigns the tag as a manual assignment by the admin and emits tag.player.assigned', async () => { + const { svc, events } = makeService(); + await seedTags('high_risk'); + const target = await seedPlayer(); + + const outcome = await svc.assignPlayerTagForProposal(assignment(target.id, 'high_risk')); + + expect(outcome.status).toBe('created'); + const rows = await activeAssignments(target.id, 'high_risk'); + expect(rows).toHaveLength(1); + expect(rows[0]?.row).toMatchObject({ + assignActor: 'manual', + assignActorUserId: ADMIN_ID, + assignReason: REASON, + }); + expect(events.emit).toHaveBeenCalledTimes(1); + expect(events.emit).toHaveBeenCalledWith('tag.player.assigned', { + playerId: target.id, + tagKey: 'high_risk', + reason: REASON, + actorId: ADMIN_ID, + }); + }); + + it('answers a replay with already_active, leaving one active row and one event', async () => { + const { svc, events } = makeService(); + await seedTags('multi_account'); + const target = await seedPlayer(); + + await svc.assignPlayerTagForProposal(assignment(target.id, 'multi_account')); + const replay = await svc.assignPlayerTagForProposal(assignment(target.id, 'multi_account')); + + expect(replay).toEqual({ status: 'already_active' }); + expect(await activeAssignments(target.id, 'multi_account')).toHaveLength(1); + expect(events.emit).toHaveBeenCalledTimes(1); + }); + + it('leaves one active row when the same proposal executes twice concurrently', async () => { + const { svc, events } = makeService(); + await seedTags('bonus_abuser'); + const target = await seedPlayer(); + + const outcomes = await Promise.all([ + svc.assignPlayerTagForProposal(assignment(target.id, 'bonus_abuser')), + svc.assignPlayerTagForProposal(assignment(target.id, 'bonus_abuser')), + ]); + + expect(outcomes.map((o) => o.status).sort()).toEqual(['already_active', 'created']); + expect(await activeAssignments(target.id, 'bonus_abuser')).toHaveLength(1); + expect(events.emit).toHaveBeenCalledTimes(1); + }); + + it('re-checks vip eligibility at execution: a player self-excluded after the proposal is refused', async () => { + const { svc, events } = makeService(); + await seedTags('vip'); + const target = await seedPlayer(); + expect(await svc.assignmentRefusal(target.id, 'vip')).toBeNull(); + await db.drizzle.db + .update(player) + .set({ status: 'self_excluded' }) + .where(eq(player.id, target.id)); + + await expect( + svc.assignPlayerTagForProposal(assignment(target.id, 'vip')), + ).rejects.toBeInstanceOf(PlayerNotEligibleForTagError); + + expect(await activeAssignments(target.id, 'vip')).toHaveLength(0); + expect(events.emit).not.toHaveBeenCalled(); + }); + + it('refuses vip at execution for a player whose RG restriction is in force', async () => { + const { svc } = makeService({ restricted: true }); + await seedTags('vip'); + const target = await seedPlayer(); + + await expect( + svc.assignPlayerTagForProposal(assignment(target.id, 'vip')), + ).rejects.toBeInstanceOf(PlayerNotEligibleForTagError); + expect(await activeAssignments(target.id, 'vip')).toHaveLength(0); + }); + + it('still answers already_active for an applied vip after the player became ineligible', async () => { + const { svc } = makeService(); + await seedTags('vip'); + const target = await seedPlayer(); + await svc.assignPlayerTagForProposal(assignment(target.id, 'vip')); + await db.drizzle.db.update(player).set({ status: 'closed' }).where(eq(player.id, target.id)); + + const replay = await svc.assignPlayerTagForProposal(assignment(target.id, 'vip')); + + expect(replay).toEqual({ status: 'already_active' }); + }); + + it('refuses an unknown player without writing a row', async () => { + const { svc } = makeService(); + await seedTags('high_risk'); + const playerId = randomUUID(); + + await expect( + svc.assignPlayerTagForProposal(assignment(playerId, 'high_risk')), + ).rejects.toBeInstanceOf(PlayerNotFoundError); + expect(await activeAssignments(playerId, 'high_risk')).toHaveLength(0); + }); + + it('refuses a tag missing from the catalog', async () => { + const { svc } = makeService(); + const target = await seedPlayer(); + + await expect( + svc.assignPlayerTagForProposal(assignment(target.id, 'high_risk')), + ).rejects.toBeInstanceOf(TagNotFoundError); + }); +}); + +function bootKernel({ restricted = false } = {}) { + const container = new Container(); + container.register(DRIZZLE, () => db.drizzle); + container.register(EVENT_BUS, () => makeEventBus()); + container.register(PLAY_ELIGIBILITY, () => playEligibility(restricted).port); + const registry = new ModuleRegistryImpl(container); + tagPlugin.register(registry); + return createMcpKernel({ + tools: registry.mcp.getTools(), + actions: registry.actions.getAll(), + container, + authorize: async () => 'allowed', + audit: makeAuditWriter(), + }); +} + +const runContext = (): RunContext => ({ + runId: randomUUID(), + actor: { kind: 'admin', adminId: ADMIN_ID }, + catalogVersion: 'test', + correlationId: 'test', +}); + +const agentExecution = (): ActionExecutionContext => ({ + proposalId: randomUUID(), + actor: { kind: 'agent', agentId: randomUUID(), agentVersion: 1, onBehalfOf: ADMIN_ID }, + correlationId: 'test', +}); + +describe('add_tag action type (plugin wiring through the MCP kernel)', () => { + it('refuses the precondition with player_not_eligible for vip on a self-excluded player', async () => { + const kernel = bootKernel(); + await seedTags('vip'); + const target = await seedPlayer('self_excluded'); + + const result = await kernel.checkPrecondition( + 'add_tag', + { playerId: target.id, tagKey: 'vip', reason: REASON }, + runContext(), + ); + + expect(result).toEqual({ ok: false, error: 'player_not_eligible' }); + }); + + it('rejects a tag outside the agent-assignable set', async () => { + const kernel = bootKernel(); + const target = await seedPlayer(); + + const result = await kernel.checkPrecondition( + 'add_tag', + { playerId: target.id, tagKey: 'withdrawal_review', reason: REASON }, + runContext(), + ); + + expect(result).toMatchObject({ ok: false, error: 'invalid_input' }); + }); + + it('applies the tag in the name of the admin the agent acts for, once per proposal', async () => { + const kernel = bootKernel(); + await seedTags('vip'); + const target = await seedPlayer(); + const execution = agentExecution(); + const payload = { playerId: target.id, tagKey: 'vip', reason: ` ${REASON} ` }; + + const first = await kernel.executeAction('add_tag', payload, execution); + const replay = await kernel.executeAction('add_tag', payload, execution); + + expect(first).toEqual({ ok: true, outcome: 'applied' }); + expect(replay).toEqual({ ok: true, outcome: 'already_applied' }); + const rows = await activeAssignments(target.id, 'vip'); + expect(rows).toHaveLength(1); + expect(rows[0]?.row).toMatchObject({ assignActorUserId: ADMIN_ID, assignReason: REASON }); + }); + + it('fails execution with player_not_eligible for vip on an RG-restricted player', async () => { + const kernel = bootKernel({ restricted: true }); + await seedTags('vip'); + const target = await seedPlayer(); + + const result = await kernel.executeAction( + 'add_tag', + { playerId: target.id, tagKey: 'vip', reason: REASON }, + agentExecution(), + ); + + expect(result).toEqual({ ok: false, error: 'player_not_eligible' }); + expect(await activeAssignments(target.id, 'vip')).toHaveLength(0); + }); +}); + +describe('send_to_manual_review action type (plugin wiring through the MCP kernel)', () => { + it('assigns withdrawal_review once and then reports already_in_manual_review', async () => { + const kernel = bootKernel(); + await seedTags('withdrawal_review'); + const target = await seedPlayer(); + const execution = agentExecution(); + const payload = { playerId: target.id, reason: REASON }; + + const before = await kernel.checkPrecondition('send_to_manual_review', payload, runContext()); + const first = await kernel.executeAction('send_to_manual_review', payload, execution); + const replay = await kernel.executeAction('send_to_manual_review', payload, execution); + const after = await kernel.checkPrecondition('send_to_manual_review', payload, runContext()); + + expect(before).toEqual({ ok: true }); + expect(first).toEqual({ ok: true, outcome: 'applied' }); + expect(replay).toEqual({ ok: true, outcome: 'already_applied' }); + expect(after).toEqual({ ok: false, error: 'already_in_manual_review' }); + const rows = await activeAssignments(target.id, 'withdrawal_review'); + expect(rows).toHaveLength(1); + expect(rows[0]?.row).toMatchObject({ + assignActor: 'manual', + assignActorUserId: ADMIN_ID, + assignReason: REASON, + }); + }); + + it('refuses the precondition with player_not_found for an unknown player', async () => { + const kernel = bootKernel(); + await seedTags('withdrawal_review'); + + const result = await kernel.checkPrecondition( + 'send_to_manual_review', + { playerId: randomUUID(), reason: REASON }, + runContext(), + ); + + expect(result).toEqual({ ok: false, error: 'player_not_found' }); + }); +}); diff --git a/packages/core/src/pam/tag/__tests__/tag.router.int.test.ts b/packages/core/src/pam/tag/__tests__/tag.router.int.test.ts index 46cc4acbf..1c35883ff 100644 --- a/packages/core/src/pam/tag/__tests__/tag.router.int.test.ts +++ b/packages/core/src/pam/tag/__tests__/tag.router.int.test.ts @@ -2,9 +2,9 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { sql } from 'drizzle-orm'; import { call, ORPCError } from '@orpc/server'; import type { AdminGuard } from '@openora/core/server'; -import type { TagKey } from '@openora/core/contracts'; +import type { PlayEligibilityPort, TagKey } from '@openora/core/contracts'; import { createTestDb, type TestDb } from '@openora/core/testing'; -import { makeEventBus, makeAdminGuard, testContext } from '../../../testing/mock.js'; +import { makeEventBus, makeAdminGuard, mock, testContext } from '../../../testing/mock.js'; import { migrate } from '../migrate.js'; import { tag, tagRule, playerTag } from '../schema/index.js'; import { createTagRouter } from '../router/index.js'; @@ -47,7 +47,7 @@ const allowingGuard = () => makeAdminGuard({ caller: { userId: CALLER } }); function build(adminGuard: AdminGuard) { const events = makeEventBus(); - const tagService = new TagService(db.drizzle, events); + const tagService = new TagService(db.drizzle, events, mock({})); const ruleService = new TagRuleService(db.drizzle, events); return { router: createTagRouter(tagService, ruleService, adminGuard), events }; } diff --git a/packages/core/src/pam/tag/__tests__/tag.service.int.test.ts b/packages/core/src/pam/tag/__tests__/tag.service.int.test.ts index c001806d8..ee9262e99 100644 --- a/packages/core/src/pam/tag/__tests__/tag.service.int.test.ts +++ b/packages/core/src/pam/tag/__tests__/tag.service.int.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { randomUUID } from 'node:crypto'; import { and, eq, isNull, sql } from 'drizzle-orm'; -import type { TagKey } from '@openora/core/contracts'; +import type { PlayEligibilityPort, TagKey } from '@openora/core/contracts'; import { createTestDb, type TestDb } from '@openora/core/testing'; import { player } from '@openora/core/pam/schema/profile'; import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; -import { makeEventBus } from '../../../testing/mock.js'; +import { makeEventBus, mock } from '../../../testing/mock.js'; import { migrate } from '../migrate.js'; import { playerTag, tag } from '../schema/index.js'; import { @@ -21,7 +21,7 @@ let db: TestDb; function makeService() { const events = makeEventBus(); - return { svc: new TagService(db.drizzle, events), events }; + return { svc: new TagService(db.drizzle, events, mock({})), events }; } async function seedTag(key: TagKey, isSticky = false) { diff --git a/packages/core/src/pam/tag/contract/agent-tools.ts b/packages/core/src/pam/tag/contract/agent-tools.ts new file mode 100644 index 000000000..3468bec8b --- /dev/null +++ b/packages/core/src/pam/tag/contract/agent-tools.ts @@ -0,0 +1,48 @@ +import z from 'zod'; +import { UuidSchema, defineActionType, type TagKey } from '@openora/core/contracts'; + +// Every other key is either computed by tag rules and compliance (kyc_*, self_excluded, level, +// inactive, high_roller, ...) or has its own action type (withdrawal_review). +export const AGENT_ASSIGNABLE_TAG_KEYS = [ + 'vip', + 'bonus_abuser', + 'high_risk', + 'multi_account', +] as const satisfies readonly TagKey[]; + +const AgentTagReasonSchema = z.string().trim().min(1).max(500); + +export const addTagAction = defineActionType({ + id: 'add_tag', + title: 'Tag a player', + description: + 'Flags a player with the vip, bonus_abuser, high_risk or multi_account tag and a reason ' + + 'admins can see; vip is refused unless the player is active or dormant and neither ' + + 'self-excluded nor on a cooling-off break. The three risk tags also stop automatic approval ' + + "of the player's withdrawals while the operator keeps them in its auto-withdrawal " + + 'exclusion list, which is the default.', + schemaVersion: 1, + iam: { resource: 'tag', action: 'create' }, + reversible: true, + payloadSchema: z.object({ + playerId: UuidSchema, + tagKey: z.enum(AGENT_ASSIGNABLE_TAG_KEYS), + reason: AgentTagReasonSchema, + }), + errors: ['player_not_found', 'tag_not_found', 'tag_already_active', 'player_not_eligible'], +}); + +export const sendToManualReviewAction = defineActionType({ + id: 'send_to_manual_review', + title: 'Send withdrawals to manual review', + description: + 'Tags the player withdrawal_review so every withdrawal they request from now on waits for ' + + 'an admin instead of being approved automatically; withdrawals already requested are not ' + + 'affected, and the tag stays until an admin removes it. This only works while the operator ' + + 'keeps withdrawal_review in its auto-withdrawal exclusion list, which is the default.', + schemaVersion: 1, + iam: { resource: 'tag', action: 'create' }, + reversible: true, + payloadSchema: z.object({ playerId: UuidSchema, reason: AgentTagReasonSchema }), + errors: ['player_not_found', 'already_in_manual_review'], +}); diff --git a/packages/core/src/pam/tag/contract/index.ts b/packages/core/src/pam/tag/contract/index.ts index 37ce00e34..5b8dc9049 100644 --- a/packages/core/src/pam/tag/contract/index.ts +++ b/packages/core/src/pam/tag/contract/index.ts @@ -15,6 +15,7 @@ import z from 'zod'; import { HighRiskAssignMetadataSchema } from './player-tag-assign-metadata.js'; export * from './player-tag-assign-metadata.js'; +export * from './agent-tools.js'; export const SYSTEM_ACTOR_ID = '00000000-0000-0000-0000-000000000000'; diff --git a/packages/core/src/pam/tag/plugin.ts b/packages/core/src/pam/tag/plugin.ts index c5ea9f9b8..182e18b15 100644 --- a/packages/core/src/pam/tag/plugin.ts +++ b/packages/core/src/pam/tag/plugin.ts @@ -13,13 +13,71 @@ import { IDENTITY_READER, ADMIN_USER_DIRECTORY, JOB_QUEUE, + PLAY_ELIGIBILITY, + McpToolError, queue, + runActorAdminId, + type ActionExecutionOutcome, + type Player, + type RunActor, + type TagKey, } from '@openora/core/contracts'; import z from 'zod'; -import { TagService } from './service/tag.service.js'; +import { + TagService, + TagNotFoundError, + PlayerNotFoundError, + PlayerNotEligibleForTagError, + type TagAssignmentRefusal, +} from './service/tag.service.js'; import { TagRuleService } from './service/tag-rule.service.js'; import { TagEvaluationService } from './service/tag-evaluation.service.js'; import { createTagRouter } from './router/index.js'; +import { addTagAction, sendToManualReviewAction } from './contract/agent-tools.js'; + +type ProposedAssignment = { + playerId: Player['id']; + tagKey: TagKey; + reason: string; + actor: RunActor; +}; + +function preconditionOutcome(refusal: string | null) { + return refusal === null ? { ok: true as const } : { ok: false as const, code: refusal }; +} + +// send_to_manual_review declares no tag_not_found: a catalog without withdrawal_review is an +// operator misconfiguration, which the kernel reports as internal_error. +function manualReviewRefusal(refusal: TagAssignmentRefusal | null) { + return refusal === 'tag_already_active' ? 'already_in_manual_review' : refusal; +} + +async function assignForProposal( + tags: TagService, + { playerId, tagKey, reason, actor }: ProposedAssignment, +): Promise { + try { + const outcome = await tags.assignPlayerTagForProposal({ + playerId, + tagKey, + assignReason: reason, + assignActor: 'manual', + assignActorUserId: runActorAdminId(actor), + }); + return { outcome: outcome.status === 'created' ? 'applied' : 'already_applied' }; + } catch (err) { + if (err instanceof PlayerNotFoundError) { + throw new McpToolError('player_not_found'); + } + if (err instanceof TagNotFoundError) { + throw new McpToolError('tag_not_found'); + } + if (err instanceof PlayerNotEligibleForTagError) { + throw new McpToolError('player_not_eligible'); + } + throw err; + } +} export default { id: 'tag', @@ -28,7 +86,7 @@ export default { // One memoized instance backs the PLAYER_TAGS port and the router closure. let svc: TagService | null = null; const tagService = (c: TypedContainer) => - (svc ??= new TagService(c.get(DRIZZLE), c.get(EVENT_BUS))); + (svc ??= new TagService(c.get(DRIZZLE), c.get(EVENT_BUS), c.get(PLAY_ELIGIBILITY))); // One memoized instance backs the router closure and the TAG_EVALUATION_COMMANDS port. let ruleSvc: TagRuleService | null = null; @@ -101,5 +159,27 @@ export default { return createTagRouter(tagSvc, ruleSvcForRouter, c.get(ADMIN_GUARD)); }); + + ctx.actions.register(addTagAction, (c) => { + const tags = tagService(c); + return { + precondition: async ({ playerId, tagKey }) => + preconditionOutcome(await tags.assignmentRefusal(playerId, tagKey)), + execute: ({ playerId, tagKey, reason }, _proposalId, actor) => + assignForProposal(tags, { playerId, tagKey, reason, actor }), + }; + }); + + ctx.actions.register(sendToManualReviewAction, (c) => { + const tags = tagService(c); + return { + precondition: async ({ playerId }) => + preconditionOutcome( + manualReviewRefusal(await tags.assignmentRefusal(playerId, 'withdrawal_review')), + ), + execute: ({ playerId, reason }, _proposalId, actor) => + assignForProposal(tags, { playerId, tagKey: 'withdrawal_review', reason, actor }), + }; + }); }, } as const satisfies Plugin; diff --git a/packages/core/src/pam/tag/service/tag.service.ts b/packages/core/src/pam/tag/service/tag.service.ts index 1eac900ed..0bccae61e 100644 --- a/packages/core/src/pam/tag/service/tag.service.ts +++ b/packages/core/src/pam/tag/service/tag.service.ts @@ -1,6 +1,7 @@ import { DrizzleService, DrizzleTx, + type DrizzleDb, EventBus, findOneOrThrow, makeNotFoundError, @@ -18,6 +19,8 @@ import { ReplacePlayerTagInput, type PlayerTags, type Player, + type PlayerStatus, + type PlayEligibilityPort, type TagKey, type User, type ClientMeta, @@ -69,6 +72,23 @@ export const TagRemovalReasonRequiredError = makeConflictError( 'TagRemovalReasonRequiredError', 'A removal reason is required for sticky tags', ); +export const PlayerNotFoundError = makeNotFoundError('Player'); +export const PlayerNotEligibleForTagError = makeConflictError( + 'PlayerNotEligibleForTagError', + 'Player is not eligible for this tag', +); + +// vip is marketing treatment: a self-excluded, RG-restricted, suspended or closed player never +// gets it. Player status alone misses an RG self-exclusion, which leaves the status 'active'. +const VIP_ELIGIBLE_STATUSES: ReadonlySet = new Set(['active', 'dormant']); + +export type TagAssignmentRefusal = + | 'player_not_found' + | 'tag_not_found' + | 'tag_already_active' + | 'player_not_eligible'; + +type AssignablePlayer = Pick; const DEFAULT_MANUAL_REMOVAL_REASON = 'manual tag removal'; @@ -108,6 +128,7 @@ export class TagService implements PlayerTags { constructor( private readonly drizzle: DrizzleService, private readonly event: EventBus, + private readonly playEligibility: PlayEligibilityPort, ) {} // Keyed by auth `userId`, not the internal `player.id`. Users with no active tags are absent from the map. @@ -534,4 +555,104 @@ export class TagService implements PlayerTags { mapDbError(e); } } + + private _assignablePlayer(db: DrizzleDb | DrizzleTx, playerId: Player['id']) { + return db + .select({ id: player.id, userId: player.userId, status: player.status }) + .from(player) + .where(eq(player.id, playerId)); + } + + private async _hasActiveTag(db: DrizzleDb | DrizzleTx, playerId: Player['id'], tagKey: TagKey) { + const [active] = await db + .select({ id: playerTag.id }) + .from(playerTag) + .innerJoin(tag, eq(tag.id, playerTag.tagId)) + .where( + and(eq(playerTag.playerId, playerId), eq(tag.key, tagKey), isNull(playerTag.removedAt)), + ) + .limit(1); + return active !== undefined; + } + + private async _isEligibleForTag( + db: DrizzleDb | DrizzleTx, + target: AssignablePlayer, + tagKey: TagKey, + ) { + if (tagKey !== 'vip') { + return true; + } + if (!VIP_ELIGIBLE_STATUSES.has(target.status)) { + return false; + } + if (await this._hasActiveTag(db, target.id, 'self_excluded')) { + return false; + } + return !(await this.playEligibility.isRestricted(target.userId)); + } + + public async assignmentRefusal( + playerId: Player['id'], + tagKey: TagKey, + ): Promise { + const db = this.drizzle.db; + const [target] = await this._assignablePlayer(db, playerId).limit(1); + if (!target) { + return 'player_not_found'; + } + const [catalogTag] = await db + .select({ id: tag.id }) + .from(tag) + .where(eq(tag.key, tagKey)) + .limit(1); + if (!catalogTag) { + return 'tag_not_found'; + } + if (await this._hasActiveTag(db, playerId, tagKey)) { + return 'tag_already_active'; + } + if (!(await this._isEligibleForTag(db, target, tagKey))) { + return 'player_not_eligible'; + } + return null; + } + + /** + * Assigns tagKey for an approved agent proposal. Eligibility is re-checked because the + * proposal may have waited days for approval. The share lock on the player row stops a player + * status change from landing between that check and the insert; the responsible-gambling block + * and the self_excluded tag are read without a lock their writers take, so a block committed + * in that window is not caught - acceptable while vip drives no money movement. An active + * assignment - this proposal's own, on a replay - reports 'already_active' before eligibility + * is looked at. + */ + public async assignPlayerTagForProposal(args: AssignPlayerTagArgs) { + try { + const outcome = await this.drizzle.db.transaction(async (trx) => { + const target = findOneOrThrow( + await this._assignablePlayer(trx, args.playerId).for('share'), + new PlayerNotFoundError(args.playerId), + ); + if (await this._hasActiveTag(trx, target.id, args.tagKey)) { + return { status: 'already_active' as const }; + } + if (!(await this._isEligibleForTag(trx, target, args.tagKey))) { + throw new PlayerNotEligibleForTagError(); + } + return this._assignPlayerTagOnTx(trx, args); + }); + if (outcome.status === 'created') { + void this.event.emit('tag.player.assigned', { + playerId: args.playerId, + tagKey: args.tagKey, + reason: args.assignReason, + actorId: args.assignActorUserId ?? SYSTEM_ACTOR_ID, + }); + } + return outcome; + } catch (e) { + mapDbError(e); + } + } } diff --git a/packages/core/src/server/auth/__tests__/admin-guard.int.test.ts b/packages/core/src/server/auth/__tests__/admin-guard.int.test.ts index 505fb0ba7..e7f1b7bc9 100644 --- a/packages/core/src/server/auth/__tests__/admin-guard.int.test.ts +++ b/packages/core/src/server/auth/__tests__/admin-guard.int.test.ts @@ -316,3 +316,83 @@ describe('AdminGuard.assertSuperAdmin (real PG)', () => { }); }); }); + +describe('AdminGuard.assertUser (real PG)', () => { + it('returns the caller for an admin holding the grant, with no client metadata', async () => { + const userId = await seedUser('admin'); + const { guard, events } = makeGuard(); + + await expect(guard.assertUser(userId, 'agent-proposal', 'approve')).resolves.toEqual({ + userId, + role: 'admin', + ip: null, + userAgent: null, + }); + expect(events.emit).not.toHaveBeenCalled(); + }); + + it('denies a user without the grant and emits the same denial event as assert', async () => { + const userId = await seedUser('admin'); + const { guard, events } = makeGuard({ grants: [{ resource: 'player', action: 'view' }] }); + + await expect(guard.assertUser(userId, 'agent-proposal', 'approve')).rejects.toThrow( + expect.objectContaining({ code: 'FORBIDDEN' }), + ); + expect(events.emit).toHaveBeenCalledWith('identity.user.unauthorized_access', { + userId, + playerId: null, + resource: 'agent-proposal', + action: 'approve', + ip: null, + userAgent: null, + role: 'admin', + }); + }); + + it('denies an id with no user row', async () => { + const userId = randomUUID(); + const { guard, events } = makeGuard(); + + await expect(guard.assertUser(userId, 'agent', 'run')).rejects.toThrow( + expect.objectContaining({ code: 'FORBIDDEN' }), + ); + expect(events.emit).toHaveBeenCalledWith( + 'identity.user.unauthorized_access', + expect.objectContaining({ userId, resource: 'agent', action: 'run', role: undefined }), + ); + }); + + it('enforces 2FA enrolment but runs no session-integrity check, having no session', async () => { + const userId = await seedUser('admin'); + const securityPolicy = mock({ + assertEnrolled: vi.fn(async () => undefined), + assertSessionIntact: vi.fn(async () => undefined), + }); + const { guard } = makeGuard({ securityPolicy }); + + await guard.assertUser(userId, 'agent-proposal', 'view'); + + expect(securityPolicy.assertEnrolled).toHaveBeenCalledWith({ + userId, + sessionId: null, + ip: null, + userAgent: null, + }); + expect(securityPolicy.assertSessionIntact).not.toHaveBeenCalled(); + }); + + it('propagates a refused enrolment check', async () => { + const userId = await seedUser('admin'); + const securityPolicy = mock({ + assertEnrolled: vi.fn(async () => { + throw new Error('two-factor enrolment required'); + }), + assertSessionIntact: vi.fn(async () => undefined), + }); + const { guard } = makeGuard({ securityPolicy }); + + await expect(guard.assertUser(userId, 'agent-proposal', 'view')).rejects.toThrow( + /two-factor enrolment required/, + ); + }); +}); diff --git a/packages/core/src/server/auth/__tests__/permissions.test.ts b/packages/core/src/server/auth/__tests__/permissions.test.ts index 392248b65..12f683aee 100644 --- a/packages/core/src/server/auth/__tests__/permissions.test.ts +++ b/packages/core/src/server/auth/__tests__/permissions.test.ts @@ -13,4 +13,19 @@ describe('admin permission catalog', () => { it('grants the built-in admin role every action in the catalog', () => { expect(adminRole.statements).toEqual(adminStatement); }); + + it('declares the agent resources and grants them to the built-in admin role', () => { + expect(adminStatement).toMatchObject({ + agent: ['view', 'create', 'update', 'publish', 'run'], + 'agent-proposal': ['view', 'approve', 'reject'], + 'agent-config': ['view', 'update'], + }); + expect( + adminRole.authorize({ + agent: ['view', 'create', 'update', 'publish', 'run'], + 'agent-proposal': ['view', 'approve', 'reject'], + 'agent-config': ['view', 'update'], + }).success, + ).toBe(true); + }); }); diff --git a/packages/core/src/server/auth/admin-guard.ts b/packages/core/src/server/auth/admin-guard.ts index 7dd0490ca..00dcc262b 100644 --- a/packages/core/src/server/auth/admin-guard.ts +++ b/packages/core/src/server/auth/admin-guard.ts @@ -89,16 +89,73 @@ export class AdminGuard { }); } + return this.enforce({ + userId, + clientMeta: { ip, userAgent }, + permission: resource !== undefined && action !== undefined ? { resource, action } : undefined, + session: { sessionId: resolvedSession?.sessionId ?? null }, + }); + } + + /** + * `assert` for a caller that is not an HTTP request (an agent run, an MCP token): the same + * user, role, grant and 2FA-enrolment checks and the same denial event, without a session - + * so no session-integrity check, and `ip`/`userAgent` are null. + */ + async assertUser( + userId: string, + resource: R, + action: ActionOf, + ): Promise { + return this.enforce({ + userId, + clientMeta: { ip: null, userAgent: null }, + permission: { resource, action }, + }); + } + + async assertSuperAdmin(context: unknown): Promise { + const caller = await this.assert(context); + const assigned = await this.permissionResolver?.isSuperAdmin(caller.userId); + const isSuper = assigned ?? caller.role === 'admin'; + + if (!isSuper) { + this.emitUnauthorized( + caller.userId, + caller.role, + 'admin', + 'super-admin', + caller.ip, + caller.userAgent, + ); + throw new ORPCError('FORBIDDEN', { + message: 'Super admin access required', + data: { reason: AuthGuardReasonSchema.enum.admin_required }, + }); + } + return caller; + } + + private async enforce({ + userId, + clientMeta: { ip, userAgent }, + permission, + session, + }: { + userId: string; + clientMeta: ClientMeta; + permission: { resource: R; action: ActionOf } | undefined; + session?: { sessionId: string | null }; + }): Promise { + const deniedResource = permission?.resource ?? 'admin'; + const deniedAction = permission?.action ?? 'access'; + const result = await this.drizzle.db.execute( sql`SELECT id, role FROM "user" WHERE id = ${userId} LIMIT 1`, ); const userRecord = result.rows[0] as { id: string; role: string } | undefined; if (!userRecord) { - if (resource !== undefined && action !== undefined) { - this.emitUnauthorized(userId, undefined, resource, action, ip, userAgent); - } else { - this.emitUnauthorized(userId, undefined, 'admin', 'access', ip, userAgent); - } + this.emitUnauthorized(userId, undefined, deniedResource, deniedAction, ip, userAgent); throw new ORPCError('FORBIDDEN', { message: 'Admin access required', data: { reason: AuthGuardReasonSchema.enum.admin_required }, @@ -107,18 +164,15 @@ export class AdminGuard { const userRole = roles[userRecord.role as keyof typeof roles]; if (!userRole) { - if (resource !== undefined && action !== undefined) { - this.emitUnauthorized(userId, userRecord.role, resource, action, ip, userAgent); - } else { - this.emitUnauthorized(userId, userRecord.role, 'admin', 'access', ip, userAgent); - } + this.emitUnauthorized(userId, userRecord.role, deniedResource, deniedAction, ip, userAgent); throw new ORPCError('FORBIDDEN', { message: 'Admin access required', data: { reason: AuthGuardReasonSchema.enum.admin_required }, }); } - if (resource !== undefined && action !== undefined) { + if (permission) { + const { resource, action } = permission; const grants = await this.resolveGrants(userId); const allowed = this.checkGrant(grants, userRole, resource, action); if (!allowed) { @@ -135,41 +189,16 @@ export class AdminGuard { } if (this.securityPolicy) { - const securityContext = { - userId, - sessionId: resolvedSession?.sessionId ?? null, - ip, - userAgent, - }; + const securityContext = { userId, sessionId: session?.sessionId ?? null, ip, userAgent }; await this.securityPolicy.assertEnrolled(securityContext); - await this.securityPolicy.assertSessionIntact(securityContext); + if (session) { + await this.securityPolicy.assertSessionIntact(securityContext); + } } return { userId, role: userRecord.role, ip, userAgent }; } - async assertSuperAdmin(context: unknown): Promise { - const caller = await this.assert(context); - const assigned = await this.permissionResolver?.isSuperAdmin(caller.userId); - const isSuper = assigned ?? caller.role === 'admin'; - - if (!isSuper) { - this.emitUnauthorized( - caller.userId, - caller.role, - 'admin', - 'super-admin', - caller.ip, - caller.userAgent, - ); - throw new ORPCError('FORBIDDEN', { - message: 'Super admin access required', - data: { reason: AuthGuardReasonSchema.enum.admin_required }, - }); - } - return caller; - } - private resolveGrants(userId: string): Promise { return this.permissionResolver ? this.permissionResolver.getGrants(userId) diff --git a/packages/core/src/server/auth/permissions.ts b/packages/core/src/server/auth/permissions.ts index 4d3a2cdef..c9b3b586d 100644 --- a/packages/core/src/server/auth/permissions.ts +++ b/packages/core/src/server/auth/permissions.ts @@ -35,6 +35,9 @@ export const adminRole = ac.newRole({ 'chat-command': ['view', 'update'], 'chat-moderation': ['view', 'moderate'], 'regulatory-overview': ['view', 'manage-country-rules', 'manage-global-kyc'], + agent: ['view', 'create', 'update', 'publish', 'run'], + 'agent-proposal': ['view', 'approve', 'reject'], + 'agent-config': ['view', 'update'], }); export const supportRole = ac.newRole({ diff --git a/packages/core/src/server/mcp/__tests__/authorize.test.ts b/packages/core/src/server/mcp/__tests__/authorize.test.ts new file mode 100644 index 000000000..15ab51948 --- /dev/null +++ b/packages/core/src/server/mcp/__tests__/authorize.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ORPCError } from '@orpc/server'; +import { adminCaller } from '../../../testing/mock.js'; +import { authorizeWithAdminGuard } from '../authorize.js'; + +const ADMIN_ID = '8d1e7c0a-3f4b-4c2d-9e5f-6a7b8c9d0e1f'; +const APPROVE = { resource: 'agent-proposal', action: 'approve' } as const; + +const guardThat = (assertUser: () => Promise) => ({ assertUser: vi.fn(assertUser) }); + +describe('authorizeWithAdminGuard', () => { + it('allows when AdminGuard accepts the admin for the resource and action', async () => { + const guard = { assertUser: vi.fn(async () => adminCaller({ userId: ADMIN_ID })) }; + + await expect(authorizeWithAdminGuard(guard, ADMIN_ID, APPROVE)).resolves.toBe('allowed'); + expect(guard.assertUser).toHaveBeenCalledWith(ADMIN_ID, 'agent-proposal', 'approve'); + }); + + it.each(['FORBIDDEN', 'UNAUTHORIZED'])('denies on a %s refusal', async (code) => { + const guard = guardThat(async () => { + throw new ORPCError(code); + }); + + await expect(authorizeWithAdminGuard(guard, ADMIN_ID, APPROVE)).resolves.toBe('denied'); + }); + + it('rethrows anything that is not a refusal, so the kernel fails closed', async () => { + const outage = new Error('connection terminated'); + const guard = guardThat(async () => { + throw outage; + }); + + await expect(authorizeWithAdminGuard(guard, ADMIN_ID, APPROVE)).rejects.toBe(outage); + }); + + it('rethrows a server-side oRPC error rather than treating it as a denial', async () => { + const guard = guardThat(async () => { + throw new ORPCError('INTERNAL_SERVER_ERROR'); + }); + + await expect(authorizeWithAdminGuard(guard, ADMIN_ID, APPROVE)).rejects.toMatchObject({ + status: 500, + }); + }); +}); diff --git a/packages/core/src/server/mcp/__tests__/canonical-json.test.ts b/packages/core/src/server/mcp/__tests__/canonical-json.test.ts new file mode 100644 index 000000000..0f2488453 --- /dev/null +++ b/packages/core/src/server/mcp/__tests__/canonical-json.test.ts @@ -0,0 +1,38 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { canonicalJson, sha256Hex } from '../canonical-json.js'; + +describe('canonicalJson', () => { + it('serializes equal objects to the same bytes whatever their key order', () => { + expect(canonicalJson({ b: 1, a: { d: [3, { f: 1, e: 2 }], c: 'x' } })).toBe( + canonicalJson({ a: { c: 'x', d: [3, { e: 2, f: 1 }] }, b: 1 }), + ); + expect(canonicalJson({ b: 1, a: 2 })).toBe('{"a":2,"b":1}'); + }); + + it('keeps array order and drops undefined members the way JSON does', () => { + expect(canonicalJson({ list: [2, 1, undefined], skipped: undefined, kept: null })).toBe( + '{"kept":null,"list":[2,1,null]}', + ); + }); + + it('serializes top-level undefined as null', () => { + expect(canonicalJson(undefined)).toBe('null'); + }); + + it('never throws on a bigint or a reference cycle', () => { + const cyclic: Record = { id: 1 }; + cyclic['self'] = cyclic; + + expect(canonicalJson({ amount: 10n })).toBe('{"amount":"10"}'); + expect(canonicalJson(cyclic)).toBe('{"id":1,"self":"[Circular]"}'); + }); +}); + +describe('sha256Hex', () => { + it('hashes the canonical JSON', () => { + expect(sha256Hex({ b: 1, a: 2 })).toBe( + createHash('sha256').update('{"a":2,"b":1}').digest('hex'), + ); + }); +}); diff --git a/packages/core/src/server/mcp/__tests__/kernel.test.ts b/packages/core/src/server/mcp/__tests__/kernel.test.ts new file mode 100644 index 000000000..0b69d7dcc --- /dev/null +++ b/packages/core/src/server/mcp/__tests__/kernel.test.ts @@ -0,0 +1,631 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { describe, expect, it, vi } from 'vitest'; +import * as z from 'zod'; +import { + McpToolError, + MoneyAmountSchema, + UuidSchema, + defineActionType, + defineMcpTool, + triggerCatalog, + type ActionExecutionOutcome, + type ActionTypeImplementation, + type McpToolHandler, + type RunContext, + type TokenCatalog, +} from '@openora/core/contracts'; +import { makeAuditWriter, mock } from '../../../testing/mock.js'; +import { Container } from '../../kernel/index.js'; +import { ModuleRegistryImpl } from '../../plugin-host/module-registry.js'; +import { canonicalJson } from '../canonical-json.js'; +import { createMcpKernel, type McpAuthorizer } from '../kernel.js'; + +const summaryInput = z.object({ + playerId: UuidSchema, + limit: z.coerce.number().int().min(1).max(50), +}); +const summaryOutput = z.object({ + playerId: UuidSchema, + email: z.string(), + balance: MoneyAmountSchema, +}); + +const summaryTool = defineMcpTool({ + id: 'player.summary', + title: 'Player summary', + description: 'Balance of one player', + class: 'read', + schemaVersion: 1, + iam: { resource: 'player', action: 'view' }, + inputSchema: summaryInput, + outputSchema: summaryOutput, + redact: { allow: ['playerId', 'balance'], personal: ['playerId'] }, + errors: ['player_not_found'], +}); + +const holdPayload = z.object({ withdrawalId: UuidSchema, reason: z.string().min(1).max(200) }); + +const holdWithdrawal = defineActionType({ + id: 'hold_withdrawal', + title: 'Hold a withdrawal', + description: 'Puts a pending withdrawal on hold for manual review', + schemaVersion: 1, + iam: { resource: 'withdrawal', action: 'hold' }, + reversible: true, + payloadSchema: holdPayload, + errors: ['withdrawal_not_pending'], +}); + +type SummaryHandler = McpToolHandler; +type HoldImplementation = ActionTypeImplementation; + +const SQL_ERROR = 'SELECT email FROM players WHERE id=991'; +const PLAYER_EMAIL = 'player@example.com'; + +const adminId = randomUUID(); +const agentId = randomUUID(); +const playerId = randomUUID(); +const withdrawalId = randomUUID(); + +const sha256 = (value: unknown) => createHash('sha256').update(canonicalJson(value)).digest('hex'); + +const runContext = (overrides: Partial = {}): RunContext => ({ + runId: randomUUID(), + actor: { kind: 'agent', agentId, agentVersion: 3, onBehalfOf: adminId }, + playerPseudonym: 'player-7f3a', + catalogVersion: 'catalog-1', + correlationId: 'correlation-1', + ...overrides, +}); + +const summaryHandler: SummaryHandler = async (input) => ({ + playerId: input.playerId, + email: PLAYER_EMAIL, + balance: '12.50', +}); + +function idempotentHold(): HoldImplementation { + const applied = new Set(); + return { + precondition: vi.fn(async () => ({ ok: true as const })), + execute: vi.fn( + async ( + _payload: z.output, + proposalId: string, + ): Promise => { + if (applied.has(proposalId)) { + return { outcome: 'already_applied' }; + } + applied.add(proposalId); + return { outcome: 'applied', detail: { held: true } }; + }, + ), + }; +} + +function kernelOf(register: (registry: ModuleRegistryImpl) => void) { + const registry = new ModuleRegistryImpl(new Container()); + register(registry); + const audit = makeAuditWriter(); + const kernel = createMcpKernel({ + tools: registry.mcp.getTools(), + actions: registry.actions.getAll(), + container: new Container(), + authorize: async () => 'allowed', + audit, + }); + return { kernel, audit }; +} + +function setup({ + handler = summaryHandler, + implementation = idempotentHold(), + authorize = async () => 'allowed', + audit = makeAuditWriter(), +}: { + handler?: SummaryHandler; + implementation?: HoldImplementation; + authorize?: McpAuthorizer; + audit?: ReturnType | null; +} = {}) { + const handlerSpy = vi.fn(handler); + const toolFactory = vi.fn(() => handlerSpy); + const actionFactory = vi.fn(() => implementation); + const authorizeSpy = vi.fn(authorize); + const registry = new ModuleRegistryImpl(new Container()); + registry.setOwner('players'); + registry.mcp.tool(summaryTool, toolFactory); + registry.setOwner('wallet'); + registry.actions.register(holdWithdrawal, actionFactory); + const kernel = createMcpKernel({ + tools: registry.mcp.getTools(), + actions: registry.actions.getAll(), + container: new Container(), + authorize: authorizeSpy, + audit, + }); + return { + kernel, + handler: handlerSpy, + toolFactory, + actionFactory, + implementation, + authorize: authorizeSpy, + audit, + }; +} + +describe('McpKernel.invokeTool', () => { + it('returns only the allow-listed output keys, with the input coerced by its schema', async () => { + const { kernel, handler } = setup(); + const run = runContext(); + + const result = await kernel.invokeTool('player.summary', { playerId, limit: '5' }, run); + + expect(result).toEqual({ ok: true, output: { playerId, balance: '12.50' } }); + expect(handler).toHaveBeenCalledWith({ playerId, limit: 5 }, run); + }); + + it('audits the call with hashes of the parsed input and the redacted output, never the data', async () => { + const { kernel, audit } = setup(); + const run = runContext(); + + await kernel.invokeTool('player.summary', { playerId, limit: '5', extra: 'dropped' }, run); + + expect(audit?.record).toHaveBeenCalledTimes(1); + expect(audit?.record).toHaveBeenCalledWith({ + actorId: adminId, + actorType: 'admin', + action: 'mcp.tool.invoked', + resourceType: 'mcp-tool', + resourceId: 'player.summary', + correlationId: 'correlation-1', + after: { + toolId: 'player.summary', + toolClass: 'read', + schemaVersion: 1, + runId: run.runId, + actorKind: 'agent', + agentId, + agentVersion: 3, + playerPseudonym: 'player-7f3a', + catalogVersion: 'catalog-1', + inputHash: sha256({ limit: 5, playerId }), + outputHash: sha256({ balance: '12.50', playerId }), + }, + }); + const audited = JSON.stringify(audit?.record.mock.calls); + expect(audited).not.toContain(playerId); + expect(audited).not.toContain(PLAYER_EMAIL); + }); + + it('records the token and its owner for an MCP token actor', async () => { + const { kernel, audit, authorize } = setup(); + const tokenId = randomUUID(); + + await kernel.invokeTool( + 'player.summary', + { playerId, limit: 1 }, + runContext({ actor: { kind: 'mcp_token', tokenId, adminId }, playerPseudonym: undefined }), + ); + + expect(authorize).toHaveBeenCalledWith(adminId, { resource: 'player', action: 'view' }); + expect(audit?.record.mock.calls[0]?.[0].after).toMatchObject({ + actorKind: 'mcp_token', + tokenId, + }); + expect(audit?.record.mock.calls[0]?.[0].after).not.toHaveProperty('playerPseudonym'); + }); + + it('turns a thrown database error into internal_error without its message anywhere', async () => { + const { kernel, audit } = setup({ + handler: async () => { + throw new Error(SQL_ERROR); + }, + }); + + const result = await kernel.invokeTool('player.summary', { playerId, limit: 5 }, runContext()); + + expect(result).toEqual({ ok: false, error: 'internal_error' }); + expect(JSON.stringify(result)).not.toContain('SELECT'); + expect(JSON.stringify(audit?.record.mock.calls)).not.toContain('SELECT'); + expect(audit?.record.mock.calls[0]?.[0]).toMatchObject({ + action: 'mcp.tool.failed', + after: { error: 'internal_error' }, + }); + }); + + it('returns a declared McpToolError code', async () => { + const { kernel } = setup({ + handler: async () => { + throw new McpToolError('player_not_found'); + }, + }); + + await expect( + kernel.invokeTool('player.summary', { playerId, limit: 5 }, runContext()), + ).resolves.toEqual({ ok: false, error: 'player_not_found' }); + }); + + it('turns an undeclared McpToolError code into internal_error', async () => { + const { kernel } = setup({ + handler: async () => { + throw new McpToolError('wallet_frozen'); + }, + }); + + await expect( + kernel.invokeTool('player.summary', { playerId, limit: 5 }, runContext()), + ).resolves.toEqual({ ok: false, error: 'internal_error' }); + }); + + it('refuses output that fails its schema without echoing it', async () => { + const leaked = 'sk_live_output_secret'; + const { kernel, audit } = setup({ + handler: async () => ({ playerId: leaked, email: PLAYER_EMAIL, balance: '12.50' }), + }); + + const result = await kernel.invokeTool('player.summary', { playerId, limit: 5 }, runContext()); + + expect(result).toEqual({ ok: false, error: 'output_invalid' }); + expect(JSON.stringify(audit?.record.mock.calls)).not.toContain(leaked); + }); + + it('refuses a caller without the grant before the handler runs', async () => { + const { kernel, handler, audit } = setup({ authorize: async () => 'denied' }); + + const result = await kernel.invokeTool('player.summary', { playerId, limit: 5 }, runContext()); + + expect(result).toEqual({ ok: false, error: 'forbidden' }); + expect(handler).not.toHaveBeenCalled(); + expect(audit?.record.mock.calls[0]?.[0]).toMatchObject({ + action: 'mcp.tool.failed', + after: { error: 'forbidden', inputHash: sha256({ limit: 5, playerId }) }, + }); + }); + + it('fails closed when authorization itself throws', async () => { + const { kernel, handler } = setup({ + authorize: async () => { + throw new Error('iam store unreachable'); + }, + }); + + await expect( + kernel.invokeTool('player.summary', { playerId, limit: 5 }, runContext()), + ).resolves.toEqual({ ok: false, error: 'internal_error' }); + expect(handler).not.toHaveBeenCalled(); + }); + + it('reports each invalid field by path, never by value', async () => { + const { kernel, handler } = setup(); + + const result = await kernel.invokeTool( + 'player.summary', + { playerId: PLAYER_EMAIL, limit: '5000' }, + runContext(), + ); + + expect(result).toMatchObject({ ok: false, error: 'invalid_input' }); + expect(result.ok ? [] : result.issues?.map(({ path }) => path)).toEqual(['playerId', 'limit']); + expect(JSON.stringify(result)).not.toContain(PLAYER_EMAIL); + expect(handler).not.toHaveBeenCalled(); + }); + + it('turns a schema refinement that throws into internal_error', async () => { + const { kernel, audit } = kernelOf((registry) => { + registry.mcp.tool( + defineMcpTool({ + ...summaryTool, + id: 'player.flaky', + inputSchema: summaryInput.superRefine(() => { + throw new Error(SQL_ERROR); + }), + }), + () => summaryHandler, + ); + }); + + const result = await kernel.invokeTool('player.flaky', { playerId, limit: 5 }, runContext()); + + expect(result).toEqual({ ok: false, error: 'internal_error' }); + expect(audit.record).toHaveBeenCalledTimes(1); + expect(JSON.stringify(audit.record.mock.calls)).not.toContain('SELECT'); + }); + + it('refuses an invalid run context without auditing or running anything', async () => { + const { kernel, handler, authorize, audit } = setup(); + + const result = await kernel.invokeTool( + 'player.summary', + { playerId, limit: 5 }, + { ...runContext(), correlationId: '' }, + ); + + expect(result).toEqual({ ok: false, error: 'invalid_run_context' }); + expect(authorize).not.toHaveBeenCalled(); + expect(handler).not.toHaveBeenCalled(); + expect(audit?.record).not.toHaveBeenCalled(); + }); + + it('audits a call to a tool that is not registered', async () => { + const { kernel, audit } = setup(); + + const result = await kernel.invokeTool('player.delete', {}, runContext()); + + expect(result).toEqual({ ok: false, error: 'unknown_tool' }); + expect(audit?.record.mock.calls[0]?.[0]).toMatchObject({ + action: 'mcp.tool.failed', + resourceId: 'player.delete', + after: { toolId: 'player.delete', error: 'unknown_tool', inputHash: sha256({}) }, + }); + }); + + it('withholds a successful result when the audit write fails', async () => { + const audit = makeAuditWriter(); + audit.record.mockRejectedValueOnce(new Error('audit store down')); + const { kernel } = setup({ audit }); + + await expect( + kernel.invokeTool('player.summary', { playerId, limit: 5 }, runContext()), + ).resolves.toEqual({ ok: false, error: 'audit_unavailable' }); + }); + + it('withholds every result when no audit writer is bound', async () => { + const { kernel, handler } = setup({ audit: null }); + + await expect( + kernel.invokeTool('player.summary', { playerId, limit: 5 }, runContext()), + ).resolves.toEqual({ ok: false, error: 'audit_unavailable' }); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); + +describe('McpKernel.checkPrecondition', () => { + const payload = { withdrawalId, reason: 'velocity spike' }; + + it('passes a satisfied precondition without IAM or audit', async () => { + const { kernel, authorize, audit } = setup(); + + await expect( + kernel.checkPrecondition('hold_withdrawal', payload, runContext()), + ).resolves.toEqual({ ok: true }); + expect(authorize).not.toHaveBeenCalled(); + expect(audit?.record).not.toHaveBeenCalled(); + }); + + it('returns a declared refusal code', async () => { + const { kernel } = setup({ + implementation: { + ...idempotentHold(), + precondition: async () => ({ ok: false, code: 'withdrawal_not_pending' }), + }, + }); + + await expect( + kernel.checkPrecondition('hold_withdrawal', payload, runContext()), + ).resolves.toEqual({ ok: false, error: 'withdrawal_not_pending' }); + }); + + it('fails closed on an undeclared refusal code', async () => { + const { kernel } = setup({ + implementation: { + ...idempotentHold(), + precondition: async () => ({ ok: false, code: 'player_is_vip' }), + }, + }); + + await expect( + kernel.checkPrecondition('hold_withdrawal', payload, runContext()), + ).resolves.toEqual({ ok: false, error: 'internal_error' }); + }); + + it('fails closed when the precondition throws', async () => { + const { kernel } = setup({ + implementation: { + ...idempotentHold(), + precondition: async () => { + throw new Error(SQL_ERROR); + }, + }, + }); + + const result = await kernel.checkPrecondition('hold_withdrawal', payload, runContext()); + + expect(result).toEqual({ ok: false, error: 'internal_error' }); + }); + + it('reports an unknown action type and an invalid payload', async () => { + const { kernel } = setup(); + + await expect(kernel.checkPrecondition('ban_player', payload, runContext())).resolves.toEqual({ + ok: false, + error: 'unknown_action', + }); + await expect( + kernel.checkPrecondition('hold_withdrawal', { withdrawalId }, runContext()), + ).resolves.toMatchObject({ ok: false, error: 'invalid_input', issues: [{ path: 'reason' }] }); + }); +}); + +describe('McpKernel.executeAction', () => { + const payload = { withdrawalId, reason: 'velocity spike' }; + const approver = { kind: 'admin', adminId } as const; + + it('applies once and answers a replay with already_applied, auditing both', async () => { + const { kernel, audit, implementation } = setup(); + const context = { proposalId: randomUUID(), actor: approver, correlationId: 'correlation-2' }; + + const first = await kernel.executeAction('hold_withdrawal', payload, context); + const replay = await kernel.executeAction('hold_withdrawal', payload, context); + + expect(first).toEqual({ ok: true, outcome: 'applied', detail: { held: true } }); + expect(replay).toEqual({ ok: true, outcome: 'already_applied' }); + expect(implementation.execute).toHaveBeenCalledTimes(2); + expect(implementation.precondition).not.toHaveBeenCalled(); + expect(audit?.record.mock.calls.map(([entry]) => entry)).toEqual( + ['applied', 'already_applied'].map((outcome) => ({ + actorId: adminId, + actorType: 'admin', + action: 'mcp.action.executed', + resourceType: 'agent-proposal', + resourceId: context.proposalId, + correlationId: 'correlation-2', + after: { + actionTypeId: 'hold_withdrawal', + schemaVersion: 1, + proposalId: context.proposalId, + actorKind: 'admin', + payloadHash: sha256(payload), + outcome, + }, + })), + ); + }); + + it('refuses an approver without the grant and never runs the executor', async () => { + const { kernel, implementation, audit } = setup({ authorize: async () => 'denied' }); + const proposalId = randomUUID(); + + const result = await kernel.executeAction('hold_withdrawal', payload, { + proposalId, + actor: approver, + correlationId: 'correlation-3', + }); + + expect(result).toEqual({ ok: false, error: 'forbidden' }); + expect(implementation.execute).not.toHaveBeenCalled(); + expect(audit?.record.mock.calls[0]?.[0]).toMatchObject({ + action: 'mcp.action.failed', + resourceId: proposalId, + after: { error: 'forbidden' }, + }); + }); + + it('turns an executor failure into internal_error without its message', async () => { + const { kernel, audit } = setup({ + implementation: { + ...idempotentHold(), + execute: async () => { + throw new Error(SQL_ERROR); + }, + }, + }); + + const result = await kernel.executeAction('hold_withdrawal', payload, { + proposalId: randomUUID(), + actor: approver, + correlationId: 'correlation-4', + }); + + expect(result).toEqual({ ok: false, error: 'internal_error' }); + expect(JSON.stringify(audit?.record.mock.calls)).not.toContain('SELECT'); + }); + + it('refuses a malformed proposal id without auditing', async () => { + const { kernel, audit, implementation } = setup(); + + const result = await kernel.executeAction('hold_withdrawal', payload, { + proposalId: 'proposal-1', + actor: approver, + correlationId: 'correlation-5', + }); + + expect(result).toEqual({ ok: false, error: 'invalid_run_context' }); + expect(implementation.execute).not.toHaveBeenCalled(); + expect(audit?.record).not.toHaveBeenCalled(); + }); + + it('retries a failed audit write after the executor ran instead of failing an applied action', async () => { + const audit = makeAuditWriter(); + audit.record.mockRejectedValueOnce(new Error('audit store blip')); + const { kernel } = setup({ audit }); + const context = { proposalId: randomUUID(), actor: approver, correlationId: 'correlation-6' }; + + const result = await kernel.executeAction('hold_withdrawal', payload, context); + + expect(result).toMatchObject({ ok: true, outcome: 'applied' }); + expect(audit.record).toHaveBeenCalledTimes(2); + expect(audit.record.mock.calls[1]?.[0]).toMatchObject({ action: 'mcp.action.executed' }); + }); + + it('asks the caller to retry when every audit write attempt fails after the executor ran', async () => { + const audit = makeAuditWriter(); + audit.record + .mockRejectedValueOnce(new Error('audit store down')) + .mockRejectedValueOnce(new Error('audit store down')) + .mockRejectedValueOnce(new Error('audit store down')); + const { kernel } = setup({ audit }); + const context = { proposalId: randomUUID(), actor: approver, correlationId: 'correlation-7' }; + + const first = await kernel.executeAction('hold_withdrawal', payload, context); + const retry = await kernel.executeAction('hold_withdrawal', payload, context); + + expect(first).toEqual({ ok: false, error: 'audit_unavailable' }); + expect(retry).toEqual({ ok: true, outcome: 'already_applied' }); + }); +}); + +describe('McpKernel catalog', () => { + it('describes each tool and action type with its owner, model name and JSON Schemas', () => { + const { kernel } = setup(); + + expect(kernel.listTools()).toEqual([ + expect.objectContaining({ + id: 'player.summary', + owner: 'players', + modelName: 'player_summary', + inputJsonSchema: expect.objectContaining({ + type: 'object', + properties: expect.objectContaining({ limit: expect.objectContaining({ maximum: 50 }) }), + }), + outputJsonSchema: expect.objectContaining({ type: 'object' }), + }), + ]); + expect(kernel.listActionTypes()).toEqual([ + expect.objectContaining({ + id: 'hold_withdrawal', + owner: 'wallet', + payloadJsonSchema: expect.objectContaining({ required: ['withdrawalId', 'reason'] }), + }), + ]); + expect(kernel.listTriggers()).toBe(triggerCatalog()); + }); + + it('runs each factory once, when the kernel is built', async () => { + const { kernel, toolFactory, actionFactory } = setup(); + + await kernel.invokeTool('player.summary', { playerId, limit: 5 }, runContext()); + await kernel.invokeTool('player.summary', { playerId, limit: 6 }, runContext()); + + expect(toolFactory).toHaveBeenCalledTimes(1); + expect(actionFactory).toHaveBeenCalledTimes(1); + }); + + it('versions the catalog by id and schema version, not by registration order', () => { + const toolFirst = kernelOf((registry) => { + registry.mcp.tool(summaryTool, () => summaryHandler); + registry.actions.register(holdWithdrawal, idempotentHold); + }).kernel.catalogVersion; + const actionFirst = kernelOf((registry) => { + registry.actions.register(holdWithdrawal, idempotentHold); + registry.mcp.tool(summaryTool, () => summaryHandler); + }).kernel.catalogVersion; + const bumped = kernelOf((registry) => { + registry.mcp.tool(defineMcpTool({ ...summaryTool, schemaVersion: 2 }), () => summaryHandler); + registry.actions.register(holdWithdrawal, idempotentHold); + }).kernel.catalogVersion; + + expect(toolFirst).toMatch(/^[0-9a-f]{16}$/); + expect(actionFirst).toBe(toolFirst); + expect(bumped).not.toBe(toolFirst); + }); + + it('fails the build when a factory returns no handler', () => { + expect(() => + kernelOf((registry) => { + registry.mcp.tool(summaryTool, () => mock()); + }), + ).toThrow(/tool "player\.summary" \(plugin "unknown"\): factory returned no handler/); + }); +}); diff --git a/packages/core/src/server/mcp/authorize.ts b/packages/core/src/server/mcp/authorize.ts new file mode 100644 index 000000000..539d597e9 --- /dev/null +++ b/packages/core/src/server/mcp/authorize.ts @@ -0,0 +1,26 @@ +import { ORPCError } from '@orpc/server'; +import type { McpIamRequirement } from '@openora/core/contracts'; +import type { AdminGuard } from '../auth/admin-guard.js'; +import type { McpAuthorization } from './kernel.js'; + +const DENIAL_STATUSES: ReadonlySet = new Set([401, 403]); + +/** + * The kernel's verdict from AdminGuard: a 401/403 is a denial; any other error is rethrown, + * so the kernel fails the call closed instead of treating an outage as a refusal. + */ +export async function authorizeWithAdminGuard( + guard: Pick, + adminId: string, + iam: McpIamRequirement, +): Promise { + try { + await guard.assertUser(adminId, iam.resource, iam.action); + return 'allowed'; + } catch (err) { + if (err instanceof ORPCError && DENIAL_STATUSES.has(err.status)) { + return 'denied'; + } + throw err; + } +} diff --git a/packages/core/src/server/mcp/canonical-json.ts b/packages/core/src/server/mcp/canonical-json.ts new file mode 100644 index 000000000..4a557373a --- /dev/null +++ b/packages/core/src/server/mcp/canonical-json.ts @@ -0,0 +1,44 @@ +import { createHash } from 'node:crypto'; + +/** + * JSON with object keys sorted at every depth and undefined members dropped, so equal + * values always produce the same bytes. Total over any input: a bigint serializes as its + * decimal string and a reference cycle as the string "[Circular]". + */ +export function canonicalJson(value: unknown): string { + return serialize(value, new Set()) ?? 'null'; +} + +export function sha256Hex(value: unknown): string { + return createHash('sha256').update(canonicalJson(value)).digest('hex'); +} + +function serialize(value: unknown, ancestors: Set): string | undefined { + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + if (typeof value === 'bigint') { + return JSON.stringify(value.toString()); + } + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (ancestors.has(value)) { + return JSON.stringify('[Circular]'); + } + ancestors.add(value); + const serialized = Array.isArray(value) + ? `[${value.map((item: unknown) => serialize(item, ancestors) ?? 'null').join(',')}]` + : `{${serializeMembers(value, ancestors).join(',')}}`; + ancestors.delete(value); + return serialized; +} + +function serializeMembers(value: object, ancestors: Set): string[] { + return Object.keys(value) + .sort() + .flatMap((key) => { + const member = serialize(Reflect.get(value, key), ancestors); + return member === undefined ? [] : [`${JSON.stringify(key)}:${member}`]; + }); +} diff --git a/packages/core/src/server/mcp/contract-validation.ts b/packages/core/src/server/mcp/contract-validation.ts new file mode 100644 index 000000000..3702370af --- /dev/null +++ b/packages/core/src/server/mcp/contract-validation.ts @@ -0,0 +1,547 @@ +import * as z from 'zod'; +import { + MCP_ACTION_TYPE_ID_PATTERN, + MCP_COMMON_ERROR_CODES, + MCP_ERROR_CODE_PATTERN, + MCP_TOOL_CLASSES, + MCP_TOOL_ID_PATTERN, + adminStatement, + type ActionTypeContract, + type McpIamRequirement, + type McpToolContract, +} from '@openora/core/contracts'; + +const MAX_ID_LENGTH = 64; +const MAX_TITLE_LENGTH = 80; +const MAX_DESCRIPTION_LENGTH = 1024; + +const INPUT_SHAPE_FIX = + 'models send numbers as strings when a tool input is a oneOf; keep a top-level z.object and put conditional rules in .superRefine()'; + +const STATEMENT_ACTIONS = new Map(Object.entries(adminStatement)); + +// A regex or an open-ended format such as email does not cap length; only these fixed shapes do. +const FIXED_WIDTH_STRING_FORMATS: ReadonlySet = new Set([ + 'uuid', + 'date', + 'time', + 'date-time', +]); + +type Rejection = { problem: string; fix: string }; + +type InputLabel = 'inputSchema' | 'payloadSchema'; + +type McpRegistrationCandidate = { + contract: Contract; + owner: string; + factory: unknown; +}; + +type McpRegistrations = { + tools: readonly { contract: McpToolContract; owner: string }[]; + actions: readonly { contract: ActionTypeContract; owner: string }[]; +}; + +export function mcpToolModelName(toolId: string): string { + return toolId.replaceAll('.', '_'); +} + +/** Throws a `[mcp] tool ...` error naming the first rule the registration breaks. */ +export function assertValidMcpTool( + { contract, owner, factory }: McpRegistrationCandidate, + registered: McpRegistrations, +): void { + const rejection = + idRejection( + contract.id, + MCP_TOOL_ID_PATTERN, + 'lowercase dotted segments like "player.summary"', + ) ?? + duplicateIdRejection(contract.id, registered) ?? + modelNameRejection(contract.id, registered.tools) ?? + describedRejection(contract) ?? + toolClassRejection(contract.class) ?? + iamRejection(contract.iam, { + problem: 'iam is missing', + fix: 'name the adminStatement resource and action the caller must hold, eg { resource: "player", action: "view" }', + }) ?? + errorCodesRejection(contract.errors) ?? + factoryRejection(factory, '(c) => async (input, run) => output') ?? + inputSchemaRejection(contract.inputSchema, 'inputSchema') ?? + outputSchemaRejection(contract); + if (rejection) { + throw new Error( + `[mcp] tool "${String(contract.id)}" (plugin "${owner}"): ${rejection.problem} - ${rejection.fix}`, + ); + } +} + +/** Throws a `[mcp] action type ...` error naming the first rule the registration breaks. */ +export function assertValidActionType( + { contract, owner, factory }: McpRegistrationCandidate, + registered: McpRegistrations, +): void { + const rejection = + idRejection( + contract.id, + MCP_ACTION_TYPE_ID_PATTERN, + 'a snake_case verb phrase like "hold_withdrawal"', + ) ?? + duplicateIdRejection(contract.id, registered) ?? + describedRejection(contract) ?? + reversibleRejection(contract.reversible) ?? + iamRejection(contract.iam, { + problem: 'every action type must name the IAM resource its executor needs', + fix: 'add iam: { resource, action } from adminStatement, eg { resource: "withdrawal", action: "hold" }', + }) ?? + errorCodesRejection(contract.errors) ?? + factoryRejection(factory, '(c) => ({ precondition, execute })') ?? + inputSchemaRejection(contract.payloadSchema, 'payloadSchema'); + if (rejection) { + throw new Error( + `[mcp] action type "${String(contract.id)}" (plugin "${owner}"): ${rejection.problem} - ${rejection.fix}`, + ); + } +} + +function idRejection(id: unknown, pattern: RegExp, example: string): Rejection | null { + if (typeof id === 'string' && id.length <= MAX_ID_LENGTH && pattern.test(id)) { + return null; + } + return { + problem: `id must match ${pattern} and be at most ${MAX_ID_LENGTH} characters`, + fix: `use ${example}`, + }; +} + +function duplicateIdRejection(id: string, registered: McpRegistrations): Rejection | null { + const tool = registered.tools.find((entry) => entry.contract.id === id); + const action = registered.actions.find((entry) => entry.contract.id === id); + const clash = tool + ? `a tool of plugin "${tool.owner}"` + : action + ? `an action type of plugin "${action.owner}"` + : null; + if (!clash) { + return null; + } + return { + problem: `id is already registered as ${clash}`, + fix: 'ids are unique across tools and action types; rename one of them', + }; +} + +function modelNameRejection(id: string, tools: McpRegistrations['tools']): Rejection | null { + const modelName = mcpToolModelName(id); + const clash = tools.find((entry) => mcpToolModelName(entry.contract.id) === modelName); + if (!clash) { + return null; + } + return { + problem: `model name "${modelName}" collides with tool "${clash.contract.id}" of plugin "${clash.owner}"`, + fix: 'models see "." as "_", so pick an id that stays unique after that replacement', + }; +} + +function isText(value: unknown, maxLength: number): boolean { + return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength; +} + +function describedRejection(contract: { + title: unknown; + description: unknown; + schemaVersion: unknown; +}): Rejection | null { + if (!isText(contract.title, MAX_TITLE_LENGTH)) { + return { + problem: `title must be 1..${MAX_TITLE_LENGTH} characters`, + fix: 'give a short human-readable name', + }; + } + if (!isText(contract.description, MAX_DESCRIPTION_LENGTH)) { + return { + problem: `description must be 1..${MAX_DESCRIPTION_LENGTH} characters`, + fix: 'say what it does and when a model should use it', + }; + } + if (!Number.isInteger(contract.schemaVersion) || Number(contract.schemaVersion) < 1) { + return { + problem: 'schemaVersion must be a positive integer', + fix: 'start at 1 and bump it whenever a schema changes shape', + }; + } + return null; +} + +function toolClassRejection(toolClass: unknown): Rejection | null { + if (MCP_TOOL_CLASSES.some((known) => known === toolClass)) { + return null; + } + return { + problem: `class must be one of ${MCP_TOOL_CLASSES.join(', ')}`, + fix: 'use "read" for a lookup and "propose" for a tool that drafts a proposal', + }; +} + +function reversibleRejection(reversible: unknown): Rejection | null { + if (typeof reversible === 'boolean') { + return null; + } + return { + problem: 'reversible must be a boolean', + fix: 'state whether an approved action can be undone', + }; +} + +function iamRejection( + iam: McpIamRequirement | undefined, + whenMissing: Rejection, +): Rejection | null { + if (!iam) { + return whenMissing; + } + const actions = STATEMENT_ACTIONS.get(iam.resource); + if (!actions) { + return { + problem: `iam.resource "${String(iam.resource)}" is not in adminStatement`, + fix: 'use a resource declared in contracts/schemas/iam.ts', + }; + } + if (!actions.includes(iam.action)) { + return { + problem: `iam.action "${String(iam.action)}" is not an action of "${iam.resource}"`, + fix: `use one of: ${actions.join(', ')}`, + }; + } + return null; +} + +function errorCodesRejection(errors: readonly unknown[] | undefined): Rejection | null { + if (!Array.isArray(errors)) { + return { + problem: 'errors must be an array of error codes', + fix: 'declare errors: [] when nothing throws McpToolError', + }; + } + const seen = new Set(); + for (const code of errors) { + if (typeof code !== 'string' || !MCP_ERROR_CODE_PATTERN.test(code)) { + return { + problem: `error code "${String(code)}" must match ${MCP_ERROR_CODE_PATTERN}`, + fix: 'use a lowercase snake_case code', + }; + } + if (MCP_COMMON_ERROR_CODES.some((common) => common === code)) { + return { + problem: `error code "${code}" is reserved by the kernel`, + fix: 'declare a domain-specific code instead', + }; + } + if (seen.has(code)) { + return { problem: `error code "${code}" is declared twice`, fix: 'list each code once' }; + } + seen.add(code); + } + return null; +} + +function factoryRejection(factory: unknown, shape: string): Rejection | null { + if (typeof factory === 'function') { + return null; + } + return { problem: 'factory must be a function', fix: `pass ${shape} as the second argument` }; +} + +function describeSchema(schema: unknown): string { + return schema instanceof z.core.$ZodType ? schema._zod.def.type : typeof schema; +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function fieldLabel(label: string, path: string): string { + return path ? `${label} field "${path}"` : label; +} + +function childPath(path: string, key: string): string { + return path ? `${path}.${key}` : key; +} + +function inputSchemaRejection(schema: unknown, label: InputLabel): Rejection | null { + if (!(schema instanceof z.core.$ZodObject)) { + return { + problem: `${label} must be a top-level z.object, not ${describeSchema(schema)}`, + fix: INPUT_SHAPE_FIX, + }; + } + const fieldRejection = zodFieldRejection(schema, { label, path: '', seen: new Set() }); + if (fieldRejection) { + return fieldRejection; + } + let jsonSchema: z.core.JSONSchema.JSONSchema; + try { + jsonSchema = z.toJSONSchema(schema, { target: 'draft-7', io: 'input' }); + z.toJSONSchema(schema, { target: 'draft-7', io: 'output' }); + } catch (err) { + return { + problem: `${label} cannot be converted to JSON Schema (${errorMessage(err)})`, + fix: 'use JSON-native types: ISO strings for dates, decimal strings for money, and no transforms', + }; + } + if (jsonSchema.type !== 'object' || jsonSchema.oneOf || jsonSchema.anyOf || jsonSchema.allOf) { + return { + problem: `${label} must convert to a JSON Schema object without a top-level oneOf/anyOf/allOf`, + fix: INPUT_SHAPE_FIX, + }; + } + return jsonFieldRejection(jsonSchema, label, ''); +} + +type ZodWalk = { label: InputLabel; path: string; seen: Set }; + +function wrappedSchema(schema: z.core.$ZodType): z.core.$ZodType | null { + if ( + schema instanceof z.core.$ZodOptional || + schema instanceof z.core.$ZodNullable || + schema instanceof z.core.$ZodDefault || + schema instanceof z.core.$ZodPrefault || + schema instanceof z.core.$ZodReadonly || + schema instanceof z.core.$ZodCatch || + schema instanceof z.core.$ZodNonOptional + ) { + return schema._zod.def.innerType; + } + return null; +} + +function nestedSchemas(schema: z.core.$ZodType, path: string): [z.core.$ZodType, string][] { + if (schema instanceof z.core.$ZodObject) { + return Object.entries(schema._zod.def.shape).map(([key, child]) => [ + child, + childPath(path, key), + ]); + } + if (schema instanceof z.core.$ZodArray) { + return [[schema._zod.def.element, `${path}[]`]]; + } + if (schema instanceof z.core.$ZodUnion) { + return schema._zod.def.options.map((option) => [option, path]); + } + if (schema instanceof z.core.$ZodIntersection) { + return [ + [schema._zod.def.left, path], + [schema._zod.def.right, path], + ]; + } + if (schema instanceof z.core.$ZodTuple) { + const { items, rest } = schema._zod.def; + const entries = items.map((item, index): [z.core.$ZodType, string] => [ + item, + `${path}[${index}]`, + ]); + return rest ? [...entries, [rest, `${path}[]`]] : entries; + } + return []; +} + +function zodFieldRejection(schema: z.core.$ZodType, walk: ZodWalk): Rejection | null { + if (walk.seen.has(schema)) { + return null; + } + walk.seen.add(schema); + const field = fieldLabel(walk.label, walk.path); + const inner = wrappedSchema(schema); + if (inner) { + return zodFieldRejection(inner, walk); + } + if (schema instanceof z.core.$ZodNumber && schema._zod.def.coerce !== true) { + return { + problem: `${field} is a z.number()`, + fix: 'use z.coerce.number() - models send numbers as strings ("50")', + }; + } + if (schema instanceof z.core.$ZodPipe || schema instanceof z.core.$ZodTransform) { + return { + problem: `${field} transforms its value`, + fix: 'a model only sees the pre-transform shape; accept plain data and convert it in the handler', + }; + } + if (schema instanceof z.core.$ZodDate) { + return { + problem: `${field} is a z.date()`, + fix: 'models send JSON; use z.iso.date() or z.iso.datetime()', + }; + } + for (const [child, path] of nestedSchemas(schema, walk.path)) { + const rejection = zodFieldRejection(child, { ...walk, path }); + if (rejection) { + return rejection; + } + } + return null; +} + +type JsonSchemaNode = z.core.JSONSchema._JSONSchema; + +function isNullSchema(node: JsonSchemaNode): boolean { + return typeof node === 'object' && node.type === 'null'; +} + +function isBound(value: unknown): boolean { + return typeof value === 'number' && Math.abs(value) < Number.MAX_SAFE_INTEGER; +} + +function jsonFieldRejection( + node: JsonSchemaNode, + label: InputLabel, + path: string, +): Rejection | null { + const field = fieldLabel(label, path); + if (typeof node === 'boolean') { + return node ? untypedRejection(field) : null; + } + if (node.$ref !== undefined) { + return { + problem: `${field} is recursive`, + fix: 'flatten the shape; a tool input must be a finite tree', + }; + } + const branches = [...(node.anyOf ?? []), ...(node.oneOf ?? []), ...(node.allOf ?? [])]; + if (branches.length > 0) { + return firstRejection( + branches.filter((branch) => !isNullSchema(branch)), + (branch) => jsonFieldRejection(branch, label, path), + ); + } + if (node.const !== undefined || node.enum !== undefined) { + return null; + } + switch (node.type) { + case 'string': + return node.maxLength !== undefined || + (node.format !== undefined && FIXED_WIDTH_STRING_FORMATS.has(node.format)) + ? null + : { + problem: `${field} is a string without a maximum length`, + fix: 'add .max(n); only an enum, a UUID or an ISO date, time or datetime may leave it out', + }; + case 'number': + case 'integer': + return (isBound(node.minimum) || isBound(node.exclusiveMinimum)) && + (isBound(node.maximum) || isBound(node.exclusiveMaximum)) + ? null + : { + problem: `${field} is a number without both a lower and an upper bound`, + fix: 'add .min() and .max() so a model cannot send an unbounded value', + }; + case 'array': + return arrayRejection(node, label, path); + case 'object': + return objectRejection(node, label, path); + case 'boolean': + case 'null': + return null; + default: + return untypedRejection(field); + } +} + +function untypedRejection(field: string): Rejection { + return { + problem: `${field} accepts any value`, + fix: 'replace z.any()/z.unknown() with a concrete, bounded type', + }; +} + +function arrayRejection( + node: z.core.JSONSchema.JSONSchema, + label: InputLabel, + path: string, +): Rejection | null { + if (node.maxItems === undefined) { + return { + problem: `${fieldLabel(label, path)} is an array without a maximum length`, + fix: 'add .max(n)', + }; + } + const items = node.items === undefined ? [] : [node.items].flat(); + return firstRejection(items, (item) => jsonFieldRejection(item, label, `${path}[]`)); +} + +function objectRejection( + node: z.core.JSONSchema.JSONSchema, + label: InputLabel, + path: string, +): Rejection | null { + const field = fieldLabel(label, path); + if (node.properties === undefined) { + return { + problem: `${field} is a free-form map`, + fix: 'declare its keys with z.object({...}) instead of z.record()', + }; + } + if (node.additionalProperties !== undefined && node.additionalProperties !== false) { + return { + problem: `${field} accepts undeclared keys`, + fix: 'drop .loose()/.catchall() and declare every key', + }; + } + return firstRejection(Object.entries(node.properties), ([key, child]) => + jsonFieldRejection(child, label, childPath(path, key)), + ); +} + +function firstRejection( + items: readonly T[], + check: (item: T) => Rejection | null, +): Rejection | null { + for (const item of items) { + const rejection = check(item); + if (rejection) { + return rejection; + } + } + return null; +} + +function outputSchemaRejection({ outputSchema, redact }: McpToolContract): Rejection | null { + if (!(outputSchema instanceof z.core.$ZodObject)) { + return { + problem: `outputSchema must be a top-level z.object, not ${describeSchema(outputSchema)}`, + fix: 'wrap the result in an object; redact.allow names its top-level keys', + }; + } + try { + z.toJSONSchema(outputSchema, { target: 'draft-7', io: 'output' }); + } catch (err) { + return { + problem: `outputSchema cannot be converted to JSON Schema (${errorMessage(err)})`, + fix: 'outputs are JSON: return ISO strings for dates, decimal strings for money, and no transforms', + }; + } + const allow: readonly string[] = Array.isArray(redact?.allow) ? redact.allow : []; + if (allow.length === 0) { + return { + problem: 'redact.allow is empty', + fix: 'list the top-level output keys that may leave the kernel', + }; + } + const shape = outputSchema._zod.def.shape; + const undeclared = allow.find((key) => !Object.hasOwn(shape, key)); + if (undeclared !== undefined) { + return { + problem: `redact.allow key "${undeclared}" is not in outputSchema`, + fix: 'allow only keys the output schema declares', + }; + } + const notAllowed = (redact.personal ?? []).find((key) => !allow.includes(key)); + if (notAllowed !== undefined) { + return { + problem: `redact.personal key "${notAllowed}" is not in redact.allow`, + fix: 'a personal key must also be allowed; list it in both or drop it from personal', + }; + } + return null; +} diff --git a/packages/core/src/server/mcp/index.ts b/packages/core/src/server/mcp/index.ts new file mode 100644 index 000000000..d839f0955 --- /dev/null +++ b/packages/core/src/server/mcp/index.ts @@ -0,0 +1,2 @@ +export { createMcpKernel } from './kernel.js'; +export type { McpAuthorization, McpAuthorizer, McpKernelDeps } from './kernel.js'; diff --git a/packages/core/src/server/mcp/kernel.ts b/packages/core/src/server/mcp/kernel.ts new file mode 100644 index 000000000..2132bc042 --- /dev/null +++ b/packages/core/src/server/mcp/kernel.ts @@ -0,0 +1,524 @@ +import * as z from 'zod'; +import { + ActionExecutionContextSchema, + ActionExecutionOutcomeSchema, + ActionPreconditionOutcomeSchema, + McpToolError, + RunContextSchema, + runActorAdminId, + triggerCatalog, + type ActionExecutionContext, + type ActionExecutionResult, + type ActionPreconditionResult, + type ActionTypeContract, + type ActionTypeDescriptor, + type ActionTypeImplementation, + type AuditWritePort, + type McpFailure, + type McpIamRequirement, + type McpInputIssue, + type McpKernel, + type McpToolContract, + type McpToolDescriptor, + type McpToolHandler, + type McpToolResult, + type RunActor, + type RunContext, + type TokenCatalog, +} from '@openora/core/contracts'; +import type { + RegisteredActionType, + RegisteredMcpTool, + TypedContainer, +} from '../plugin-host/define-plugin.js'; +import { createLogger } from '../kernel/logger.js'; +import { sha256Hex } from './canonical-json.js'; +import { mcpToolModelName } from './contract-validation.js'; + +const logger = createLogger('mcp-kernel'); + +const MAX_INPUT_ISSUES = 20; +const MAX_ISSUE_MESSAGE_LENGTH = 200; +const MAX_UNKNOWN_ID_LENGTH = 128; +const CATALOG_VERSION_LENGTH = 16; +const EXECUTION_AUDIT_ATTEMPTS = 3; +const EXECUTION_AUDIT_RETRY_DELAY_MS = 50; + +export type McpAuthorization = 'allowed' | 'denied'; + +export type McpAuthorizer = (adminId: string, iam: McpIamRequirement) => Promise; + +export type McpKernelDeps = { + tools: readonly RegisteredMcpTool[]; + actions: readonly RegisteredActionType[]; + container: TypedContainer; + authorize: McpAuthorizer; + audit: AuditWritePort | null; +}; + +type ServedTool = { + contract: McpToolContract; + handler: McpToolHandler; + descriptor: McpToolDescriptor; +}; + +type ServedAction = { + contract: ActionTypeContract; + implementation: ActionTypeImplementation; + descriptor: ActionTypeDescriptor; +}; + +type KernelState = { + tools: ReadonlyMap; + actions: ReadonlyMap; + authorize: McpAuthorizer; + audit: AuditWritePort | null; +}; + +type AuditEntry = Parameters[0]; + +type ToolOutcome = { result: McpToolResult; inputHash: string; outputHash?: string }; + +type ExecutionOutcome = { result: ActionExecutionResult; payloadHash: string }; + +/** + * Builds the kernel behind MCP_KERNEL: runs every tool and action-type factory once, + * precomputes the descriptors, and wraps every call in IAM, schema validation, error-code + * mapping and one audit record. Throws when a factory throws or returns the wrong shape, + * so a broken registration fails boot. + */ +export function createMcpKernel(deps: McpKernelDeps): McpKernel { + const tools = indexById( + deps.tools.map((tool) => serveTool(tool, deps.container)), + 'tool', + ); + const actions = indexById( + deps.actions.map((action) => serveAction(action, deps.container)), + 'action type', + ); + if (!deps.audit && (tools.size > 0 || actions.size > 0)) { + logger.warn('no AUDIT_WRITER is bound: every MCP call will fail with audit_unavailable'); + } + const state: KernelState = { tools, actions, authorize: deps.authorize, audit: deps.audit }; + const toolDescriptors = [...tools.values()].map((tool) => tool.descriptor); + const actionDescriptors = [...actions.values()].map((action) => action.descriptor); + + return { + catalogVersion: catalogVersionOf(deps.tools, deps.actions), + listTools: () => toolDescriptors, + listActionTypes: () => actionDescriptors, + listTriggers: () => triggerCatalog(), + invokeTool: (toolId, input, run) => invokeTool(state, { toolId, input, run }), + checkPrecondition: (actionTypeId, payload, run) => + checkPrecondition(state, { actionTypeId, payload, run }), + executeAction: (actionTypeId, payload, context) => + executeAction(state, { actionTypeId, payload, context }), + }; +} + +function serveTool( + { contract, owner, factory }: RegisteredMcpTool, + container: TypedContainer, +): ServedTool { + const handler = factory(container); + if (typeof handler !== 'function') { + throw new Error( + `[mcp] tool "${contract.id}" (plugin "${owner}"): factory returned no handler - return async (input, run) => output`, + ); + } + return { + contract, + handler, + descriptor: { + ...contract, + owner, + modelName: mcpToolModelName(contract.id), + inputJsonSchema: z.toJSONSchema(contract.inputSchema, { target: 'draft-7', io: 'input' }), + outputJsonSchema: z.toJSONSchema(contract.outputSchema, { target: 'draft-7', io: 'output' }), + }, + }; +} + +function serveAction( + { contract, owner, factory }: RegisteredActionType, + container: TypedContainer, +): ServedAction { + const implementation = factory(container); + if ( + typeof implementation?.precondition !== 'function' || + typeof implementation.execute !== 'function' + ) { + throw new Error( + `[mcp] action type "${contract.id}" (plugin "${owner}"): factory returned no { precondition, execute } pair - return both functions`, + ); + } + return { + contract, + implementation, + descriptor: { + ...contract, + owner, + payloadJsonSchema: z.toJSONSchema(contract.payloadSchema, { + target: 'draft-7', + io: 'input', + }), + }, + }; +} + +function indexById( + entries: readonly T[], + kind: string, +): ReadonlyMap { + const byId = new Map(); + for (const entry of entries) { + if (byId.has(entry.contract.id)) { + throw new Error(`[mcp] ${kind} "${entry.contract.id}" is registered twice`); + } + byId.set(entry.contract.id, entry); + } + return byId; +} + +function compareText(left: string, right: string): number { + if (left === right) { + return 0; + } + return left < right ? -1 : 1; +} + +function catalogVersionOf( + tools: readonly { contract: McpToolContract }[], + actions: readonly { contract: ActionTypeContract }[], +): string { + const entries = [ + ...tools.map(({ contract }) => ['tool', contract.id, contract.schemaVersion] as const), + ...actions.map(({ contract }) => ['action', contract.id, contract.schemaVersion] as const), + ].sort(([leftKind, leftId], [rightKind, rightId]) => + leftKind === rightKind ? compareText(leftId, rightId) : compareText(leftKind, rightKind), + ); + return sha256Hex(entries).slice(0, CATALOG_VERSION_LENGTH); +} + +function failure(error: string, issues?: McpInputIssue[]): McpFailure { + return issues ? { ok: false, error, issues } : { ok: false, error }; +} + +function issuePaths(error: z.core.$ZodError): string[] { + return error.issues.map((issue) => issue.path.map(String).join('.')); +} + +function invalidInput(error: z.core.$ZodError): McpFailure { + return failure( + 'invalid_input', + error.issues.slice(0, MAX_INPUT_ISSUES).map((issue) => ({ + path: issue.path.map(String).join('.'), + message: issue.message.slice(0, MAX_ISSUE_MESSAGE_LENGTH), + })), + ); +} + +type Parsed = + | { success: true; data: Record } + | { success: false; failure: McpFailure }; + +function parseInput( + schema: z.ZodObject, + value: unknown, + logContext: Record, +): Parsed { + try { + const parsed = z.safeParse(schema, value); + return parsed.success + ? { success: true, data: parsed.data } + : { success: false, failure: invalidInput(parsed.error) }; + } catch (err) { + logger.error({ err, ...logContext }, 'mcp input schema threw while parsing'); + return { success: false, failure: failure('internal_error') }; + } +} + +function boundedId(id: unknown): string { + return String(id).slice(0, MAX_UNKNOWN_ID_LENGTH); +} + +function withoutUndefined(record: Record): Record { + return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)); +} + +function actorAuditFields(actor: RunActor): Record { + switch (actor.kind) { + case 'admin': + return { actorKind: actor.kind }; + case 'agent': + return { actorKind: actor.kind, agentId: actor.agentId, agentVersion: actor.agentVersion }; + case 'mcp_token': + return { actorKind: actor.kind, tokenId: actor.tokenId }; + } +} + +async function authorizeActor( + authorize: McpAuthorizer, + actor: RunActor, + iam: McpIamRequirement, +): Promise { + try { + return await authorize(runActorAdminId(actor), iam); + } catch (err) { + logger.error({ err, resource: iam.resource, action: iam.action }, 'mcp authorization failed'); + return 'failed'; + } +} + +function authorizationFailure(decision: McpAuthorization | 'failed'): McpFailure | null { + if (decision === 'allowed') { + return null; + } + return failure(decision === 'denied' ? 'forbidden' : 'internal_error'); +} + +function declaredCode( + err: unknown, + declared: readonly string[], + context: Record, +): string { + if (err instanceof McpToolError && declared.includes(err.code)) { + return err.code; + } + logger.error({ err, ...context }, 'mcp call failed with an undeclared error'); + return 'internal_error'; +} + +function pickAllowed( + output: Record, + allow: readonly string[], +): Record { + return withoutUndefined(Object.fromEntries(allow.map((key) => [key, output[key]]))); +} + +async function recordAudit(audit: AuditWritePort | null, entry: AuditEntry): Promise { + if (!audit) { + return false; + } + try { + await audit.record(entry); + return true; + } catch (err) { + logger.error( + { err, action: entry.action, resourceId: entry.resourceId }, + 'mcp audit write failed', + ); + return false; + } +} + +// An executor's effect is already committed when its audit record is written, so a transient +// write failure is retried here instead of being handed back as a failure the caller replays - +// by then the state may have moved on, and the replay would report an applied action as refused. +async function recordExecutionAudit( + audit: AuditWritePort | null, + entry: AuditEntry, +): Promise { + for (let attempt = 1; attempt <= EXECUTION_AUDIT_ATTEMPTS; attempt += 1) { + if (await recordAudit(audit, entry)) { + return true; + } + if (!audit || attempt === EXECUTION_AUDIT_ATTEMPTS) { + return false; + } + await new Promise((resolve) => setTimeout(resolve, EXECUTION_AUDIT_RETRY_DELAY_MS * attempt)); + } + return false; +} + +async function invokeTool( + state: KernelState, + call: { toolId: string; input: unknown; run: unknown }, +): Promise { + const run = RunContextSchema.safeParse(call.run); + if (!run.success) { + logger.warn( + { toolId: boundedId(call.toolId), issuePaths: issuePaths(run.error) }, + 'mcp tool call refused: invalid run context', + ); + return failure('invalid_run_context'); + } + const tool = state.tools.get(call.toolId); + const outcome = tool + ? await runTool(state, tool, call.input, run.data) + : { result: failure('unknown_tool'), inputHash: sha256Hex(call.input ?? {}) }; + const audited = await recordAudit(state.audit, { + actorId: runActorAdminId(run.data.actor), + actorType: 'admin', + action: outcome.result.ok ? 'mcp.tool.invoked' : 'mcp.tool.failed', + resourceType: 'mcp-tool', + resourceId: tool ? tool.contract.id : boundedId(call.toolId), + correlationId: run.data.correlationId, + after: withoutUndefined({ + toolId: tool ? tool.contract.id : boundedId(call.toolId), + toolClass: tool?.contract.class, + schemaVersion: tool?.contract.schemaVersion, + runId: run.data.runId, + ...actorAuditFields(run.data.actor), + playerPseudonym: run.data.playerPseudonym, + catalogVersion: run.data.catalogVersion, + inputHash: outcome.inputHash, + outputHash: outcome.outputHash, + error: outcome.result.ok ? undefined : outcome.result.error, + }), + }); + return audited ? outcome.result : failure('audit_unavailable'); +} + +async function runTool( + state: KernelState, + tool: ServedTool, + input: unknown, + run: RunContext, +): Promise { + const rawInput = input ?? {}; + const refusal = authorizationFailure( + await authorizeActor(state.authorize, run.actor, tool.contract.iam), + ); + if (refusal) { + return { result: refusal, inputHash: sha256Hex(rawInput) }; + } + const logContext = { toolId: tool.contract.id, runId: run.runId }; + const parsed = parseInput(tool.contract.inputSchema, rawInput, logContext); + if (!parsed.success) { + return { result: parsed.failure, inputHash: sha256Hex(rawInput) }; + } + const inputHash = sha256Hex(parsed.data); + try { + const output = z.safeParse(tool.contract.outputSchema, await tool.handler(parsed.data, run)); + if (!output.success) { + logger.error( + { ...logContext, issuePaths: issuePaths(output.error) }, + 'mcp tool output does not match its outputSchema', + ); + return { result: failure('output_invalid'), inputHash }; + } + const redacted = pickAllowed(output.data, tool.contract.redact.allow); + return { result: { ok: true, output: redacted }, inputHash, outputHash: sha256Hex(redacted) }; + } catch (err) { + return { result: failure(declaredCode(err, tool.contract.errors, logContext)), inputHash }; + } +} + +async function checkPrecondition( + state: KernelState, + call: { actionTypeId: string; payload: unknown; run: unknown }, +): Promise { + const run = RunContextSchema.safeParse(call.run); + if (!run.success) { + logger.warn( + { actionTypeId: boundedId(call.actionTypeId), issuePaths: issuePaths(run.error) }, + 'mcp precondition refused: invalid run context', + ); + return failure('invalid_run_context'); + } + const action = state.actions.get(call.actionTypeId); + if (!action) { + return failure('unknown_action'); + } + const logContext = { actionTypeId: action.contract.id, runId: run.data.runId }; + const parsed = parseInput(action.contract.payloadSchema, call.payload ?? {}, logContext); + if (!parsed.success) { + return parsed.failure; + } + try { + const outcome = ActionPreconditionOutcomeSchema.safeParse( + await action.implementation.precondition(parsed.data, run.data), + ); + if (!outcome.success) { + logger.error(logContext, 'mcp precondition returned no { ok } outcome'); + return failure('internal_error'); + } + if (outcome.data.ok) { + return { ok: true }; + } + if (action.contract.errors.includes(outcome.data.code)) { + return failure(outcome.data.code); + } + logger.error( + { ...logContext, code: outcome.data.code }, + 'mcp precondition refused with an undeclared code', + ); + return failure('internal_error'); + } catch (err) { + return failure(declaredCode(err, action.contract.errors, logContext)); + } +} + +async function executeAction( + state: KernelState, + call: { actionTypeId: string; payload: unknown; context: unknown }, +): Promise { + const context = ActionExecutionContextSchema.safeParse(call.context); + if (!context.success) { + logger.warn( + { actionTypeId: boundedId(call.actionTypeId), issuePaths: issuePaths(context.error) }, + 'mcp action execution refused: invalid run context', + ); + return failure('invalid_run_context'); + } + const { proposalId, actor, correlationId } = context.data; + const action = state.actions.get(call.actionTypeId); + const outcome = action + ? await runExecution(state, action, call.payload, context.data) + : { result: failure('unknown_action'), payloadHash: sha256Hex(call.payload ?? {}) }; + const audited = await recordExecutionAudit(state.audit, { + actorId: runActorAdminId(actor), + actorType: 'admin', + action: outcome.result.ok ? 'mcp.action.executed' : 'mcp.action.failed', + resourceType: 'agent-proposal', + resourceId: proposalId, + correlationId, + after: withoutUndefined({ + actionTypeId: action ? action.contract.id : boundedId(call.actionTypeId), + schemaVersion: action?.contract.schemaVersion, + proposalId, + ...actorAuditFields(actor), + payloadHash: outcome.payloadHash, + outcome: outcome.result.ok ? outcome.result.outcome : undefined, + error: outcome.result.ok ? undefined : outcome.result.error, + }), + }); + return audited ? outcome.result : failure('audit_unavailable'); +} + +async function runExecution( + state: KernelState, + action: ServedAction, + payload: unknown, + { proposalId, actor }: ActionExecutionContext, +): Promise { + const rawPayload = payload ?? {}; + const refusal = authorizationFailure( + await authorizeActor(state.authorize, actor, action.contract.iam), + ); + if (refusal) { + return { result: refusal, payloadHash: sha256Hex(rawPayload) }; + } + const logContext = { actionTypeId: action.contract.id, proposalId }; + const parsed = parseInput(action.contract.payloadSchema, rawPayload, logContext); + if (!parsed.success) { + return { result: parsed.failure, payloadHash: sha256Hex(rawPayload) }; + } + const payloadHash = sha256Hex(parsed.data); + try { + const outcome = ActionExecutionOutcomeSchema.safeParse( + await action.implementation.execute(parsed.data, proposalId, actor), + ); + if (!outcome.success) { + logger.error(logContext, 'mcp executor returned no applied/already_applied outcome'); + return { result: failure('internal_error'), payloadHash }; + } + return { result: { ok: true, ...outcome.data }, payloadHash }; + } catch (err) { + return { + result: failure(declaredCode(err, action.contract.errors, logContext)), + payloadHash, + }; + } +} diff --git a/packages/core/src/server/plugin-host/__tests__/fixtures/agent-surface-plugin.ts b/packages/core/src/server/plugin-host/__tests__/fixtures/agent-surface-plugin.ts new file mode 100644 index 000000000..5856eac2a --- /dev/null +++ b/packages/core/src/server/plugin-host/__tests__/fixtures/agent-surface-plugin.ts @@ -0,0 +1,43 @@ +import * as z from 'zod'; +import { + defineActionType, + defineMcpTool, + UuidSchema, + type TokenCatalog, +} from '@openora/core/contracts'; +import type { Plugin } from '../../define-plugin.js'; + +const statusTool = defineMcpTool({ + id: 'withdrawal.status', + title: 'Withdrawal status', + description: 'Status of one withdrawal', + class: 'read', + schemaVersion: 1, + iam: { resource: 'withdrawal', action: 'view' }, + inputSchema: z.object({ withdrawalId: UuidSchema }), + outputSchema: z.object({ status: z.string() }), + redact: { allow: ['status'] }, + errors: [], +}); + +const holdAction = defineActionType({ + id: 'hold_withdrawal', + title: 'Hold a withdrawal', + description: 'Puts a pending withdrawal on hold for manual review', + schemaVersion: 1, + iam: { resource: 'withdrawal', action: 'hold' }, + reversible: true, + payloadSchema: z.object({ withdrawalId: UuidSchema }), + errors: [], +}); + +export default { + id: 'agent-surface', + register(ctx) { + ctx.mcp.tool(statusTool, () => async () => ({ status: 'pending' })); + ctx.actions.register(holdAction, () => ({ + precondition: async () => ({ ok: true }), + execute: async () => ({ outcome: 'applied' }), + })); + }, +} satisfies Plugin; 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 5c12bb78c..dd0f5a933 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,14 @@ +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import type { TokenCatalog } from '@openora/core/contracts'; +import { Container } from '../../kernel/index.js'; import type { Plugin } from '../define-plugin.js'; -import { assertEntryMatchesPlugin, topoSort, type PluginEntry } from '../load-plugins.js'; +import { + assertEntryMatchesPlugin, + loadPlugins, + topoSort, + type PluginEntry, +} from '../load-plugins.js'; function plugin(id: string, dependsOn: string[] = []): Plugin { return { @@ -44,3 +51,14 @@ describe('assertEntryMatchesPlugin', () => { ); }); }); + +describe('loadPlugins', () => { + it('attributes each tool and action type to the plugin whose register() added it', async () => { + const path = fileURLToPath(new URL('./fixtures/agent-surface-plugin.ts', import.meta.url)); + + const registry = await loadPlugins([{ id: 'agent-surface', path }], new Container()); + + expect(registry.mcp.getTools().map(({ owner }) => owner)).toEqual(['agent-surface']); + expect(registry.actions.getAll().map(({ owner }) => owner)).toEqual(['agent-surface']); + }); +}); 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 dfefd9ff4..bb5c90ea7 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,6 +1,20 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import * as z from 'zod'; import { Container } from '../../kernel/index.js'; -import { createToken, createSealedToken, type Token } from '@openora/core/contracts'; +import { + createToken, + createSealedToken, + defineActionType, + defineMcpTool, + MoneyAmountSchema, + PlayerStatusSchema, + UuidSchema, + type ActionTypeContract, + type McpToolContract, + type Token, +} from '@openora/core/contracts'; +import { mock } from '../../../testing/mock.js'; import { ModuleRegistryImpl } from '../module-registry.js'; function newRegistry() { @@ -54,3 +68,330 @@ describe('ModuleRegistryImpl', () => { expect(reg.events.getAll().get('wallet.deposit.completed')).toEqual([h1, h2]); }); }); + +const validTool = defineMcpTool({ + id: 'player.summary', + title: 'Player summary', + description: 'Status and balance of one player', + class: 'read', + schemaVersion: 1, + iam: { resource: 'player', action: 'view' }, + inputSchema: z + .object({ + playerId: UuidSchema, + limit: z.coerce.number().int().min(1).max(50).default(10), + status: PlayerStatusSchema.optional(), + note: z.string().max(200).nullable().optional(), + window: z.object({ from: z.iso.date(), to: z.iso.date() }).optional(), + tags: z.array(z.string().max(32)).max(10).optional(), + }) + .superRefine(() => undefined), + outputSchema: z.object({ + playerId: UuidSchema, + status: PlayerStatusSchema, + email: z.string(), + balance: MoneyAmountSchema, + lastLoginAt: z.iso.datetime().nullable(), + }), + redact: { allow: ['playerId', 'status', 'balance', 'lastLoginAt'], personal: ['playerId'] }, + errors: ['player_not_found'], +}); + +const toolFactory = () => async () => ({ + playerId: randomUUID(), + status: 'active' as const, + email: 'player@example.com', + balance: '10.00', + lastLoginAt: null, +}); + +const validAction = defineActionType({ + id: 'hold_withdrawal', + title: 'Hold a withdrawal', + description: 'Puts a pending withdrawal on hold for manual review', + schemaVersion: 1, + iam: { resource: 'withdrawal', action: 'hold' }, + reversible: true, + payloadSchema: z.object({ withdrawalId: UuidSchema, reason: z.string().min(1).max(500) }), + errors: ['withdrawal_not_pending'], +}); + +const actionFactory = () => ({ + precondition: async () => ({ ok: true as const }), + execute: async () => ({ outcome: 'applied' as const }), +}); + +const toolWith = (overrides: object) => mock({ ...validTool, ...overrides }); +const actionWith = (overrides: object) => + mock({ ...validAction, ...overrides }); + +describe('ModuleRegistryImpl - MCP tools and action types', () => { + it('registers a valid tool and action type without running their factories', () => { + const { reg } = newRegistry(); + const tool = vi.fn(toolFactory); + const action = vi.fn(actionFactory); + + reg.mcp.tool(validTool, tool); + reg.actions.register(validAction, action); + + expect(reg.mcp.getTools()).toEqual([{ contract: validTool, owner: 'unknown', factory: tool }]); + expect(reg.actions.getAll()).toEqual([ + { contract: validAction, owner: 'unknown', factory: action }, + ]); + expect(tool).not.toHaveBeenCalled(); + expect(action).not.toHaveBeenCalled(); + }); + + it('attributes a registration to the plugin set as owner, and to unknown after it', () => { + const { reg } = newRegistry(); + + reg.setOwner('wallet'); + reg.actions.register(validAction, actionFactory); + reg.setOwner(null); + reg.mcp.tool(validTool, toolFactory); + + expect(reg.actions.getAll().map(({ owner }) => owner)).toEqual(['wallet']); + expect(reg.mcp.getTools().map(({ owner }) => owner)).toEqual(['unknown']); + }); + + it('keeps the legacy untyped tool listed by getAll() and out of the kernel registry', () => { + const { reg } = newRegistry(); + const legacy = { + name: 'legacy_lookup', + description: 'Legacy lookup', + inputSchema: { type: 'object' }, + handler: () => 'ok', + }; + + reg.mcp.tool(legacy); + + expect(reg.mcp.getAll()).toEqual([legacy]); + expect(reg.mcp.getTools()).toEqual([]); + }); + + it('names the tool, its plugin, the problem and the fix in a rejection', () => { + const { reg } = newRegistry(); + reg.setOwner('players'); + + expect(() => + reg.mcp.tool(toolWith({ inputSchema: z.object({ q: z.string() }) }), toolFactory), + ).toThrow( + '[mcp] tool "player.summary" (plugin "players"): inputSchema field "q" is a string without a maximum length - add .max(n); only an enum, a UUID or an ISO date, time or datetime may leave it out', + ); + }); + + it.each<[string, object, RegExp]>([ + [ + 'a top-level discriminated union', + { + inputSchema: z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('player'), playerId: UuidSchema }), + z.object({ kind: z.literal('email'), email: z.email() }), + ]), + }, + /inputSchema must be a top-level z\.object, not union - models send numbers as strings when a tool input is a oneOf/, + ], + [ + 'an optional top-level object', + { inputSchema: z.object({ playerId: UuidSchema }).optional() }, + /inputSchema must be a top-level z\.object, not optional/, + ], + [ + 'a transform', + { inputSchema: z.object({ playerId: UuidSchema.transform((id) => id.toLowerCase()) }) }, + /inputSchema field "playerId" transforms its value/, + ], + [ + 'an unbounded string', + { inputSchema: z.object({ window: z.object({ label: z.string() }) }) }, + /inputSchema field "window\.label" is a string without a maximum length/, + ], + [ + 'a string bounded only by a regex', + { inputSchema: z.object({ code: z.string().regex(/^[a-z]+$/) }) }, + /inputSchema field "code" is a string without a maximum length/, + ], + [ + 'an email without a maximum length', + { inputSchema: z.object({ email: z.email() }) }, + /inputSchema field "email" is a string without a maximum length/, + ], + [ + 'a number without an upper bound', + { inputSchema: z.object({ limit: z.coerce.number().int().min(1) }) }, + /inputSchema field "limit" is a number without both a lower and an upper bound/, + ], + [ + 'a number that is not coerced', + { inputSchema: z.object({ limit: z.number().int().min(1).max(50) }) }, + /inputSchema field "limit" is a z\.number\(\) - use z\.coerce\.number\(\)/, + ], + [ + 'a number inside an array that is not coerced', + { inputSchema: z.object({ amounts: z.array(z.number().min(0).max(5)).max(3) }) }, + /inputSchema field "amounts\[\]" is a z\.number\(\)/, + ], + [ + 'a z.record', + { inputSchema: z.object({ filters: z.record(z.string().max(10), z.string().max(10)) }) }, + /inputSchema field "filters" is a free-form map/, + ], + [ + 'an object that accepts undeclared keys', + { inputSchema: z.looseObject({ playerId: UuidSchema }) }, + /inputSchema accepts undeclared keys/, + ], + [ + 'an untyped field', + { inputSchema: z.object({ blob: z.unknown() }) }, + /inputSchema field "blob" accepts any value/, + ], + [ + 'an array without a maximum length', + { inputSchema: z.object({ playerIds: z.array(UuidSchema) }) }, + /inputSchema field "playerIds" is an array without a maximum length/, + ], + [ + 'a date in the input', + { inputSchema: z.object({ since: z.date() }) }, + /inputSchema field "since" is a z\.date\(\)/, + ], + [ + 'a date in the output', + { outputSchema: z.object({ at: z.date() }), redact: { allow: ['at'] } }, + /outputSchema cannot be converted to JSON Schema \(Date cannot be represented/, + ], + [ + 'an output that is not a top-level object', + { outputSchema: z.array(z.string().max(5)).max(5) }, + /outputSchema must be a top-level z\.object, not array/, + ], + ['an empty allow-list', { redact: { allow: [] } }, /redact\.allow is empty/], + [ + 'an allow key the output schema does not declare', + { redact: { allow: ['playerId', 'phone'] } }, + /redact\.allow key "phone" is not in outputSchema/, + ], + [ + 'a personal key that is not allowed', + { redact: { allow: ['status'], personal: ['playerId'] } }, + /redact\.personal key "playerId" is not in redact\.allow/, + ], + ['an uppercase id', { id: 'Player.summary' }, /id must match .* and be at most 64 characters/], + ['an id with an empty segment', { id: 'player..summary' }, /id must match/], + ['an id longer than 64 characters', { id: `player.${'a'.repeat(58)}` }, /id must match/], + [ + 'an IAM action the resource does not declare', + { iam: { resource: 'player', action: 'approve' } }, + /iam\.action "approve" is not an action of "player" - use one of: view, update, ban/, + ], + [ + 'an IAM resource outside the admin statement', + { iam: { resource: 'tournament', action: 'view' } }, + /iam\.resource "tournament" is not in adminStatement/, + ], + ['a missing IAM requirement', { iam: undefined }, /iam is missing/], + [ + 'an error code reserved by the kernel', + { errors: ['forbidden'] }, + /error code "forbidden" is reserved by the kernel/, + ], + [ + 'a malformed error code', + { errors: ['Player-Not-Found'] }, + /error code "Player-Not-Found" must match/, + ], + [ + 'a duplicated error code', + { errors: ['player_not_found', 'player_not_found'] }, + /declared twice/, + ], + ['a blank title', { title: ' ' }, /title must be 1\.\.80 characters/], + [ + 'an overlong description', + { description: 'x'.repeat(1025) }, + /description must be 1\.\.1024 characters/, + ], + ['a zero schemaVersion', { schemaVersion: 0 }, /schemaVersion must be a positive integer/], + ['an unknown class', { class: 'write' }, /class must be one of read, propose/], + ])('rejects a tool with %s', (_label, overrides, message) => { + const { reg } = newRegistry(); + + expect(() => reg.mcp.tool(toolWith(overrides), toolFactory)).toThrow(message); + expect(reg.mcp.getTools()).toEqual([]); + }); + + it('rejects a tool registered without a factory function', () => { + const { reg } = newRegistry(); + + expect(() => reg.mcp.tool(validTool, mock())).toThrow(/factory must be a function/); + }); + + it.each<[string, object, RegExp]>([ + [ + 'no IAM requirement', + { iam: undefined }, + /every action type must name the IAM resource its executor needs/, + ], + ['a dotted id', { id: 'wallet.hold' }, /id must match/], + [ + 'a reversible flag that is not a boolean', + { reversible: 'yes' }, + /reversible must be a boolean/, + ], + [ + 'a payload whose top level is a union', + { + payloadSchema: z.union([ + z.object({ withdrawalId: UuidSchema }), + z.object({ playerId: UuidSchema }), + ]), + }, + /payloadSchema must be a top-level z\.object, not union/, + ], + [ + 'a payload number that is not coerced', + { payloadSchema: z.object({ amount: z.number().min(0).max(10) }) }, + /payloadSchema field "amount" is a z\.number\(\)/, + ], + [ + 'an error code reserved by the kernel', + { errors: ['internal_error'] }, + /error code "internal_error" is reserved by the kernel/, + ], + ])('rejects an action type with %s', (_label, overrides, message) => { + const { reg } = newRegistry(); + + expect(() => reg.actions.register(actionWith(overrides), actionFactory)).toThrow(message); + expect(reg.actions.getAll()).toEqual([]); + }); + + it('rejects a tool id an action type already holds', () => { + const { reg } = newRegistry(); + reg.setOwner('wallet'); + reg.actions.register(validAction, actionFactory); + reg.setOwner('players'); + + expect(() => reg.mcp.tool(toolWith({ id: 'hold_withdrawal' }), toolFactory)).toThrow( + '[mcp] tool "hold_withdrawal" (plugin "players"): id is already registered as an action type of plugin "wallet"', + ); + }); + + it('rejects an action type id a tool already holds', () => { + const { reg } = newRegistry(); + reg.mcp.tool(toolWith({ id: 'hold_withdrawal' }), toolFactory); + + expect(() => reg.actions.register(validAction, actionFactory)).toThrow( + /action type "hold_withdrawal" .*id is already registered as a tool/, + ); + }); + + it('rejects a second tool whose model name collides with the first', () => { + const { reg } = newRegistry(); + reg.mcp.tool(validTool, toolFactory); + + expect(() => reg.mcp.tool(toolWith({ id: 'player_summary' }), toolFactory)).toThrow( + /model name "player_summary" collides with tool "player\.summary"/, + ); + }); +}); diff --git a/packages/core/src/server/plugin-host/define-plugin.ts b/packages/core/src/server/plugin-host/define-plugin.ts index fa7c3f73f..ca056f826 100644 --- a/packages/core/src/server/plugin-host/define-plugin.ts +++ b/packages/core/src/server/plugin-host/define-plugin.ts @@ -1,5 +1,10 @@ +import type * as z from 'zod'; import type { + ActionTypeContract, + ActionTypeImplementation, EventEnvelope, + McpToolContract, + McpToolHandler, SealedToken, Token, TokenCatalog, @@ -31,6 +36,30 @@ export type RouterFactory = (c: TypedContainer) => un export type EventHandler = (payload: unknown, envelope?: EventEnvelope) => void | Promise; +// Tool and action-type factories run once, when MCP_KERNEL is built after every provider is +// bound - never at registration. +export type McpToolFactory< + C extends TokenCatalog, + I extends z.ZodObject = z.ZodObject, + O extends z.ZodObject = z.ZodObject, +> = (c: TypedContainer) => McpToolHandler; + +export type ActionTypeFactory = ( + c: TypedContainer, +) => ActionTypeImplementation

; + +export type RegisteredMcpTool = { + contract: McpToolContract; + owner: string; + factory: McpToolFactory; +}; + +export type RegisteredActionType = { + contract: ActionTypeContract; + owner: string; + factory: ActionTypeFactory; +}; + export type ModuleRegistry = { // Last registration wins - an overlay loaded after a module can rebind its adapter token. // T is inferred directly from the token argument (never a keyof reverse lookup) - @@ -61,8 +90,21 @@ export type ModuleRegistry = { getAll(): WorkerRegistration[]; }; mcp: { + /** Legacy untyped form: listed by `getAll()` only, never served by MCP_KERNEL. */ tool(definition: McpToolDefinition): void; + tool( + contract: McpToolContract, + factory: McpToolFactory, + ): void; getAll(): McpToolDefinition[]; + getTools(): readonly RegisteredMcpTool[]; + }; + actions: { + register

( + contract: ActionTypeContract

, + factory: ActionTypeFactory, + ): void; + getAll(): readonly RegisteredActionType[]; }; }; diff --git a/packages/core/src/server/plugin-host/index.ts b/packages/core/src/server/plugin-host/index.ts index 15c0d2cf6..d85f0486e 100644 --- a/packages/core/src/server/plugin-host/index.ts +++ b/packages/core/src/server/plugin-host/index.ts @@ -4,6 +4,10 @@ export type { PluginContext, RouterFactory, McpToolDefinition, + McpToolFactory, + ActionTypeFactory, + RegisteredMcpTool, + RegisteredActionType, 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 c0b2bce6f..2fa96ee73 100644 --- a/packages/core/src/server/plugin-host/load-plugins.ts +++ b/packages/core/src/server/plugin-host/load-plugins.ts @@ -107,7 +107,9 @@ export async function loadPlugins( const registry = new ModuleRegistryImpl(container); for (const plugin of ordered) { + registry.setOwner(plugin.id); await plugin.register(registry); + registry.setOwner(null); } assertRequiredPorts(ordered, container); diff --git a/packages/core/src/server/plugin-host/module-registry.ts b/packages/core/src/server/plugin-host/module-registry.ts index eb9b6d541..4b752c2f5 100644 --- a/packages/core/src/server/plugin-host/module-registry.ts +++ b/packages/core/src/server/plugin-host/module-registry.ts @@ -1,5 +1,7 @@ import type { Container } from '../kernel/index.js'; import type { + ActionTypeContract, + McpToolContract, SealedToken, Token, TokenCatalog, @@ -7,22 +9,36 @@ import type { WorkerRegistration, } from '@openora/core/contracts'; import type { + ActionTypeFactory, ModuleRegistry, McpToolDefinition, + McpToolFactory, + RegisteredActionType, + RegisteredMcpTool, RouterFactory, TypedContainer, EventHandler, } from './define-plugin.js'; +import { assertValidActionType, assertValidMcpTool } from '../mcp/contract-validation.js'; + +const UNKNOWN_OWNER = 'unknown'; export class ModuleRegistryImpl implements ModuleRegistry { private _routers = new Map>(); private _events = new Map(); private _jobs: WorkerRegistration[] = []; private _mcpTools: McpToolDefinition[] = []; + private _tools: RegisteredMcpTool[] = []; + private _actions: RegisteredActionType[] = []; private _sealedBound = new Set(); + private _owner = UNKNOWN_OWNER; constructor(private readonly container: Container) {} + setOwner(pluginId: string | null): void { + this._owner = pluginId ?? UNKNOWN_OWNER; + } + // Last-wins, so an overlay loaded after a module can rebind its adapter token. // 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. @@ -88,10 +104,31 @@ export class ModuleRegistryImpl implements ModuleRegistr getAll: () => this._jobs, }; - mcp = { - tool: (definition: McpToolDefinition) => { - this._mcpTools.push(definition); + mcp: ModuleRegistry['mcp'] = { + tool: (...args: [McpToolDefinition] | [McpToolContract, McpToolFactory]) => { + if (args.length === 1) { + this._mcpTools.push(args[0]); + return; + } + const [contract, factory] = args; + const registration = { contract, owner: this._owner, factory }; + assertValidMcpTool(registration, this.registrations()); + this._tools.push(registration); }, getAll: () => this._mcpTools, + getTools: () => this._tools, }; + + actions: ModuleRegistry['actions'] = { + register: (contract: ActionTypeContract, factory: ActionTypeFactory) => { + const registration = { contract, owner: this._owner, factory }; + assertValidActionType(registration, this.registrations()); + this._actions.push(registration); + }, + getAll: () => this._actions, + }; + + private registrations() { + return { tools: this._tools, actions: this._actions }; + } } diff --git a/packages/core/src/server/runtime/core-token-catalog.ts b/packages/core/src/server/runtime/core-token-catalog.ts index fe097a608..0237a085d 100644 --- a/packages/core/src/server/runtime/core-token-catalog.ts +++ b/packages/core/src/server/runtime/core-token-catalog.ts @@ -44,6 +44,7 @@ import { LOBBY_SECTION_CATALOG, MAIL_DISPATCH, MAIL_RECIPIENT_DIRECTORY, + MCP_KERNEL, MESSAGE_BROKER, OUTBOX, PAYMENT_ADAPTER, @@ -123,6 +124,7 @@ const coreTokenCatalog = { LOBBY_SECTION_CATALOG, MAIL_DISPATCH, MAIL_RECIPIENT_DIRECTORY, + MCP_KERNEL, MESSAGE_BROKER, OUTBOX, PAYMENT_ADAPTER, diff --git a/packages/core/src/server/runtime/create-app.ts b/packages/core/src/server/runtime/create-app.ts index 94f84363e..4520d0bc7 100644 --- a/packages/core/src/server/runtime/create-app.ts +++ b/packages/core/src/server/runtime/create-app.ts @@ -31,7 +31,9 @@ import { OUTBOX, ADMIN_PERMISSION_RESOLVER, ADMIN_SECURITY_POLICY, + AUDIT_WRITER, IDENTITY_READER, + MCP_KERNEL, RATE_LIMITER, CACHE, REALTIME_TRANSPORT, @@ -46,6 +48,8 @@ import { import { DrizzleService, DRIZZLE, DrizzleOutboxWriter, OutboxRelay } from '../db/index.js'; import { AdminGuard, ADMIN_GUARD, SessionResolver, AUTH_SESSION } from '../auth/index.js'; import { loadPlugins, type PluginEntry } from '../plugin-host/index.js'; +import { createMcpKernel } from '../mcp/index.js'; +import { authorizeWithAdminGuard } from '../mcp/authorize.js'; import { assertDurableSeamsBound } from './assert-durable-seams.js'; import { applyClientAddress, resolveTrustedProxies } from './client-address.js'; import { loadPlatformConfig, resolvePlatformConfigPath } from '../kernel/platform-config-loader.js'; @@ -296,6 +300,18 @@ export async function createApp( assertDurableSeamsBound(container); + // Bound after configure() so no overlay or composition root can replace the wrapper that + // enforces IAM and audit, and before the routers so a router factory can resolve it. + container.register(MCP_KERNEL, (c) => + createMcpKernel({ + tools: registry.mcp.getTools(), + actions: registry.actions.getAll(), + container: c, + authorize: (adminId, iam) => authorizeWithAdminGuard(c.get(ADMIN_GUARD), adminId, iam), + audit: c.has(AUDIT_WRITER) ? c.get(AUDIT_WRITER) : null, + }), + ); + if (container.has(ERROR_TRACKING)) { const tracker = container.get(ERROR_TRACKING); setErrorReporter((error, context) => tracker.captureException(error, context)); @@ -321,6 +337,8 @@ export async function createApp( } router[namespace] = factory(container) as AnyRouter; } + // Built now so a broken tool or action-type factory fails boot, not the first call. + container.get(MCP_KERNEL); for (const registration of registry.jobs.getAll()) { jobQueue.registerWorker(registration); diff --git a/packages/core/src/testing/index.ts b/packages/core/src/testing/index.ts index 60d00752f..9b4efa738 100644 --- a/packages/core/src/testing/index.ts +++ b/packages/core/src/testing/index.ts @@ -19,3 +19,11 @@ export { type SeedPlayerOverrides, } from './seed-player.js'; export { seedCompletedDeposit } from './seed-wallet.js'; +// Tests build a kernel over a module's own registrations; production code reaches it only +// through MCP_KERNEL, which createApp binds. +export { + createMcpKernel, + type McpAuthorization, + type McpAuthorizer, + type McpKernelDeps, +} from '../server/mcp/index.js'; diff --git a/packages/core/src/wallet/__tests__/wallet-agent-registration.test.ts b/packages/core/src/wallet/__tests__/wallet-agent-registration.test.ts new file mode 100644 index 000000000..f1677ff35 --- /dev/null +++ b/packages/core/src/wallet/__tests__/wallet-agent-registration.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { Container, ModuleRegistryImpl, type CoreTokenCatalog } from '@openora/core/server'; +import walletPlugin from '../plugin.js'; + +describe('wallet agent surface registration', () => { + it('registers wallet.activity and hold_withdrawal within the kernel contract rules', async () => { + const registry = new ModuleRegistryImpl(new Container()); + registry.setOwner('wallet'); + + await walletPlugin.register(registry); + + expect(registry.mcp.getTools().map((tool) => tool.contract.id)).toEqual(['wallet.activity']); + expect(registry.actions.getAll().map((action) => action.contract.id)).toEqual([ + 'hold_withdrawal', + ]); + }); +}); diff --git a/packages/core/src/wallet/__tests__/wallet-agent-tools.int.test.ts b/packages/core/src/wallet/__tests__/wallet-agent-tools.int.test.ts new file mode 100644 index 000000000..7bd1393d1 --- /dev/null +++ b/packages/core/src/wallet/__tests__/wallet-agent-tools.int.test.ts @@ -0,0 +1,429 @@ +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { eq, sql } from 'drizzle-orm'; +import { + Container, + DRIZZLE, + EVENT_BUS, + ModuleRegistryImpl, + findOneOrThrow, + type CoreTokenCatalog, +} from '@openora/core/server'; +import { + ADMIN_USER_DIRECTORY, + AUDIT_WRITER, + IDENTITY_READER, + RATE_LIMITER, + type AdminUserDirectory, + type IdentityReader, + type RateLimiterAdapter, + type RunContext, +} from '@openora/core/contracts'; +import { createMcpKernel, createTestDb, type TestDb } from '@openora/core/testing'; +import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; +import { makeAuditWriter, makeEventBus, makeIdentityReader, mock } from '../../testing/mock.js'; +import { migrate } from '../migrate.js'; +import { wallet, walletBalance, walletTransaction } from '../schema/index.js'; +import { OPEN_WITHDRAWALS_LIMIT } from '../contract/index.js'; +import walletPlugin from '../plugin.js'; + +let db: TestDb; + +const HOLD_REASON = 'pattern matches a mule account'; + +type Player = { playerId: string; userId: string; walletId: string }; + +function bootKernel(identity: IdentityReader = playerDirectory()) { + const container = new Container(); + const domainAudit = makeAuditWriter(); + container.register(DRIZZLE, () => db.drizzle); + container.register(EVENT_BUS, () => makeEventBus()); + container.register(AUDIT_WRITER, () => domainAudit); + container.register(IDENTITY_READER, () => identity); + container.register(ADMIN_USER_DIRECTORY, () => mock({})); + container.register(RATE_LIMITER, () => mock>({})); + const registry = new ModuleRegistryImpl(container); + registry.setOwner('wallet'); + walletPlugin.register(registry); + const kernel = createMcpKernel({ + tools: registry.mcp.getTools(), + actions: registry.actions.getAll(), + container, + authorize: async () => 'allowed', + audit: makeAuditWriter(), + }); + return { kernel, domainAudit }; +} + +const players = new Map(); + +function playerDirectory(): IdentityReader { + return { + ...makeIdentityReader(), + getUserIdByPlayerId: vi.fn(async (playerId: string) => players.get(playerId) ?? null), + }; +} + +const adminId = randomUUID(); + +const runContext = (): RunContext => ({ + runId: randomUUID(), + actor: { kind: 'agent', agentId: randomUUID(), agentVersion: 1, onBehalfOf: adminId }, + catalogVersion: 'test', + correlationId: 'wallet-agent-tools', +}); + +const execution = (proposalId: string = randomUUID()) => ({ + proposalId, + actor: runContext().actor, + correlationId: 'wallet-agent-tools', +}); + +beforeAll(async () => { + db = await createTestDb([migrate, migrateProfile]); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + players.clear(); + await db.drizzle.db.execute( + sql`TRUNCATE ${walletTransaction}, ${wallet} RESTART IDENTITY CASCADE`, + ); +}); + +async function seedPlayer(balance = '100'): Promise { + const playerId = randomUUID(); + const record = findOneOrThrow( + await db.drizzle.db + .insert(wallet) + .values({ userId: randomUUID(), currency: 'USD' }) + .returning(), + new Error('seedPlayer: query returned no row'), + ); + await db.drizzle.db + .insert(walletBalance) + .values({ walletId: record.id, currency: 'USD', amount: balance }); + players.set(playerId, record.userId); + return { playerId, userId: record.userId, walletId: record.id }; +} + +async function seedTx( + walletId: string, + values: Pick & + Partial, +) { + return findOneOrThrow( + await db.drizzle.db + .insert(walletTransaction) + .values({ walletId, currency: 'USD', status: 'completed', ...values }) + .returning(), + new Error('seedTx: query returned no row'), + ); +} + +async function statusOf(id: string) { + const row = findOneOrThrow( + await db.drizzle.db + .select({ status: walletTransaction.status }) + .from(walletTransaction) + .where(eq(walletTransaction.id, id)), + new Error('statusOf: query returned no row'), + ); + return row.status; +} + +const daysAgo = (days: number) => new Date(Date.now() - days * 24 * 60 * 60 * 1000); + +describe('wallet.activity through the MCP kernel (real PG)', () => { + it('reports balances, completed totals inside the window per currency, and open withdrawals', async () => { + const { kernel } = bootKernel(); + const player = await seedPlayer(); + const stranger = await seedPlayer(); + await seedTx(player.walletId, { type: 'deposit', amount: '50' }); + await seedTx(player.walletId, { type: 'deposit', amount: '25' }); + await seedTx(player.walletId, { type: 'deposit', amount: '99', status: 'pending' }); + await seedTx(player.walletId, { type: 'deposit', amount: '500', createdAt: daysAgo(40) }); + await seedTx(player.walletId, { type: 'deposit', amount: '0.5', currency: 'BTC' }); + await seedTx(player.walletId, { type: 'withdrawal', amount: '30' }); + await seedTx(player.walletId, { type: 'bet', amount: '10' }); + await seedTx(player.walletId, { type: 'bet', amount: '5' }); + await seedTx(player.walletId, { type: 'win', amount: '7' }); + const pending = await seedTx(player.walletId, { + type: 'withdrawal', + amount: '20', + status: 'pending', + createdAt: daysAgo(2), + }); + const held = await seedTx(player.walletId, { + type: 'withdrawal', + amount: '15', + status: 'on_hold', + createdAt: daysAgo(1), + }); + await seedTx(player.walletId, { type: 'withdrawal', amount: '11', status: 'processing' }); + await seedTx(stranger.walletId, { type: 'withdrawal', amount: '9', status: 'pending' }); + + const result = await kernel.invokeTool( + 'wallet.activity', + { playerId: player.playerId, windowDays: '30' }, + runContext(), + ); + + expect(result).toEqual({ + ok: true, + output: { + playerId: player.playerId, + windowDays: 30, + activeCurrency: 'USD', + balances: [{ currency: 'USD', balance: '100.000000000000000000' }], + totals: [ + { + currency: 'BTC', + deposits: '0.500000000000000000', + depositCount: 1, + withdrawals: '0', + withdrawalCount: 0, + bets: '0', + wins: '0', + }, + { + currency: 'USD', + deposits: '75.000000000000000000', + depositCount: 2, + withdrawals: '30.000000000000000000', + withdrawalCount: 1, + bets: '15.000000000000000000', + wins: '7.000000000000000000', + }, + ], + openWithdrawals: [ + { + withdrawalId: held.id, + amount: '15.000000000000000000', + currency: 'USD', + status: 'on_hold', + requestedAt: held.createdAt.toISOString(), + }, + { + withdrawalId: pending.id, + amount: '20.000000000000000000', + currency: 'USD', + status: 'pending', + requestedAt: pending.createdAt.toISOString(), + }, + ], + }, + }); + }); + + it('lists at most the newest open withdrawals', async () => { + const { kernel } = bootKernel(); + const player = await seedPlayer(); + for (let day = 1; day <= OPEN_WITHDRAWALS_LIMIT + 1; day += 1) { + await seedTx(player.walletId, { + type: 'withdrawal', + amount: '1', + status: 'pending', + createdAt: daysAgo(day), + }); + } + + const result = await kernel.invokeTool( + 'wallet.activity', + { playerId: player.playerId }, + runContext(), + ); + + const open = result.ok ? result.output['openWithdrawals'] : null; + expect(Array.isArray(open) && open.length).toBe(OPEN_WITHDRAWALS_LIMIT); + }); + + it('reads a player without a wallet as having no activity', async () => { + const { kernel } = bootKernel(); + const playerId = randomUUID(); + players.set(playerId, randomUUID()); + + const result = await kernel.invokeTool('wallet.activity', { playerId }, runContext()); + + expect(result).toEqual({ + ok: true, + output: { + playerId, + windowDays: 30, + activeCurrency: 'USD', + balances: [], + totals: [], + openWithdrawals: [], + }, + }); + }); + + it('answers player_not_found for an unknown player', async () => { + const { kernel } = bootKernel(); + + const result = await kernel.invokeTool( + 'wallet.activity', + { playerId: randomUUID() }, + runContext(), + ); + + expect(result).toEqual({ ok: false, error: 'player_not_found' }); + }); + + it('refuses a window outside 1..365 days', async () => { + const { kernel } = bootKernel(); + const player = await seedPlayer(); + + const result = await kernel.invokeTool( + 'wallet.activity', + { playerId: player.playerId, windowDays: 366 }, + runContext(), + ); + + expect(result).toMatchObject({ ok: false, error: 'invalid_input' }); + }); +}); + +describe('hold_withdrawal through the MCP kernel (real PG)', () => { + const pendingWithdrawal = (walletId: string) => + seedTx(walletId, { type: 'withdrawal', amount: '40', status: 'pending' }); + + it('passes the precondition for a pending withdrawal of the named player', async () => { + const { kernel } = bootKernel(); + const player = await seedPlayer(); + const withdrawal = await pendingWithdrawal(player.walletId); + + const result = await kernel.checkPrecondition( + 'hold_withdrawal', + { playerId: player.playerId, withdrawalId: withdrawal.id, reason: HOLD_REASON }, + runContext(), + ); + + expect(result).toEqual({ ok: true }); + }); + + it('refuses the precondition with the code for each unholdable case', async () => { + const { kernel } = bootKernel(); + const player = await seedPlayer(); + const stranger = await seedPlayer(); + const processing = await seedTx(player.walletId, { + type: 'withdrawal', + amount: '40', + status: 'processing', + }); + const deposit = await seedTx(player.walletId, { type: 'deposit', amount: '40' }); + const strangers = await pendingWithdrawal(stranger.walletId); + const precondition = (playerId: string, withdrawalId: string) => + kernel.checkPrecondition( + 'hold_withdrawal', + { playerId, withdrawalId, reason: HOLD_REASON }, + runContext(), + ); + + expect(await precondition(randomUUID(), strangers.id)).toEqual({ + ok: false, + error: 'player_not_found', + }); + expect(await precondition(player.playerId, strangers.id)).toEqual({ + ok: false, + error: 'withdrawal_not_found', + }); + expect(await precondition(player.playerId, deposit.id)).toEqual({ + ok: false, + error: 'withdrawal_not_found', + }); + expect(await precondition(player.playerId, processing.id)).toEqual({ + ok: false, + error: 'withdrawal_not_pending', + }); + }); + + it('holds once per proposal: a replay resolves already_applied with one audit record', async () => { + const { kernel, domainAudit } = bootKernel(); + const player = await seedPlayer(); + const withdrawal = await pendingWithdrawal(player.walletId); + const payload = { playerId: player.playerId, withdrawalId: withdrawal.id, reason: HOLD_REASON }; + const proposal = execution(); + + const first = await kernel.executeAction('hold_withdrawal', payload, proposal); + const replay = await kernel.executeAction('hold_withdrawal', payload, proposal); + + expect(first).toEqual({ ok: true, outcome: 'applied' }); + expect(replay).toEqual({ ok: true, outcome: 'already_applied' }); + expect(await statusOf(withdrawal.id)).toBe('on_hold'); + expect(domainAudit.recordInTransaction).toHaveBeenCalledTimes(1); + expect(domainAudit.recordInTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + actorId: adminId, + action: 'wallet.withdrawal.held', + resourceId: withdrawal.id, + after: { status: 'on_hold', reason: HOLD_REASON, proposalId: proposal.proposalId }, + }), + ); + }); + + it("answers withdrawal_not_found for another player's withdrawal and leaves it pending", async () => { + const { kernel } = bootKernel(); + const player = await seedPlayer(); + const stranger = await seedPlayer(); + const strangers = await pendingWithdrawal(stranger.walletId); + + const result = await kernel.executeAction( + 'hold_withdrawal', + { playerId: player.playerId, withdrawalId: strangers.id, reason: HOLD_REASON }, + execution(), + ); + + expect(result).toEqual({ ok: false, error: 'withdrawal_not_found' }); + expect(await statusOf(strangers.id)).toBe('pending'); + }); + + it('answers withdrawal_not_pending once the withdrawal was decided', async () => { + const { kernel } = bootKernel(); + const player = await seedPlayer(); + const completed = await seedTx(player.walletId, { type: 'withdrawal', amount: '40' }); + + const result = await kernel.executeAction( + 'hold_withdrawal', + { playerId: player.playerId, withdrawalId: completed.id, reason: HOLD_REASON }, + execution(), + ); + + expect(result).toEqual({ ok: false, error: 'withdrawal_not_pending' }); + expect(await statusOf(completed.id)).toBe('completed'); + }); + + it('fails closed when the bound identity reader cannot resolve a player id', async () => { + const { kernel } = bootKernel(makeIdentityReader()); + const player = await seedPlayer(); + const withdrawal = await pendingWithdrawal(player.walletId); + + const result = await kernel.executeAction( + 'hold_withdrawal', + { playerId: player.playerId, withdrawalId: withdrawal.id, reason: HOLD_REASON }, + execution(), + ); + + expect(result).toEqual({ ok: false, error: 'internal_error' }); + expect(await statusOf(withdrawal.id)).toBe('pending'); + }); + + it('refuses a blank reason before anything runs', async () => { + const { kernel } = bootKernel(); + const player = await seedPlayer(); + const withdrawal = await pendingWithdrawal(player.walletId); + + const result = await kernel.executeAction( + 'hold_withdrawal', + { playerId: player.playerId, withdrawalId: withdrawal.id, reason: ' ' }, + execution(), + ); + + expect(result).toMatchObject({ ok: false, error: 'invalid_input' }); + expect(await statusOf(withdrawal.id)).toBe('pending'); + }); +}); diff --git a/packages/core/src/wallet/__tests__/wallet-withdrawal-hold.int.test.ts b/packages/core/src/wallet/__tests__/wallet-withdrawal-hold.int.test.ts new file mode 100644 index 000000000..724d387a5 --- /dev/null +++ b/packages/core/src/wallet/__tests__/wallet-withdrawal-hold.int.test.ts @@ -0,0 +1,488 @@ +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { and, eq, sql } from 'drizzle-orm'; +import { findOneOrThrow } from '@openora/core/server'; +import { + type AdminPlayerSummary, + type AdminUserDirectory, + type PaymentAdapter, + type PlatformConfig, +} from '@openora/core/contracts'; +import { createTestDb, type TestDb } from '@openora/core/testing'; +import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; +import { + mock, + makeEventBus, + makeIdentityReader, + makeAuditWriter, + makePaymentProviderRegistry, + NO_CLIENT_META, +} from '../../testing/mock.js'; +import { migrate } from '../migrate.js'; +import { + wallet, + walletAsset, + walletAutoWithdrawalConfig, + walletBalance, + walletTransaction, +} from '../schema/index.js'; +import { + WalletService, + WalletAssetHasInFlightTransactionsError, + WithdrawalNotFoundError, + WithdrawalNotPendingError, + type WalletServiceDeps, +} from '../service/wallet.service.js'; + +let db: TestDb; + +const HOLD_REASON = 'velocity spike under review'; +const AUTO_APPROVAL_THRESHOLD = '1000'; + +function makePsp() { + return { + processDeposit: vi.fn(), + processWithdrawal: vi.fn(async () => ({ + externalId: randomUUID(), + status: 'completed' as const, + })), + }; +} + +function makeService(overrides: Partial = {}) { + const events = makeEventBus(); + const psp = makePsp(); + const audit = makeAuditWriter(); + const svc = new WalletService({ + drizzle: db.drizzle, + events, + payment: mock(psp), + paymentProviders: makePaymentProviderRegistry(), + audit, + identityReader: makeIdentityReader(), + ...overrides, + }); + return { svc, events, psp, audit }; +} + +async function seedWallet(balance = '0') { + const row = findOneOrThrow( + await db.drizzle.db + .insert(wallet) + .values({ userId: randomUUID(), currency: 'USD' }) + .returning(), + new Error('seedWallet: query returned no row'), + ); + await db.drizzle.db + .insert(walletBalance) + .values({ walletId: row.id, currency: row.currency, amount: balance }); + return row; +} + +async function seedWithdrawal( + walletId: string, + overrides: Partial = {}, +) { + return findOneOrThrow( + await db.drizzle.db + .insert(walletTransaction) + .values({ + walletId, + type: 'withdrawal', + amount: '40', + currency: 'USD', + status: 'pending', + rail: 'fiat', + direction: 'debit', + ...overrides, + }) + .returning(), + new Error('seedWithdrawal: query returned no row'), + ); +} + +async function txById(id: string) { + return findOneOrThrow( + await db.drizzle.db.select().from(walletTransaction).where(eq(walletTransaction.id, id)), + new Error('txById: query returned no row'), + ); +} + +async function balanceOf(walletId: string) { + const [row] = await db.drizzle.db + .select({ amount: walletBalance.amount }) + .from(walletBalance) + .where(and(eq(walletBalance.walletId, walletId), eq(walletBalance.currency, 'USD'))); + return Number(row?.amount ?? 0); +} + +function holdInput(userId: string, withdrawalId: string, adminId = randomUUID()) { + return { adminId, userId, withdrawalId, reason: HOLD_REASON, proposalId: randomUUID() }; +} + +const approvedPlayer = (userId: string) => + mock({ userId, username: 'player', kycStatus: 'approved' }); + +async function seedAutoApprovalConfig() { + await db.drizzle.db.insert(walletAutoWithdrawalConfig).values({ + singletonKey: 'global', + fiatThreshold: AUTO_APPROVAL_THRESHOLD, + cryptoThreshold: '0', + excludeRiskFlags: [], + }); +} + +function autoApprovalConfig( + autoWithdrawal: Partial> = {}, +) { + return mock({ autoWithdrawal: { enabled: true, ...autoWithdrawal } }); +} + +beforeAll(async () => { + db = await createTestDb([migrate, migrateProfile]); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + await db.drizzle.db.execute( + sql`TRUNCATE ${walletTransaction}, ${walletAsset}, ${walletAutoWithdrawalConfig}, ${wallet} RESTART IDENTITY CASCADE`, + ); +}); + +describe('WalletService.holdWithdrawal (real PG)', () => { + it('moves a pending withdrawal to on_hold and records the reviewer, without moving money', async () => { + const { svc, events } = makeService(); + const w = await seedWallet('60'); + const pending = await seedWithdrawal(w.id); + const input = holdInput(w.userId, pending.id); + + const result = await svc.holdWithdrawal(input); + + expect(result).toEqual({ changed: true }); + expect(await txById(pending.id)).toMatchObject({ + status: 'on_hold', + reviewedBy: input.adminId, + reviewReason: HOLD_REASON, + }); + expect((await txById(pending.id)).reviewedAt).toBeInstanceOf(Date); + expect(await balanceOf(w.id)).toBe(60); + expect(events.emit).not.toHaveBeenCalled(); + }); + + it('writes the held audit record before the status change commits', async () => { + const { svc, audit } = makeService(); + const w = await seedWallet(); + const pending = await seedWithdrawal(w.id); + const input = holdInput(w.userId, pending.id); + const statusSeenByAnotherConnection: string[] = []; + audit.recordInTransaction.mockImplementation(async () => { + statusSeenByAnotherConnection.push((await txById(pending.id)).status); + }); + + await svc.holdWithdrawal(input); + + expect(statusSeenByAnotherConnection).toEqual(['pending']); + expect(audit.recordInTransaction).toHaveBeenCalledTimes(1); + expect(audit.recordInTransaction).toHaveBeenCalledWith(expect.anything(), { + actorId: input.adminId, + actorType: 'admin', + action: 'wallet.withdrawal.held', + resourceType: 'withdrawal', + resourceId: pending.id, + before: { status: 'pending' }, + after: { status: 'on_hold', reason: HOLD_REASON, proposalId: input.proposalId }, + }); + expect(audit.record).not.toHaveBeenCalled(); + }); + + it('rolls the hold back when its audit record cannot be written', async () => { + const { svc, audit } = makeService(); + const w = await seedWallet(); + const pending = await seedWithdrawal(w.id); + audit.recordInTransaction.mockRejectedValueOnce(new Error('audit sink down')); + + await expect(svc.holdWithdrawal(holdInput(w.userId, pending.id))).rejects.toThrow( + 'audit sink down', + ); + + expect(await txById(pending.id)).toMatchObject({ + status: 'pending', + reviewedBy: null, + reviewedAt: null, + reviewReason: null, + }); + }); + + it('reports a repeated hold as unchanged, with one status change and one audit record', async () => { + const { svc, audit } = makeService(); + const w = await seedWallet(); + const pending = await seedWithdrawal(w.id); + const first = holdInput(w.userId, pending.id); + await svc.holdWithdrawal(first); + const heldAt = (await txById(pending.id)).reviewedAt; + + const replay = await svc.holdWithdrawal({ ...first, reason: 'a different reason' }); + + expect(replay).toEqual({ changed: false }); + expect(audit.recordInTransaction).toHaveBeenCalledTimes(1); + expect(await txById(pending.id)).toMatchObject({ + status: 'on_hold', + reviewedBy: first.adminId, + reviewedAt: heldAt, + reviewReason: HOLD_REASON, + }); + }); + + it('holds a withdrawal once when two holds race', async () => { + const { svc, audit } = makeService(); + const w = await seedWallet(); + const pending = await seedWithdrawal(w.id); + + const results = await Promise.all([ + svc.holdWithdrawal(holdInput(w.userId, pending.id)), + svc.holdWithdrawal(holdInput(w.userId, pending.id)), + ]); + + expect(results.map((r) => r.changed).sort()).toEqual([false, true]); + expect(audit.recordInTransaction).toHaveBeenCalledTimes(1); + }); + + it.each(['processing', 'completed', 'rejected', 'failed'] as const)( + 'refuses to hold a %s withdrawal and leaves it untouched', + async (status) => { + const { svc, audit } = makeService(); + const w = await seedWallet(); + const decided = await seedWithdrawal(w.id, { status }); + + await expect(svc.holdWithdrawal(holdInput(w.userId, decided.id))).rejects.toBeInstanceOf( + WithdrawalNotPendingError, + ); + + expect((await txById(decided.id)).status).toBe(status); + expect(audit.recordInTransaction).not.toHaveBeenCalled(); + }, + ); + + it("does not find another player's withdrawal", async () => { + const { svc } = makeService(); + const owner = await seedWallet(); + const stranger = await seedWallet(); + const pending = await seedWithdrawal(owner.id); + + await expect(svc.holdWithdrawal(holdInput(stranger.userId, pending.id))).rejects.toBeInstanceOf( + WithdrawalNotFoundError, + ); + + expect((await txById(pending.id)).status).toBe('pending'); + }); + + it('does not find a transaction that is not a withdrawal', async () => { + const { svc } = makeService(); + const w = await seedWallet(); + const deposit = await seedWithdrawal(w.id, { type: 'deposit', direction: 'credit' }); + + await expect(svc.holdWithdrawal(holdInput(w.userId, deposit.id))).rejects.toBeInstanceOf( + WithdrawalNotFoundError, + ); + }); + + it('reports the status of a withdrawal to its owner only', async () => { + const { svc } = makeService(); + const owner = await seedWallet(); + const stranger = await seedWallet(); + const pending = await seedWithdrawal(owner.id); + + expect( + await svc.getPlayerWithdrawalStatus({ userId: owner.userId, withdrawalId: pending.id }), + ).toBe('pending'); + expect( + await svc.getPlayerWithdrawalStatus({ userId: stranger.userId, withdrawalId: pending.id }), + ).toBeNull(); + }); +}); + +describe('WalletService review of a held withdrawal (real PG)', () => { + it('approves an on_hold withdrawal and pays it out once', async () => { + const { svc, psp, events } = makeService(); + const w = await seedWallet('20'); + const pending = await seedWithdrawal(w.id); + await svc.holdWithdrawal(holdInput(w.userId, pending.id)); + const approver = randomUUID(); + + const result = await svc.approveWithdrawal(approver, pending.id, NO_CLIENT_META); + + expect(result).toEqual({ transactionId: pending.id, status: 'completed' }); + expect(psp.processWithdrawal).toHaveBeenCalledTimes(1); + expect(await txById(pending.id)).toMatchObject({ status: 'completed', reviewedBy: approver }); + expect(await balanceOf(w.id)).toBe(20); + expect(events.emit.mock.calls.map(([topic]) => topic)).toEqual([ + 'wallet.withdrawal.approved', + 'wallet.withdrawal.completed', + ]); + }); + + it('rejects an on_hold withdrawal and returns the held funds', async () => { + const { svc, events } = makeService(); + const w = await seedWallet('20'); + const pending = await seedWithdrawal(w.id); + await svc.holdWithdrawal(holdInput(w.userId, pending.id)); + const reviewer = randomUUID(); + + const result = await svc.rejectWithdrawal(reviewer, pending.id, 'source of funds unclear'); + + expect(result).toEqual({ transactionId: pending.id, status: 'rejected' }); + expect(await balanceOf(w.id)).toBe(60); + expect(await txById(pending.id)).toMatchObject({ + status: 'rejected', + reviewedBy: reviewer, + reviewReason: 'source of funds unclear', + }); + expect(events.emit).toHaveBeenCalledWith( + 'wallet.withdrawal.rejected', + expect.objectContaining({ transactionId: pending.id, adminId: reviewer }), + ); + }); + + it('credits a held withdrawal back exactly once when two rejects race', async () => { + const { svc } = makeService(); + const w = await seedWallet('20'); + const pending = await seedWithdrawal(w.id); + await svc.holdWithdrawal(holdInput(w.userId, pending.id)); + + const results = await Promise.allSettled([ + svc.rejectWithdrawal(randomUUID(), pending.id, 'first'), + svc.rejectWithdrawal(randomUUID(), pending.id, 'second'), + ]); + + const refusals = results.flatMap((r) => (r.status === 'rejected' ? [r.reason] : [])); + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + expect(refusals).toHaveLength(1); + expect(refusals[0]).toBeInstanceOf(WithdrawalNotPendingError); + expect(await balanceOf(w.id)).toBe(60); + }); + + it('refuses a second review once the held withdrawal is decided', async () => { + const { svc } = makeService(); + const w = await seedWallet('20'); + const pending = await seedWithdrawal(w.id); + await svc.holdWithdrawal(holdInput(w.userId, pending.id)); + await svc.rejectWithdrawal(randomUUID(), pending.id, 'rejected'); + + await expect(svc.approveWithdrawal(randomUUID(), pending.id)).rejects.toBeInstanceOf( + WithdrawalNotPendingError, + ); + expect(await balanceOf(w.id)).toBe(60); + }); +}); + +describe('WalletService auto-approval against held withdrawals (real PG)', () => { + function makeAutoService(onKycLookup: () => Promise = async () => undefined) { + const directory = mock({ + lookupPlayers: vi.fn(async (ids: string[]) => { + await onKycLookup(); + return ids.map(approvedPlayer); + }), + }); + return makeService({ directory, platformConfig: autoApprovalConfig() }); + } + + it('auto-approves a withdrawal nobody held', async () => { + await seedAutoApprovalConfig(); + const { svc, psp } = makeAutoService(); + const w = await seedWallet('100'); + + const result = await svc.withdraw({ + userId: w.userId, + amount: '40', + currency: 'USD', + ...NO_CLIENT_META, + }); + + expect(result.status).toBe('completed'); + expect(psp.processWithdrawal).toHaveBeenCalledTimes(1); + }); + + it('never pays out a withdrawal held while its auto-approval was being evaluated', async () => { + await seedAutoApprovalConfig(); + const w = await seedWallet('100'); + const holder = randomUUID(); + const service: { svc?: WalletService } = {}; + const { svc, psp } = makeAutoService(async () => { + const [queued] = await db.drizzle.db + .select({ id: walletTransaction.id }) + .from(walletTransaction) + .where(eq(walletTransaction.walletId, w.id)); + if (queued && service.svc) { + await service.svc.holdWithdrawal(holdInput(w.userId, queued.id, holder)); + } + }); + service.svc = svc; + + const result = await svc.withdraw({ + userId: w.userId, + amount: '40', + currency: 'USD', + ...NO_CLIENT_META, + }); + + expect(psp.processWithdrawal).not.toHaveBeenCalled(); + expect(await txById(result.transactionId)).toMatchObject({ + status: 'on_hold', + reviewedBy: holder, + reviewReason: HOLD_REASON, + autoApprovalPivotAmount: null, + }); + expect(await balanceOf(w.id)).toBe(60); + }); + + it("does not count a reviewer's hold that reads 'auto-approved' towards the daily cap", async () => { + await seedAutoApprovalConfig(); + const directory = mock({ + lookupPlayers: vi.fn(async (ids: string[]) => ids.map(approvedPlayer)), + }); + const { svc } = makeService({ + directory, + platformConfig: autoApprovalConfig({ dailyCapCount: 1 }), + }); + const w = await seedWallet('100'); + const held = await seedWithdrawal(w.id, { amount: '10' }); + await svc.holdWithdrawal({ ...holdInput(w.userId, held.id), reason: 'auto-approved' }); + + const result = await svc.withdraw({ + userId: w.userId, + amount: '40', + currency: 'USD', + ...NO_CLIENT_META, + }); + + expect(result.status).toBe('completed'); + }); +}); + +describe('WalletService.deleteWalletAsset with a held withdrawal (real PG)', () => { + it('refuses to delete a pair while a withdrawal on it is on hold', async () => { + const { svc } = makeService(); + await db.drizzle.db.insert(walletAsset).values({ + currency: 'USDT', + network: 'TRC20', + providerAssetId: 'usdt-trc20', + minDeposit: '1', + minWithdrawal: '1', + withdrawalFee: '0', + }); + const w = await seedWallet(); + await seedWithdrawal(w.id, { + currency: 'USDT', + network: 'TRC20', + rail: 'crypto', + status: 'on_hold', + }); + + await expect( + svc.deleteWalletAsset(randomUUID(), 'USDT', 'TRC20', NO_CLIENT_META), + ).rejects.toBeInstanceOf(WalletAssetHasInFlightTransactionsError); + }); +}); diff --git a/packages/core/src/wallet/contract/agent-tools.ts b/packages/core/src/wallet/contract/agent-tools.ts new file mode 100644 index 000000000..bc94acf39 --- /dev/null +++ b/packages/core/src/wallet/contract/agent-tools.ts @@ -0,0 +1,89 @@ +import * as z from 'zod'; +import { + CurrencyTickerSchema, + MoneyAmountSchema, + NonEmptyReasonSchema, + TimestampSchema, + UuidSchema, + WalletTransactionStatusSchema, + defineActionType, + defineMcpTool, +} from '@openora/core/contracts'; + +export const OPEN_WITHDRAWALS_LIMIT = 20; + +export const WalletActivityInputSchema = z.object({ + playerId: UuidSchema, + windowDays: z.coerce.number().int().min(1).max(365).default(30), +}); +export type WalletActivityInput = z.infer; + +export const WalletActivityOutputSchema = z.object({ + playerId: UuidSchema, + windowDays: z.number().int(), + activeCurrency: CurrencyTickerSchema, + balances: z.array(z.object({ currency: CurrencyTickerSchema, balance: MoneyAmountSchema })), + totals: z.array( + z.object({ + currency: CurrencyTickerSchema, + deposits: MoneyAmountSchema, + depositCount: z.number().int().nonnegative(), + withdrawals: MoneyAmountSchema, + withdrawalCount: z.number().int().nonnegative(), + bets: MoneyAmountSchema, + wins: MoneyAmountSchema, + }), + ), + openWithdrawals: z + .array( + z.object({ + withdrawalId: UuidSchema, + amount: MoneyAmountSchema, + currency: CurrencyTickerSchema, + status: WalletTransactionStatusSchema, + requestedAt: TimestampSchema, + }), + ) + .max(OPEN_WITHDRAWALS_LIMIT), +}); +export type WalletActivity = z.infer; + +export const walletActivityTool = defineMcpTool({ + id: 'wallet.activity', + title: 'Wallet activity', + description: + "A player's balances, their completed deposits, withdrawals, bets and wins per currency " + + 'over the last windowDays days, and the withdrawals still awaiting a decision (pending or ' + + 'on hold, newest first). Use it to judge recent money movement, and to find the ' + + 'withdrawalId a hold_withdrawal proposal needs. Amounts are decimal strings.', + class: 'read', + schemaVersion: 1, + iam: { resource: 'transaction', action: 'view' }, + inputSchema: WalletActivityInputSchema, + outputSchema: WalletActivityOutputSchema, + redact: { + allow: ['playerId', 'windowDays', 'activeCurrency', 'balances', 'totals', 'openWithdrawals'], + }, + errors: ['player_not_found'], +}); + +export const HoldWithdrawalPayloadSchema = z.object({ + playerId: UuidSchema, + withdrawalId: UuidSchema, + reason: NonEmptyReasonSchema.max(500), +}); +export type HoldWithdrawalPayload = z.infer; + +export const holdWithdrawalAction = defineActionType({ + id: 'hold_withdrawal', + title: 'Hold a withdrawal', + description: + "Holds a player's pending withdrawal for manual review, so it is not paid out " + + 'automatically. The funds stay held on the withdrawal; an admin later approves or rejects ' + + 'it. Only a pending withdrawal of the named player can be held.', + schemaVersion: 1, + iam: { resource: 'withdrawal', action: 'hold' }, + reversible: true, + payloadSchema: HoldWithdrawalPayloadSchema, + errors: ['player_not_found', 'withdrawal_not_found', 'withdrawal_not_pending'], +}); diff --git a/packages/core/src/wallet/contract/index.ts b/packages/core/src/wallet/contract/index.ts index 8a8e24e71..4079857b4 100644 --- a/packages/core/src/wallet/contract/index.ts +++ b/packages/core/src/wallet/contract/index.ts @@ -782,3 +782,5 @@ export const walletContract = { .output(JobRunResultSchema), }, }; + +export * from './agent-tools.js'; diff --git a/packages/core/src/wallet/plugin.ts b/packages/core/src/wallet/plugin.ts index f054000c6..942c4fbe4 100644 --- a/packages/core/src/wallet/plugin.ts +++ b/packages/core/src/wallet/plugin.ts @@ -27,13 +27,24 @@ import { SWAP_WEBHOOK_VERIFIER, AUDIT_WRITER, JOB_QUEUE, + McpToolError, UuidSchema, domainEventSchemas, queue, + runActorAdminId, + userIdOfPlayer, type RealtimeTransport, } from '@openora/core/contracts'; -import type { WalletBalanceChangeReason } from './contract/index.js'; -import { WalletService } from './service/wallet.service.js'; +import { + holdWithdrawalAction, + walletActivityTool, + type WalletBalanceChangeReason, +} from './contract/index.js'; +import { + WalletService, + WithdrawalNotFoundError, + WithdrawalNotPendingError, +} from './service/wallet.service.js'; import { WalletCommandsService } from './service/wallet-commands.service.js'; import { CustodySweepService, @@ -59,6 +70,16 @@ const DEFAULT_SWEEP_CRON = '*/15 * * * *'; const WALLET_RECONCILIATION_QUEUE = queue('wallet-reconciliation'); const WalletReconciliationJobSchema = z.object({ runId: UuidSchema.optional() }); +function holdRefusalCode(err: unknown) { + if (err instanceof WithdrawalNotFoundError) { + return 'withdrawal_not_found'; + } + if (err instanceof WithdrawalNotPendingError) { + return 'withdrawal_not_pending'; + } + throw err; +} + 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 @@ -185,6 +206,83 @@ export default { platformConfig: c.has(PLATFORM_CONFIG) ? c.get(PLATFORM_CONFIG) : undefined, })); + // One instance backs the router and the agent tools; either factory may build it first. + let walletSvc: WalletService | null = null; + const walletService = (c: TypedContainer) => + (walletSvc ??= new WalletService({ + drizzle: c.get(DRIZZLE), + events: c.get(EVENT_BUS), + payment: c.get(PAYMENT_ADAPTER), + paymentProviders: c.get(PAYMENT_PROVIDERS), + identityReader: c.get(IDENTITY_READER), + directory: c.get(ADMIN_USER_DIRECTORY), + platformConfig: c.has(PLATFORM_CONFIG) ? c.get(PLATFORM_CONFIG) : undefined, + limiter: c.get(RATE_LIMITER), + riskTags: c.has(PLAYER_TAGS) ? c.get(PLAYER_TAGS) : undefined, + tagEvaluationCommands: c.has(TAG_EVALUATION_COMMANDS) + ? c.get(TAG_EVALUATION_COMMANDS) + : undefined, + audit: c.get(AUDIT_WRITER), + rgLimits: c.has(RG_LIMITS) ? c.get(RG_LIMITS) : undefined, + rates: c.has(EXCHANGE_RATE_READER) ? c.get(EXCHANGE_RATE_READER) : undefined, + })); + + ctx.mcp.tool(walletActivityTool, (c) => { + const identity = c.get(IDENTITY_READER); + if (!identity.getUserIdByPlayerId) { + logger.warn( + 'the bound IDENTITY_READER has no getUserIdByPlayerId - wallet.activity and hold_withdrawal will answer internal_error', + ); + } + const wallet = walletService(c); + return async ({ playerId, windowDays }) => { + const userId = await userIdOfPlayer(identity, playerId); + if (!userId) { + throw new McpToolError('player_not_found'); + } + return { playerId, ...(await wallet.getActivity({ userId, windowDays })) }; + }; + }); + + ctx.actions.register(holdWithdrawalAction, (c) => { + const identity = c.get(IDENTITY_READER); + const wallet = walletService(c); + return { + precondition: async ({ playerId, withdrawalId }) => { + const userId = await userIdOfPlayer(identity, playerId); + if (!userId) { + return { ok: false, code: 'player_not_found' }; + } + const status = await wallet.getPlayerWithdrawalStatus({ userId, withdrawalId }); + if (status === null) { + return { ok: false, code: 'withdrawal_not_found' }; + } + if (status !== 'pending') { + return { ok: false, code: 'withdrawal_not_pending' }; + } + return { ok: true }; + }, + execute: async ({ playerId, withdrawalId, reason }, proposalId, actor) => { + const userId = await userIdOfPlayer(identity, playerId); + if (!userId) { + throw new McpToolError('player_not_found'); + } + const { changed } = await wallet + .holdWithdrawal({ + adminId: runActorAdminId(actor), + userId, + withdrawalId, + reason, + proposalId, + }) + .catch((err: unknown) => { + throw new McpToolError(holdRefusalCode(err)); + }); + return { outcome: changed ? 'applied' : 'already_applied' }; + }, + }; + }); + ctx.jobs.worker({ queue: CUSTODY_SWEEP_QUEUE, schema: CustodySweepJobPayloadSchema, @@ -271,28 +369,12 @@ export default { 'wallet loaded without RG_LIMITS - deposit limits are not enforced on any route', ); } - const walletService = new WalletService({ - drizzle: c.get(DRIZZLE), - events: c.get(EVENT_BUS), - payment: c.get(PAYMENT_ADAPTER), - paymentProviders: c.get(PAYMENT_PROVIDERS), - identityReader: c.get(IDENTITY_READER), - directory: c.get(ADMIN_USER_DIRECTORY), - platformConfig, - limiter: c.get(RATE_LIMITER), - riskTags: c.has(PLAYER_TAGS) ? c.get(PLAYER_TAGS) : undefined, - tagEvaluationCommands: c.has(TAG_EVALUATION_COMMANDS) - ? c.get(TAG_EVALUATION_COMMANDS) - : undefined, - audit: c.get(AUDIT_WRITER), - rgLimits, - rates: c.has(EXCHANGE_RATE_READER) ? c.get(EXCHANGE_RATE_READER) : undefined, - }); + const wallet = walletService(c); const reconciliation = new ReconciliationService({ drizzle: c.get(DRIZZLE), events: c.get(EVENT_BUS), - wallet: walletService, + wallet, paymentProviders: c.get(PAYMENT_PROVIDERS), audit: c.get(AUDIT_WRITER), platformConfig, @@ -348,7 +430,7 @@ export default { : undefined; return createWalletRouter({ - wallet: walletService, + wallet, adminGuard: c.get(ADMIN_GUARD), audit: c.get(AUDIT_WRITER), paymentProviders: c.get(PAYMENT_PROVIDERS), diff --git a/packages/core/src/wallet/service/wallet.service.ts b/packages/core/src/wallet/service/wallet.service.ts index be1068ba3..8e8337836 100644 --- a/packages/core/src/wallet/service/wallet.service.ts +++ b/packages/core/src/wallet/service/wallet.service.ts @@ -46,6 +46,7 @@ import { type Uuid, type PaginationOptions, type WalletProviderRef, + type ActionExecutionContext, } from '@openora/core/contracts'; import { eq, asc, desc, sql, and, gte, lte, count, inArray, isNull, or } from 'drizzle-orm'; import { createHash } from 'node:crypto'; @@ -88,6 +89,7 @@ import type { WithdrawalAddress, CreateWithdrawalAddressInput, } from '../contract/index.js'; +import { OPEN_WITHDRAWALS_LIMIT } from '../contract/index.js'; const logger = createLogger('wallet'); @@ -162,7 +164,7 @@ export const WalletAssetUnknownProviderError = makeConflictError( export const WalletAssetHasInFlightTransactionsError = makeConflictError( 'WalletAssetHasInFlightTransactionsError', - 'A pending or processing transaction exists for this currency and network', + 'A pending, on-hold or processing transaction exists for this currency and network', { code: 'WALLET_ASSET_TRANSACTION_IN_FLIGHT' }, ); @@ -521,14 +523,40 @@ const LARGE_WITHDRAWAL_THRESHOLD = '5000'; const HIGH_FREQUENCY_WINDOW_MS = 24 * 60 * 60 * 1000; const HIGH_FREQUENCY_MIN_COUNT = 3; -// A withdrawal still waiting on a decision: the queue summary counts and totals only these. +// A withdrawal still waiting on a decision: the queue summary counts and totals only these, and +// an admin may approve or reject only these. const QUEUED_WITHDRAWAL_STATUSES = ['pending', 'on_hold'] as const; +const IN_FLIGHT_TRANSACTION_STATUSES = ['pending', 'on_hold', 'processing'] as const; + +const ACTIVITY_TRANSACTION_TYPES = ['deposit', 'withdrawal', 'bet', 'win'] as const; + +const DAY_MS = 24 * 60 * 60 * 1000; + const DAILY_CAP_WINDOW_MS = 24 * 60 * 60 * 1000; -// Also the marker the cumulative-cap query reads back to identify auto-approved payouts - keep write and read in lockstep. +// Also, with no reviewer, the marker the cumulative-cap query reads back to identify +// auto-approved payouts - keep write and read in lockstep. A reviewer's free-text reason can +// hold the same words, so the reviewer column is part of the marker. const AUTO_APPROVED_REASON = 'auto-approved'; +function assertWithdrawalIn( + row: WalletTransaction, + statuses: readonly WalletTransaction['status'][], +) { + if (row.type !== 'withdrawal' || !statuses.includes(row.status)) { + throw new WithdrawalNotPendingError(); + } +} + +function playerWithdrawal(userId: User['id'], withdrawalId: WalletTransaction['id']) { + return and( + eq(walletTransaction.id, withdrawalId), + eq(walletTransaction.type, 'withdrawal'), + eq(wallet.userId, userId), + ); +} + type AutoApprovalDecision = { threshold: string; thresholdSource: 'per-player' | 'global'; @@ -821,6 +849,69 @@ export class WalletService { ); } + /** A player without a wallet reads as empty rather than as an error. */ + async getActivity({ userId, windowDays }: { userId: User['id']; windowDays: number }) { + const since = new Date(Date.now() - windowDays * DAY_MS); + const [{ activeCurrency, balances }, totals, openWithdrawals] = await Promise.all([ + this.getBalances(userId), + this.completedTotalsSince(userId, since), + this.openWithdrawals(userId), + ]); + return { windowDays, activeCurrency, balances, totals, openWithdrawals }; + } + + private completedTotalsSince(userId: User['id'], since: Date) { + const sumOf = (type: (typeof ACTIVITY_TRANSACTION_TYPES)[number]) => + sql`coalesce(sum(${walletTransaction.amount}) filter (where ${walletTransaction.type} = ${type}), 0)`; + const countOf = (type: (typeof ACTIVITY_TRANSACTION_TYPES)[number]) => + sql`count(*) filter (where ${walletTransaction.type} = ${type})`.mapWith(Number); + return this.drizzle.db + .select({ + currency: walletTransaction.currency, + deposits: sumOf('deposit'), + depositCount: countOf('deposit'), + withdrawals: sumOf('withdrawal'), + withdrawalCount: countOf('withdrawal'), + bets: sumOf('bet'), + wins: sumOf('win'), + }) + .from(walletTransaction) + .innerJoin(wallet, eq(wallet.id, walletTransaction.walletId)) + .where( + and( + eq(wallet.userId, userId), + eq(walletTransaction.status, 'completed'), + inArray(walletTransaction.type, [...ACTIVITY_TRANSACTION_TYPES]), + gte(walletTransaction.createdAt, since), + ), + ) + .groupBy(walletTransaction.currency) + .orderBy(walletTransaction.currency); + } + + private async openWithdrawals(userId: User['id']) { + const rows = await this.drizzle.db + .select({ + withdrawalId: walletTransaction.id, + amount: walletTransaction.amount, + currency: walletTransaction.currency, + status: walletTransaction.status, + createdAt: walletTransaction.createdAt, + }) + .from(walletTransaction) + .innerJoin(wallet, eq(wallet.id, walletTransaction.walletId)) + .where( + and( + eq(wallet.userId, userId), + eq(walletTransaction.type, 'withdrawal'), + inArray(walletTransaction.status, [...QUEUED_WITHDRAWAL_STATUSES]), + ), + ) + .orderBy(desc(walletTransaction.createdAt), desc(walletTransaction.id)) + .limit(OPEN_WITHDRAWALS_LIMIT); + return rows.map(({ createdAt, ...row }) => ({ ...row, requestedAt: createdAt.toISOString() })); + } + // TODO: validate `currency` against a canonical supported-currency list once one // exists - `platformConfig.wallet.cryptoCurrencies` classifies rails, it is not an // allowlist, so today any non-empty string is accepted and persisted. @@ -1659,23 +1750,27 @@ export class WalletService { // Two-phase: commit the `processing` flip first (FOR UPDATE lock), then call the PSP OUTSIDE // the tx (a failure refunds in a second tx). Never inline the PSP call inside the hold transaction. const tx = await this.drizzle.db.transaction((txn) => - this.flipToProcessing({ txn, withdrawalId, adminId }), + this.flipToProcessing({ txn, withdrawalId, adminId, from: QUEUED_WITHDRAWAL_STATUSES }), ); return this.settleApproved(tx, adminId, meta); } - // Phase one: the pending -> processing flip under a FOR UPDATE lock. Extracted so the auto path can run it + // Phase one: the flip to processing under a FOR UPDATE lock. Extracted so the auto path can run it // inside its advisory-locked cap-check transaction, committing the marker atomically. Never call the PSP here. + // `from` has no default: the auto path must never inherit the admin path's on_hold and pay out a + // withdrawal someone held for review. private async flipToProcessing({ txn, withdrawalId, adminId, + from, reviewReason, autoApprovalPivotAmount, }: { txn: DrizzleTx; withdrawalId: WalletTransaction['id']; adminId: User['id'] | null; + from: readonly WalletTransaction['status'][]; reviewReason?: string; autoApprovalPivotAmount?: string; }): Promise { @@ -1687,10 +1782,8 @@ export class WalletService { .for('update'), new WithdrawalNotFoundError(withdrawalId), ); - // Only a pending withdrawal can be approved, so a concurrent or repeated approve can't double-send to the PSP. - if (current.status !== 'pending' || current.type !== 'withdrawal') { - throw new WithdrawalNotPendingError(); - } + // Only a withdrawal still awaiting a decision can be approved, so a concurrent or repeated approve can't double-send to the PSP. + assertWithdrawalIn(current, from); return findOneOrThrow( await txn .update(walletTransaction) @@ -1968,6 +2061,7 @@ export class WalletService { txn, withdrawalId: args.transactionId, adminId: null, + from: ['pending'], reviewReason: AUTO_APPROVED_REASON, autoApprovalPivotAmount: gates.pivotAmount, }); @@ -2361,7 +2455,7 @@ export class WalletService { } // Renaming a pair is a delete plus a create (the (currency, network) key AND // providerName are immutable), so this delete is the only way providerName ever - // effectively changes. Block it while a pending/processing transaction exists for + // effectively changes. Block it while a pending/on-hold/processing transaction exists for // this exact (currency, network) pair - otherwise the vendor reference an in-flight // payout is settling through gets rewritten out from under it. const [inFlight] = await txn @@ -2371,7 +2465,7 @@ export class WalletService { and( eq(walletTransaction.currency, currency), eq(walletTransaction.network, network), - inArray(walletTransaction.status, ['pending', 'processing']), + inArray(walletTransaction.status, [...IN_FLIGHT_TRANSACTION_STATUSES]), ), ); if ((inFlight?.n ?? 0) > 0) { @@ -2493,6 +2587,7 @@ export class WalletService { eq(walletTransaction.walletId, walletId), eq(walletTransaction.type, 'withdrawal'), eq(walletTransaction.reviewReason, AUTO_APPROVED_REASON), + isNull(walletTransaction.reviewedBy), gte(walletTransaction.createdAt, since), ), ); @@ -2576,9 +2671,7 @@ export class WalletService { .for('update'), new WithdrawalNotFoundError(withdrawalId), ); - if (current.status !== 'pending' || current.type !== 'withdrawal') { - throw new WithdrawalNotPendingError(); - } + assertWithdrawalIn(current, QUEUED_WITHDRAWAL_STATUSES); const updated = findOneOrThrow( await txn .update(walletTransaction) @@ -2608,6 +2701,79 @@ export class WalletService { return { transactionId: tx.id, status: 'rejected' }; } + /** The status of the player's own withdrawal, or null when no withdrawal of theirs has that id. */ + async getPlayerWithdrawalStatus({ + userId, + withdrawalId, + }: { + userId: User['id']; + withdrawalId: WalletTransaction['id']; + }) { + const [row] = await this.drizzle.db + .select({ status: walletTransaction.status }) + .from(walletTransaction) + .innerJoin(wallet, eq(wallet.id, walletTransaction.walletId)) + .where(playerWithdrawal(userId, withdrawalId)); + return row?.status ?? null; + } + + /** + * Holds the player's pending withdrawal for manual review, on an agent proposal an admin + * approved. No money moves: the funds were held when the withdrawal was requested, and an admin + * later approves or rejects it. A withdrawal already on hold is left as it is and reported as + * `changed: false`, so a replayed proposal holds it once. + */ + async holdWithdrawal({ + adminId, + userId, + withdrawalId, + reason, + proposalId, + }: { + adminId: User['id']; + userId: User['id']; + withdrawalId: WalletTransaction['id']; + reason: string; + proposalId: ActionExecutionContext['proposalId']; + }) { + return this.drizzle.db.transaction(async (txn) => { + const current = findOneOrThrow( + await txn + .select({ status: walletTransaction.status }) + .from(walletTransaction) + .innerJoin(wallet, eq(wallet.id, walletTransaction.walletId)) + .where(playerWithdrawal(userId, withdrawalId)) + .for('update', { of: walletTransaction }), + new WithdrawalNotFoundError(withdrawalId), + ); + if (current.status === 'on_hold') { + return { changed: false }; + } + if (current.status !== 'pending') { + throw new WithdrawalNotPendingError(); + } + await txn + .update(walletTransaction) + .set({ + status: 'on_hold', + reviewedBy: adminId, + reviewedAt: new Date(), + reviewReason: reason, + }) + .where(eq(walletTransaction.id, withdrawalId)); + await this.audit.recordInTransaction(txn, { + actorId: adminId, + actorType: 'admin', + action: 'wallet.withdrawal.held', + resourceType: 'withdrawal', + resourceId: withdrawalId, + before: { status: 'pending' }, + after: { status: 'on_hold', reason, proposalId }, + }); + return { changed: true }; + }); + } + async getTransactions({ userId, page, diff --git a/packages/mcp/README.md b/packages/mcp/README.md index c35a06257..16b2161ef 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -12,16 +12,17 @@ shipped in the published package, or a catalog you point it at. ## Tools -| Tool | Args | Purpose | -| ------------------- | --------- | ------------------------------------------------------- | -| `catalog-overview` | - | Start here: counts + adapter table + config fields. | -| `list-adapters` | - | Vendor swap-seams: interface, token, wired-vs-stub. | -| `list-routes` | `module?` | oRPC route namespaces, optionally scoped to one module. | -| `list-events` | - | Cross-module domain events to subscribe to. | -| `list-slots` | - | Named UI slots for extending the backoffice. | -| `describe-module` | `name` | One module's tables + routes. | -| `schema-get` | `name` | Where a Zod contract schema is defined. | -| `get-config-schema` | - | iGaming-config token, source, and fields. | +| Tool | Args | Purpose | +| ------------------- | --------- | ---------------------------------------------------------- | +| `catalog-overview` | - | Start here: counts + adapter table + config fields. | +| `list-adapters` | - | Vendor swap-seams: interface, token, wired-vs-stub. | +| `list-routes` | `module?` | oRPC route namespaces, optionally scoped to one module. | +| `list-events` | - | Cross-module domain events to subscribe to. | +| `list-agent-tools` | `module?` | Agent tools and action types, with IAM and schema version. | +| `list-slots` | - | Named UI slots for extending the backoffice. | +| `describe-module` | `name` | One module's tables, routes and agent surface. | +| `schema-get` | `name` | Where a Zod contract schema is defined. | +| `get-config-schema` | - | iGaming-config token, source, and fields. | All tools are read-only. If the catalog cannot be located, every tool returns a helpful message instead of crashing. diff --git a/packages/mcp/src/main.ts b/packages/mcp/src/main.ts index 2f65e2cbf..2e3bf46a1 100644 --- a/packages/mcp/src/main.ts +++ b/packages/mcp/src/main.ts @@ -39,6 +39,30 @@ type CatalogConfig = { fields: CatalogConfigField[]; }; +type CatalogAgentIam = { resource: string; action: string }; + +type CatalogAgentTool = { + id: string; + module: string; + file: string; + title: string | null; + description: string | null; + class: string | null; + schemaVersion: number | null; + iam: CatalogAgentIam | null; +}; + +type CatalogAgentAction = { + id: string; + module: string; + file: string; + title: string | null; + description: string | null; + schemaVersion: number | null; + iam: CatalogAgentIam | null; + reversible: boolean | null; +}; + type Catalog = { modules: CatalogModule[]; adapters: CatalogAdapter[]; @@ -46,8 +70,34 @@ type Catalog = { schemas: CatalogSchema[]; config: CatalogConfig; pluginContract: string[]; + // Absent in a catalog generated before the agent surface existed. + agentTools?: CatalogAgentTool[]; + agentActions?: CatalogAgentAction[]; }; +const formatIam = (iam: CatalogAgentIam | null) => + iam ? `${iam.resource}:${iam.action}` : '(non-literal)'; + +function formatAgentTool(t: CatalogAgentTool): string[] { + return [ + `- ${t.id} [${t.class ?? '?'}, v${t.schemaVersion ?? '?'}, iam ${formatIam(t.iam)}, module ${t.module}]`, + ...(t.title || t.description + ? [` ${[t.title, t.description].filter(Boolean).join(' - ')}`] + : []), + ]; +} + +function formatAgentAction(a: CatalogAgentAction): string[] { + const reversibility = + a.reversible === null ? 'reversible ?' : a.reversible ? 'reversible' : 'irreversible'; + return [ + `- ${a.id} [${reversibility}, v${a.schemaVersion ?? '?'}, iam ${formatIam(a.iam)}, module ${a.module}]`, + ...(a.title || a.description + ? [` ${[a.title, a.description].filter(Boolean).join(' - ')}`] + : []), + ]; +} + const NOT_FOUND_MESSAGE = 'catalog.json not found - run `pnpm regen` in the platform repo, or set OSS_CATALOG to its path.'; @@ -120,7 +170,8 @@ server.registerTool( const lines: string[] = ['=== OSS igaming platform catalog ===']; lines.push( `modules: ${c.modules.length} adapters: ${c.adapters.length} events: ${c.events.length} ` + - `schemas: ${c.schemas.length}`, + `schemas: ${c.schemas.length} agent tools: ${c.agentTools?.length ?? 0} ` + + `action types: ${c.agentActions?.length ?? 0}`, ); lines.push('\n--- Adapter seams (implement an interface, bind to the token) ---'); @@ -134,7 +185,7 @@ server.registerTool( } lines.push( - '\nNext: list-adapters | list-routes | list-events | describe-module | schema-get | get-config-schema', + '\nNext: list-adapters | list-routes | list-events | list-agent-tools | describe-module | schema-get | get-config-schema', ); return lines.join('\n'); }), @@ -218,6 +269,31 @@ server.registerTool( }), ); +server.registerTool( + 'list-agent-tools', + { + description: + 'List the agent surface: MCP tools (read calls a model may make during a run) and action types (state changes a model may only propose; an idempotent executor runs them after approval). Each entry shows its IAM resource:action, schema version and owning module. Modules register them in plugin.ts with ctx.mcp.tool(defineMcpTool({...}), factory) and ctx.actions.register(defineActionType({...}), factory); consumers reach them only through the MCP_KERNEL token, which validates input, checks IAM, applies the output allow-list and audits every call. Pass `module` to scope to one module.', + inputSchema: { + module: z.string().optional().describe('Module id to filter by (e.g. "wallet")'), + }, + }, + async ({ module: mod }) => { + const loaded = loadCatalog(); + if (!loaded) { + return { content: [{ type: 'text' as const, text: NOT_FOUND_MESSAGE }] }; + } + const c = loaded.catalog; + const tools = (c.agentTools ?? []).filter((t) => !mod || t.module === mod); + const actions = (c.agentActions ?? []).filter((a) => !mod || a.module === mod); + const lines: string[] = [`=== Agent tools (${tools.length}) ===`]; + lines.push(...(tools.length > 0 ? tools.flatMap(formatAgentTool) : ['(none)'])); + lines.push(`\n=== Agent action types (${actions.length}) ===`); + lines.push(...(actions.length > 0 ? actions.flatMap(formatAgentAction) : ['(none)'])); + return { content: [{ type: 'text' as const, text: lines.join('\n') }] }; + }, +); + server.registerTool( 'describe-module', { @@ -250,6 +326,14 @@ server.registerTool( lines.push( `\n--- Routes ---\n${m.routes.length > 0 ? m.routes.map((r) => `- ${r}`).join('\n') : '(none)'}`, ); + const tools = (c.agentTools ?? []).filter((t) => t.module === m.id); + const actions = (c.agentActions ?? []).filter((a) => a.module === m.id); + if (tools.length > 0) { + lines.push(`\n--- Agent tools ---\n${tools.flatMap(formatAgentTool).join('\n')}`); + } + if (actions.length > 0) { + lines.push(`\n--- Agent action types ---\n${actions.flatMap(formatAgentAction).join('\n')}`); + } return { content: [{ type: 'text' as const, text: lines.join('\n') }] }; }, ); diff --git a/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts new file mode 100644 index 000000000..e1e8fcf2f --- /dev/null +++ b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts @@ -0,0 +1,702 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { and, asc, eq, sql } from 'drizzle-orm'; +import * as z from 'zod'; +import { loadExtensions, DRIZZLE } from '@openora/core/server'; +import { + MCP_KERNEL, + type ActionExecutionContext, + type McpKernel, + type McpToolResult, + type RunContext, +} from '@openora/core/contracts'; +import { auditLog } from '@openora/core/audit/schema'; +import { kycVerification } from '@openora/core/compliance/schema'; +import { user } from '@openora/core/pam/schema/identity'; +import { playerTag, tag } from '@openora/core/pam/schema/tag'; +import { walletTransaction } from '@openora/core/wallet/schema'; +import { + setupTestDb, + bootTestApp, + seedMinimal, + registerAndMaterializePlayer, + asAdmin, + type TestDb, + type TestApp, + type TestClient, +} from '../index.js'; + +let db: TestDb; +let app: TestApp; +let kernel: McpKernel; +let admin: TestClient; +let adminId: string; + +const SEEDED_ADMIN_EMAIL = 'admin@oss.dev'; + +const EXPECTED_TOOL_OWNERS = { + 'ggr.summary': 'analytics', + 'kyc.status': 'compliance', + 'player.summary': 'player-management', + 'wallet.activity': 'wallet', +} as const; + +const EXPECTED_ACTION_OWNERS = { + add_note: 'player-note', + add_tag: 'tag', + hold_withdrawal: 'wallet', + request_enhanced_kyc: 'compliance', + send_to_manual_review: 'tag', +} as const; + +// Parsing keeps only the fields both sides carry: `module` and `file` are the generator's own. +const CatalogIamSchema = z.object({ resource: z.string(), action: z.string() }).nullable(); + +const GeneratedAgentSurfaceSchema = z.object({ + agentTools: z.array( + z.object({ + id: z.string(), + title: z.string().nullable(), + description: z.string().nullable(), + class: z.string().nullable(), + schemaVersion: z.number().nullable(), + iam: CatalogIamSchema, + }), + ), + agentActions: z.array( + z.object({ + id: z.string(), + title: z.string().nullable(), + description: z.string().nullable(), + schemaVersion: z.number().nullable(), + iam: CatalogIamSchema, + reversible: z.boolean().nullable(), + }), + ), +}); + +const GENERATED_CATALOG_PATHS = [ + '../../../../docs/catalog.json', + '../../../mcp/docs/catalog.json', +] as const; + +function readGeneratedAgentSurface() { + for (const relative of GENERATED_CATALOG_PATHS) { + const path = fileURLToPath(new URL(relative, import.meta.url)); + if (existsSync(path)) { + return GeneratedAgentSurfaceSchema.parse(JSON.parse(readFileSync(path, 'utf8'))); + } + } + throw new Error( + 'no generated catalog.json - run `pnpm gen:catalog` (`pnpm install` also runs it)', + ); +} + +function byId(entries: readonly T[]): T[] { + return [...entries].sort((left, right) => left.id.localeCompare(right.id)); +} + +// A fresh correlation id per call is what lets a test find the one audit row that call wrote. +function adminRun(actingAdminId: string): RunContext { + return { + runId: randomUUID(), + actor: { kind: 'admin', adminId: actingAdminId }, + catalogVersion: kernel.catalogVersion, + correlationId: `mcp-e2e-${randomUUID()}`, + }; +} + +function execution(proposalId: string): ActionExecutionContext { + return { + proposalId, + actor: { kind: 'admin', adminId }, + correlationId: `mcp-e2e-${randomUUID()}`, + }; +} + +function outputOf(result: McpToolResult): Record { + if (!result.ok) { + throw new Error(`expected an ok tool result, got ${result.error}`); + } + return result.output; +} + +function servedTool(toolId: string) { + const served = kernel.listTools().find((tool) => tool.id === toolId); + if (!served) { + throw new Error(`tool ${toolId} is not served`); + } + return served; +} + +function allowListOf(toolId: string): string[] { + return [...servedTool(toolId).redact.allow].sort(); +} + +function object(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`expected a JSON object, got ${JSON.stringify(value)}`); + } + return Object.fromEntries(Object.entries(value)); +} + +function stringField(record: Record, key: string): string { + const value = record[key]; + if (typeof value !== 'string') { + throw new Error(`expected "${key}" to be a string, got ${JSON.stringify(value)}`); + } + return value; +} + +// Amounts come back as fixed-scale decimal strings (eg `30.000000000000000000`). +function wholeAmount(units: number): RegExp { + return new RegExp(`^${units}(\\.0+)?$`); +} + +const drizzle = () => app.container.get(DRIZZLE).db; + +function auditRowsOfCall(correlationId: string) { + return drizzle() + .select() + .from(auditLog) + .where(eq(auditLog.correlationId, correlationId)) + .orderBy(asc(auditLog.seq)); +} + +function auditRowsOf(action: string, resourceId: string) { + return drizzle() + .select() + .from(auditLog) + .where(and(eq(auditLog.action, action), eq(auditLog.resourceId, resourceId))) + .orderBy(asc(auditLog.seq)); +} + +async function withdrawalRow(withdrawalId: string) { + const [row] = await drizzle() + .select({ + status: walletTransaction.status, + reviewedBy: walletTransaction.reviewedBy, + reviewReason: walletTransaction.reviewReason, + }) + .from(walletTransaction) + .where(eq(walletTransaction.id, withdrawalId)); + if (!row) { + throw new Error(`no wallet_transaction row ${withdrawalId}`); + } + return row; +} + +function vipTagRows(playerId: string) { + return drizzle() + .select({ id: playerTag.id, removedAt: playerTag.removedAt }) + .from(playerTag) + .innerJoin(tag, eq(tag.id, playerTag.tagId)) + .where(and(eq(playerTag.playerId, playerId), eq(tag.key, 'vip'))); +} + +// player-note exports no schema subpath, so its rows are read by table name. +async function noteIdsOf(playerId: string): Promise { + const result = await drizzle().execute<{ id: string }>( + sql`SELECT id FROM player_note WHERE player_id = ${playerId}`, + ); + return result.rows.map((row) => row.id); +} + +function advancedKycRows(userId: string) { + return drizzle() + .select({ status: kycVerification.status, triggeredBy: kycVerification.triggeredBy }) + .from(kycVerification) + .where(and(eq(kycVerification.userId, userId), eq(kycVerification.tier, 'advanced'))); +} + +async function newPlayer(label: string) { + const email = `mcp-${label}-${randomUUID()}@e2e.test`; + return { email, ...(await registerAndMaterializePlayer(app, { email })) }; +} + +async function depositUsd(client: TestClient, amount: string) { + const res = await client.post('/wallet/deposit', { + idempotencyKey: randomUUID(), + amount, + currency: 'USD', + }); + expect(res.status).toBe(200); +} + +async function requestUsdWithdrawal(client: TestClient, amount: string): Promise { + const res = await client.post('/wallet/withdraw', { + idempotencyKey: randomUUID(), + amount, + currency: 'USD', + }); + expect(res.status).toBe(200); + const body = object(await res.json()); + // An auto-approved withdrawal never reaches the queue these flows act on. + expect(body['status']).toBe('pending'); + return stringField(body, 'transactionId'); +} + +async function usdBalance(client: TestClient): Promise { + const res = await client.get('/wallet/balances'); + expect(res.status).toBe(200); + const balances = object(await res.json())['balances']; + if (!Array.isArray(balances)) { + throw new Error(`expected a balances array, got ${JSON.stringify(balances)}`); + } + const usd = balances.map(object).find((entry) => entry['currency'] === 'USD'); + if (!usd) { + throw new Error('no USD balance'); + } + return stringField(usd, 'balance'); +} + +beforeAll(async () => { + process.env['BETTER_AUTH_SECRET'] ??= 'e2e-test-better-auth-secret-please-change-000000'; + process.env['AUTH_SECRET'] ??= process.env['BETTER_AUTH_SECRET']; + process.env['WITHDRAWAL_PIN_HMAC_SECRET'] ??= 'e2e-test-withdrawal-pin-hmac-secret-000000'; + process.env['NODE_ENV'] ??= 'test'; + + db = await setupTestDb(); + app = await bootTestApp({ plugins: await loadExtensions(), databaseUrl: db.url }); + await seedMinimal(app.container, { playerCount: 0 }); + admin = await asAdmin(app.app); + kernel = app.container.get(MCP_KERNEL); + + const [seededAdmin] = await drizzle() + .select({ id: user.id }) + .from(user) + .where(eq(user.email, SEEDED_ADMIN_EMAIL)); + if (!seededAdmin) { + throw new Error('the seeded admin is missing'); + } + adminId = seededAdmin.id; +}, 60_000); + +afterAll(async () => { + await app?.close(); + await db?.dispose(); +}); + +describe('MCP kernel registries match the generated catalog', () => { + it('serves exactly the four read tools and five action types, each under its owning plugin', () => { + const tools = kernel.listTools(); + const actions = kernel.listActionTypes(); + + expect(tools).toHaveLength(Object.keys(EXPECTED_TOOL_OWNERS).length); + expect(Object.fromEntries(tools.map((tool) => [tool.id, tool.owner]))).toEqual( + EXPECTED_TOOL_OWNERS, + ); + expect(actions).toHaveLength(Object.keys(EXPECTED_ACTION_OWNERS).length); + expect(Object.fromEntries(actions.map((action) => [action.id, action.owner]))).toEqual( + EXPECTED_ACTION_OWNERS, + ); + expect(tools.every((tool) => tool.class === 'read')).toBe(true); + expect(kernel.catalogVersion).toMatch(/^[0-9a-f]{16}$/); + }); + + it('serves the same ids, titles, descriptions, versions and IAM grants the static catalog advertises', () => { + const generated = readGeneratedAgentSurface(); + + const servedTools = kernel.listTools().map((tool) => ({ + id: tool.id, + title: tool.title, + description: tool.description, + class: tool.class, + schemaVersion: tool.schemaVersion, + iam: { resource: tool.iam.resource, action: tool.iam.action }, + })); + const servedActions = kernel.listActionTypes().map((action) => ({ + id: action.id, + title: action.title, + description: action.description, + schemaVersion: action.schemaVersion, + iam: { resource: action.iam.resource, action: action.iam.action }, + reversible: action.reversible, + })); + + expect(byId(generated.agentTools).map((entry) => entry.id)).toEqual( + byId(servedTools).map((entry) => entry.id), + ); + expect(byId(generated.agentActions).map((entry) => entry.id)).toEqual( + byId(servedActions).map((entry) => entry.id), + ); + expect(byId(generated.agentTools)).toEqual(byId(servedTools)); + expect(byId(generated.agentActions)).toEqual(byId(servedActions)); + }); +}); + +describe('MCP read tools are IAM-checked, allow-listed and audited by hash', () => { + let target: Awaited>; + let intruder: Awaited>; + + beforeAll(async () => { + target = await newPlayer('summary-target'); + intruder = await newPlayer('summary-intruder'); + }); + + it('returns only the allow-listed player.summary keys and audits the call without its raw input', async () => { + const run = adminRun(adminId); + + const output = outputOf( + await kernel.invokeTool('player.summary', { playerId: target.playerId }, run), + ); + + expect(Object.keys(output).sort()).toEqual(allowListOf('player.summary')); + expect(output).toMatchObject({ + playerId: target.playerId, + userId: target.userId, + email: target.email, + tags: expect.any(Array), + }); + + const rows = await auditRowsOfCall(run.correlationId); + expect(rows).toHaveLength(1); + const [row] = rows; + expect(row).toMatchObject({ + action: 'mcp.tool.invoked', + actorId: adminId, + actorType: 'admin', + resourceType: 'mcp-tool', + resourceId: 'player.summary', + before: null, + }); + expect(row?.after).toMatchObject({ + toolId: 'player.summary', + toolClass: 'read', + schemaVersion: servedTool('player.summary').schemaVersion, + runId: run.runId, + actorKind: 'admin', + catalogVersion: kernel.catalogVersion, + inputHash: expect.stringMatching(/^[0-9a-f]{64}$/), + outputHash: expect.stringMatching(/^[0-9a-f]{64}$/), + }); + const recorded = JSON.stringify(row?.after); + expect(recorded).not.toContain(target.playerId); + expect(recorded).not.toContain(target.email); + }); + + it("refuses a player's user id acting as an admin and records the refusal and the IAM denial", async () => { + const run = adminRun(intruder.userId); + + const result = await kernel.invokeTool('player.summary', { playerId: target.playerId }, run); + + expect(result).toEqual({ ok: false, error: 'forbidden' }); + + const rows = await auditRowsOfCall(run.correlationId); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + action: 'mcp.tool.failed', + actorId: intruder.userId, + resourceType: 'mcp-tool', + resourceId: 'player.summary', + }); + expect(rows[0]?.after).toMatchObject({ + toolId: 'player.summary', + error: 'forbidden', + inputHash: expect.stringMatching(/^[0-9a-f]{64}$/), + }); + expect(rows[0]?.after).not.toHaveProperty('outputHash'); + + // AdminGuard emits the denial as an event, so its audit row lands asynchronously. + await vi.waitFor( + async () => { + const denials = await auditRowsOf('identity.user.unauthorized_access', 'player:view'); + expect(denials).toContainEqual( + expect.objectContaining({ + actorType: 'player', + actorId: intruder.playerId, + resourceType: 'player', + result: 'failure', + }), + ); + }, + { timeout: 15_000, interval: 100 }, + ); + }); + + it('answers ggr.summary with exactly its declared keys and refuses an inverted range as invalid input', async () => { + const input = { dateFrom: '2026-01-01', dateTo: '2026-03-31', granularity: 'month' }; + + const output = outputOf(await kernel.invokeTool('ggr.summary', input, adminRun(adminId))); + + expect(Object.keys(output).sort()).toEqual(allowListOf('ggr.summary')); + expect(output).toMatchObject({ ...input, series: expect.any(Array) }); + + const inverted = adminRun(adminId); + const refusal = await kernel.invokeTool( + 'ggr.summary', + { dateFrom: '2026-03-31', dateTo: '2026-01-01' }, + inverted, + ); + expect(refusal).toMatchObject({ + ok: false, + error: 'invalid_input', + issues: [expect.objectContaining({ path: 'dateFrom' })], + }); + const rows = await auditRowsOfCall(inverted.correlationId); + expect(rows.map((row) => row.action)).toEqual(['mcp.tool.failed']); + }); +}); + +describe('an agent-proposed withdrawal hold, then an admin decision', () => { + const holdReason = 'agent flagged a deposit-and-withdraw cycle for review'; + const holdProposalId = randomUUID(); + let holder: Awaited>; + let heldWithdrawalId: string; + + beforeAll(async () => { + holder = await newPlayer('withdrawal-holder'); + await depositUsd(holder.client, '50'); + heldWithdrawalId = await requestUsdWithdrawal(holder.client, '20'); + }); + + it('lists the pending withdrawal in wallet.activity, with only the allow-listed keys', async () => { + const output = outputOf( + await kernel.invokeTool('wallet.activity', { playerId: holder.playerId }, adminRun(adminId)), + ); + + expect(Object.keys(output).sort()).toEqual(allowListOf('wallet.activity')); + expect(output['openWithdrawals']).toEqual([ + expect.objectContaining({ + withdrawalId: heldWithdrawalId, + status: 'pending', + currency: 'USD', + amount: expect.stringMatching(wholeAmount(20)), + }), + ]); + expect(output['totals']).toContainEqual( + expect.objectContaining({ + currency: 'USD', + depositCount: 1, + deposits: expect.stringMatching(wholeAmount(50)), + }), + ); + }); + + it('holds the pending withdrawal exactly once when the approved proposal is replayed', async () => { + const payload = { + playerId: holder.playerId, + withdrawalId: heldWithdrawalId, + reason: holdReason, + }; + const evaluation = adminRun(adminId); + + expect(await kernel.checkPrecondition('hold_withdrawal', payload, evaluation)).toEqual({ + ok: true, + }); + expect(await auditRowsOfCall(evaluation.correlationId)).toHaveLength(0); + + expect( + await kernel.executeAction('hold_withdrawal', payload, execution(holdProposalId)), + ).toEqual({ ok: true, outcome: 'applied' }); + expect( + await kernel.executeAction('hold_withdrawal', payload, execution(holdProposalId)), + ).toEqual({ ok: true, outcome: 'already_applied' }); + + expect(await withdrawalRow(heldWithdrawalId)).toEqual({ + status: 'on_hold', + reviewedBy: adminId, + reviewReason: holdReason, + }); + + const held = await auditRowsOf('wallet.withdrawal.held', heldWithdrawalId); + expect(held).toHaveLength(1); + expect(held[0]).toMatchObject({ + actorId: adminId, + resourceType: 'withdrawal', + before: { status: 'pending' }, + after: { status: 'on_hold', reason: holdReason, proposalId: holdProposalId }, + }); + + const executed = await auditRowsOf('mcp.action.executed', holdProposalId); + expect(executed).toHaveLength(2); + expect(executed.map((row) => row.after?.['outcome'])).toEqual(['applied', 'already_applied']); + for (const row of executed) { + expect(row).toMatchObject({ + actorId: adminId, + resourceType: 'agent-proposal', + after: { actionTypeId: 'hold_withdrawal', proposalId: holdProposalId }, + }); + } + }); + + it('refuses to hold a withdrawal that is no longer pending, which activity lists as on hold', async () => { + const payload = { + playerId: holder.playerId, + withdrawalId: heldWithdrawalId, + reason: holdReason, + }; + + expect(await kernel.checkPrecondition('hold_withdrawal', payload, adminRun(adminId))).toEqual({ + ok: false, + error: 'withdrawal_not_pending', + }); + + const output = outputOf( + await kernel.invokeTool('wallet.activity', { playerId: holder.playerId }, adminRun(adminId)), + ); + expect(output['openWithdrawals']).toEqual([ + expect.objectContaining({ withdrawalId: heldWithdrawalId, status: 'on_hold' }), + ]); + }); + + it('forbids the player the approve route, then lets an admin approve the held withdrawal', async () => { + const approvePath = `/wallet/withdrawals/${heldWithdrawalId}/approve`; + + const forbidden = await holder.client.post(approvePath, {}); + expect(forbidden.status).toBe(403); + expect((await withdrawalRow(heldWithdrawalId)).status).toBe('on_hold'); + + const approved = await admin.post(approvePath, {}); + expect(approved.status).toBe(200); + // The default extensions bind the mock payment adapter, which settles a payout at once. + expect(object(await approved.json())).toEqual({ + transactionId: heldWithdrawalId, + status: 'completed', + }); + expect((await withdrawalRow(heldWithdrawalId)).status).toBe('completed'); + }); + + it('returns the funds of a rejected held withdrawal exactly once', async () => { + const rejecting = await newPlayer('withdrawal-rejected'); + await depositUsd(rejecting.client, '50'); + const withdrawalId = await requestUsdWithdrawal(rejecting.client, '20'); + const payload = { playerId: rejecting.playerId, withdrawalId, reason: holdReason }; + expect(await kernel.executeAction('hold_withdrawal', payload, execution(randomUUID()))).toEqual( + { ok: true, outcome: 'applied' }, + ); + expect((await withdrawalRow(withdrawalId)).status).toBe('on_hold'); + expect(await usdBalance(rejecting.client)).toMatch(wholeAmount(30)); + + const rejectPath = `/wallet/withdrawals/${withdrawalId}/reject`; + const rejected = await admin.post(rejectPath, { reason: 'held payout declined on review' }); + expect(rejected.status).toBe(200); + expect(object(await rejected.json())).toEqual({ + transactionId: withdrawalId, + status: 'rejected', + }); + expect(await usdBalance(rejecting.client)).toMatch(wholeAmount(50)); + + const repeated = await admin.post(rejectPath, { reason: 'held payout declined on review' }); + expect(repeated.status).toBe(409); + expect(await usdBalance(rejecting.client)).toMatch(wholeAmount(50)); + expect((await withdrawalRow(withdrawalId)).status).toBe('rejected'); + }); +}); + +describe('agent tag, note and enhanced-KYC actions are replay-safe', () => { + const vipReason = 'high lifetime deposits with steady play'; + let active: Awaited>; + let excluded: Awaited>; + + beforeAll(async () => { + active = await newPlayer('actions-active'); + excluded = await newPlayer('actions-excluded'); + const exclusion = await admin.post(`/compliance/players/${excluded.userId}/self-exclusion`, { + isPermanent: true, + reason: 'player requested, permanent', + confirm: true, + }); + expect(exclusion.status).toBe(200); + }); + + it('refuses vip for a self-excluded player when evaluated and again when executed', async () => { + const payload = { playerId: excluded.playerId, tagKey: 'vip', reason: vipReason }; + const proposalId = randomUUID(); + + expect(await kernel.checkPrecondition('add_tag', payload, adminRun(adminId))).toEqual({ + ok: false, + error: 'player_not_eligible', + }); + expect(await kernel.executeAction('add_tag', payload, execution(proposalId))).toEqual({ + ok: false, + error: 'player_not_eligible', + }); + + expect(await vipTagRows(excluded.playerId)).toHaveLength(0); + const failed = await auditRowsOf('mcp.action.failed', proposalId); + expect(failed).toHaveLength(1); + expect(failed[0]?.after).toMatchObject({ + actionTypeId: 'add_tag', + error: 'player_not_eligible', + }); + }); + + it('tags an active player vip once when the approved proposal is replayed', async () => { + const payload = { playerId: active.playerId, tagKey: 'vip', reason: vipReason }; + const proposalId = randomUUID(); + + expect(await kernel.checkPrecondition('add_tag', payload, adminRun(adminId))).toEqual({ + ok: true, + }); + expect(await kernel.executeAction('add_tag', payload, execution(proposalId))).toEqual({ + ok: true, + outcome: 'applied', + }); + expect(await kernel.executeAction('add_tag', payload, execution(proposalId))).toEqual({ + ok: true, + outcome: 'already_applied', + }); + + expect(await vipTagRows(active.playerId)).toEqual([ + { id: expect.any(String), removedAt: null }, + ]); + }); + + it('writes the proposed note once when the approved proposal is replayed', async () => { + const payload = { + playerId: active.playerId, + content: 'Agent review: deposit pattern matches the declared source of funds.', + }; + const proposalId = randomUUID(); + + const first = await kernel.executeAction('add_note', payload, execution(proposalId)); + expect(first).toEqual({ ok: true, outcome: 'applied', detail: { noteId: expect.any(String) } }); + expect(await kernel.executeAction('add_note', payload, execution(proposalId))).toEqual({ + ok: true, + outcome: 'already_applied', + }); + + const noteId = first.ok ? first.detail?.['noteId'] : undefined; + expect(await noteIdsOf(active.playerId)).toEqual([noteId]); + const created = await auditRowsOf('admin.player_note.created', active.playerId); + expect(created).toHaveLength(1); + expect(created[0]?.after).toMatchObject({ noteId, proposalId }); + }); + + it('requests enhanced KYC once when the approved proposal is replayed, and kyc.status shows it', async () => { + const reason = 'deposits crossed the enhanced due diligence threshold'; + const payload = { playerId: active.playerId, reason }; + const proposalId = randomUUID(); + + expect( + await kernel.checkPrecondition('request_enhanced_kyc', payload, adminRun(adminId)), + ).toEqual({ ok: true }); + expect( + await kernel.executeAction('request_enhanced_kyc', payload, execution(proposalId)), + ).toEqual({ ok: true, outcome: 'applied' }); + expect( + await kernel.executeAction('request_enhanced_kyc', payload, execution(proposalId)), + ).toEqual({ ok: true, outcome: 'already_applied' }); + + expect(await advancedKycRows(active.userId)).toEqual([ + { status: 'resubmission_requested', triggeredBy: 'manual' }, + ]); + + const output = outputOf( + await kernel.invokeTool('kyc.status', { playerId: active.playerId }, adminRun(adminId)), + ); + expect(Object.keys(output).sort()).toEqual(allowListOf('kyc.status')); + expect(output).toMatchObject({ + playerId: active.playerId, + advanced: { status: 'resubmission_requested', triggeredBy: 'manual' }, + advancedDecisionReason: reason, + }); + + expect( + await kernel.checkPrecondition('request_enhanced_kyc', payload, adminRun(adminId)), + ).toEqual({ ok: false, error: 'already_requested' }); + }); +}); diff --git a/packages/testing/src/db.ts b/packages/testing/src/db.ts index 6eacbee5f..c73df5e85 100644 --- a/packages/testing/src/db.ts +++ b/packages/testing/src/db.ts @@ -8,6 +8,7 @@ import { migrate as migrateOutbox } from '@openora/core/server/migrate'; import { migrate as migrateIdentity } from '@openora/core/pam/migrate/identity'; import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; import { migrate as migrateTag } from '@openora/core/pam/migrate/tag'; +import { migrate as migratePlayerNote } from '@openora/core/pam/migrate/player-note'; import { migrate as migrateAudit } from '@openora/core/audit/migrate'; import { migrate as migrateIam } from '@openora/core/iam/migrate'; import { migrate as migrateCms } from '@openora/core/cms/migrate'; @@ -31,6 +32,7 @@ async function applyAllMigrations(url: string): Promise { await migrateIdentity(url); await migrateProfile(url); await migrateTag(url); + await migratePlayerNote(url); await migrateAudit(url); await migrateIam(url); await migrateCms(url); diff --git a/tools/__tests__/gen-catalog-agent-surface.test.mjs b/tools/__tests__/gen-catalog-agent-surface.test.mjs new file mode 100644 index 000000000..7b2b63292 --- /dev/null +++ b/tools/__tests__/gen-catalog-agent-surface.test.mjs @@ -0,0 +1,166 @@ +// Runs the real catalog generator against a throwaway repo root and checks the agent +// surface it reads from defineMcpTool / defineActionType literals. +// Run: node --test tools/__tests__/gen-catalog-agent-surface.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const script = join(dirname(fileURLToPath(import.meta.url)), '../gen/gen-catalog.ts'); + +function generate(files) { + const root = mkdtempSync(join(tmpdir(), 'gen-catalog-')); + try { + for (const [path, contents] of Object.entries(files)) { + mkdirSync(join(root, dirname(path)), { recursive: true }); + writeFileSync(join(root, path), contents); + } + const result = spawnSync('pnpm', ['exec', 'tsx', script, root], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stdout + result.stderr); + return { + catalog: JSON.parse(readFileSync(join(root, 'docs', 'catalog.json'), 'utf8')), + stderr: result.stderr, + }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const moduleFiles = { + 'packages/core/src/demo/index.ts': 'export {};\n', + 'packages/core/src/demo/plugin.ts': 'export default {};\n', + 'packages/core/src/demo/contract/agent-tools.ts': ` +export const lookup = defineMcpTool({ + // a leading comment must not hide the key + id: 'demo.lookup', + title: 'Demo lookup', + description: 'Reads one demo record, ' + "nothing else.", + class: 'read', + schemaVersion: 2, + iam: { resource: 'player', action: 'view' }, + inputSchema: z.object({ code: z.string().regex(/^[^}]{1,8}$/), note: z.string().max(5) }), + outputSchema: z.object({ id: z.string() }), + redact: { allow: ['id'] }, + errors: ['demo_missing'], +}); + +export const apply = defineActionType({ + id: 'demo_apply', + title: \`Apply demo\`, + description: 'Applies it, idempotently.', + schemaVersion: 1, + iam: { resource: 'tag', action: 'create' }, + reversible: false, + payloadSchema: z.object({ playerId: UuidSchema }).superRefine((v, ctx) => { + if (/[{]/.test(String(v.playerId))) { + ctx.addIssue({ code: 'custom', message: 'no braces' }); + } + }), + errors: [], +}); + +export const dynamic = defineMcpTool({ id: TOOL_ID, title: 'x' }); +`, + 'packages/core/src/demo/__tests__/fake.test.ts': ` +defineMcpTool({ id: 'test.only', title: 'ignored' }); +`, +}; + +test('catalogues tool and action-type literals with their IAM and flags', () => { + const { catalog } = generate(moduleFiles); + assert.deepEqual(catalog.agentTools, [ + { + id: 'demo.lookup', + module: 'demo', + file: 'packages/core/src/demo/contract/agent-tools.ts', + title: 'Demo lookup', + description: 'Reads one demo record, nothing else.', + schemaVersion: 2, + iam: { resource: 'player', action: 'view' }, + class: 'read', + }, + ]); + assert.deepEqual(catalog.agentActions, [ + { + id: 'demo_apply', + module: 'demo', + file: 'packages/core/src/demo/contract/agent-tools.ts', + title: 'Apply demo', + description: 'Applies it, idempotently.', + schemaVersion: 1, + iam: { resource: 'tag', action: 'create' }, + reversible: false, + }, + ]); +}); + +test('skips a definition whose id is not a literal, and says so', () => { + const { catalog, stderr } = generate(moduleFiles); + assert.equal( + catalog.agentTools.some((t) => t.title === 'x'), + false, + ); + assert.match(stderr, /defineMcpTool\(\{\.\.\.\}\) without a literal id - skipped/); +}); + +const trickyFiles = { + 'packages/core/src/demo/index.ts': 'export {};\n', + 'packages/core/src/demo/plugin.ts': 'export default {};\n', + 'packages/core/src/demo/contract/agent-tools.ts': ` +/** + * Example for authors, not a registration: defineActionType({ id: 'doc_example', title: 'x' }) + */ +// defineMcpTool({ id: 'line.comment', title: 'x' }) +export const generic = defineMcpTool({ + id: 'demo.generic', + title: 'Generic', + description: 'Explicit type arguments.', + class: 'read', + schemaVersion: 1, + iam: { resource: 'player', action: 'view' }, + inputSchema: z.object({ + code: z.string().max(8).refine((s) => /[}]/.test(s) === false), + tag: z.string().max(8).refine((s) => { + return /["}]/.test(s) === false; + }), + }), + outputSchema: z.object({ id: z.string() }), + redact: { allow: ['id'] }, + errors: [], +}); + +export const after = defineActionType({ + id: 'demo_after', + title: 'After', + description: 'Parsed after the tricky tool.', + schemaVersion: 3, + iam: { resource: 'tag', action: 'create' }, + reversible: true, + payloadSchema: z.object({ playerId: UuidSchema }), + errors: [], +}); +`, + 'packages/core/src/demo/contract/broken.ts': `export const broken = defineMcpTool({ id: 'demo.broken', title: 'x',\n`, +}; + +test('ignores examples in comments and reads generic calls and literals holding regexes', () => { + const { catalog, stderr } = generate(trickyFiles); + assert.deepEqual( + catalog.agentTools.map((t) => t.id), + ['demo.generic'], + ); + assert.deepEqual( + catalog.agentActions.map((a) => [a.id, a.schemaVersion, a.reversible]), + [['demo_after', 3, true]], + ); + assert.match(stderr, /broken\.ts: defineMcpTool\(\{\.\.\.\}\) could not be parsed - skipped/); +}); + +test('an empty repo yields empty agent sections', () => { + const { catalog } = generate({ 'README.md': '# empty\n' }); + assert.deepEqual(catalog.agentTools, []); + assert.deepEqual(catalog.agentActions, []); +}); diff --git a/tools/gen/gen-catalog.ts b/tools/gen/gen-catalog.ts index a729f5360..7c4c09b23 100644 --- a/tools/gen/gen-catalog.ts +++ b/tools/gen/gen-catalog.ts @@ -13,18 +13,21 @@ * * It captures: modules (+ tables + routes), adapter seams (+ wired-vs-stub * status), domain events, Zod schema index, the igaming-config shape, - * and the plugin-contract surface. + * the plugin-contract surface, and the agent surface (MCP tools + action types). * * Pure filesystem parsing - no package imports, no build - so it is cheap enough to run on every * install, and DETERMINISTIC (no timestamp). * - * Run via `pnpm regen` (or `pnpm gen:catalog`). + * Run via `pnpm regen` (or `pnpm gen:catalog`). An optional first argument points it at + * another repo root, which is how its tests run it against a fixture. */ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from 'node:fs'; -import { join, dirname } from 'node:path'; +import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../..'); +const repoRoot = process.argv[2] + ? resolve(process.argv[2]) + : join(dirname(fileURLToPath(import.meta.url)), '../..'); const read = (p: string): string => (existsSync(p) ? readFileSync(p, 'utf8') : ''); @@ -258,6 +261,347 @@ function collectPluginSurface(): string[] { return [...body.matchAll(/^\s{2}(\w+):/gm)].map((m) => m[1] ?? '').sort(); } +// The agent surface is read from the object literal passed to defineMcpTool({...}) / +// defineActionType({...}), so the generator stays a text pass and never imports a module. +// Only literal values are catalogued; a non-literal id is skipped with a warning. + +type AgentIam = { resource: string; action: string }; + +type AgentToolInfo = { + id: string; + module: string; + file: string; + title: string | null; + description: string | null; + class: string | null; + schemaVersion: number | null; + iam: AgentIam | null; +}; + +type AgentActionInfo = { + id: string; + module: string; + file: string; + title: string | null; + description: string | null; + schemaVersion: number | null; + iam: AgentIam | null; + reversible: boolean | null; +}; + +// A '/' after one of these characters or words starts a regex literal, not a division. +const REGEX_PRECEDERS = '(,=:[!&|?{};>'; +const REGEX_PRECEDING_WORDS: ReadonlySet = new Set([ + 'return', + 'typeof', + 'case', + 'do', + 'else', + 'in', + 'of', + 'new', + 'delete', + 'void', + 'throw', + 'instanceof', + 'yield', + 'await', +]); +const IDENTIFIER_CHAR = /[\w$]/; +const CLOSERS: Record = { '{': '}', '[': ']', '(': ')' }; + +function startsRegex(prev: string, prevWord: string): boolean { + return prev === '' || REGEX_PRECEDERS.includes(prev) || REGEX_PRECEDING_WORDS.has(prevWord); +} + +// Tracks the identifier that ends right before position `i`, so `return /x/` reads as a regex. +function nextWord(src: string, i: number, prevWord: string): string { + const ch = src[i] ?? ''; + if (IDENTIFIER_CHAR.test(ch)) { + return IDENTIFIER_CHAR.test(src[i - 1] ?? '') ? prevWord + ch : ch; + } + return /\s/.test(ch) ? prevWord : ''; +} + +function skipString(src: string, start: number): number { + const quote = src[start]; + for (let i = start + 1; i < src.length; i++) { + const ch = src[i]; + if (ch === '\\') { + i++; + } else if (quote === '`' && ch === '$' && src[i + 1] === '{') { + i = closingBracket(src, i + 1); + if (i < 0) { + return -1; + } + } else if (ch === quote) { + return i; + } + } + return -1; +} + +// A zod `.regex(/[^}]/)` inside a contract literal must not unbalance the brace count. +function skipRegex(src: string, start: number): number { + let inClass = false; + for (let i = start + 1; i < src.length; i++) { + const ch = src[i]; + if (ch === '\\') { + i++; + } else if (ch === '[') { + inClass = true; + } else if (ch === ']') { + inClass = false; + } else if (ch === '/' && !inClass) { + while (/[a-z]/.test(src[i + 1] ?? '')) { + i++; + } + return i; + } else if (ch === '\n') { + return -1; + } + } + return -1; +} + +// Calls `visit` for every top-level character outside strings, comments and regex literals; +// a nested bracket is visited at its opening character and then skipped whole. Returns false +// when the text is unbalanced. +function scanTopLevel( + src: string, + from: number, + visit: (ch: string, index: number) => 'stop' | void, +): boolean { + let prev = ''; + let prevWord = ''; + for (let i = from; i < src.length; i++) { + const ch = src[i] ?? ''; + const next = src[i + 1]; + if (ch === '/' && next === '/') { + const newline = src.indexOf('\n', i); + i = newline === -1 ? src.length : newline; + continue; + } + if (ch === '/' && next === '*') { + const close = src.indexOf('*/', i + 2); + if (close === -1) { + return false; + } + i = close + 1; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + i = skipString(src, i); + if (i < 0) { + return false; + } + prev = 'x'; + prevWord = ''; + continue; + } + if (ch === '/' && startsRegex(prev, prevWord)) { + i = skipRegex(src, i); + if (i < 0) { + return false; + } + prev = 'x'; + prevWord = ''; + continue; + } + if (visit(ch, i) === 'stop') { + return true; + } + if (ch in CLOSERS) { + i = closingBracket(src, i); + if (i < 0) { + return false; + } + prev = ')'; + prevWord = ''; + continue; + } + prevWord = nextWord(src, i, prevWord); + if (!/\s/.test(ch)) { + prev = ch; + } + } + return true; +} + +// The source with every comment blanked and every offset kept, so a call written inside a +// comment (a JSDoc example) is never read as a registration. +function withoutComments(src: string): string { + const out = src.split(''); + let prev = ''; + let prevWord = ''; + for (let i = 0; i < src.length; i++) { + const ch = src[i] ?? ''; + const next = src[i + 1]; + if (ch === '/' && (next === '/' || next === '*')) { + const end = next === '/' ? src.indexOf('\n', i) : src.indexOf('*/', i + 2); + const stop = end === -1 ? src.length : next === '/' ? end : end + 2; + for (let j = i; j < stop; j++) { + if (out[j] !== '\n') { + out[j] = ' '; + } + } + i = stop - 1; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') { + const end = skipString(src, i); + if (end < 0) { + break; + } + i = end; + prev = 'x'; + prevWord = ''; + continue; + } + if (ch === '/' && startsRegex(prev, prevWord)) { + const end = skipRegex(src, i); + if (end >= 0) { + i = end; + prev = 'x'; + prevWord = ''; + continue; + } + } + prevWord = nextWord(src, i, prevWord); + if (!/\s/.test(ch)) { + prev = ch; + } + } + return out.join(''); +} + +function closingBracket(src: string, open: number): number { + const expected = CLOSERS[src[open] ?? '']; + let found = -1; + const balanced = scanTopLevel(src, open + 1, (ch, i) => { + if (ch === '}' || ch === ']' || ch === ')') { + found = ch === expected ? i : -1; + return 'stop'; + } + }); + return balanced ? found : -1; +} + +// The object literal bodies passed as the first argument to `fnName(` or `fnName<...>(`. +function callObjectArguments(src: string, fnName: string, file: string): string[] { + const code = withoutComments(src); + const out: string[] = []; + const call = new RegExp(`\\b${fnName}\\s*(?:<[^()]*?>)?\\s*\\(\\s*\\{`, 'g'); + for (const m of code.matchAll(call)) { + const open = (m.index ?? 0) + m[0].length - 1; + const close = closingBracket(code, open); + if (close > open) { + out.push(src.slice(open + 1, close)); + } else { + console.warn(`[catalog] ${file}: ${fnName}({...}) could not be parsed - skipped`); + } + } + return out; +} + +function objectEntries(body: string): Map { + const segments: string[] = []; + let start = 0; + scanTopLevel(body, 0, (ch, i) => { + if (ch === ',') { + segments.push(body.slice(start, i)); + start = i + 1; + } + }); + segments.push(body.slice(start)); + const entries = new Map(); + for (const raw of segments) { + const segment = raw.replace(/^(?:\s*(?:\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/))*\s*/, '').trim(); + const m = segment.match(/^(?:'([^']+)'|"([^"]+)"|([A-Za-z_$][\w$]*))\s*:\s*([\s\S]*)$/); + if (m) { + entries.set(m[1] ?? m[2] ?? m[3] ?? '', (m[4] ?? '').trim()); + } + } + return entries; +} + +const STRING_LITERAL = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`[^`$]*`/g; + +function stringLiteral(value: string | undefined): string | null { + if (value === undefined) { + return null; + } + const parts = [...value.matchAll(STRING_LITERAL)].map((m) => m[0]); + if (parts.length === 0 || value.replace(STRING_LITERAL, '').replace(/[\s+]/g, '') !== '') { + return null; + } + return parts.map((p) => p.slice(1, -1).replace(/\\(.)/g, '$1')).join(''); +} + +function numberLiteral(value: string | undefined): number | null { + return value !== undefined && /^\d+$/.test(value) ? Number(value) : null; +} + +function booleanLiteral(value: string | undefined): boolean | null { + return value === 'true' ? true : value === 'false' ? false : null; +} + +function iamLiteral(value: string | undefined): AgentIam | null { + const body = value?.match(/^\{([\s\S]*)\}$/)?.[1]; + if (body === undefined) { + return null; + } + const entries = objectEntries(body); + const resource = stringLiteral(entries.get('resource')); + const action = stringLiteral(entries.get('action')); + return resource && action ? { resource, action } : null; +} + +function collectAgentSurface(): { tools: AgentToolInfo[]; actions: AgentActionInfo[] } { + const tools: AgentToolInfo[] = []; + const actions: AgentActionInfo[] = []; + for (const { id: module, srcDir } of moduleSrcDirs()) { + const files = walk(srcDir, '.ts').filter( + (f) => !f.includes('/__tests__/') && !f.endsWith('.d.ts'), + ); + for (const f of files) { + const src = readFileSync(f, 'utf8'); + const file = f.replace(`${repoRoot}/`, ''); + for (const [fnName, kind] of [ + ['defineMcpTool', 'tool'], + ['defineActionType', 'action'], + ] as const) { + for (const body of callObjectArguments(src, fnName, file)) { + const entries = objectEntries(body); + const id = stringLiteral(entries.get('id')); + if (!id) { + console.warn(`[catalog] ${file}: ${fnName}({...}) without a literal id - skipped`); + continue; + } + const common = { + id, + module, + file, + title: stringLiteral(entries.get('title')), + description: stringLiteral(entries.get('description')), + schemaVersion: numberLiteral(entries.get('schemaVersion')), + iam: iamLiteral(entries.get('iam')), + }; + if (kind === 'tool') { + tools.push({ ...common, class: stringLiteral(entries.get('class')) }); + } else { + actions.push({ ...common, reversible: booleanLiteral(entries.get('reversible')) }); + } + } + } + } + } + const byId = (a: { id: string }, b: { id: string }) => a.id.localeCompare(b.id); + return { tools: tools.sort(byId), actions: actions.sort(byId) }; +} + +const agentSurfaceInfo = collectAgentSurface(); + const catalog = { modules: collectModules(), adapters: collectAdapters(), @@ -269,6 +613,8 @@ const catalog = { fields: collectConfigFields(), }, pluginContract: collectPluginSurface(), + agentTools: agentSurfaceInfo.tools, + agentActions: agentSurfaceInfo.actions, }; const docsDir = join(repoRoot, 'docs'); @@ -280,6 +626,7 @@ for (const dir of [docsDir, join(repoRoot, 'packages', 'mcp', 'docs')]) { console.log( `[catalog] ${catalog.modules.length} modules, ${catalog.adapters.length} adapters ` + `(${catalog.adapters.filter((a) => a.status === 'wired').length} wired), ` + - `${catalog.events.length} events, ${catalog.schemas.length} schemas`, + `${catalog.events.length} events, ${catalog.schemas.length} schemas, ` + + `${catalog.agentTools.length} agent tools, ${catalog.agentActions.length} action types`, ); console.log('[catalog] wrote docs/catalog.json and packages/mcp/docs/catalog.json'); diff --git a/tools/templates/consumer/__dot__rulesync/commands/scaffold-plugin.md b/tools/templates/consumer/__dot__rulesync/commands/scaffold-plugin.md index 7f90e096f..631a9d18b 100644 --- a/tools/templates/consumer/__dot__rulesync/commands/scaffold-plugin.md +++ b/tools/templates/consumer/__dot__rulesync/commands/scaffold-plugin.md @@ -1,6 +1,6 @@ --- targets: ['*'] -description: 'Generate an overlay extension under apps/api/src/extensions//. Adds routes, providers, event handlers, or MCP tools without touching @openora/* core. Args: .' +description: 'Generate an overlay extension under apps/api/src/extensions//. Adds routes, providers, event handlers, agent tools, or agent action types without touching @openora/* core. Args: .' --- Run `pnpm gen plugin $ARGUMENTS` in the repo root. For a vendor swap use @@ -13,7 +13,16 @@ After the generator finishes, open `apps/api/src/extensions//plugin.ts` an compile + runtime). - `ctx.routers.add(namespace, (c) => router)` - mount oRPC routes (Zod input/output, no inline schemas). - `ctx.events.on(event, handler)` - subscribe to the typed `EventBus`. -- `ctx.mcp.tool(definition)` - expose an MCP tool. +- `ctx.mcp.tool(defineMcpTool({...}), (c) => async (input, run) => output)` - register an agent tool, + a read a model makes during a run. +- `ctx.actions.register(defineActionType({...}), (c) => ({ precondition, execute }))` - register an + agent action type, a change a model may only propose. `execute` performs nothing on a replay + while its effect still holds, and answers `already_applied`. + +Both are served only through the `MCP_KERNEL` token, which checks IAM, validates input, applies the +output allow-list and audits every tool call and action execution; a shape it cannot serve fails +boot with the fix. The one-argument `ctx.mcp.tool(definition)` still registers but the kernel does +not serve it. Then: diff --git a/tools/templates/consumer/__dot__rulesync/rules/overview.md b/tools/templates/consumer/__dot__rulesync/rules/overview.md index 3f882e076..a365d1c4a 100644 --- a/tools/templates/consumer/__dot__rulesync/rules/overview.md +++ b/tools/templates/consumer/__dot__rulesync/rules/overview.md @@ -107,6 +107,7 @@ This server reads the platform CATALOG (not OSS source) - it tells you what exis - `list-adapters` - vendor swap seams (interface + token + status) - `list-routes [module]` - oRPC route namespaces - `list-events` - cross-module domain events you can subscribe to -- `describe-module ` - one module's group, tables, routes +- `list-agent-tools [module]` - agent tools and action types, with their IAM resource and schema version +- `describe-module ` - one module's group, tables, routes, agent tools and action types - `schema-get ` - locate a Zod contract schema's file - `get-config-schema` - the igaming-config fields a consumer can set From b93d5b28c6fd5f5db1051befc5f4f3d52f02317c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Mon, 5 Oct 2026 12:29:26 +0200 Subject: [PATCH 02/14] refactor(wallet): keep the auto-approval cap marker as dev has it The agent hub adds behaviour and leaves existing flows alone, so the daily cap reads the auto-approved marker by the reason alone again. A held withdrawal whose reason reads "auto-approved" counts towards the cap, which fails closed. --- .../wallet-withdrawal-hold.int.test.ts | 23 ------------------- .../core/src/wallet/service/wallet.service.ts | 5 +--- 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/packages/core/src/wallet/__tests__/wallet-withdrawal-hold.int.test.ts b/packages/core/src/wallet/__tests__/wallet-withdrawal-hold.int.test.ts index d560b3744..15653e7ec 100644 --- a/packages/core/src/wallet/__tests__/wallet-withdrawal-hold.int.test.ts +++ b/packages/core/src/wallet/__tests__/wallet-withdrawal-hold.int.test.ts @@ -464,29 +464,6 @@ describe('WalletService auto-approval against held withdrawals (real PG)', () => }); expect(await balanceOf(w.id)).toBe(60); }); - - it("does not count a reviewer's hold that reads 'auto-approved' towards the daily cap", async () => { - await seedAutoApprovalConfig(); - const directory = mock({ - lookupPlayers: vi.fn(async (ids: string[]) => ids.map(approvedPlayer)), - }); - const { svc } = makeService({ - directory, - platformConfig: autoApprovalConfig({ dailyCapCount: 1 }), - }); - const w = await seedWallet('100'); - const held = await seedWithdrawal(w.id, { amount: '10' }); - await svc.holdWithdrawal({ ...holdInput(w.userId, held.id), reason: 'auto-approved' }); - - const result = await svc.withdraw({ - userId: w.userId, - amount: '40', - currency: 'USD', - ...NO_CLIENT_META, - }); - - expect(result.status).toBe('completed'); - }); }); describe('WalletService.deleteWalletAsset with a held withdrawal (real PG)', () => { diff --git a/packages/core/src/wallet/service/wallet.service.ts b/packages/core/src/wallet/service/wallet.service.ts index 10e91b8a4..77502f388 100644 --- a/packages/core/src/wallet/service/wallet.service.ts +++ b/packages/core/src/wallet/service/wallet.service.ts @@ -655,9 +655,7 @@ const DAY_MS = 24 * 60 * 60 * 1000; const DAILY_CAP_WINDOW_MS = 24 * 60 * 60 * 1000; -// Also, with no reviewer, the marker the cumulative-cap query reads back to identify -// auto-approved payouts - keep write and read in lockstep. A reviewer's free-text reason can -// hold the same words, so the reviewer column is part of the marker. +// Also the marker the cumulative-cap query reads back to identify auto-approved payouts - keep write and read in lockstep. const AUTO_APPROVED_REASON = 'auto-approved'; function assertWithdrawalIn( @@ -2858,7 +2856,6 @@ export class WalletService { eq(walletTransaction.walletId, walletId), eq(walletTransaction.type, 'withdrawal'), eq(walletTransaction.reviewReason, AUTO_APPROVED_REASON), - isNull(walletTransaction.reviewedBy), gte(walletTransaction.createdAt, since), ), ); From d5d4056d8d80c50c00eb46ee35cfda1208368cde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Mon, 5 Oct 2026 13:02:54 +0200 Subject: [PATCH 03/14] docs(core): accept the mcp kernel adr --- ...42-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md b/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md index 0d6e815b3..bfc4a2228 100644 --- a/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md +++ b/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md @@ -1,7 +1,7 @@ # ADR-0042: MCP kernel - two registries behind one enforcing wrapper **Date**: 2026-09-25 -**Status**: Proposed +**Status**: Accepted ## Context From 8b386526debc5e0faa8f72ff57eb3921e8b47bc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Tue, 6 Oct 2026 08:10:15 +0200 Subject: [PATCH 04/14] test(tag): hold the player row until both concurrent replaces queue A replace loses the race only when its first read precedes the winner's commit. The test left that to timing, so a slow runner started the second replace after the first committed and both succeeded. A third transaction now holds the player row until both replaces wait on it. --- .../pam/tag/__tests__/tag.service.int.test.ts | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/core/src/pam/tag/__tests__/tag.service.int.test.ts b/packages/core/src/pam/tag/__tests__/tag.service.int.test.ts index ee9262e99..2ac753b2c 100644 --- a/packages/core/src/pam/tag/__tests__/tag.service.int.test.ts +++ b/packages/core/src/pam/tag/__tests__/tag.service.int.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { and, eq, isNull, sql } from 'drizzle-orm'; import type { PlayEligibilityPort, TagKey } from '@openora/core/contracts'; @@ -707,16 +707,25 @@ describe('TagService.replacePlayerTag (real PG)', () => { }; const otherArgs = { ...args, assignReason: 'player level set to 2' }; - // Both replaces must actually overlap at the DB for one of them to lose. The seeds - // above leave a single warm connection in the pool, so without this the second call - // pays a TCP connect + startup handshake and can begin only after the first has - // already committed - a legitimate sequential replace, and both succeed. - await Promise.all([playerTagsOf(p.id), playerTagsOf(p.id)]); - - const results = await Promise.allSettled([ - svc.replacePlayerTag(args), - svc.replacePlayerTag(otherArgs), - ]); + // A replace loses only when its first read precedes the winner's commit. Holding the + // player row until both replaces queue on it guarantees that; left to timing, a slow + // runner starts the second after the first commits, and both succeed. + let replaces: Promise[]> = Promise.resolve([]); + await db.drizzle.db.transaction(async (tx) => { + await tx.select({ id: player.id }).from(player).where(eq(player.id, p.id)).for('update'); + replaces = Promise.allSettled([svc.replacePlayerTag(args), svc.replacePlayerTag(otherArgs)]); + await vi.waitFor( + async () => { + const { rows } = await db.drizzle.db.execute<{ waiting: number }>( + sql`select count(*)::int as waiting from pg_stat_activity + where datname = current_database() and wait_event_type = 'Lock'`, + ); + expect(rows[0]?.waiting).toBe(2); + }, + { timeout: 5_000, interval: 10 }, + ); + }); + const results = await replaces; const fulfilled = results.filter((r) => r.status === 'fulfilled'); const rejected = results.filter((r) => r.status === 'rejected'); From b39a19a39867b20df4a8c7f57594f1882961993d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Wed, 7 Oct 2026 14:33:18 +0200 Subject: [PATCH 05/14] fix(core): hash any input depth and audit a tool call that fails outside its handler The canonical JSON behind every input and output hash recursed without a limit, so an input nested a few hundred thousand levels deep threw a RangeError while the kernel hashed it, outside any catch, and the call rejected without its audit record. Nesting past 64 levels now serializes as a fixed marker, and a tool call that throws anywhere outside its handler answers internal_error and still writes its one mcp.tool.failed record. The change matches the one already on the stacked transport branch, so the two merge without rework. --- .../mcp/__tests__/canonical-json.test.ts | 42 ++++++++++++++ .../src/server/mcp/__tests__/kernel.test.ts | 50 ++++++++++++++++ .../core/src/server/mcp/canonical-json.ts | 11 +++- packages/core/src/server/mcp/kernel.ts | 31 ++++++++-- .../src/__tests__/mcp-kernel.e2e.test.ts | 57 +++++++++++++++++++ 5 files changed, 183 insertions(+), 8 deletions(-) diff --git a/packages/core/src/server/mcp/__tests__/canonical-json.test.ts b/packages/core/src/server/mcp/__tests__/canonical-json.test.ts index 0f2488453..3a9c648db 100644 --- a/packages/core/src/server/mcp/__tests__/canonical-json.test.ts +++ b/packages/core/src/server/mcp/__tests__/canonical-json.test.ts @@ -2,6 +2,25 @@ import { createHash } from 'node:crypto'; import { describe, expect, it } from 'vitest'; import { canonicalJson, sha256Hex } from '../canonical-json.js'; +const MAX_NESTING = 64; +const REQUEST_BODY_NESTING = 400_000; + +function nestedArrays(levels: number): unknown { + let value: unknown = 'leaf'; + for (let level = 0; level < levels; level += 1) { + value = [value]; + } + return value; +} + +function nestedObjects(levels: number): unknown { + let value: unknown = 'leaf'; + for (let level = 0; level < levels; level += 1) { + value = { child: value }; + } + return value; +} + describe('canonicalJson', () => { it('serializes equal objects to the same bytes whatever their key order', () => { expect(canonicalJson({ b: 1, a: { d: [3, { f: 1, e: 2 }], c: 'x' } })).toBe( @@ -27,6 +46,29 @@ describe('canonicalJson', () => { expect(canonicalJson({ amount: 10n })).toBe('{"amount":"10"}'); expect(canonicalJson(cyclic)).toBe('{"id":1,"self":"[Circular]"}'); }); + + it('serializes a value nested 64 levels deep in full', () => { + expect(canonicalJson(nestedArrays(MAX_NESTING))).toBe( + `${'['.repeat(MAX_NESTING)}"leaf"${']'.repeat(MAX_NESTING)}`, + ); + }); + + it('serializes whatever nests deeper than 64 levels as a fixed marker', () => { + expect(canonicalJson(nestedArrays(MAX_NESTING + 1))).toBe( + `${'['.repeat(MAX_NESTING)}"[TooDeep]"${']'.repeat(MAX_NESTING)}`, + ); + expect(canonicalJson(nestedObjects(MAX_NESTING + 1))).toBe( + `${'{"child":'.repeat(MAX_NESTING)}"[TooDeep]"${'}'.repeat(MAX_NESTING)}`, + ); + }); + + it('never overflows the stack on a value nested as deep as a 1 MiB request body allows', () => { + const deep = nestedArrays(REQUEST_BODY_NESTING); + + expect(canonicalJson(deep)).toBe(canonicalJson(nestedArrays(MAX_NESTING + 1))); + expect(sha256Hex(deep)).toMatch(/^[0-9a-f]{64}$/); + expect(sha256Hex(nestedObjects(REQUEST_BODY_NESTING))).toMatch(/^[0-9a-f]{64}$/); + }); }); describe('sha256Hex', () => { diff --git a/packages/core/src/server/mcp/__tests__/kernel.test.ts b/packages/core/src/server/mcp/__tests__/kernel.test.ts index 0b69d7dcc..92a40ccd7 100644 --- a/packages/core/src/server/mcp/__tests__/kernel.test.ts +++ b/packages/core/src/server/mcp/__tests__/kernel.test.ts @@ -61,6 +61,7 @@ type HoldImplementation = ActionTypeImplementation; const SQL_ERROR = 'SELECT email FROM players WHERE id=991'; const PLAYER_EMAIL = 'player@example.com'; +const REQUEST_BODY_NESTING = 400_000; const adminId = randomUUID(); const agentId = randomUUID(); @@ -84,6 +85,23 @@ const summaryHandler: SummaryHandler = async (input) => ({ balance: '12.50', }); +function nestedArrays(levels: number): unknown { + let value: unknown = playerId; + for (let level = 0; level < levels; level += 1) { + value = [value]; + } + return value; +} + +function unreadableInput(): Record { + return Object.defineProperty({}, 'playerId', { + enumerable: true, + get() { + throw new Error(SQL_ERROR); + }, + }); +} + function idempotentHold(): HoldImplementation { const applied = new Set(); return { @@ -217,6 +235,38 @@ describe('McpKernel.invokeTool', () => { expect(audit?.record.mock.calls[0]?.[0].after).not.toHaveProperty('playerPseudonym'); }); + it('audits a call whose input nests deeper than the call stack reaches', async () => { + const { kernel, audit, handler } = setup(); + const deep = nestedArrays(REQUEST_BODY_NESTING); + + const refused = await kernel.invokeTool('player.summary', deep, runContext()); + const unknown = await kernel.invokeTool('player.delete', deep, runContext()); + + expect(refused).toMatchObject({ ok: false, error: 'invalid_input' }); + expect(unknown).toEqual({ ok: false, error: 'unknown_tool' }); + expect(handler).not.toHaveBeenCalled(); + expect(audit?.record.mock.calls.map(([entry]) => entry.after)).toEqual([ + expect.objectContaining({ error: 'invalid_input', inputHash: sha256(deep) }), + expect.objectContaining({ error: 'unknown_tool', inputHash: sha256(deep) }), + ]); + }); + + it('audits once and answers internal_error when the call throws outside its handler', async () => { + const { kernel, audit, handler } = setup(); + + const result = await kernel.invokeTool('player.summary', unreadableInput(), runContext()); + + expect(result).toEqual({ ok: false, error: 'internal_error' }); + expect(handler).not.toHaveBeenCalled(); + expect(audit?.record).toHaveBeenCalledTimes(1); + expect(audit?.record.mock.calls[0]?.[0]).toMatchObject({ + action: 'mcp.tool.failed', + resourceId: 'player.summary', + after: { toolId: 'player.summary', error: 'internal_error' }, + }); + expect(JSON.stringify(audit?.record.mock.calls)).not.toContain('SELECT'); + }); + it('turns a thrown database error into internal_error without its message anywhere', async () => { const { kernel, audit } = setup({ handler: async () => { diff --git a/packages/core/src/server/mcp/canonical-json.ts b/packages/core/src/server/mcp/canonical-json.ts index 4a557373a..8d1d4eff0 100644 --- a/packages/core/src/server/mcp/canonical-json.ts +++ b/packages/core/src/server/mcp/canonical-json.ts @@ -1,9 +1,13 @@ import { createHash } from 'node:crypto'; +const MAX_NESTING = 64; + /** * JSON with object keys sorted at every depth and undefined members dropped, so equal - * values always produce the same bytes. Total over any input: a bigint serializes as its - * decimal string and a reference cycle as the string "[Circular]". + * values always produce the same bytes. Total over any JSON-shaped input: a bigint + * serializes as its decimal string, a reference cycle as the string "[Circular]", and an + * object or array nested more than 64 levels deep as the string "[TooDeep]", so values that + * differ only below that depth serialize alike. */ export function canonicalJson(value: unknown): string { return serialize(value, new Set()) ?? 'null'; @@ -26,6 +30,9 @@ function serialize(value: unknown, ancestors: Set): string | undefined { if (ancestors.has(value)) { return JSON.stringify('[Circular]'); } + if (ancestors.size >= MAX_NESTING) { + return JSON.stringify('[TooDeep]'); + } ancestors.add(value); const serialized = Array.isArray(value) ? `[${value.map((item: unknown) => serialize(item, ancestors) ?? 'null').join(',')}]` diff --git a/packages/core/src/server/mcp/kernel.ts b/packages/core/src/server/mcp/kernel.ts index 2132bc042..153fb037d 100644 --- a/packages/core/src/server/mcp/kernel.ts +++ b/packages/core/src/server/mcp/kernel.ts @@ -77,7 +77,7 @@ type KernelState = { type AuditEntry = Parameters[0]; -type ToolOutcome = { result: McpToolResult; inputHash: string; outputHash?: string }; +type ToolOutcome = { result: McpToolResult; inputHash?: string; outputHash?: string }; type ExecutionOutcome = { result: ActionExecutionResult; payloadHash: string }; @@ -344,18 +344,17 @@ async function invokeTool( return failure('invalid_run_context'); } const tool = state.tools.get(call.toolId); - const outcome = tool - ? await runTool(state, tool, call.input, run.data) - : { result: failure('unknown_tool'), inputHash: sha256Hex(call.input ?? {}) }; + const toolId = tool ? tool.contract.id : boundedId(call.toolId); + const outcome = await toolOutcome(state, { tool, toolId, input: call.input, run: run.data }); const audited = await recordAudit(state.audit, { actorId: runActorAdminId(run.data.actor), actorType: 'admin', action: outcome.result.ok ? 'mcp.tool.invoked' : 'mcp.tool.failed', resourceType: 'mcp-tool', - resourceId: tool ? tool.contract.id : boundedId(call.toolId), + resourceId: toolId, correlationId: run.data.correlationId, after: withoutUndefined({ - toolId: tool ? tool.contract.id : boundedId(call.toolId), + toolId, toolClass: tool?.contract.class, schemaVersion: tool?.contract.schemaVersion, runId: run.data.runId, @@ -370,6 +369,26 @@ async function invokeTool( return audited ? outcome.result : failure('audit_unavailable'); } +async function toolOutcome( + state: KernelState, + { + tool, + toolId, + input, + run, + }: { tool: ServedTool | undefined; toolId: string; input: unknown; run: RunContext }, +): Promise { + try { + if (!tool) { + return { result: failure('unknown_tool'), inputHash: sha256Hex(input ?? {}) }; + } + return await runTool(state, tool, input, run); + } catch (err) { + logger.error({ err, toolId, runId: run.runId }, 'mcp tool call failed outside its handler'); + return { result: failure('internal_error') }; + } +} + async function runTool( state: KernelState, tool: ServedTool, diff --git a/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts index 118d0058e..2c215d94d 100644 --- a/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts +++ b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts @@ -35,6 +35,8 @@ let admin: TestClient; let adminId: string; const SEEDED_ADMIN_EMAIL = 'admin@oss.dev'; +const REQUEST_BODY_NESTING = 400_000; +const SQL_ERROR = 'SELECT email FROM player WHERE id = 991'; const EXPECTED_TOOL_OWNERS = { 'ggr.summary': 'analytics', @@ -94,6 +96,23 @@ function readGeneratedAgentSurface() { ); } +function nestedArrays(levels: number): unknown { + let value: unknown = randomUUID(); + for (let level = 0; level < levels; level += 1) { + value = [value]; + } + return value; +} + +function unreadableInput(): Record { + return Object.defineProperty({}, 'playerId', { + enumerable: true, + get() { + throw new Error(SQL_ERROR); + }, + }); +} + function byId(entries: readonly T[]): T[] { return [...entries].sort((left, right) => left.id.localeCompare(right.id)); } @@ -438,6 +457,44 @@ describe('MCP read tools are IAM-checked, allow-listed and audited by hash', () const rows = await auditRowsOfCall(inverted.correlationId); expect(rows.map((row) => row.action)).toEqual(['mcp.tool.failed']); }); + + it('refuses and audits an input nested deeper than the call stack reaches', async () => { + const run = adminRun(adminId); + + const result = await kernel.invokeTool( + 'player.summary', + nestedArrays(REQUEST_BODY_NESTING), + run, + ); + + expect(result).toMatchObject({ ok: false, error: 'invalid_input' }); + const rows = await auditRowsOfCall(run.correlationId); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + action: 'mcp.tool.failed', + actorId: adminId, + resourceType: 'mcp-tool', + resourceId: 'player.summary', + }); + expect(rows[0]?.after).toMatchObject({ + error: 'invalid_input', + inputHash: expect.stringMatching(/^[0-9a-f]{64}$/), + }); + }); + + it('answers internal_error and still writes the one audit row when a call throws outside its handler', async () => { + const run = adminRun(adminId); + + const result = await kernel.invokeTool('player.summary', unreadableInput(), run); + + expect(result).toEqual({ ok: false, error: 'internal_error' }); + const rows = await auditRowsOfCall(run.correlationId); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ action: 'mcp.tool.failed', resourceId: 'player.summary' }); + expect(rows[0]?.after).toMatchObject({ error: 'internal_error' }); + expect(rows[0]?.after).not.toHaveProperty('inputHash'); + expect(JSON.stringify(rows[0])).not.toContain(SQL_ERROR); + }); }); describe('an agent-proposed withdrawal hold, then an admin decision', () => { From e0c2fa559101ae9aa6566338632b548d8aec92cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Wed, 7 Oct 2026 14:33:52 +0200 Subject: [PATCH 06/14] fix(core): check the caller's grant before answering an action precondition checkPrecondition answered any well-formed run actor without the IAM check a tool call and an execution make, so a caller without the action type's grant could learn from add_tag whether a player is self-excluded or on a break, from hold_withdrawal whether a withdrawal exists, and from request_enhanced_kyc where a player's verification stands. The precondition now resolves the actor's admin and checks the action type's grant through AdminGuard first, answering forbidden, or internal_error when the check itself fails, before the payload is parsed or the precondition runs. AdminGuard records the denial as it does for every route; the kernel still writes no record of its own for a precondition. --- packages/core/src/contracts/adapters/mcp.ts | 5 +++- .../src/server/mcp/__tests__/kernel.test.ts | 27 +++++++++++++++++-- packages/core/src/server/mcp/kernel.ts | 6 +++++ .../src/__tests__/mcp-kernel.e2e.test.ts | 26 ++++++++++++++++++ 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/core/src/contracts/adapters/mcp.ts b/packages/core/src/contracts/adapters/mcp.ts index 65e672ab0..2328ac463 100644 --- a/packages/core/src/contracts/adapters/mcp.ts +++ b/packages/core/src/contracts/adapters/mcp.ts @@ -260,7 +260,10 @@ export type McpKernel = { listActionTypes(): readonly ActionTypeDescriptor[]; listTriggers(): readonly TriggerDescriptor[]; invokeTool(toolId: string, input: unknown, run: RunContext): Promise; - /** Evaluation only: no IAM check and no audit record. */ + /** + * Checks the caller's IAM grant for the action type, as an execution does, and answers + * `forbidden` without running the precondition when it is missing. Writes no audit record. + */ checkPrecondition( actionTypeId: string, payload: unknown, diff --git a/packages/core/src/server/mcp/__tests__/kernel.test.ts b/packages/core/src/server/mcp/__tests__/kernel.test.ts index 92a40ccd7..f0f3e98bc 100644 --- a/packages/core/src/server/mcp/__tests__/kernel.test.ts +++ b/packages/core/src/server/mcp/__tests__/kernel.test.ts @@ -434,16 +434,39 @@ describe('McpKernel.invokeTool', () => { describe('McpKernel.checkPrecondition', () => { const payload = { withdrawalId, reason: 'velocity spike' }; - it('passes a satisfied precondition without IAM or audit', async () => { + it("passes a satisfied precondition once the caller holds the action type's grant, without an audit record", async () => { const { kernel, authorize, audit } = setup(); await expect( kernel.checkPrecondition('hold_withdrawal', payload, runContext()), ).resolves.toEqual({ ok: true }); - expect(authorize).not.toHaveBeenCalled(); + expect(authorize).toHaveBeenCalledWith(adminId, { resource: 'withdrawal', action: 'hold' }); + expect(audit?.record).not.toHaveBeenCalled(); + }); + + it('refuses a caller without the grant before the precondition runs', async () => { + const { kernel, implementation, audit } = setup({ authorize: async () => 'denied' }); + + await expect( + kernel.checkPrecondition('hold_withdrawal', payload, runContext()), + ).resolves.toEqual({ ok: false, error: 'forbidden' }); + expect(implementation.precondition).not.toHaveBeenCalled(); expect(audit?.record).not.toHaveBeenCalled(); }); + it('fails closed without running the precondition when authorization itself throws', async () => { + const { kernel, implementation } = setup({ + authorize: async () => { + throw new Error('iam store unreachable'); + }, + }); + + await expect( + kernel.checkPrecondition('hold_withdrawal', payload, runContext()), + ).resolves.toEqual({ ok: false, error: 'internal_error' }); + expect(implementation.precondition).not.toHaveBeenCalled(); + }); + it('returns a declared refusal code', async () => { const { kernel } = setup({ implementation: { diff --git a/packages/core/src/server/mcp/kernel.ts b/packages/core/src/server/mcp/kernel.ts index 153fb037d..39bd04f10 100644 --- a/packages/core/src/server/mcp/kernel.ts +++ b/packages/core/src/server/mcp/kernel.ts @@ -440,6 +440,12 @@ async function checkPrecondition( if (!action) { return failure('unknown_action'); } + const refusal = authorizationFailure( + await authorizeActor(state.authorize, run.data.actor, action.contract.iam), + ); + if (refusal) { + return refusal; + } const logContext = { actionTypeId: action.contract.id, runId: run.data.runId }; const parsed = parseInput(action.contract.payloadSchema, call.payload ?? {}, logContext); if (!parsed.success) { diff --git a/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts index 2c215d94d..8aa35d204 100644 --- a/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts +++ b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts @@ -686,6 +686,32 @@ describe('agent tag, note and enhanced-KYC actions are replay-safe', () => { }); }); + it("forbids a player's user id acting as an admin the precondition, which would reveal the exclusion", async () => { + const intruder = await newPlayer('precondition-intruder'); + const run = adminRun(intruder.userId); + const payload = { playerId: excluded.playerId, tagKey: 'vip', reason: vipReason }; + + expect(await kernel.checkPrecondition('add_tag', payload, run)).toEqual({ + ok: false, + error: 'forbidden', + }); + expect(await auditRowsOfCall(run.correlationId)).toHaveLength(0); + await vi.waitFor( + async () => { + const denials = await auditRowsOf('identity.user.unauthorized_access', 'tag:create'); + expect(denials).toContainEqual( + expect.objectContaining({ + actorType: 'player', + actorId: intruder.playerId, + resourceType: 'tag', + result: 'failure', + }), + ); + }, + { timeout: 15_000, interval: 100 }, + ); + }); + it('tags an active player vip once when the approved proposal is replayed', async () => { const payload = { playerId: active.playerId, tagKey: 'vip', reason: vipReason }; const proposalId = randomUUID(); From ef11c52917c2ede7e03edfaaa627adceb04f0490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Wed, 7 Oct 2026 14:34:07 +0200 Subject: [PATCH 07/14] fix(core): seal the mcp kernel binding so nothing can replace it after boot createApp binds MCP_KERNEL after every plugin and configure(), but it returns the container, and a later register() of the token dropped the cached kernel and served the replacement, without the IAM check or the audit record the kernel enforces. The container gains registerSealed(), which binds a token for good: every later registration of it throws. createApp binds the kernel that way, so a binding made in configure() is still overridden and a rebind after boot is refused. --- .../server/kernel/__tests__/container.test.ts | 24 ++++++++++++++ packages/core/src/server/kernel/container.ts | 22 +++++++++++-- .../runtime/__tests__/create-app.test.ts | 33 +++++++++++++++++++ .../core/src/server/runtime/create-app.ts | 7 ++-- .../src/__tests__/mcp-kernel.e2e.test.ts | 9 +++++ 5 files changed, 89 insertions(+), 6 deletions(-) diff --git a/packages/core/src/server/kernel/__tests__/container.test.ts b/packages/core/src/server/kernel/__tests__/container.test.ts index dc30d34e5..070d88d9b 100644 --- a/packages/core/src/server/kernel/__tests__/container.test.ts +++ b/packages/core/src/server/kernel/__tests__/container.test.ts @@ -26,6 +26,30 @@ describe('Container', () => { expect(c.get(TOKEN)).toBe('second'); }); + it('refuses every later registration of a sealed token and keeps serving it', () => { + const TOKEN = createToken('kernel'); + const c = new Container(); + c.registerSealed(TOKEN, () => 'enforcing'); + expect(c.get(TOKEN)).toBe('enforcing'); + + expect(() => c.register(TOKEN, () => 'replacement')).toThrow( + '[container] Token "kernel" is sealed: it is bound once and never rebound.', + ); + expect(() => c.registerUnsafe(TOKEN, () => 'replacement')).toThrow(/is sealed/); + expect(() => c.registerSealed(TOKEN, () => 'replacement')).toThrow(/is sealed/); + expect(c.get(TOKEN)).toBe('enforcing'); + }); + + it('lets a sealed registration replace an earlier ordinary one', () => { + const TOKEN = createToken('kernel'); + const c = new Container(); + c.register(TOKEN, () => 'overlay'); + + c.registerSealed(TOKEN, () => 'enforcing'); + + expect(c.get(TOKEN)).toBe('enforcing'); + }); + it('throws for an unregistered token', () => { const c = new Container(); expect(() => c.get(createToken('missing'))).toThrow(/No provider registered/); diff --git a/packages/core/src/server/kernel/container.ts b/packages/core/src/server/kernel/container.ts index e3273099a..a93596ffa 100644 --- a/packages/core/src/server/kernel/container.ts +++ b/packages/core/src/server/kernel/container.ts @@ -1,7 +1,8 @@ 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. +// token wins - overlays rebind adapters by registering after the default binding - +// until `registerSealed` binds it for good. // // Container itself is token-shape-agnostic (AnyToken, the shape Token AND // SealedToken both share) - it's a type-erased-at-runtime symbol map. The @@ -15,8 +16,9 @@ export type Factory = (c: Container) => T; * lazily and caches the instance for the container's lifetime; `register()` * for an already-resolved token clears the cached instance, so a later * `register()` (an overlay rebinding an adapter after the default plugin - * loads) always wins on the next `get()`. Resolving a token whose factory - * transitively depends on itself throws rather than recursing forever. + * loads) always wins on the next `get()`. A token bound with `registerSealed()` + * is the exception: every later registration of it throws. Resolving a token + * whose factory transitively depends on itself throws rather than recursing forever. * `dispose()` runs every `onDispose` callback in REVERSE registration order - * register dependencies before their dependents so teardown happens safely. */ @@ -24,6 +26,7 @@ export class Container { private readonly factories = new Map>(); private readonly instances = new Map(); private readonly resolving = new Set(); + private readonly sealed = new Set(); private readonly disposers: Array<() => void | Promise> = []; register( @@ -33,7 +36,20 @@ export class Container { this.registerUnsafe(token, factory); } + registerSealed( + token: T, + factory: (container: Container) => TokenValue, + ): void { + this.registerUnsafe(token, factory); + this.sealed.add(token); + } + registerUnsafe(token: AnyToken, factory: Factory): void { + if (this.sealed.has(token)) { + throw new Error( + `[container] Token "${token.description ?? String(token)}" is sealed: it is bound once and never rebound.`, + ); + } this.factories.set(token, factory as Factory); this.instances.delete(token); } 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 16cc51a47..56171f991 100644 --- a/packages/core/src/server/runtime/__tests__/create-app.test.ts +++ b/packages/core/src/server/runtime/__tests__/create-app.test.ts @@ -3,8 +3,10 @@ import { MESSAGE_BROKER, JOB_QUEUE, CACHE, + MCP_KERNEL, RATE_LIMITER, PLAYER_ACTIVITY_TRACKER, + type McpKernel, } from '@openora/core/contracts'; import { redisUrlForWorker } from '@openora/core/testing'; import { mock } from '../../../testing/mock.js'; @@ -60,6 +62,37 @@ describe('createApp - distributed-only durable seams (ADR-0030)', () => { }); }); +describe('createApp - the MCP kernel binding', () => { + it('serves its own kernel over a configure() binding and refuses a rebind after boot', async () => { + const saved = process.env['REDIS_URL']; + process.env['REDIS_URL'] = redisUrlForWorker(); + try { + const imposter = mock(); + const created = await createApp( + { plugins: [], databaseUrl: DUMMY_DATABASE_URL }, + (container) => { + container.register(MCP_KERNEL, () => imposter); + }, + ); + const kernel = created.container.get(MCP_KERNEL); + + expect(kernel).not.toBe(imposter); + expect(() => created.container.register(MCP_KERNEL, () => imposter)).toThrow( + /Token "MCP_KERNEL" is sealed/, + ); + expect(created.container.get(MCP_KERNEL)).toBe(kernel); + + await created.close(); + } finally { + if (saved === undefined) { + delete process.env['REDIS_URL']; + } else { + process.env['REDIS_URL'] = saved; + } + } + }); +}); + describe('createApp - streaming responses opt out of transformation', () => { it('marks an SSE response no-transform so an intermediary cannot batch its frames', async () => { const saved = process.env['REDIS_URL']; diff --git a/packages/core/src/server/runtime/create-app.ts b/packages/core/src/server/runtime/create-app.ts index 58b729aa7..f54240662 100644 --- a/packages/core/src/server/runtime/create-app.ts +++ b/packages/core/src/server/runtime/create-app.ts @@ -301,9 +301,10 @@ export async function createApp( assertDurableSeamsBound(container); - // Bound after configure() so no overlay or composition root can replace the wrapper that - // enforces IAM and audit, and before the routers so a router factory can resolve it. - container.register(MCP_KERNEL, (c) => + // Sealed after configure() so neither an overlay, the composition root nor a holder of the + // returned container can replace the wrapper that enforces IAM and audit, and bound before + // the routers so a router factory can resolve it. + container.registerSealed(MCP_KERNEL, (c) => createMcpKernel({ tools: registry.mcp.getTools(), actions: registry.actions.getAll(), diff --git a/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts index 8aa35d204..1870acc5c 100644 --- a/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts +++ b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts @@ -344,6 +344,15 @@ describe('MCP kernel registries match the generated catalog', () => { expect(byId(generated.agentTools)).toEqual(byId(servedTools)); expect(byId(generated.agentActions)).toEqual(byId(servedActions)); }); + + it('keeps serving the kernel createApp bound and refuses to rebind it', () => { + const imposter: McpKernel = { ...kernel, invokeTool: async () => ({ ok: true, output: {} }) }; + + expect(() => app.container.register(MCP_KERNEL, () => imposter)).toThrow( + /Token "MCP_KERNEL" is sealed/, + ); + expect(app.container.get(MCP_KERNEL)).toBe(kernel); + }); }); describe('MCP read tools are IAM-checked, allow-listed and audited by hash', () => { From d8d6f84724cf5cb713a27b41859dc5f0a81d00e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Wed, 7 Oct 2026 14:34:26 +0200 Subject: [PATCH 08/14] fix(core): refuse a tool output schema that passes undeclared keys through The output allow-list picks top-level keys only, and registration checked the output schema's top level only, so a tool allowing "player" with a z.looseObject, a catchall, a record or an untyped field under it booted and returned whatever the handler put there, personal data included. Registration now runs the input walker's record, loose, catchall, untyped and recursive rules over the whole output schema, at every depth, without the bounds an input needs. The parse then strips every key a schema does not declare at each level below an allowed key. --- .../src/server/mcp/__tests__/kernel.test.ts | 27 +++++++ .../src/server/mcp/contract-validation.ts | 57 +++++++++----- .../__tests__/module-registry.test.ts | 76 +++++++++++++++++++ 3 files changed, 139 insertions(+), 21 deletions(-) diff --git a/packages/core/src/server/mcp/__tests__/kernel.test.ts b/packages/core/src/server/mcp/__tests__/kernel.test.ts index f0f3e98bc..6111ecad6 100644 --- a/packages/core/src/server/mcp/__tests__/kernel.test.ts +++ b/packages/core/src/server/mcp/__tests__/kernel.test.ts @@ -184,6 +184,33 @@ describe('McpKernel.invokeTool', () => { expect(handler).toHaveBeenCalledWith({ playerId, limit: 5 }, run); }); + it('strips the keys an allowed output key does not declare, at every depth', async () => { + const nestedOutput = z.object({ + player: z.object({ id: UuidSchema, wallet: z.object({ balance: MoneyAmountSchema }) }), + }); + const fullRow = { + player: { id: playerId, email: PLAYER_EMAIL, wallet: { balance: '1.00', iban: SQL_ERROR } }, + }; + const { kernel } = kernelOf((registry) => { + registry.mcp.tool( + defineMcpTool({ + ...summaryTool, + id: 'player.nested', + outputSchema: nestedOutput, + redact: { allow: ['player'] }, + }), + () => async () => fullRow, + ); + }); + + const result = await kernel.invokeTool('player.nested', { playerId, limit: 5 }, runContext()); + + expect(result).toEqual({ + ok: true, + output: { player: { id: playerId, wallet: { balance: '1.00' } } }, + }); + }); + it('audits the call with hashes of the parsed input and the redacted output, never the data', async () => { const { kernel, audit } = setup(); const run = runContext(); diff --git a/packages/core/src/server/mcp/contract-validation.ts b/packages/core/src/server/mcp/contract-validation.ts index 3702370af..79df81f05 100644 --- a/packages/core/src/server/mcp/contract-validation.ts +++ b/packages/core/src/server/mcp/contract-validation.ts @@ -32,6 +32,8 @@ type Rejection = { problem: string; fix: string }; type InputLabel = 'inputSchema' | 'payloadSchema'; +type SchemaLabel = InputLabel | 'outputSchema'; + type McpRegistrationCandidate = { contract: Contract; owner: string; @@ -395,7 +397,7 @@ function isBound(value: unknown): boolean { function jsonFieldRejection( node: JsonSchemaNode, - label: InputLabel, + label: SchemaLabel, path: string, ): Rejection | null { const field = fieldLabel(label, path); @@ -405,7 +407,7 @@ function jsonFieldRejection( if (node.$ref !== undefined) { return { problem: `${field} is recursive`, - fix: 'flatten the shape; a tool input must be a finite tree', + fix: 'flatten the shape; a tool schema must be a finite tree', }; } const branches = [...(node.anyOf ?? []), ...(node.oneOf ?? []), ...(node.allOf ?? [])]; @@ -420,22 +422,9 @@ function jsonFieldRejection( } switch (node.type) { case 'string': - return node.maxLength !== undefined || - (node.format !== undefined && FIXED_WIDTH_STRING_FORMATS.has(node.format)) - ? null - : { - problem: `${field} is a string without a maximum length`, - fix: 'add .max(n); only an enum, a UUID or an ISO date, time or datetime may leave it out', - }; case 'number': case 'integer': - return (isBound(node.minimum) || isBound(node.exclusiveMinimum)) && - (isBound(node.maximum) || isBound(node.exclusiveMaximum)) - ? null - : { - problem: `${field} is a number without both a lower and an upper bound`, - fix: 'add .min() and .max() so a model cannot send an unbounded value', - }; + return label === 'outputSchema' ? null : unboundedRejection(node, field); case 'array': return arrayRejection(node, label, path); case 'object': @@ -448,6 +437,25 @@ function jsonFieldRejection( } } +function unboundedRejection(node: z.core.JSONSchema.JSONSchema, field: string): Rejection | null { + if (node.type === 'string') { + return node.maxLength !== undefined || + (node.format !== undefined && FIXED_WIDTH_STRING_FORMATS.has(node.format)) + ? null + : { + problem: `${field} is a string without a maximum length`, + fix: 'add .max(n); only an enum, a UUID or an ISO date, time or datetime may leave it out', + }; + } + return (isBound(node.minimum) || isBound(node.exclusiveMinimum)) && + (isBound(node.maximum) || isBound(node.exclusiveMaximum)) + ? null + : { + problem: `${field} is a number without both a lower and an upper bound`, + fix: 'add .min() and .max() so a model cannot send an unbounded value', + }; +} + function untypedRejection(field: string): Rejection { return { problem: `${field} accepts any value`, @@ -457,22 +465,24 @@ function untypedRejection(field: string): Rejection { function arrayRejection( node: z.core.JSONSchema.JSONSchema, - label: InputLabel, + label: SchemaLabel, path: string, ): Rejection | null { - if (node.maxItems === undefined) { + if (label !== 'outputSchema' && node.maxItems === undefined) { return { problem: `${fieldLabel(label, path)} is an array without a maximum length`, fix: 'add .max(n)', }; } - const items = node.items === undefined ? [] : [node.items].flat(); + const items = [node.items, node.additionalItems] + .flat() + .filter((item): item is JsonSchemaNode => item !== undefined); return firstRejection(items, (item) => jsonFieldRejection(item, label, `${path}[]`)); } function objectRejection( node: z.core.JSONSchema.JSONSchema, - label: InputLabel, + label: SchemaLabel, path: string, ): Rejection | null { const field = fieldLabel(label, path); @@ -513,14 +523,19 @@ function outputSchemaRejection({ outputSchema, redact }: McpToolContract): Rejec fix: 'wrap the result in an object; redact.allow names its top-level keys', }; } + let jsonSchema: z.core.JSONSchema.JSONSchema; try { - z.toJSONSchema(outputSchema, { target: 'draft-7', io: 'output' }); + jsonSchema = z.toJSONSchema(outputSchema, { target: 'draft-7', io: 'output' }); } catch (err) { return { problem: `outputSchema cannot be converted to JSON Schema (${errorMessage(err)})`, fix: 'outputs are JSON: return ISO strings for dates, decimal strings for money, and no transforms', }; } + const passThrough = jsonFieldRejection(jsonSchema, 'outputSchema', ''); + if (passThrough) { + return passThrough; + } const allow: readonly string[] = Array.isArray(redact?.allow) ? redact.allow : []; if (allow.length === 0) { return { 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 bb5c90ea7..fe245523a 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 @@ -266,6 +266,65 @@ describe('ModuleRegistryImpl - MCP tools and action types', () => { { outputSchema: z.array(z.string().max(5)).max(5) }, /outputSchema must be a top-level z\.object, not array/, ], + [ + 'a loose top-level output', + { outputSchema: z.looseObject({ playerId: UuidSchema }), redact: { allow: ['playerId'] } }, + /outputSchema accepts undeclared keys/, + ], + [ + 'a loose object under an allowed output key', + { + outputSchema: z.object({ playerId: UuidSchema, player: z.looseObject({ id: UuidSchema }) }), + redact: { allow: ['playerId', 'player'] }, + }, + /outputSchema field "player" accepts undeclared keys - drop \.loose\(\)\/\.catchall\(\) and declare every key/, + ], + [ + 'a catchall object inside an output array', + { + outputSchema: z.object({ + rows: z.array(z.object({ id: UuidSchema }).catchall(z.string())), + }), + redact: { allow: ['rows'] }, + }, + /outputSchema field "rows\[\]" accepts undeclared keys/, + ], + [ + 'a loose object in the rest of an output tuple', + { + outputSchema: z.object({ pair: z.tuple([UuidSchema], z.looseObject({ id: UuidSchema })) }), + redact: { allow: ['pair'] }, + }, + /outputSchema field "pair\[\]" accepts undeclared keys/, + ], + [ + 'a loose option in an output union', + { + outputSchema: z.object({ + subject: z.union([z.object({ id: UuidSchema }), z.looseObject({ email: z.string() })]), + }), + redact: { allow: ['subject'] }, + }, + /outputSchema field "subject" accepts undeclared keys/, + ], + [ + 'a z.record in the output', + { + outputSchema: z.object({ limits: z.record(z.string(), z.string()) }), + redact: { allow: ['limits'] }, + }, + /outputSchema field "limits" is a free-form map/, + ], + [ + 'an untyped output field', + { outputSchema: z.object({ raw: z.unknown() }), redact: { allow: ['raw'] } }, + /outputSchema field "raw" accepts any value/, + ], + [ + 'a free-form JSON output field', + { outputSchema: z.object({ data: z.json() }), redact: { allow: ['data'] } }, + /outputSchema field "data" is recursive/, + ], ['an empty allow-list', { redact: { allow: [] } }, /redact\.allow is empty/], [ 'an allow key the output schema does not declare', @@ -321,6 +380,23 @@ describe('ModuleRegistryImpl - MCP tools and action types', () => { expect(reg.mcp.getTools()).toEqual([]); }); + it('accepts unbounded strings, numbers and arrays in an output, which no model fills in', () => { + const { reg } = newRegistry(); + const tool = toolWith({ + outputSchema: z.object({ + note: z.string(), + level: z.number().int(), + history: z.array(z.object({ at: z.iso.datetime(), amount: MoneyAmountSchema })), + manager: z.object({ id: UuidSchema }).nullable(), + }), + redact: { allow: ['note', 'level', 'history', 'manager'] }, + }); + + reg.mcp.tool(tool, toolFactory); + + expect(reg.mcp.getTools()).toHaveLength(1); + }); + it('rejects a tool registered without a factory function', () => { const { reg } = newRegistry(); From 735431e9b12975f8e2b4f224460ef7d71bb74f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Wed, 7 Oct 2026 14:34:41 +0200 Subject: [PATCH 09/14] fix(wallet): clear a hold's reason when an admin approves the held withdrawal hold_withdrawal writes the proposed reason into the withdrawal's review reason, and approval rewrote the reviewer and review time but kept the reason, so an approved withdrawal showed its approver next to the text it had been held for. An admin approval now clears the reason, as a rejection replaces it with its own; the hold's reason stays on the wallet.withdrawal.held audit record. A pending withdrawal carries no reason, so approving one is unchanged. --- .../__tests__/wallet-withdrawal-hold.int.test.ts | 6 +++++- packages/core/src/wallet/service/wallet.service.ts | 10 ++++++++-- packages/testing/src/__tests__/mcp-kernel.e2e.test.ts | 6 +++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/core/src/wallet/__tests__/wallet-withdrawal-hold.int.test.ts b/packages/core/src/wallet/__tests__/wallet-withdrawal-hold.int.test.ts index 15653e7ec..809d75460 100644 --- a/packages/core/src/wallet/__tests__/wallet-withdrawal-hold.int.test.ts +++ b/packages/core/src/wallet/__tests__/wallet-withdrawal-hold.int.test.ts @@ -343,7 +343,11 @@ describe('WalletService review of a held withdrawal (real PG)', () => { expect(result).toEqual({ transactionId: pending.id, status: 'completed' }); expect(psp.processWithdrawal).toHaveBeenCalledTimes(1); - expect(await txById(pending.id)).toMatchObject({ status: 'completed', reviewedBy: approver }); + expect(await txById(pending.id)).toMatchObject({ + status: 'completed', + reviewedBy: approver, + reviewReason: null, + }); expect(await balanceOf(w.id)).toBe(20); expect(events.emit.mock.calls.map(([topic]) => topic)).toEqual([ 'wallet.withdrawal.approved', diff --git a/packages/core/src/wallet/service/wallet.service.ts b/packages/core/src/wallet/service/wallet.service.ts index 77502f388..593e6cc2e 100644 --- a/packages/core/src/wallet/service/wallet.service.ts +++ b/packages/core/src/wallet/service/wallet.service.ts @@ -1992,7 +1992,13 @@ export class WalletService { // Two-phase: commit the `processing` flip first (FOR UPDATE lock), then call the PSP OUTSIDE // the tx (a failure refunds in a second tx). Never inline the PSP call inside the hold transaction. const tx = await this.drizzle.db.transaction((txn) => - this.flipToProcessing({ txn, withdrawalId, adminId, from: QUEUED_WITHDRAWAL_STATUSES }), + this.flipToProcessing({ + txn, + withdrawalId, + adminId, + from: QUEUED_WITHDRAWAL_STATUSES, + reviewReason: null, + }), ); return this.settleApproved(tx, adminId, meta); } @@ -2013,7 +2019,7 @@ export class WalletService { withdrawalId: WalletTransaction['id']; adminId: User['id'] | null; from: readonly WalletTransaction['status'][]; - reviewReason?: string; + reviewReason?: WalletTransaction['reviewReason']; autoApprovalPivotAmount?: string; }): Promise { const current = findOneOrThrow( diff --git a/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts index 1870acc5c..b3c133479 100644 --- a/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts +++ b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts @@ -627,7 +627,11 @@ describe('an agent-proposed withdrawal hold, then an admin decision', () => { transactionId: heldWithdrawalId, status: 'completed', }); - expect((await withdrawalRow(heldWithdrawalId)).status).toBe('completed'); + expect(await withdrawalRow(heldWithdrawalId)).toEqual({ + status: 'completed', + reviewedBy: adminId, + reviewReason: null, + }); }); it('returns the funds of a rejected held withdrawal exactly once', async () => { From 3711e02cb2a2d2441dfb6241946042d3e307c3bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Wed, 7 Oct 2026 14:34:53 +0200 Subject: [PATCH 10/14] perf(wallet): index completed activity by wallet, status and time wallet.activity sums a wallet's completed deposits, withdrawals, bets and wins over the last 1 to 365 days, and a model may call it as often as its run allows. No index leads on (wallet_id, status, created_at), so the planner read every completed row of those types the wallet ever had through (wallet_id, type, status) and dropped the ones outside the window in the heap. The new index bounds the scan by the window. The generated migration is a plain CREATE INDEX, because the migrator applies each file inside a transaction. It holds a SHARE lock on wallet_transaction while it builds, which blocks every ledger write but no read, so a large deployment applies it in a quiet window. --- .../migrations/0028_abnormal_vertigo.sql | 1 + .../migrations/meta/0028_snapshot.json | 2075 +++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + packages/core/src/wallet/schema/index.ts | 5 + 4 files changed, 2088 insertions(+) create mode 100644 packages/core/src/wallet/drizzle/migrations/0028_abnormal_vertigo.sql create mode 100644 packages/core/src/wallet/drizzle/migrations/meta/0028_snapshot.json diff --git a/packages/core/src/wallet/drizzle/migrations/0028_abnormal_vertigo.sql b/packages/core/src/wallet/drizzle/migrations/0028_abnormal_vertigo.sql new file mode 100644 index 000000000..f50cd6c0d --- /dev/null +++ b/packages/core/src/wallet/drizzle/migrations/0028_abnormal_vertigo.sql @@ -0,0 +1 @@ +CREATE INDEX "wallet_transaction_wallet_id_status_created_at_idx" ON "wallet_transaction" USING btree ("wallet_id","status","created_at"); \ No newline at end of file diff --git a/packages/core/src/wallet/drizzle/migrations/meta/0028_snapshot.json b/packages/core/src/wallet/drizzle/migrations/meta/0028_snapshot.json new file mode 100644 index 000000000..376688bd3 --- /dev/null +++ b/packages/core/src/wallet/drizzle/migrations/meta/0028_snapshot.json @@ -0,0 +1,2075 @@ +{ + "id": "218b7cde-267a-4d9e-85ac-a0f205b72380", + "prevId": "b6e0e13c-0afa-4a67-bf5d-e95981a0d164", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auto_withdrawal_rule": { + "name": "auto_withdrawal_rule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auto_withdrawal_rule_user_id_unique": { + "name": "auto_withdrawal_rule_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet": { + "name": "wallet", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_user_id_unique": { + "name": "wallet_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_asset": { + "name": "wallet_asset", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_asset_id": { + "name": "provider_asset_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "min_deposit": { + "name": "min_deposit", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "min_withdrawal": { + "name": "min_withdrawal", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "withdrawal_fee": { + "name": "withdrawal_fee", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "deposit_enabled": { + "name": "deposit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "withdrawal_enabled": { + "name": "withdrawal_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sweep_dust_threshold": { + "name": "sweep_dust_threshold", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "sweep_fee_ceiling": { + "name": "sweep_fee_ceiling", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "pool_liquidity_floor": { + "name": "pool_liquidity_floor", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_asset_currency_network_idx": { + "name": "wallet_asset_currency_network_idx", + "columns": [ + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_auto_withdrawal_config": { + "name": "wallet_auto_withdrawal_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "singleton_key": { + "name": "singleton_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "fiat_threshold": { + "name": "fiat_threshold", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "crypto_threshold": { + "name": "crypto_threshold", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "exclude_risk_flags": { + "name": "exclude_risk_flags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['high_risk','bonus_abuser','kyc_rejected','withdrawal_review','multi_account']::text[]" + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_auto_withdrawal_config_singletonKey_unique": { + "name": "wallet_auto_withdrawal_config_singletonKey_unique", + "nullsNotDistinct": false, + "columns": ["singleton_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_balance": { + "name": "wallet_balance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_id": { + "name": "wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_balance_wallet_id_currency_idx": { + "name": "wallet_balance_wallet_id_currency_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_balance_wallet_id_wallet_id_fk": { + "name": "wallet_balance_wallet_id_wallet_id_fk", + "tableFrom": "wallet_balance", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_bonus_credit": { + "name": "wallet_bonus_credit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_id": { + "name": "wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "wallet_bonus_credit_source_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "credited_amount": { + "name": "credited_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "rollover_multiplier": { + "name": "rollover_multiplier", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "rollover_required": { + "name": "rollover_required", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "rollover_progress": { + "name": "rollover_progress", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "status": { + "name": "status", + "type": "wallet_bonus_credit_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "wallet_bonus_credit_user_id_currency_status_idx": { + "name": "wallet_bonus_credit_user_id_currency_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_bonus_credit_wallet_id_idx": { + "name": "wallet_bonus_credit_wallet_id_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_bonus_credit_wallet_id_wallet_id_fk": { + "name": "wallet_bonus_credit_wallet_id_wallet_id_fk", + "tableFrom": "wallet_bonus_credit", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_bonus_rollover_config": { + "name": "wallet_bonus_rollover_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "singleton_key": { + "name": "singleton_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "multiplier": { + "name": "multiplier", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_bonus_rollover_config_singletonKey_unique": { + "name": "wallet_bonus_rollover_config_singletonKey_unique", + "nullsNotDistinct": false, + "columns": ["singleton_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_custody_sweep": { + "name": "wallet_custody_sweep", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "estimated_fee": { + "name": "estimated_fee", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pool_ref": { + "name": "pool_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "wallet_custody_sweep_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_custody_sweep_user_id_currency_network_idx": { + "name": "wallet_custody_sweep_user_id_currency_network_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status IN ('pending','processing','unknown')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_custody_sweep_external_id_idx": { + "name": "wallet_custody_sweep_external_id_idx", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_custody_sweep\".\"external_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_custody_sweep_status_created_at_idx": { + "name": "wallet_custody_sweep_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_custody_sweep_provider_name_created_at_idx": { + "name": "wallet_custody_sweep_provider_name_created_at_idx", + "columns": [ + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_deposit_address": { + "name": "wallet_deposit_address", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_deposit_address_user_id_currency_network_idx": { + "name": "wallet_deposit_address_user_id_currency_network_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_deposit_address\".\"network\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_deposit_address_user_id_currency_idx": { + "name": "wallet_deposit_address_user_id_currency_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_deposit_address\".\"network\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_deposit_address_address_tag_idx": { + "name": "wallet_deposit_address_address_tag_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_deposit_address\".\"tag\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_deposit_address_address_network_currency_idx": { + "name": "wallet_deposit_address_address_network_currency_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_deposit_address\".\"tag\" IS NULL AND \"wallet_deposit_address\".\"network\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_deposit_address_address_idx": { + "name": "wallet_deposit_address_address_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_job_run": { + "name": "wallet_job_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_name": { + "name": "job_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "wallet_job_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_job_run_job_name_idx": { + "name": "wallet_job_run_job_name_idx", + "columns": [ + { + "expression": "job_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_job_run\".\"finished_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_job_run_job_name_started_at_idx": { + "name": "wallet_job_run_job_name_started_at_idx", + "columns": [ + { + "expression": "job_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_provider_vault": { + "name": "wallet_provider_vault", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vault_account_id": { + "name": "vault_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_provider_vault_user_id_provider_name_idx": { + "name": "wallet_provider_vault_user_id_provider_name_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_provider_vault_provider_name_vault_account_id_idx": { + "name": "wallet_provider_vault_provider_name_vault_account_id_idx", + "columns": [ + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vault_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_reconciliation_finding": { + "name": "wallet_reconciliation_finding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "wallet_reconciliation_finding_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transaction_id": { + "name": "transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "wallet_reconciliation_finding_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_reconciliation_finding_kind_provider_external_id_idx": { + "name": "wallet_reconciliation_finding_kind_provider_external_id_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_reconciliation_finding\".\"external_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_reconciliation_finding_status_created_at_idx": { + "name": "wallet_reconciliation_finding_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_reconciliation_finding_run_id_idx": { + "name": "wallet_reconciliation_finding_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_transaction": { + "name": "wallet_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_id": { + "name": "wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "wallet_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "wallet_transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "rail": { + "name": "rail", + "type": "wallet_rail", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "wallet_transaction_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_reason": { + "name": "review_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_approval_pivot_amount": { + "name": "auto_approval_pivot_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "reference_currency": { + "name": "reference_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_amount": { + "name": "reference_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "reference_rate": { + "name": "reference_rate", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "reference_rate_as_of": { + "name": "reference_rate_as_of", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fee": { + "name": "fee", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_ref_id": { + "name": "provider_ref_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_round_id": { + "name": "external_round_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_address": { + "name": "destination_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_tag": { + "name": "destination_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_wallet_id": { + "name": "destination_wallet_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_transaction_wallet_id_idx": { + "name": "wallet_transaction_wallet_id_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_created_at_idx": { + "name": "wallet_transaction_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_type_idx": { + "name": "wallet_transaction_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_status_idx": { + "name": "wallet_transaction_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_rail_idx": { + "name": "wallet_transaction_rail_idx", + "columns": [ + { + "expression": "rail", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_currency_idx": { + "name": "wallet_transaction_currency_idx", + "columns": [ + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_currency_network_idx": { + "name": "wallet_transaction_currency_network_idx", + "columns": [ + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_tx_hash_idx": { + "name": "wallet_transaction_tx_hash_idx", + "columns": [ + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_destination_address_idx": { + "name": "wallet_transaction_destination_address_idx", + "columns": [ + { + "expression": "lower(\"destination_address\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_provider_ref_idx": { + "name": "wallet_transaction_provider_ref_idx", + "columns": [ + { + "expression": "provider_ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_status_type_created_at_idx": { + "name": "wallet_transaction_status_type_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_wallet_id_type_status_idx": { + "name": "wallet_transaction_wallet_id_type_status_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_wallet_id_status_created_at_idx": { + "name": "wallet_transaction_wallet_id_status_created_at_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_provider_ref_id_idx": { + "name": "wallet_transaction_provider_ref_id_idx", + "columns": [ + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_transaction\".\"provider_ref_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_external_round_id_idx": { + "name": "wallet_transaction_external_round_id_idx", + "columns": [ + { + "expression": "external_round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_wallet_id_idempotency_key_idx": { + "name": "wallet_transaction_wallet_id_idempotency_key_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_transaction\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_transaction_wallet_id_wallet_id_fk": { + "name": "wallet_transaction_wallet_id_wallet_id_fk", + "tableFrom": "wallet_transaction", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_withdrawal_address": { + "name": "wallet_withdrawal_address", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination_tag": { + "name": "destination_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_wallet_id": { + "name": "provider_wallet_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_withdrawal_address_user_id_currency_network_address_idx": { + "name": "wallet_withdrawal_address_user_id_currency_network_address_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"destination_tag\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_withdrawal_address_user_id_created_at_idx": { + "name": "wallet_withdrawal_address_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.wallet_bonus_credit_source_type": { + "name": "wallet_bonus_credit_source_type", + "schema": "public", + "values": ["gift", "rain"] + }, + "public.wallet_bonus_credit_status": { + "name": "wallet_bonus_credit_status", + "schema": "public", + "values": ["active", "completed"] + }, + "public.wallet_custody_sweep_status": { + "name": "wallet_custody_sweep_status", + "schema": "public", + "values": ["pending", "processing", "completed", "failed", "unknown"] + }, + "public.wallet_job_run_status": { + "name": "wallet_job_run_status", + "schema": "public", + "values": ["running", "completed", "skipped", "failed", "abandoned"] + }, + "public.wallet_rail": { + "name": "wallet_rail", + "schema": "public", + "values": ["crypto", "fiat"] + }, + "public.wallet_reconciliation_finding_kind": { + "name": "wallet_reconciliation_finding_kind", + "schema": "public", + "values": [ + "missing_deposit", + "unattributed_deposit", + "amount_mismatch", + "currency_mismatch", + "status_mismatch", + "unknown_at_provider", + "unconfigured_asset", + "stuck_sweep", + "rg_limit_breach", + "stuck_swap" + ] + }, + "public.wallet_reconciliation_finding_status": { + "name": "wallet_reconciliation_finding_status", + "schema": "public", + "values": ["open", "resolved"] + }, + "public.wallet_transaction_direction": { + "name": "wallet_transaction_direction", + "schema": "public", + "values": ["credit", "debit"] + }, + "public.wallet_transaction_status": { + "name": "wallet_transaction_status", + "schema": "public", + "values": ["pending", "processing", "completed", "failed", "rejected", "on_hold", "cancelled"] + }, + "public.wallet_transaction_type": { + "name": "wallet_transaction_type", + "schema": "public", + "values": [ + "deposit", + "withdrawal", + "bet", + "win", + "loss", + "bonus", + "tip", + "gift", + "rain", + "manual_credit", + "manual_debit", + "swap_out", + "swap_in", + "bet_reversal", + "cashback" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/wallet/drizzle/migrations/meta/_journal.json b/packages/core/src/wallet/drizzle/migrations/meta/_journal.json index c55180b02..6864b1375 100644 --- a/packages/core/src/wallet/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/wallet/drizzle/migrations/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1790954745596, "tag": "0027_unique_mindworm", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1791376081198, + "tag": "0028_abnormal_vertigo", + "breakpoints": true } ] } diff --git a/packages/core/src/wallet/schema/index.ts b/packages/core/src/wallet/schema/index.ts index f9968ceb3..f9d262df9 100644 --- a/packages/core/src/wallet/schema/index.ts +++ b/packages/core/src/wallet/schema/index.ts @@ -176,6 +176,11 @@ export const walletTransaction = pgTable( index('wallet_transaction_provider_ref_idx').on(t.providerRefId), index('wallet_transaction_status_type_created_at_idx').on(t.status, t.type, t.createdAt), index('wallet_transaction_wallet_id_type_status_idx').on(t.walletId, t.type, t.status), + index('wallet_transaction_wallet_id_status_created_at_idx').on( + t.walletId, + t.status, + t.createdAt, + ), uniqueIndex('wallet_transaction_provider_ref_id_idx') .on(t.providerName, t.providerRefId) .where(sql`${t.providerRefId} IS NOT NULL`), From 70b818a3355079e435fcd6fe0433674a712f41e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Wed, 7 Oct 2026 14:36:53 +0200 Subject: [PATCH 11/14] chore(release): bump core a major for the mcp kernel's breaking types The changeset lists breaking type changes for core but asked for a minor bump, so the release notes would file them under minor changes. Core now bumps major, as a breaking change does elsewhere; @openora/mcp only adds tools and stays minor. The summary also covers what the review changed: the sealed MCP_KERNEL binding, the output schema rule, the precondition's IAM check, the cleared hold reason on approval, and the wallet index the migration builds while it blocks ledger writes. --- .changeset/mcp-kernel.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.changeset/mcp-kernel.md b/.changeset/mcp-kernel.md index fd98bb9e9..f29742dbe 100644 --- a/.changeset/mcp-kernel.md +++ b/.changeset/mcp-kernel.md @@ -1,23 +1,25 @@ --- -'@openora/core': minor +'@openora/core': major '@openora/mcp': minor --- -MCP kernel. A module now registers agent **tools** with `ctx.mcp.tool(defineMcpTool({...}), factory)` and agent **action types** with `ctx.actions.register(defineActionType({...}), factory)` in its own `plugin.ts`. An agent runtime or an MCP transport reaches both only through the `MCP_KERNEL` token, never by importing a module. The kernel wrapper: +MCP kernel. A module now registers agent **tools** with `ctx.mcp.tool(defineMcpTool({...}), factory)` and agent **action types** with `ctx.actions.register(defineActionType({...}), factory)` in its own `plugin.ts`. An agent runtime or an MCP transport reaches both only through the `MCP_KERNEL` token, never by importing a module; `createApp` binds that token sealed, so registering it again after boot throws. The kernel wrapper: -- rejects at boot a tool input or action payload that is not a top-level `z.object`, has a string without a maximum length (enums, UUIDs and ISO dates aside), a number without both bounds or without `z.coerce`, an array without a maximum, or a free-form map, and an action type without an IAM resource; -- checks the IAM grant of the admin behind the run's actor (`AdminGuard.assertUser`, the same enforcement and denial audit as an admin route) before a handler or executor runs; +- rejects at boot a tool input or action payload that is not a top-level `z.object`, has a string without a maximum length (enums, UUIDs and ISO dates aside), a number without both bounds or without `z.coerce`, an array without a maximum, or a free-form map, a tool output schema that lets undeclared keys through at any depth (a loose or catchall object, a record, an untyped field), and an action type without an IAM resource; +- checks the IAM grant of the admin behind the run's actor (`AdminGuard.assertUser`, the same enforcement and denial audit as an admin route) before a handler, a precondition or an executor runs; - returns only the output keys on the tool's allow-list, maps any exception to a declared error code or `internal_error` (never the exception text), and writes one audit record per call (`mcp.tool.invoked` / `mcp.tool.failed` / `mcp.action.executed` / `mcp.action.failed`, with SHA-256 hashes of input and output). A call whose audit record cannot be written fails with `audit_unavailable`; an action execution retries the write first, because its effect is already committed. An action executor is replay-safe on the state it writes: a replay while its effect still holds performs nothing and answers `already_applied`. Core stores no proposals, so after someone else changes that state a replay may apply again or answer a declared refusal. Whatever stores proposals must not re-execute one it recorded as executed. -Also new: the shared `RunContext` type; `PlatformConfig.agents` (run limits, retention, and a model allow-list of `//` ids with step timeout, fallbacks, capability flags and deprecation date, where the gateway must be listed in `agents.modelGateways`, `['openrouter']` by default); a trigger catalog derived from the domain events; and the IAM resources `agent` (`view`, `create`, `update`, `publish`, `run`), `agent-proposal` (`view`, `approve`, `reject`) and `agent-config` (`view`, `update`). +Also new: `Container.registerSealed`, which binds a token that no later registration may replace; the shared `RunContext` type; `PlatformConfig.agents` (run limits, retention, and a model allow-list of `//` ids with step timeout, fallbacks, capability flags and deprecation date, where the gateway must be listed in `agents.modelGateways`, `['openrouter']` by default); a trigger catalog derived from the domain events; and the IAM resources `agent` (`view`, `create`, `update`, `publish`, `run`), `agent-proposal` (`view`, `approve`, `reject`) and `agent-config` (`view`, `update`). Who holds the new IAM resources after an upgrade: a Super Admin gets them at once, because a super-admin role's grants are derived from the permission statement at runtime, and so does the built-in static `admin` role. No other seeded or custom role is granted them. Grant `agent`, `agent-proposal` or `agent-config` to the roles that should run agents, review proposals or edit agent settings (`iam.setRolePermissions`) before a screen is gated on them. The kernel checks each tool's and action type's own resource, so an agent acting for an admin can only read and execute what that admin could through the Backoffice. First registrations: tools `player.summary`, `wallet.activity`, `kyc.status`, `ggr.summary`; action types `add_tag`, `add_note`, `hold_withdrawal`, `send_to_manual_review`, `request_enhanced_kyc`. Tools return data as the module has it and declare which output keys carry personal data (`redact.personal`); pseudonymising it before a model sees it is the consumer's job. `IdentityReader` gains an optional `getUserIdByPlayerId`, which the wallet and compliance registrations need: an operator who binds their own `IDENTITY_READER` without it gets a boot warning, and those four registrations answer `internal_error` until it is added. -A withdrawal held by `hold_withdrawal` (`on_hold`) can now be approved or rejected by an admin through the existing routes; before, both refused anything but `pending`. Auto-approval still only ever takes a `pending` withdrawal. +A withdrawal held by `hold_withdrawal` (`on_hold`) can now be approved or rejected by an admin through the existing routes; before, both refused anything but `pending`. Approving one clears the reason it was held for, which its `wallet.withdrawal.held` audit record keeps. Auto-approval still only ever takes a `pending` withdrawal. + +The wallet migration adds an index on `wallet_transaction` by wallet, status and creation time for the activity read. It is a plain `CREATE INDEX`, which blocks writes to that table, and with them every ledger movement, while it builds; on a large ledger, apply it in a quiet window. `@openora/mcp` lists the agent surface: `list-agent-tools`, counts in `catalog-overview`, and a module's tools and action types in `describe-module`. From 88502cc0c3aa3ab78d02a2052e71dd3aed809f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Wed, 7 Oct 2026 15:17:43 +0200 Subject: [PATCH 12/14] fix(player-note): key an agent's note on its proposal, not on its text add_note recognised a replay by a note with the same player, author and text, so an approver who approved two proposals saying "reviewed, no issues" got one note and an already_applied for the second, and a note the same admin had typed in the Backoffice suppressed a proposal's note too. The note now keeps the proposal id in a nullable proposal_id column under a partial unique index, and the executor inserts with ON CONFLICT DO NOTHING on it: nothing inserted means a replay. The unique index also serialises two executions of one proposal, so the per-proposal advisory lock and the content lookup are gone. Backoffice notes keep proposal_id null and are never matched, and the note routes still do not return it. ADR-0042's trade-off, the executor contract wording and the changeset describe the new key. --- .changeset/mcp-kernel.md | 2 + ...registries-behind-one-enforcing-wrapper.md | 6 +- docs/standards/module-structure.md | 2 +- packages/core/src/contracts/adapters/mcp.ts | 8 +- .../player-note.agent-actions.int.test.ts | 75 +++++++++++++-- .../0001_broken_carmella_unuscione.sql | 2 + .../migrations/meta/0001_snapshot.json | 93 +++++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 ++ .../core/src/pam/player-note/schema/index.ts | 34 ++++--- .../service/player-note.service.ts | 86 ++++++++--------- .../src/__tests__/mcp-kernel.e2e.test.ts | 85 ++++++++++++++++- 11 files changed, 324 insertions(+), 76 deletions(-) create mode 100644 packages/core/src/pam/player-note/drizzle/migrations/0001_broken_carmella_unuscione.sql create mode 100644 packages/core/src/pam/player-note/drizzle/migrations/meta/0001_snapshot.json diff --git a/.changeset/mcp-kernel.md b/.changeset/mcp-kernel.md index f29742dbe..c24263f55 100644 --- a/.changeset/mcp-kernel.md +++ b/.changeset/mcp-kernel.md @@ -21,6 +21,8 @@ A withdrawal held by `hold_withdrawal` (`on_hold`) can now be approved or reject The wallet migration adds an index on `wallet_transaction` by wallet, status and creation time for the activity read. It is a plain `CREATE INDEX`, which blocks writes to that table, and with them every ledger movement, while it builds; on a large ledger, apply it in a quiet window. +The player-note migration adds a nullable `proposal_id` to `player_note` under a partial unique index, so `add_note` writes one note per approved proposal: a replay adds nothing, while two proposals with the same text each add theirs. A note written in the Backoffice keeps it null, and the note routes do not return it. + `@openora/mcp` lists the agent surface: `list-agent-tools`, counts in `catalog-overview`, and a module's tools and action types in `describe-module`. **Breaking (types):** a hand-written `ModuleRegistry` implementation, a test double for example, needs the new `actions` member, `mcp.getTools`, and the two-argument `mcp.tool` overload. A TypeScript `PlatformConfig` object literal needs `agents`; config files parse unchanged, because the section defaults. The earlier untyped `ctx.mcp.tool({ name, description, inputSchema, handler })` still registers, but the kernel does not serve it; move such a tool to the contract form. `createMcpKernel` is exported only from `@openora/core/testing`; production code resolves `MCP_KERNEL`. diff --git a/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md b/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md index bfc4a2228..25eaa2ff0 100644 --- a/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md +++ b/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md @@ -120,8 +120,10 @@ Rejected alternatives: - The input rules constrain authors: a free-text field needs a `max`, a number needs coercion and bounds, and a union must be flattened. - Without a proposals table in core, replay safety is each executor's responsibility, keyed on - the state it writes. `add_note` recognises a replay by the same player, author and text, so a - second approved proposal carrying exactly the same note from the same approver adds nothing. + the state it writes. `add_note` keeps the proposal id on the note it writes, under a partial + unique index, so the note table carries a column of agent-proposal state: a replay of that + proposal adds nothing, another proposal with the same text adds its own note, and a note an + admin wrote never stands in for one. - The earlier untyped `ctx.mcp.tool(definition)` form still registers, for compatibility, but the kernel does not serve it. diff --git a/docs/standards/module-structure.md b/docs/standards/module-structure.md index a1f87f680..15fc03800 100644 --- a/docs/standards/module-structure.md +++ b/docs/standards/module-structure.md @@ -15,7 +15,7 @@ Detail for the boundary lines in `conventions`. Read this when creating a module Service methods are data-in/data-out; side effects (DB writes, event emits, adapter calls) at the edges. -A module's agent surface keeps the same split: tool and action-type contracts (`defineMcpTool` / `defineActionType`) in `contract/agent-tools.ts`, the registration and its factory in `plugin.ts` (`ctx.mcp.tool`, `ctx.actions.register`), the logic in the service. The MCP kernel serves them and refuses at boot a shape it cannot serve, naming the fix. A handler signals a declared error code by throwing `McpToolError`; anything else reaches the caller as an internal error. The kernel does not re-run a precondition before execution, so an executor re-checks its own state under its own lock, and a replay while its effect still holds performs nothing and answers `already_applied`. Executors key on that state, not on the proposal id, so whatever stores proposals must not re-execute one it recorded as executed. ADR-0042. +A module's agent surface keeps the same split: tool and action-type contracts (`defineMcpTool` / `defineActionType`) in `contract/agent-tools.ts`, the registration and its factory in `plugin.ts` (`ctx.mcp.tool`, `ctx.actions.register`), the logic in the service. The MCP kernel serves them and refuses at boot a shape it cannot serve, naming the fix. A handler signals a declared error code by throwing `McpToolError`; anything else reaches the caller as an internal error. The kernel does not re-run a precondition before execution, so an executor re-checks its own state under its own lock, and a replay while its effect still holds performs nothing and answers `already_applied`. Core stores no proposals, so executors key on that state, which may record the proposal id (an agent's note does), and whatever stores proposals must not re-execute one it recorded as executed. ADR-0042. ## Dependency injection (no decorators, no reflect-metadata) diff --git a/packages/core/src/contracts/adapters/mcp.ts b/packages/core/src/contracts/adapters/mcp.ts index 2328ac463..b0d4be273 100644 --- a/packages/core/src/contracts/adapters/mcp.ts +++ b/packages/core/src/contracts/adapters/mcp.ts @@ -175,10 +175,10 @@ export type ActionExecutionOutcome = z.infer = ( payload: z.output

, diff --git a/packages/core/src/pam/player-note/__tests__/player-note.agent-actions.int.test.ts b/packages/core/src/pam/player-note/__tests__/player-note.agent-actions.int.test.ts index 11b5bdf2f..1af7fb1fe 100644 --- a/packages/core/src/pam/player-note/__tests__/player-note.agent-actions.int.test.ts +++ b/packages/core/src/pam/player-note/__tests__/player-note.agent-actions.int.test.ts @@ -12,7 +12,12 @@ import { type ActionExecutionContext, type RunContext, } from '@openora/core/contracts'; -import { createMcpKernel, createTestDb, type TestDb } from '@openora/core/testing'; +import { + createMcpKernel, + createTestDb, + waitForRowLockWaiter, + type TestDb, +} from '@openora/core/testing'; import { player } from '@openora/core/pam/schema/profile'; import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; import { makeAuditWriter } from '../../../testing/mock.js'; @@ -22,6 +27,7 @@ import { PlayerNoteService, PlayerNotFoundError } from '../service/player-note.s import playerNotePlugin from '../plugin.js'; const ADMIN_ID = randomUUID(); +const REVIEWED = 'Reviewed, no issues.'; let db: TestDb; @@ -108,8 +114,13 @@ describe('PlayerNoteService.createForProposal (real PG)', () => { const rows = await notesOf(target.id); expect(rows).toHaveLength(1); - expect(rows[0]).toMatchObject({ actorId: ADMIN_ID, content: proposal.content }); + expect(rows[0]).toMatchObject({ + actorId: ADMIN_ID, + content: proposal.content, + proposalId: proposal.proposalId, + }); expect(result).toMatchObject({ status: 'created', note: { id: rows[0]!.id } }); + expect(result).not.toHaveProperty('note.proposalId'); expect(audit.recordInTransaction).toHaveBeenCalledTimes(1); expect(audit.recordInTransaction).toHaveBeenCalledWith(expect.anything(), { actorId: ADMIN_ID, @@ -135,6 +146,39 @@ describe('PlayerNoteService.createForProposal (real PG)', () => { expect(audit.recordInTransaction).toHaveBeenCalledTimes(1); }); + it('writes a note for each of two proposals carrying the same text from the same admin', async () => { + const { svc, audit } = makeService(); + const target = await seedPlayer(); + const first = proposalFor(target.id, REVIEWED); + const second = proposalFor(target.id, REVIEWED); + + const results = [await svc.createForProposal(first), await svc.createForProposal(second)]; + + expect(results.map((r) => r.status)).toEqual(['created', 'created']); + const rows = await notesOf(target.id); + expect(rows).toHaveLength(2); + expect(rows.map((row) => row.proposalId)).toEqual( + expect.arrayContaining([first.proposalId, second.proposalId]), + ); + expect(audit.recordInTransaction).toHaveBeenCalledTimes(2); + }); + + it('adds the proposal note beside an identical note the same admin wrote by hand', async () => { + const { svc } = makeService(); + const target = await seedPlayer(); + const proposal = proposalFor(target.id, REVIEWED); + await svc.create({ playerId: target.id, content: REVIEWED }, ADMIN_ID); + + const result = await svc.createForProposal(proposal); + + expect(result).toMatchObject({ status: 'created' }); + const rows = await notesOf(target.id); + expect(rows).toHaveLength(2); + expect(rows.map((row) => row.proposalId)).toEqual( + expect.arrayContaining([null, proposal.proposalId]), + ); + }); + it('writes exactly one note when the same proposal executes twice concurrently', async () => { const { svc, audit } = makeService(); const target = await seedPlayer(); @@ -150,6 +194,23 @@ describe('PlayerNoteService.createForProposal (real PG)', () => { expect(audit.recordInTransaction).toHaveBeenCalledTimes(1); }); + it('waits out an uncommitted execution of the same proposal, then writes nothing', async () => { + const { svc, audit } = makeService(); + const target = await seedPlayer(); + const proposal = proposalFor(target.id); + + const { racing } = await db.drizzle.db.transaction(async (tx) => { + await tx.insert(playerNote).values(proposal); + const pending = svc.createForProposal(proposal); + await waitForRowLockWaiter(db); + return { racing: pending }; + }); + + expect(await racing).toEqual({ status: 'already_created' }); + expect(await notesOf(target.id)).toHaveLength(1); + expect(audit.recordInTransaction).not.toHaveBeenCalled(); + }); + it('refuses an unknown player and writes neither a note nor an audit record', async () => { const { svc, audit } = makeService(); const proposal = proposalFor(randomUUID()); @@ -160,16 +221,17 @@ describe('PlayerNoteService.createForProposal (real PG)', () => { expect(audit.recordInTransaction).not.toHaveBeenCalled(); }); - it('rolls the note back when its audit record cannot be written', async () => { + it('rolls the note back when its audit record cannot be written, so a retry still applies it', async () => { const { svc, audit } = makeService(); audit.recordInTransaction.mockRejectedValueOnce(new Error('audit store unavailable')); const target = await seedPlayer(); + const proposal = proposalFor(target.id); - await expect(svc.createForProposal(proposalFor(target.id))).rejects.toThrow( - 'audit store unavailable', - ); + await expect(svc.createForProposal(proposal)).rejects.toThrow('audit store unavailable'); expect(await notesOf(target.id)).toHaveLength(0); + expect(await svc.createForProposal(proposal)).toMatchObject({ status: 'created' }); + expect(await notesOf(target.id)).toHaveLength(1); }); }); @@ -226,6 +288,7 @@ describe('add_note action type (plugin wiring through the MCP kernel)', () => { expect(rows[0]).toMatchObject({ actorId: ADMIN_ID, content: 'Player asked to close the account.', + proposalId: execution.proposalId, }); expect(first).toEqual({ ok: true, outcome: 'applied', detail: { noteId: rows[0]!.id } }); expect(replay).toEqual({ ok: true, outcome: 'already_applied' }); diff --git a/packages/core/src/pam/player-note/drizzle/migrations/0001_broken_carmella_unuscione.sql b/packages/core/src/pam/player-note/drizzle/migrations/0001_broken_carmella_unuscione.sql new file mode 100644 index 000000000..994419ac6 --- /dev/null +++ b/packages/core/src/pam/player-note/drizzle/migrations/0001_broken_carmella_unuscione.sql @@ -0,0 +1,2 @@ +ALTER TABLE "player_note" ADD COLUMN "proposal_id" uuid;--> statement-breakpoint +CREATE UNIQUE INDEX "player_note_proposal_id_idx" ON "player_note" USING btree ("proposal_id") WHERE "player_note"."proposal_id" IS NOT NULL; \ No newline at end of file diff --git a/packages/core/src/pam/player-note/drizzle/migrations/meta/0001_snapshot.json b/packages/core/src/pam/player-note/drizzle/migrations/meta/0001_snapshot.json new file mode 100644 index 000000000..a71013a7a --- /dev/null +++ b/packages/core/src/pam/player-note/drizzle/migrations/meta/0001_snapshot.json @@ -0,0 +1,93 @@ +{ + "id": "bca5972b-a9f3-4626-9094-e554b34fb02a", + "prevId": "02b9d4ff-29f1-406f-ac2b-9272f3500a4d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.player_note": { + "name": "player_note", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "player_id": { + "name": "player_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposal_id": { + "name": "proposal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "player_note_proposal_id_idx": { + "name": "player_note_proposal_id_idx", + "columns": [ + { + "expression": "proposal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"player_note\".\"proposal_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/pam/player-note/drizzle/migrations/meta/_journal.json b/packages/core/src/pam/player-note/drizzle/migrations/meta/_journal.json index e465efb50..4f8e98de7 100644 --- a/packages/core/src/pam/player-note/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/pam/player-note/drizzle/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1783555225812, "tag": "0000_tranquil_fantastic_four", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1791378650055, + "tag": "0001_broken_carmella_unuscione", + "breakpoints": true } ] } diff --git a/packages/core/src/pam/player-note/schema/index.ts b/packages/core/src/pam/player-note/schema/index.ts index 2f7a70db3..00fdad2c1 100644 --- a/packages/core/src/pam/player-note/schema/index.ts +++ b/packages/core/src/pam/player-note/schema/index.ts @@ -1,14 +1,26 @@ -import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { pgTable, uuid, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; -export const playerNote = pgTable('player_note', { - id: uuid().primaryKey().defaultRandom(), - playerId: uuid().notNull(), - actorId: uuid().notNull(), - content: text().notNull(), - createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp({ withTimezone: true }) - .notNull() - .$onUpdateFn(() => new Date()), -}); +export const playerNote = pgTable( + 'player_note', + { + id: uuid().primaryKey().defaultRandom(), + playerId: uuid().notNull(), + actorId: uuid().notNull(), + content: text().notNull(), + // The agent proposal whose execution wrote the note; null for a note an admin wrote. Bare + // uuid, no .references: core stores no proposals. + proposalId: uuid(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp({ withTimezone: true }) + .notNull() + .$onUpdateFn(() => new Date()), + }, + (t) => [ + uniqueIndex('player_note_proposal_id_idx') + .on(t.proposalId) + .where(sql`${t.proposalId} IS NOT NULL`), + ], +); export type PlayerNote = typeof playerNote.$inferSelect; diff --git a/packages/core/src/pam/player-note/service/player-note.service.ts b/packages/core/src/pam/player-note/service/player-note.service.ts index 18a70eae1..7f0b5965c 100644 --- a/packages/core/src/pam/player-note/service/player-note.service.ts +++ b/packages/core/src/pam/player-note/service/player-note.service.ts @@ -3,9 +3,8 @@ import { makeNotFoundError, pageToOffset, serializeRow, - withAdvisoryXactLock, } from '@openora/core/server'; -import { and, asc, count, desc, eq } from 'drizzle-orm'; +import { asc, count, desc, eq, isNotNull } from 'drizzle-orm'; import type { AuditWritePort, Player, @@ -21,8 +20,11 @@ const DATE_FIELDS = ['createdAt', 'updatedAt'] as const; export const PlayerNotFoundError = makeNotFoundError('Player'); -function toItem(row: typeof playerNote.$inferSelect): PlayerNoteItem { - return serializeRow(row, { dateFields: DATE_FIELDS }); +function toItem({ + proposalId: _proposalId, + ...note +}: typeof playerNote.$inferSelect): PlayerNoteItem { + return serializeRow(note, { dateFields: DATE_FIELDS }); } export class PlayerNoteService { @@ -73,10 +75,9 @@ export class PlayerNoteService { } /** - * Adds the note an approved agent proposal carries, at most once per proposal. With no - * proposal column to key on, a replay is recognised by the note it already wrote (same - * player, author and content); the lock on the proposal id stops two concurrent executions - * from both missing it. + * Adds the note an approved agent proposal carries, at most once per proposal. The note keeps + * the proposal id under a unique index, so a replay of the proposal, or a second execution + * racing the first, inserts nothing; another proposal with the same text adds its own note. */ async createForProposal({ playerId, @@ -89,44 +90,35 @@ export class PlayerNoteService { actorId: User['id']; proposalId: Uuid; }) { - return this.drizzle.db.transaction((tx) => - withAdvisoryXactLock(tx, `agent-proposal:${proposalId}`, async () => { - const [existing] = await tx - .select({ id: playerNote.id }) - .from(playerNote) - .where( - and( - eq(playerNote.playerId, playerId), - eq(playerNote.actorId, actorId), - eq(playerNote.content, content), - ), - ) - .limit(1); - if (existing) { - return { status: 'already_created' as const }; - } - const [target] = await tx - .select({ id: player.id }) - .from(player) - .where(eq(player.id, playerId)) - .limit(1); - if (!target) { - throw new PlayerNotFoundError(playerId); - } - const [created] = await tx - .insert(playerNote) - .values({ playerId, actorId, content }) - .returning(); - await this.audit.recordInTransaction(tx, { - actorId, - actorType: 'admin', - action: 'admin.player_note.created', - resourceType: 'player', - resourceId: playerId, - after: { noteId: created.id, content: created.content, proposalId }, - }); - return { status: 'created' as const, note: toItem(created) }; - }), - ); + return this.drizzle.db.transaction(async (tx) => { + const [target] = await tx + .select({ id: player.id }) + .from(player) + .where(eq(player.id, playerId)) + .limit(1); + if (!target) { + throw new PlayerNotFoundError(playerId); + } + const [created] = await tx + .insert(playerNote) + .values({ playerId, actorId, content, proposalId }) + .onConflictDoNothing({ + target: playerNote.proposalId, + where: isNotNull(playerNote.proposalId), + }) + .returning(); + if (!created) { + return { status: 'already_created' as const }; + } + await this.audit.recordInTransaction(tx, { + actorId, + actorType: 'admin', + action: 'admin.player_note.created', + resourceType: 'player', + resourceId: playerId, + after: { noteId: created.id, content: created.content, proposalId }, + }); + return { status: 'created' as const, note: toItem(created) }; + }); } } diff --git a/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts index b3c133479..b71e2f1ae 100644 --- a/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts +++ b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts @@ -37,6 +37,8 @@ let adminId: string; const SEEDED_ADMIN_EMAIL = 'admin@oss.dev'; const REQUEST_BODY_NESTING = 400_000; const SQL_ERROR = 'SELECT email FROM player WHERE id = 991'; +const REVIEWED = 'Reviewed, no issues.'; +const NOTE_ROUTE_KEYS = ['actorId', 'content', 'createdAt', 'id', 'playerId', 'updatedAt']; const EXPECTED_TOOL_OWNERS = { 'ggr.summary': 'analytics', @@ -216,11 +218,11 @@ function vipTagRows(playerId: string) { } // player-note exports no schema subpath, so its rows are read by table name. -async function noteIdsOf(playerId: string): Promise { - const result = await drizzle().execute<{ id: string }>( - sql`SELECT id FROM player_note WHERE player_id = ${playerId}`, +async function notesOf(playerId: string) { + const result = await drizzle().execute<{ id: string; proposal_id: string | null }>( + sql`SELECT id, proposal_id FROM player_note WHERE player_id = ${playerId}`, ); - return result.rows.map((row) => row.id); + return result.rows.map((row) => ({ id: row.id, proposalId: row.proposal_id })); } function advancedKycRows(userId: string) { @@ -761,7 +763,7 @@ describe('agent tag, note and enhanced-KYC actions are replay-safe', () => { }); const noteId = first.ok ? first.detail?.['noteId'] : undefined; - expect(await noteIdsOf(active.playerId)).toEqual([noteId]); + expect(await notesOf(active.playerId)).toEqual([{ id: noteId, proposalId }]); const created = await auditRowsOf('admin.player_note.created', active.playerId); expect(created).toHaveLength(1); expect(created[0]?.after).toMatchObject({ noteId, proposalId }); @@ -801,3 +803,76 @@ describe('agent tag, note and enhanced-KYC actions are replay-safe', () => { ).toEqual({ ok: false, error: 'already_requested' }); }); }); + +describe('add_note writes one note per approved proposal', () => { + it('writes a note for each of two proposals carrying the same text from the same approver', async () => { + const target = await newPlayer('notes-two-proposals'); + const payload = { playerId: target.playerId, content: REVIEWED }; + const [first, second] = [randomUUID(), randomUUID()]; + + expect(await kernel.executeAction('add_note', payload, execution(first))).toMatchObject({ + ok: true, + outcome: 'applied', + }); + expect(await kernel.executeAction('add_note', payload, execution(second))).toMatchObject({ + ok: true, + outcome: 'applied', + }); + + const notes = await notesOf(target.playerId); + expect(notes).toHaveLength(2); + expect(notes.map((note) => note.proposalId)).toEqual(expect.arrayContaining([first, second])); + expect(await auditRowsOf('admin.player_note.created', target.playerId)).toHaveLength(2); + }); + + it("adds the proposal's note beside an identical one the approver wrote, and lists neither with a proposal id", async () => { + const target = await newPlayer('notes-hand-written'); + const notePath = `/player/${target.playerId}/note`; + const written = await admin.post(notePath, { content: REVIEWED }); + expect(written.status).toBe(200); + const proposalId = randomUUID(); + + expect( + await kernel.executeAction( + 'add_note', + { playerId: target.playerId, content: REVIEWED }, + execution(proposalId), + ), + ).toMatchObject({ ok: true, outcome: 'applied' }); + + const notes = await notesOf(target.playerId); + expect(notes).toHaveLength(2); + expect(notes.map((note) => note.proposalId)).toEqual( + expect.arrayContaining([null, proposalId]), + ); + const listed = await admin.get(notePath); + expect(listed.status).toBe(200); + const items = object(await listed.json())['items']; + if (!Array.isArray(items)) { + throw new Error(`expected an items array, got ${JSON.stringify(items)}`); + } + expect(items.map((item) => Object.keys(object(item)).sort())).toEqual([ + NOTE_ROUTE_KEYS, + NOTE_ROUTE_KEYS, + ]); + }); + + it('writes exactly one note when the same proposal executes twice at once', async () => { + const target = await newPlayer('notes-concurrent'); + const payload = { playerId: target.playerId, content: REVIEWED }; + const proposalId = randomUUID(); + + const results = await Promise.all([ + kernel.executeAction('add_note', payload, execution(proposalId)), + kernel.executeAction('add_note', payload, execution(proposalId)), + ]); + + expect(results.map((result) => (result.ok ? result.outcome : result.error)).sort()).toEqual([ + 'already_applied', + 'applied', + ]); + expect(await notesOf(target.playerId)).toEqual([{ id: expect.any(String), proposalId }]); + expect(await auditRowsOf('admin.player_note.created', target.playerId)).toHaveLength(1); + expect(await auditRowsOf('mcp.action.executed', proposalId)).toHaveLength(2); + }); +}); From 4ddbb5a49bdc2e6e8d5f26a343dfe973684b86cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Thu, 8 Oct 2026 12:44:48 +0200 Subject: [PATCH 13/14] docs(core): record the sealed kernel binding and the kernel's newer rules ADR-0042 still said binding MCP_KERNEL after every plugin keeps it from being replaced, which the returned container disproved. It now names the sealed binding, the output-schema rule at every depth, the IAM check before a precondition, and total input hashing. It also records how a tool references a player (its id, no pseudonym format in core) and a money amount (a bounded decimal string, value checked in superRefine), and that a money-moving executor keys durably on the proposal id. --- ...registries-behind-one-enforcing-wrapper.md | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md b/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md index 25eaa2ff0..d466407c2 100644 --- a/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md +++ b/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md @@ -46,17 +46,25 @@ once, when the kernel is built after every provider is bound, and returns the ha `{ precondition, execute }` pair. The catalog generator reads the contract literals, so `docs/catalog.json` and `@openora/mcp` list both registries without running anything. -Consumers reach both registries only through `MCP_KERNEL`, which `createApp` binds after every -plugin and the composition root have registered, so no overlay can replace it. The kernel -wrapper, not the runtime, enforces: +Consumers reach both registries only through `MCP_KERNEL`, which `createApp` binds sealed +(`Container.registerSealed`) after every plugin and the composition root have registered, so +neither an overlay, the composition root nor a holder of the container `createApp` returns can +replace it: a later registration throws. The kernel wrapper, not the runtime, enforces: - **Shape at registration.** Tool inputs and action payloads are a top-level `z.object`; conditional rules go in `.superRefine()`. Every number is `z.coerce.number()` with both bounds, every string is bounded, every array has a maximum, free-form maps are refused, and schemas - must convert to JSON Schema. A violation fails boot with the field path and the fix. + must convert to JSON Schema. A player is referenced by its id (`UuidSchema`); core defines no + pseudonym format, because pseudonymising is the hub's job. A money amount is a decimal string, + as everywhere in the platform (`docs/standards/money.md`), so it needs a maximum length like + any string, and an action type that takes one checks its value in `.superRefine()`. A tool's + output schema may not let undeclared keys through at any depth - no loose or catchall object, + record or untyped field - so its parse strips them under every allowed key. A violation fails + boot with the field path and the fix. - **IAM per call.** The run's actor (an admin, an agent acting for an admin, or an MCP token - owned by an admin) resolves to one admin, whose grants are checked by `AdminGuard.assertUser` - - the same enforcement point as every admin route, including the denial audit event. + owned by an admin) resolves to one admin, whose grants are checked by `AdminGuard.assertUser` + before a handler, a precondition or an executor runs - the same enforcement point as every + admin route, including the denial audit event. - **Output allow-list.** The handler's output is parsed by its schema and only allow-listed keys leave the kernel. A schema mismatch returns `output_invalid`, never the raw output. - **Error codes, never messages.** A handler returns a declared code by throwing `McpToolError`; @@ -65,16 +73,21 @@ wrapper, not the runtime, enforces: - **An audit record per call**, with tool id, schema version, actor, correlation id and SHA-256 hashes of the parsed input and the redacted output. If the record cannot be written the call fails with `audit_unavailable` instead of returning data. An action execution retries the - write first, because by then the executor's effect is committed. + write first, because by then the executor's effect is committed. Hashing is total: anything + nested deeper than 64 levels hashes as a fixed marker, and a tool call that fails outside its + handler still writes its record, as `internal_error`. - **Preconditions before proposals.** Each action type declares a precondition the hub must pass - before a proposal is created. It is not re-run at execution, where the executor guards its - own state under its own locks. + before a proposal is created. It runs under the action type's IAM check and writes no audit + record. It is not re-run at execution, where the executor guards its own state under its own + locks. - **Replay-safe execution.** An executor receives `(payload, proposalId, actor)`. A replay while its effect still holds performs nothing and answers `already_applied`, so a retry after a crash is safe. Core stores no proposals, so executors key on the resulting state: after someone else changes that state (an admin removes the tag, resolves the held withdrawal), a replay may apply again or answer a declared refusal. The store that keeps proposals must not re-execute one it - recorded as executed. + recorded as executed. An executor that moves money needs the durable idempotency guard + `docs/standards/money.md` requires of every money mutation, keyed on the proposal id under a + database constraint, the way `add_note` keys its note. `RunContext` (run id, actor, optional player pseudonym, catalog version, correlation id) is one shared type, so the hub and the transport cannot disagree on it. `PlatformConfig.agents` holds @@ -98,8 +111,8 @@ Rejected alternatives: each runtime and each transport would re-implement the same rules, and one would get them wrong. - **A rebindable kernel token.** An overlay could swap the wrapper and bypass IAM and audit. - `createApp` binds `MCP_KERNEL` after every plugin, and the kernel factory is exported only - from `@openora/core/testing`. + `createApp` binds `MCP_KERNEL` sealed after every plugin, so a later registration throws, and + the kernel factory is exported only from `@openora/core/testing`. - **Pseudonymisation in core.** It needs a mapping store and belongs at the model boundary, which only the hub knows. From aacd910a26a31209c08baae9fb99aa0d667e924d Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 9 Oct 2026 19:50:43 +0200 Subject: [PATCH 14/14] chore(wallet): format the regenerated migration metadata --- .../migrations/meta/0029_snapshot.json | 93 ++++--------------- .../drizzle/migrations/meta/_journal.json | 2 +- 2 files changed, 20 insertions(+), 75 deletions(-) diff --git a/packages/core/src/wallet/drizzle/migrations/meta/0029_snapshot.json b/packages/core/src/wallet/drizzle/migrations/meta/0029_snapshot.json index a1e9063b0..5719f92a7 100644 --- a/packages/core/src/wallet/drizzle/migrations/meta/0029_snapshot.json +++ b/packages/core/src/wallet/drizzle/migrations/meta/0029_snapshot.json @@ -60,9 +60,7 @@ "auto_withdrawal_rule_user_id_unique": { "name": "auto_withdrawal_rule_user_id_unique", "nullsNotDistinct": false, - "columns": [ - "user_id" - ] + "columns": ["user_id"] } }, "policies": {}, @@ -107,9 +105,7 @@ "wallet_user_id_unique": { "name": "wallet_user_id_unique", "nullsNotDistinct": false, - "columns": [ - "user_id" - ] + "columns": ["user_id"] } }, "policies": {}, @@ -324,9 +320,7 @@ "wallet_auto_withdrawal_config_singletonKey_unique": { "name": "wallet_auto_withdrawal_config_singletonKey_unique", "nullsNotDistinct": false, - "columns": [ - "singleton_key" - ] + "columns": ["singleton_key"] } }, "policies": {}, @@ -406,12 +400,8 @@ "name": "wallet_balance_wallet_id_wallet_id_fk", "tableFrom": "wallet_balance", "tableTo": "wallet", - "columnsFrom": [ - "wallet_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -554,12 +544,8 @@ "name": "wallet_bonus_credit_wallet_id_wallet_id_fk", "tableFrom": "wallet_bonus_credit", "tableTo": "wallet", - "columnsFrom": [ - "wallet_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -621,9 +607,7 @@ "wallet_bonus_rollover_config_singletonKey_unique": { "name": "wallet_bonus_rollover_config_singletonKey_unique", "nullsNotDistinct": false, - "columns": [ - "singleton_key" - ] + "columns": ["singleton_key"] } }, "policies": {}, @@ -1864,12 +1848,8 @@ "name": "wallet_transaction_wallet_id_wallet_id_fk", "tableFrom": "wallet_transaction", "tableTo": "wallet", - "columnsFrom": [ - "wallet_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -2021,48 +2001,27 @@ "public.wallet_bonus_credit_source_type": { "name": "wallet_bonus_credit_source_type", "schema": "public", - "values": [ - "gift", - "rain" - ] + "values": ["gift", "rain"] }, "public.wallet_bonus_credit_status": { "name": "wallet_bonus_credit_status", "schema": "public", - "values": [ - "active", - "completed" - ] + "values": ["active", "completed"] }, "public.wallet_custody_sweep_status": { "name": "wallet_custody_sweep_status", "schema": "public", - "values": [ - "pending", - "processing", - "completed", - "failed", - "unknown" - ] + "values": ["pending", "processing", "completed", "failed", "unknown"] }, "public.wallet_job_run_status": { "name": "wallet_job_run_status", "schema": "public", - "values": [ - "running", - "completed", - "skipped", - "failed", - "abandoned" - ] + "values": ["running", "completed", "skipped", "failed", "abandoned"] }, "public.wallet_rail": { "name": "wallet_rail", "schema": "public", - "values": [ - "crypto", - "fiat" - ] + "values": ["crypto", "fiat"] }, "public.wallet_reconciliation_finding_kind": { "name": "wallet_reconciliation_finding_kind", @@ -2083,31 +2042,17 @@ "public.wallet_reconciliation_finding_status": { "name": "wallet_reconciliation_finding_status", "schema": "public", - "values": [ - "open", - "resolved" - ] + "values": ["open", "resolved"] }, "public.wallet_transaction_direction": { "name": "wallet_transaction_direction", "schema": "public", - "values": [ - "credit", - "debit" - ] + "values": ["credit", "debit"] }, "public.wallet_transaction_status": { "name": "wallet_transaction_status", "schema": "public", - "values": [ - "pending", - "processing", - "completed", - "failed", - "rejected", - "on_hold", - "cancelled" - ] + "values": ["pending", "processing", "completed", "failed", "rejected", "on_hold", "cancelled"] }, "public.wallet_transaction_type": { "name": "wallet_transaction_type", @@ -2141,4 +2086,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/packages/core/src/wallet/drizzle/migrations/meta/_journal.json b/packages/core/src/wallet/drizzle/migrations/meta/_journal.json index 49a01fd04..8a04d3738 100644 --- a/packages/core/src/wallet/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/wallet/drizzle/migrations/meta/_journal.json @@ -213,4 +213,4 @@ "breakpoints": true } ] -} \ No newline at end of file +}