diff --git a/.rulesync/commands/scaffold-plugin.md b/.rulesync/commands/scaffold-plugin.md index 0dc52b07f..ec3930109 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-0042). 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 16a2234f2..2b986c074 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 643bfeca7..f42f7f60d 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-0042. + 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 23532ea13..371df3dad 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 @@ -91,6 +91,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..ff8e38e12 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-0042](./0042-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/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md b/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md new file mode 100644 index 000000000..d466407c2 --- /dev/null +++ b/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md @@ -0,0 +1,146 @@ +# ADR-0042: MCP kernel - two registries behind one enforcing wrapper + +**Date**: 2026-09-25 +**Status**: Accepted + +## 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 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 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` + 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`; + 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. 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 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. 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 +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` 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. + +## 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` 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. + +**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 d9e287c80..f3f974324 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 fdd1e673b..a13a8a8b9 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-0042). ## Rules that are easy to get wrong here @@ -43,8 +46,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..2d346b0d4 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-0042), 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..bda5b868e 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-0042 | +| **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..5a7456fca 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-0042). - 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..15fc03800 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`. 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) - 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 179215499..d30847820 100644 --- a/packages/core/src/compliance/contract/index.ts +++ b/packages/core/src/compliance/contract/index.ts @@ -18,8 +18,7 @@ import { paginated, MoneyAmountSchema, } 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'; @@ -27,9 +26,7 @@ const PositiveMoneyAmountSchema = MoneyAmountSchema.refine((v) => Number(v) > 0, message: 'Must be greater than zero', }); -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, @@ -480,3 +477,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 6f337e548..d276e13e7 100644 --- a/packages/core/src/compliance/plugin.ts +++ b/packages/core/src/compliance/plugin.ts @@ -22,6 +22,7 @@ import { KycTierSchema, LOGIN_ENFORCEMENT, MAIL_DISPATCH, + McpToolError, PLATFORM_CONFIG, RATE_LIMITER, REALTIME_TRANSPORT, @@ -30,11 +31,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 { @@ -92,6 +101,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'], @@ -129,9 +151,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 @@ -302,17 +386,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 211569bd6..5b150c845 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, @@ -71,6 +72,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. @@ -157,6 +173,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 { @@ -609,6 +638,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( @@ -814,48 +864,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 f7747105f..6c540901e 100644 --- a/packages/core/src/contracts/adapters/audit.ts +++ b/packages/core/src/contracts/adapters/audit.ts @@ -71,7 +71,12 @@ export type DirectAuditAction = | 'promo.rank_ladder.set' | 'promo.rank_ladder.installed' | 'promo.rank_config.set' - | 'promo.rank_config.installed'; + | 'promo.rank_config.installed' + | '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 1f6512d13..e32ddb874 100644 --- a/packages/core/src/contracts/adapters/identity-reader.ts +++ b/packages/core/src/contracts/adapters/identity-reader.ts @@ -26,6 +26,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 8ddfe7366..f5d4f7032 100644 --- a/packages/core/src/contracts/adapters/index.ts +++ b/packages/core/src/contracts/adapters/index.ts @@ -115,6 +115,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 { @@ -413,3 +414,58 @@ export { TWO_FACTOR_SETUP_POLICY, type TwoFactorSetupPolicy } from './two-factor 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..b0d4be273 --- /dev/null +++ b/packages/core/src/contracts/adapters/mcp.ts @@ -0,0 +1,284 @@ +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. Core stores no proposals, so + * executors key on the resulting state, which may record the 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; + /** + * 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, + 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 6ff3d0fc5..8d3c53efe 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'; /** @@ -468,6 +469,8 @@ export const PlatformConfigSchema = z gaming: GamingConfigSchema.default({ allowedThumbnailHosts: [] }), /** 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 index 8d3a59150..ede8c333c 100644 --- 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 @@ -1,6 +1,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq, sql } from 'drizzle-orm'; +import { findOneOrThrow } from '@openora/core/server'; import { createTestDb, type TestDb, seedPlayerWithUser } from '@openora/core/testing'; import { player } from '@openora/core/pam/schema/profile'; import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; @@ -64,3 +65,22 @@ describe('IdentityReaderService.getPlayerUserIdsSharingLoginIp (real PG)', () => expect(linked).toEqual([]); }); }); + +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 a2c83612b..3766d5f30 100644 --- a/packages/core/src/pam/identity/adapters/identity-reader.service.ts +++ b/packages/core/src/pam/identity/adapters/identity-reader.service.ts @@ -55,6 +55,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 9dd4b08bb..a096ebb42 100644 --- a/packages/core/src/pam/player-management/plugin.ts +++ b/packages/core/src/pam/player-management/plugin.ts @@ -8,6 +8,7 @@ import { SESSION_COMMANDS, USER_COMMANDS, PLAYER_ACTIVITY_TRACKER, + McpToolError, EXCHANGE_RATE_READER, JOB_QUEUE, domainEventSchemas, @@ -17,9 +18,10 @@ import { import type { JobQueueAdapter } from '@openora/core/contracts'; import type { CoreTokenCatalog, Plugin, TypedContainer } from '@openora/core/server'; import * as z from 'zod'; -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( @@ -90,5 +92,16 @@ export default { ctx.routers.add('player', (c) => createPlayerRouter(playerService(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..1af7fb1fe --- /dev/null +++ b/packages/core/src/pam/player-note/__tests__/player-note.agent-actions.int.test.ts @@ -0,0 +1,311 @@ +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, + 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'; +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(); +const REVIEWED = 'Reviewed, no issues.'; + +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, + 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, + 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 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(); + 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('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()); + + 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, 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(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); + }); +}); + +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.', + proposalId: execution.proposalId, + }); + 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/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/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/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 f5cdd9496..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 @@ -1,17 +1,37 @@ -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, +} from '@openora/core/server'; +import { asc, count, desc, eq, isNotNull } 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; -function toItem(row: typeof playerNote.$inferSelect): PlayerNoteItem { - return serializeRow(row, { dateFields: DATE_FIELDS }); +export const PlayerNotFoundError = makeNotFoundError('Player'); + +function toItem({ + proposalId: _proposalId, + ...note +}: typeof playerNote.$inferSelect): PlayerNoteItem { + return serializeRow(note, { 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 +64,61 @@ 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. 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, + content, + actorId, + proposalId, + }: { + playerId: Player['id']; + content: string; + actorId: User['id']; + proposalId: Uuid; + }) { + 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/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..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,11 +1,11 @@ -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 { 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) { @@ -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'); 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 d9d1c2ea8..6c73703c3 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 @@ -325,3 +325,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 da1e34243..e4e05e886 100644 --- a/packages/core/src/server/auth/admin-guard.ts +++ b/packages/core/src/server/auth/admin-guard.ts @@ -88,16 +88,74 @@ 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 isSuper = this.permissionResolver + ? await this.permissionResolver.isSuperAdmin(caller.userId) + : 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 }, @@ -106,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) { @@ -134,42 +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 isSuper = this.permissionResolver - ? await this.permissionResolver.isSuperAdmin(caller.userId) - : 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; - } - // null only when no resolver is bound - an unassigned admin under a bound resolver gets []. private resolveGrants(userId: string): Promise { return this.permissionResolver 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/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/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..3a9c648db --- /dev/null +++ b/packages/core/src/server/mcp/__tests__/canonical-json.test.ts @@ -0,0 +1,80 @@ +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( + 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]"}'); + }); + + 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', () => { + 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..6111ecad6 --- /dev/null +++ b/packages/core/src/server/mcp/__tests__/kernel.test.ts @@ -0,0 +1,731 @@ +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 REQUEST_BODY_NESTING = 400_000; + +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 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 { + 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('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(); + + 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('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 () => { + 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 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).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: { + ...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..8d1d4eff0 --- /dev/null +++ b/packages/core/src/server/mcp/canonical-json.ts @@ -0,0 +1,51 @@ +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 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'; +} + +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]'); + } + 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(',')}]` + : `{${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..79df81f05 --- /dev/null +++ b/packages/core/src/server/mcp/contract-validation.ts @@ -0,0 +1,562 @@ +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 SchemaLabel = InputLabel | 'outputSchema'; + +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: SchemaLabel, + 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 schema 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': + case 'number': + case 'integer': + return label === 'outputSchema' ? null : unboundedRejection(node, field); + 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 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`, + fix: 'replace z.any()/z.unknown() with a concrete, bounded type', + }; +} + +function arrayRejection( + node: z.core.JSONSchema.JSONSchema, + label: SchemaLabel, + path: string, +): Rejection | null { + 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, 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: SchemaLabel, + 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', + }; + } + let jsonSchema: z.core.JSONSchema.JSONSchema; + try { + 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 { + 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..39bd04f10 --- /dev/null +++ b/packages/core/src/server/mcp/kernel.ts @@ -0,0 +1,549 @@ +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 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: toolId, + correlationId: run.data.correlationId, + after: withoutUndefined({ + 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 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, + 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 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) { + 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..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 @@ -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,406 @@ 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/, + ], + [ + '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', + { 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('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(); + + 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/__tests__/create-app.test.ts b/packages/core/src/server/runtime/__tests__/create-app.test.ts index 6583eace9..f602fe072 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/core-token-catalog.ts b/packages/core/src/server/runtime/core-token-catalog.ts index bba89aa95..12249be88 100644 --- a/packages/core/src/server/runtime/core-token-catalog.ts +++ b/packages/core/src/server/runtime/core-token-catalog.ts @@ -48,6 +48,7 @@ import { LOBBY_SECTION_CATALOG, MAIL_DISPATCH, MAIL_RECIPIENT_DIRECTORY, + MCP_KERNEL, MESSAGE_BROKER, OUTBOX, PAYMENT_ADAPTER, @@ -133,6 +134,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 344df0c3f..3a5e6ad80 100644 --- a/packages/core/src/server/runtime/create-app.ts +++ b/packages/core/src/server/runtime/create-app.ts @@ -32,7 +32,9 @@ import { OUTBOX, ADMIN_PERMISSION_RESOLVER, ADMIN_SECURITY_POLICY, + AUDIT_WRITER, IDENTITY_READER, + MCP_KERNEL, RATE_LIMITER, CACHE, REALTIME_TRANSPORT, @@ -48,6 +50,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'; @@ -333,6 +337,19 @@ export async function createApp( assertDurableSeamsBound(container); + // 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(), + 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)); @@ -358,6 +375,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 a5df4bd6a..26598adc1 100644 --- a/packages/core/src/testing/index.ts +++ b/packages/core/src/testing/index.ts @@ -21,3 +21,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..7bb684c45 --- /dev/null +++ b/packages/core/src/wallet/__tests__/wallet-agent-tools.int.test.ts @@ -0,0 +1,435 @@ +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: { + userId: player.userId, + transactionId: withdrawal.id, + 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..809d75460 --- /dev/null +++ b/packages/core/src/wallet/__tests__/wallet-withdrawal-hold.int.test.ts @@ -0,0 +1,496 @@ +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: { + userId: w.userId, + transactionId: pending.id, + status: 'on_hold', + reason: HOLD_REASON, + proposalId: input.proposalId, + }, + }); + expect(audit.record).not.toHaveBeenCalled(); + }); + + it('files the held record under the player when a player backs the wallet', async () => { + const playerId = randomUUID(); + const identityReader = makeIdentityReader(); + vi.mocked(identityReader.getPlayerIdByUserIdSafe).mockResolvedValue(playerId); + const { svc, audit } = makeService({ identityReader }); + const w = await seedWallet(); + const pending = await seedWithdrawal(w.id); + + await svc.holdWithdrawal(holdInput(w.userId, pending.id)); + + expect(audit.recordInTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: 'wallet.withdrawal.held', + resourceType: 'player', + resourceId: playerId, + after: expect.objectContaining({ transactionId: pending.id }), + }), + ); + }); + + 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, + reviewReason: null, + }); + 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); + }); +}); + +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 c2a274bd8..d73621b4c 100644 --- a/packages/core/src/wallet/contract/index.ts +++ b/packages/core/src/wallet/contract/index.ts @@ -896,3 +896,5 @@ export const walletContract = { .output(JobRunResultSchema), }, }; + +export * from './agent-tools.js'; diff --git a/packages/core/src/wallet/drizzle/migrations/0029_mute_prism.sql b/packages/core/src/wallet/drizzle/migrations/0029_mute_prism.sql new file mode 100644 index 000000000..f50cd6c0d --- /dev/null +++ b/packages/core/src/wallet/drizzle/migrations/0029_mute_prism.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/0029_snapshot.json b/packages/core/src/wallet/drizzle/migrations/meta/0029_snapshot.json new file mode 100644 index 000000000..5719f92a7 --- /dev/null +++ b/packages/core/src/wallet/drizzle/migrations/meta/0029_snapshot.json @@ -0,0 +1,2089 @@ +{ + "id": "de41758c-6208-4af9-bcc6-b469cd0d508a", + "prevId": "c9abf14a-0c0e-4864-9ca6-f185010c8bfe", + "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[]" + }, + "velocity_count": { + "name": "velocity_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "velocity_window_hours": { + "name": "velocity_window_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "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 12c9740ee..8a04d3738 100644 --- a/packages/core/src/wallet/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/wallet/drizzle/migrations/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1791561357780, "tag": "0028_absent_mephistopheles", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1791567596966, + "tag": "0029_mute_prism", + "breakpoints": true } ] } diff --git a/packages/core/src/wallet/plugin.ts b/packages/core/src/wallet/plugin.ts index 08628c133..f7b0eae67 100644 --- a/packages/core/src/wallet/plugin.ts +++ b/packages/core/src/wallet/plugin.ts @@ -29,13 +29,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, @@ -61,6 +72,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 @@ -187,6 +208,85 @@ 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, + kycPolicy: c.has(KYC_WITHDRAWAL_POLICY) ? c.get(KYC_WITHDRAWAL_POLICY) : undefined, + grantLedger: c.has(BONUS_GRANT_LEDGER) ? c.get(BONUS_GRANT_LEDGER) : 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, @@ -273,30 +373,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, - kycPolicy: c.has(KYC_WITHDRAWAL_POLICY) ? c.get(KYC_WITHDRAWAL_POLICY) : undefined, - grantLedger: c.has(BONUS_GRANT_LEDGER) ? c.get(BONUS_GRANT_LEDGER) : 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, @@ -352,7 +434,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/schema/index.ts b/packages/core/src/wallet/schema/index.ts index b18752f92..7d8a1ed96 100644 --- a/packages/core/src/wallet/schema/index.ts +++ b/packages/core/src/wallet/schema/index.ts @@ -177,6 +177,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`), diff --git a/packages/core/src/wallet/service/wallet.service.ts b/packages/core/src/wallet/service/wallet.service.ts index b502b5fcb..4fb9048f3 100644 --- a/packages/core/src/wallet/service/wallet.service.ts +++ b/packages/core/src/wallet/service/wallet.service.ts @@ -52,6 +52,7 @@ import { type Uuid, type PaginationOptions, type WalletProviderRef, + type ActionExecutionContext, type WalletTransactionType, type WalletTransactionStatus, type BonusGrantLedgerReader, @@ -113,6 +114,7 @@ import type { CreateWithdrawalAddressInput, WalletReferenceConversion, } from '../contract/index.js'; +import { OPEN_WITHDRAWALS_LIMIT } from '../contract/index.js'; const logger = createLogger('wallet'); @@ -214,7 +216,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' }, ); @@ -653,14 +655,38 @@ const LARGE_WITHDRAWAL_THRESHOLD = '5000'; // still counts: it is a human judgement on this player and leaves no risk tag behind. const VELOCITY_IGNORED_STATUSES = ['failed', 'cancelled'] as const; -// 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. 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'; @@ -1040,6 +1066,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. @@ -1920,24 +2009,34 @@ 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, + reviewReason: null, + }), ); 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; - reviewReason?: string; + from: readonly WalletTransaction['status'][]; + reviewReason?: WalletTransaction['reviewReason']; autoApprovalPivotAmount?: string; }): Promise { const current = findOneOrThrow( @@ -1948,10 +2047,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) @@ -2244,6 +2341,7 @@ export class WalletService { txn, withdrawalId: args.transactionId, adminId: null, + from: ['pending'], reviewReason: AUTO_APPROVED_REASON, autoApprovalPivotAmount: gates.pivotAmount, }); @@ -2673,7 +2771,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 @@ -2683,7 +2781,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) { @@ -2894,9 +2992,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) @@ -2927,6 +3023,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']; + }) { + const playerId = await this.identityReader.getPlayerIdByUserIdSafe(userId); + 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', + ...withdrawalAuditSubject(playerId, withdrawalId), + before: { status: 'pending' }, + after: { userId, transactionId: withdrawalId, status: 'on_hold', reason, proposalId }, + }); + return { changed: true }; + }); + } + /** * The player's history: their ledger rows and, when `includeGrants`, the bonus grants they * received (a bonus, gift or rain credit lands on a grant, never on the ledger). Both halves diff --git a/packages/mcp/README.md b/packages/mcp/README.md index cab26f81f..f613ef097 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -18,8 +18,9 @@ shipped in the published package, or a catalog you point it at. | `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. | +| `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. | | `impact` | `files` | Transitive importers of files; platform files are followed into this repo. | diff --git a/packages/mcp/src/main.ts b/packages/mcp/src/main.ts index cfa589888..492d89323 100644 --- a/packages/mcp/src/main.ts +++ b/packages/mcp/src/main.ts @@ -40,6 +40,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[]; @@ -47,8 +71,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.'; @@ -121,7 +171,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) ---'); @@ -135,7 +186,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'); }), @@ -219,6 +270,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', { @@ -251,6 +327,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..b71e2f1ae --- /dev/null +++ b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts @@ -0,0 +1,878 @@ +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 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', + '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 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)); +} + +// 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 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) => ({ id: row.id, proposalId: row.proposal_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)); + }); + + 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', () => { + 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']); + }); + + 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', () => { + 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', holder.playerId); + expect(held).toHaveLength(1); + expect(held[0]).toMatchObject({ + actorId: adminId, + resourceType: 'player', + before: { status: 'pending' }, + after: { + transactionId: heldWithdrawalId, + 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)).toEqual({ + status: 'completed', + reviewedBy: adminId, + reviewReason: null, + }); + }); + + 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("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(); + + 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 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 }); + }); + + 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' }); + }); +}); + +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); + }); +}); diff --git a/packages/testing/src/db.ts b/packages/testing/src/db.ts index 862b98e51..c9532175d 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 36d1f22fa..4f5d032b1 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