diff --git a/.gitignore b/.gitignore index 73ff2191..71daa883 100644 --- a/.gitignore +++ b/.gitignore @@ -22,12 +22,8 @@ routeTree.gen.ts .tanstack # AI-agent instruction files - ALL generated, never commit. Regenerated on -# `pnpm install` (prepare) or `pnpm gen:agents`; nested CLAUDE.md stubs by -# tools/gen/gen-claude-stubs.mjs. Never hand-write a CLAUDE.md anywhere (git -# silently skips it) - module docs belong in the module's committed AGENTS.md. -# AGENTS.md is root-anchored so per-package AGENTS.md (hand-written) stay tracked. -# CLAUDE.md is NOT anchored: every per-module CLAUDE.md is a generated `@AGENTS.md` -# redirect stub, so none of them belong in git at any depth. +# `pnpm install` (prepare) or `pnpm gen:agents`. Never hand-write a CLAUDE.md +# anywhere; root instructions are generated from `.rulesync/`. CLAUDE.md /AGENTS.md /.mcp.json diff --git a/.rulesync/commands/regen.md b/.rulesync/commands/regen.md index 609db082..c6bac31b 100644 --- a/.rulesync/commands/regen.md +++ b/.rulesync/commands/regen.md @@ -13,7 +13,7 @@ This runs in order (see root `package.json`): `src/**/drizzle.config.ts` and runs `drizzle-kit generate` per module, against that module's own co-located `drizzle/migrations/` history (ADR-0027). 3. `pnpm run gen:catalog` (`tsx tools/gen/gen-catalog.ts`) - emits `docs/catalog.json`: the - machine-readable surface listing routes / schemas / adapters / slots / events. The MCP + machine-readable surface listing routes / schemas / adapters / events. The MCP dev server and AI catalogs read from this file. `regen` does NOT create a migration. After it succeeds and you've changed table shape, diff --git a/.rulesync/commands/release-plugin.md b/.rulesync/commands/release-plugin.md index b71ed418..a0f903dd 100644 --- a/.rulesync/commands/release-plugin.md +++ b/.rulesync/commands/release-plugin.md @@ -8,7 +8,6 @@ Given the plugin path from $ARGUMENTS: 1. Read `plugin.ts` - it must default-export `{ id, register } satisfies Plugin`. 2. `pnpm verify --filter ` - types and tests pass. -3. Check `AGENTS.md` exists and is filled in (not the template). -4. `pnpm -F build`. -5. Ask the user before publishing; on yes: `pnpm -F publish --access public`. -6. Report the result. +3. `pnpm -F build`. +4. Ask the user before publishing; on yes: `pnpm -F publish --access public`. +5. Report the result. diff --git a/.rulesync/commands/scaffold-module.md b/.rulesync/commands/scaffold-module.md index d1b61b2d..3eb827e9 100644 --- a/.rulesync/commands/scaffold-module.md +++ b/.rulesync/commands/scaffold-module.md @@ -1,7 +1,7 @@ --- targets: - '*' -description: 'Generate a new OSS module via turbo gen. Creates schema, contract, service, router, plugin.ts, a working `list` route, AGENTS.md, and wires the domain barrels, @openora/core exports, contract slice, and extensions.config.ts.' +description: 'Generate a new OSS module via turbo gen. Creates schema, contract, service, router, plugin.ts, a working `list` route, and wires the domain barrels, @openora/core exports, contract slice, and extensions.config.ts.' --- Run `pnpm gen module $ARGUMENTS` (args: ` `, eg `casino tournaments`) in the repo root. The module lands in `packages/core/src///`; the domain may be new. diff --git a/.rulesync/rules/chat.md b/.rulesync/rules/chat.md deleted file mode 100644 index 62fdca4d..00000000 --- a/.rulesync/rules/chat.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -root: false -targets: - - agentsmd -globs: - - packages/core/src/engagement/chat/** -description: Chat module routes, access model, room lifecycle, and realtime delivery rules. -agentsmd: - subprojectPath: packages/core/src/engagement/chat ---- - -# Chat - -Room-based and global messaging. Global chat has `roomId: null` and no `chatRoom` row or category. `chatRoom` stores name, unique slug, required category (`games-sports`, `regions`, `languages`, or `private-channels`), `isPublic`, nullable unique joinCode for private rooms, nullable creatorId, and soft-delete via `deletedAt`. Other tables: `chatMessage` (soft-delete via `isDeleted`, indexed by room + createdAt), `chatUserBlock` (directional mute, blocker-keyed), `chatRoomMember` (role: member/moderator, unique per room+user), and `chatRoomBan` (unique per room+user). - -Routes (player-facing): `listRooms` (public rooms + private rooms the caller is a member of), `getRoomMessages`/`sendRoomMessage` (public read, authenticated send; private rooms require membership), `getGlobalMessages`/`sendGlobalMessage` (player-only, global scope), `deleteMessage` (player, ownership-enforced), `getConnection` (issues a per-player realtime grant covering global + all public rooms + all private rooms where caller is a member), and `streamMessages` (SSE, roomId null = global; private-room access is enforced at router level). - -Realtime: by default, the bindings are `InProcessRealtimeTransport` and `SseClientAuthorizer`. The browser receives through the first-party SSE `streamMessages` route, authorized by its session cookie; this in-process fan-out and presence are limited to one API process. `extensions/ably/` is an optional infrastructure overlay. It rebinds both `REALTIME_TRANSPORT` and `REALTIME_CLIENT_AUTHORIZER` only when both `ABLY_API_KEY` and `ABLY_BROWSER_REALTIME_ENABLED=true` are set, after the browser adapter is deployed and selected. This explicit adapter flag prevents an API key alone from disabling SSE. Openora publishes to Ably and counts provider-backed presence, while the consumer realtime adapter connects the browser directly to Ably. The adapter obtains `tokenRequest` from `getConnection`, subscribes only to the returned `chat:global` / `chat:room:{id}` channels, enters/leaves presence for each connection, and refreshes authorization after membership changes. Ably grants are bound to the authenticated Openora user ID, scoped to exact channels, and permit only `subscribe` and `presence`; never expose `ABLY_API_KEY`, grant browser `publish`, or let the browser bypass Openora message routes. Persist and authorize messages in Openora before publishing; realtime delivery is best-effort, not the system of record. - -Presence and FE adoption: call `getOnlineCount({ roomId })` to display the current unique online count (`roomId: null` is global chat). Opening the SSE `streamMessages` route enters presence and closing it leaves; authenticated tabs are de-duplicated per user, while anonymous global viewers count separately. A managed realtime adapter must enter and leave presence on the same `chat:global` / `chat:room:{id}` channel for each connection and refresh grants after a room membership change. - -Routes (private-room lifecycle): `createPrivateRoom` (a player creates up to 15 active `private-channels` rooms, is auto-joined as moderator, and receives a join code), `joinRoom` (join by code; 404 if invalid, 403 if banned), `leaveRoom` (idempotent), `getRoom` (room detail; joinCode populated for members of private rooms), `kickMember` (moderator removes member - can rejoin), `banMember` (moderator bans member - cannot rejoin even with code; idempotent), and `listRoomMembers` (members only for private rooms). - -Routes (admin-only, AdminGuard-enforced): `createRoom` (POST /backoffice/chat/rooms - creates a public room by slug and required category), `listAdminRooms` (GET /backoffice/chat/rooms - paginated public rooms, sortable by name or creation time), `updateRoom` (PATCH /backoffice/chat/rooms/{id} - changes name, slug, and/or category), and `deleteRoom` (DELETE /backoffice/chat/rooms/{id} - soft-deletes the room while preserving messages, memberships, and bans). - -Per-viewer block filtering: message list filters out senders the viewer has blocked; the blocked player is unaffected and can still send. Filters apply to both room and global streams. Username resolves from the verified user row (falls back to header, then `anonymous`); userId resolves from auth. Realtime push uses `REALTIME_TRANSPORT`. - -Access model: public rooms are open to all; private rooms require membership. Deleted rooms are excluded from all room reads, lists, joins, streams, and moderation operations. `verifyRoomAccess(roomId, viewerId?)` is the single authority - called by `getRoomMessages`, `sendRoomMessage`, `getRoom`, `listRoomMembers`, `leaveRoom`, `kickMember`, `banMember`, and, via router, `streamMessages`. Moderator checks in `kickMember`/`banMember` require `role = 'moderator'` in `chatRoomMember`. - -Audit events: `chat.private_room.created`, `chat.room.member.kicked`, and `chat.room.member.banned` are subscribed by the audit module. `chat.user.blocked`/`chat.user.unblocked` are also audited. `chat.message.sent` is not audited because it is high-volume. - -Join code: 6 chars, 31-character alphabet (no ambiguous 0/1/I/O/L), crypto `randomInt`, globally unique at the DB layer. The private-room slug is generated independently as `private-{randomUUID()}` and must never contain the join code. - -Channel name convention (mirrors `chatChannel()` in the service): `chat:global` for null roomId and `chat:room:{roomId}` for rooms. - -Admin role requires the `chat-room` resource declared in `server/auth/permissions.ts`. diff --git a/.rulesync/rules/conventions.md b/.rulesync/rules/conventions.md index 415510ad..1e9fce78 100644 --- a/.rulesync/rules/conventions.md +++ b/.rulesync/rules/conventions.md @@ -17,58 +17,34 @@ globs: - 'packages/core/src/server/db/**' - 'packages/testing/src/**' - 'tools/db/**' -description: Engineering conventions - code, SQL, Drizzle, migrations, and database tooling. +description: Engineering conventions - compact universal baseline and routing to topical standards. --- -# Engineering Conventions - -The always-on core of the code standard: what you must obey while typing. Detail, examples and rationale live in `docs/standards/` - read the one file that matches the change instead of carrying all of it. Async seams: `messaging-and-microservices`. SQL / Drizzle: `docs/standards/database.md`. Repo map, decision tree, dependency rules: `overview`. - -| Change you are making | Read first | -| -------------------------------------- | ------------------------------------ | -| schema, type, enum-like value set | `docs/standards/types.md` | -| SQL, Drizzle, migration, seed, DB tool | `docs/standards/database.md` | -| function, service method, constructor | `docs/standards/functions.md` | -| new module, DI wiring, integration | `docs/standards/module-structure.md` | -| error class, catch, money path | `docs/standards/errors.md` | -| a test | `docs/standards/testing.md` | -| a comment or JSDoc | `docs/standards/comments.md` | -| a hook / the typed client | `docs/standards/react-sdk.md` | -| commit, PR | `docs/standards/git-delivery.md` | -| a failing gate, a new lint rule | `docs/standards/enforcement.md` | - -## Philosophy - -- **Functional and declarative by default.** Pure functions, immutable data, composition over imperative mutation and stateful classes. -- **Explicit over magic.** No auto-discovery, no decorator/reflection soup; every wiring point is a greppable, typed call. -- **Self-documenting.** Clear names beat comments. -- **Small and composable.** One concept per file; `parseUser()` + `sendWelcomeEmail()`, not `parseUserAndSendEmail()`. -- **YAGNI + DRY, in that order.** Abstract on the third occurrence, not the first. -- **Boring and consistent.** Match the surrounding code's idiom, naming, and density. - -## Never (lint-enforced unless noted) - -- `any` (tests included), `!` non-null assertions, `arr[i]!`, `as` casts to silence the compiler (`as const` is fine; test doubles go through the `mock` helper). -- `interface`, TS `enum`, decorators, inheritance for reuse, default exports (except `plugin.ts` + `drizzle.config.ts`). -- Hand-written duplicates of an inferrable type, re-inferring an imported schema, re-typing derived schema fields. -- Raw `z.uuid()` (use `UuidSchema`), inline `z.enum([...])` outside a contract dir. -- Inline `fetch`/`axios` in module code - third-party access is a port + adapter bound at the root. -- Comments. The only exception is a fact the code cannot contain (external-system behaviour, a spec constraint) and JSDoc on a public export. A rationale is not a fact - it goes in the commit or an ADR. -- Deep (`../../`+) relative imports that leave your zone/module, imports of another module's internals, import cycles, deep `dist/`/`src/` paths into another package. -- Hand-edited generated files: migrations, `docs/catalog.json`, per-tool agent mirrors. - -## Always - -- **One source of truth per shape - infer, never hand-write:** `z.infer`, `typeof x.$inferSelect`. -- **Schema-first at every boundary** (HTTP, config, env, events); validate once at the edge, trust the type after. -- **Enum-like sets are a values + schema + type triple on the contract surface**; `pgEnum` derives from the tuple. -- **Entity ids typed through their owning type** (`roleId: AdminRole['id']`, never a bare `string`). -- **Guard clauses first, main path last; brace every control statement; >3 params -> one named object.** -- **Construct objects by spread + override**, never a hand-copied field list. -- **Side effects at the edges**; events emit after the DB commit; money paths are transactional AND idempotent (a DB guard inside the transaction, not just an idempotency key). -- **Typed, named error classes** from the shared factories, mapped to transport in the router's `mapErrors`. -- **Cross-module coupling only via** a domain event, a command port, a shared contract, or a read-only `/schema` subpath. -- **Reuse the shared helpers** (`findOneOrThrow`, `pageToOffset`, `assertOwnership`, `serializeRow`, `createEventStreamGenerator`, `IdInputSchema`/`PageQuerySchema`) instead of re-rolling them - full table in `docs/standards/module-structure.md`. -- **Tests co-locate in `__tests__/`**; a file using `createTestDb`/`createTestRedis` is named `*.int.test.ts` (integration tier, needs docker pg + redis). Test behaviour, not the query builder; always cover authz negatives. -- **Pin exact dependency versions** (no `^`/`~`), and add a dependency deliberately. -- **Green before review:** `pnpm verify` passes, `pnpm regen` after any contract/schema change. Conventional commits, lowercase subject, one PR per concern. Never push without explicit confirmation. +# Engineering conventions + +Use pure, composable functions and explicit typed wiring. Match local naming and structure. Prefer clear names, guard clauses, immutable construction, and side effects at boundaries. Reuse an existing helper before adding one. + +| Change | Read first | +| ------------------------------------------------------- | ------------------------------------ | +| schema, type, enum-like value set | `docs/standards/types.md` | +| 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` | +| error class or catch | `docs/standards/errors.md` | +| money movement or payment settlement | `docs/standards/money.md` | +| KYC or responsible gambling | `docs/standards/compliance.md` | +| audit production or consumption | `docs/standards/audit.md` | +| async seam, event, job, or realtime | `messaging-and-microservices` | +| test | `docs/standards/testing.md` | +| comment or JSDoc | `docs/standards/comments.md` | +| hook or typed client | `docs/standards/react-sdk.md` | +| commit or PR | `docs/standards/git-delivery.md` | +| failing gate or lint rule | `docs/standards/enforcement.md` | + +## Universal baseline + +- Schema-first at trust boundaries. Infer types from their owning schema or row; do not hand-write duplicates. +- No `any`, `interface`, decorators, reuse inheritance, suppressive casts, or default exports except `plugin.ts` and `drizzle.config.ts`. +- Keep third-party access behind an owning adapter port. Do not import another module's internals or create cycles. +- Do not hand-edit generated artifacts: migrations, `docs/catalog.json`, or per-tool agent mirrors. +- State-changing work is transactional where its standard requires it. Money work is also idempotent with a durable database guard inside that transaction. diff --git a/.rulesync/rules/messaging-and-microservices.md b/.rulesync/rules/messaging-and-microservices.md index 83519022..61293d60 100644 --- a/.rulesync/rules/messaging-and-microservices.md +++ b/.rulesync/rules/messaging-and-microservices.md @@ -2,7 +2,7 @@ root: false targets: - '*' -description: Messaging seams (broker / job-queue / realtime), command vs event vs job, the event envelope, outbox, and the service-manifest path to microservices. +description: Messaging seams - channel choice and operational safety for events, jobs, realtime, and outbox. # Scoped to where async seams are used: services (emit/enqueue), plugins (subscribe/ # provide/workers), adapters, module contracts (eventIterator SSE routes) + the core # contracts zone (event schemas, seam ports), module-root port impls (admin-*.ts), @@ -23,68 +23,17 @@ globs: # Messaging and microservices-readiness -A modular monolith today, designed so high-impact modules extract into their own services later with zero module-code changes. Three swappable seams carry all async/cross-process traffic; a transactional outbox makes events durable; a service manifest boots the same codebase as monolith or single-module service. ADR-0010/0014/0016/0017. **Production is distributed-only (ADR-0030):** `createApp` no longer ships an in-process default for `MESSAGE_BROKER`/`JOB_QUEUE`/`CACHE`/`RATE_LIMITER` - it calls `assertDurableSeamsBound` right after plugins load and throws a clear, actionable error listing every seam still unbound. `REDIS_URL` auto-binds all four to their Redis reference drivers, so setting it is the whole production wiring. There is no in-process stand-in for these four any more - `InMemoryBroker`, `InProcessJobQueue`, `InProcessCache` and `InProcessRateLimiter` were deleted. Every tier binds the same Redis drivers production uses: `bootTestApp` gives each booted app its own Redis logical database, and core's unit tests use `createTestRedis()`/`redisUrlForWorker()` from `@openora/core/testing`. A test can no longer pass against delivery semantics the deployed system does not have. `InProcessRealtimeTransport`/`SseClientAuthorizer` remain, because core ships no realtime driver at all - they are `createApp`'s production default, not a test double. +Use the seams and contracts in code; this rule owns channel selection and the safety limits that affect a design. -## The three seams (ports in `packages/core/src/contracts/adapters/`, durable drivers in core) +| Need | Channel | +| ------------------------------------------- | ------------------------------------------------ | +| An answer or mutation now, including money | Synchronous command port | +| A fact happened and others may react | Domain event via `EventBus` | +| Durable, retryable, or scheduled work later | `JOB_QUEUE` | +| Server-to-client push | `REALTIME_TRANSPORT` with an SSE `eventIterator` | -| Seam | Token | For | Driver (production AND test) | -| -------------------------- | -------------------- | -------------------------------- | ------------------------------------------------------------------------- | -| Inter-module domain events | `MESSAGE_BROKER` | one module reacts to another | Redis Streams (`REDIS_URL`); RabbitMQ/Kafka via overlay | -| Background jobs | `JOB_QUEUE` | durable/retryable/scheduled work | BullMQ + Redis (`REDIS_URL`) | -| Client push | `REALTIME_TRANSPORT` | SSE/WS to the browser | `InProcessRealtimeTransport`; managed vendor (Ably/GetStream) via overlay | - -`MESSAGE_BROKER` is module-to-module. `REALTIME_TRANSPORT` is server-to-client. Do not conflate them. - -The `MESSAGE_BROKER` reference driver (`RedisStreamsBroker`) ships in core and auto-binds when `REDIS_URL` is set - so a monolith needs only Redis, no separate broker. It uses one Redis Streams consumer group per **service name** (`SERVICE_NAME`, else `monolith`): every replica of a service competes in that group, so an event is handled by exactly one replica, which then fans out to all local handlers for the topic - once per cluster, not once per replica (correct for audit/notifications). Distinct deployments sharing one Redis MUST set distinct `SERVICE_NAME`, so a split service names itself explicitly - `createApp` throws when `SERVICE_MANIFEST` is set without one. The group name can't be derived from `SERVICE_MANIFEST`: that is a list of module ids whose value reorders and grows, while a group name is a durable identity (renaming it strands the old group's pending entries and restarts consumption at `$`). Delivery is at-least-once from group creation onward (crashed-consumer entries are reclaimed via `XAUTOCLAIM`), so handlers stay idempotent; a group's first-ever creation starts at `$`, so a service extracted after events were already flowing does not replay the backlog (`startId: '0'` opts into that, accepting duplicate handler runs). `orderingKey` is not honoured (Streams have no partitioning). A consumer overlay can still re-provide `MESSAGE_BROKER` with RabbitMQ/Kafka. - -The `JOB_QUEUE` BullMQ reference driver (`BullMqJobQueue`) ships in core and auto-binds when `REDIS_URL` is set - jobs survive restarts and cron runs for real, zero consumer code (same auto-bind treatment as the Redis cache/rate-limiter, ADR-0028). Production requires either `REDIS_URL` or a consumer overlay to bind `JOB_QUEUE` (Container last-wins) - there is no in-process fallback once `createApp` finishes booting (ADR-0030). `orderingKey` is not honoured by this driver (no ordering groups in OSS BullMQ) - restore strict ordering with BullMQ Pro groups or a custom overlay. - -## Choose the channel: command vs event vs job - -| Need | Channel | How | -| ------------------------------------------------- | ---------------------------- | ------------------------------------------------------------ | -| "I need an answer / a mutation now" (incl. money) | **synchronous command port** | an adapter-port token the owner binds (eg `WALLET_COMMANDS`) | -| "this happened, others may react" | **domain event** | `EventBus` (`MESSAGE_BROKER`) | -| "do this reliably later" | **background job** | `JOB_QUEUE` | - -Never move money or a needed-now answer over events. - -## Synchronous cross-module commands - command ports (ADR-0017) - -When module A must mutate/query module B synchronously, it goes through a command port B owns - never B's tables. Reference: a game round settling debits the wallet via `WALLET_COMMANDS.debit(tx, { userId, amount })`, passing its own transaction handle so the round-write + debit stay atomic in-process. A remote wallet service later rebinds the port with a saga impl - the caller is unchanged. Declare `dependsOn: ['']` in the consumer's plugin. - -## Domain events - always through `EventBus`, never the broker directly - -- Declare the payload in `domainEventSchemas` (`packages/core/src/contracts/schemas/events.ts`). -- Emit from a service AFTER the DB commit: `this.events.emit('wallet.deposit.completed', {...})` (best-effort fan-out), OR durably inside the transaction via the outbox (below) when the event must not be lost. -- Subscribe in a plugin: `ctx.events.on('wallet.deposit.completed', (payload) => ...)`. Handlers receive the typed payload; the full `EventEnvelope` is an optional 2nd arg. -- Versioning: bump `domainEventVersions` (sparse map; default 1) in the SAME commit that changes a payload shape. `eventCatalog()` lists every topic + version. - -### Transactional outbox (ADR-0017) - -`emit()` is best-effort (lost if the broker is down between commit and publish). For a must-not-lose event - or across a process boundary - call `await this.events.emitInTransaction(tx, 'topic', payload)` INSIDE `db.transaction`: the envelope is written to `event_outbox` atomically with the state change; the `OutboxRelay` publishes after commit. At-least-once - consumers dedup on `eventId`. Bound only when `OUTBOX_ENABLED=1` or a durable broker is set; in the default in-process monolith `emitInTransaction` throws a guiding error. - -### The event envelope (ADR-0016) - -The `EventBus` wraps every emission at the broker boundary; module code never builds or sees it. `EventEnvelope` = `{ eventId, topic, payload, occurredAt, schemaVersion }` + optional `{ orderingKey, traceId }`: - -- `eventId` - consumer-side idempotency/dedup key (real brokers are at-least-once). -- `orderingKey` - Kafka partition key / RabbitMQ routing for per-user ordering. -- `schemaVersion` - forward-compatible payload evolution. `traceId` - correlation. - -Because the envelope isolates transport from domain logic, binding a durable broker is an overlay swap (a plugin object re-providing `MESSAGE_BROKER`) and extracting a module needs no module edits. Migration path: Redis Streams (default, `REDIS_URL`) -> RabbitMQ/Kafka (a consumer overlay implementing `MessageBrokerAdapter`); `topic` maps to routing key/topic, `orderingKey` to partition key, `consumerGroup` to durable queue/consumer group, `eventId` to dedup. Swap when you need what Streams lacks: partitioned ordering (`orderingKey` is not honoured), unbounded retention (Streams trim at `STREAM_MAXLEN`), or a dead-letter queue. `AMQP_URL`/`RABBITMQ_URL` do NOT bind a broker - core ships no AMQP driver; they only enable the transactional outbox, same as `OUTBOX_ENABLED`. - -## Deployable topology - the service manifest (ADR-0017) - -`SERVICE_MANIFEST` (comma-separated module ids) selects which modules a process loads; unset = all (monolith). Infra overlays (`kind: 'infra'` in `extensions.config.ts`) always load; filtering lives in `applyServiceManifest` (`@openora/core/server`). - -- Run a subset: `SERVICE_MANIFEST=identity,wallet `. -- Scaffold a thin host: `pnpm create:service ` -> `apps//` baking the manifest, reusing the root `extensions.config.ts`. -- A manifest must include each module's `dependsOn` deps (topo-sort fails fast). Split services exchanging events need a durable broker (`REDIS_URL`, or a broker overlay) and a distinct `SERVICE_NAME` each - it is the consumer group, so services sharing one compete for the same events instead of each getting a copy. `createApp` throws when `SERVICE_MANIFEST` is set without it. - -## Rules for async work - -- Handlers MUST be idempotent (at-least-once delivery). Money-adjacent handlers/jobs use a DB guard (unique row / status check), not just `eventId`/`idempotencyKey`. -- Money never flows over events - synchronous and transactional, via a command port. -- Background work: `enqueue(queue('name'), payload, { idempotencyKey, attempts, backoff, orderingKey })`; a worker overlay registers the handler via `ctx.jobs.worker(...)`. -- Client push: publish on `REALTIME_TRANSPORT`, expose an oRPC `eventIterator` served as SSE, bridge push->pull with `createEventStreamGenerator`. No private listener `Set`s in services - go through the seam so a managed vendor can fan out across instances. +- Handlers and jobs are at-least-once: they must be idempotent. Money-adjacent work needs a durable database guard, not only an event ID or idempotency key. +- Money and any needed-now answer never travel over events. Use a synchronous, transactional command port. +- `emit()` is best-effort. Use `emitInTransaction()` only when the transactional outbox is explicitly enabled by `OUTBOX_ENABLED`, `AMQP_URL`, or `RABBITMQ_URL`; those AMQP variables enable the outbox but do not bind an AMQP broker. +- A Redis Streams deployment uses `SERVICE_NAME` as its durable consumer-group identity. Every independently deployed service sharing Redis needs a distinct name. +- The shipped Redis Streams and BullMQ drivers do not honor `orderingKey`; use an overlay when strict ordering is required. diff --git a/.rulesync/rules/overview.md b/.rulesync/rules/overview.md index bd605b13..d0ce6131 100644 --- a/.rulesync/rules/overview.md +++ b/.rulesync/rules/overview.md @@ -8,149 +8,30 @@ globs: # AGENTS.md -Canonical brief for AI agents and humans. Per-tool files (`AGENTS.md`, `CLAUDE.md`, `.github/copilot-instructions.md`, `.codex/config.toml`, subagent + command mirrors) are generated by [rulesync](https://github.com/dyoshikawa/rulesync) from `.rulesync/`. Edit the source, run `pnpm gen:agents`. Never hand-edit generated files. - -Sibling rules (load on demand; do not reopen settled questions): `conventions` (the always-on code and database standard, with a table routing to the matching deep-dive file in `docs/standards/`), `messaging-and-microservices` (async seams, command vs event vs job, outbox). Module layering, DI, ports and the shared-helper table live in `docs/standards/module-structure.md`. +Canonical brief for AI agents and humans. Per-tool files are generated from `.rulesync/`; edit the source, then run `pnpm gen:agents`. Never hand-edit generated mirrors. ## Mission -Open-source, headless, plugin-based, AI-native igaming framework. Consumers clone/install and extend it with their own modules, plugins, and adapters; the frontend lives in their consumer repo. The default backend covers auth, wallet, player management, compliance, audit, chat and backoffice; game lobby and CMS are early, and bonuses/tournaments/affiliates/jackpots are not built yet. Nothing consumer-specific lives here. - -## Enhance the ask first (pre-step) - -Before acting on any non-trivial request - and before delegating - run the `enhance-prompt` skill on the raw ask to produce the brief you actually execute. Skip it only when the ask is already precise. Subagents act on the brief they are handed; they do not re-enhance it. - -## Architecture pillars - -1. **Zod-first contracts.** Every shape is a Zod schema; types are `z.infer`'d, never hand-written. Cross-cutting schemas in `packages/core/src/contracts/schemas/`; each module OWNS its route contract + req/res schemas + `z.infer`'d types in its `contract/` dir - the single source of wire truth, nothing else re-declares a wire shape. `composeContract` (`@openora/core/contracts`) owns only `health`; the consumer composition root composes each enabled module's `/contract` slice into the one runtime contract the SDK links against. ADR-0021/0025. -2. **oRPC + Hono.** oRPC owns route definition + Zod validation; its `OpenAPIHandler` mounts on a Hono server with a live OpenAPI reference. DI is a functional `Container` (`@openora/core/server`) - typed-token factories, no decorators, no `reflect-metadata`. ADR-0009. -3. **Plugin host.** Typed plugin objects are the only way new functionality enters. Everything (core modules included) loads through `extensions.config.ts`. -4. **Headless.** Backend modules + contracts + SDK surface only. UI lives in the consumer, which imports `@openora/core/react` (hooks, typed client, auth, realtime). No UI packages here. -5. **Explicit > magic.** No auto-discovery, no decorators. Everything greppable; every wiring point a typed call. -6. **AI-first.** Every module has an `AGENTS.md`; every scaffold a command; contracts queryable via the `oss-dev` MCP server + generated `docs/catalog.json`. -7. **Functional & declarative.** Pure functions, immutable data, composition. A `class` only as a thin DI shell delegating to pure functions. Rationale + examples in `conventions`. - -## Repo map - -Ships `@openora/core` (domains + engine), the SDK, tooling. The API server lives in the consumer - this repo has no runnable server (`apps/api` removed 2026-06-22). - -``` -apps/ - mcp-server-dev/ # MCP dev server (stdio) - agents connect via .mcp.json -packages/ - config/ # tsconfig, vitest, oxlint presets; boundary lint plugins - core/ # @openora/core - THE single published package (ADR-0025). Subpaths: - src/contracts/ # isomorphic: composeContract + healthContract, base zod schemas (schemas/), adapter interfaces + DI tokens (adapters/) - src/react/ # domain-agnostic SDK: createClient, typed client, auth, realtime. No UI. - src/server/ # node engine: kernel (logger, EventBus + EVENT_BUS, Container), plugin-host (Plugin, ModuleRegistry, loader), db (DrizzleService), auth (better-auth + AdminGuard), runtime (createApp - domain-agnostic, single-tenant). Subpaths: /orm, /migrate - src/compliance/ # sealed-token list + assertSealedServicesBound (engine); also the compliance domain (/contracts, /schema, /plugins) - src// # 9 folded domains (casino, cms, compliance, engagement, pam, wallet, iam, audit, admin-console), exposed as @openora/core//{contracts,schema,plugins,server,react}. The BARE root (@openora/core/) is the public consumer surface: an isomorphic contract barrel (schemas, enum triples, z.infer types; multi-slice domains namespace per slice, eg `import { chat } from '@openora/core/engagement'`) - never server code. Services/routers/plugin live under /server; tables under /schema. A domain imports engine zones + a sibling's read-only /schema only - never a sibling's internals. - src///drizzle/ # each module owns its drizzle.config.ts + migrations/ history (ADR-0027); scripts/generate-all.mjs runs them all - mcp/ # @openora/mcp - publishable MCP server consumers run against their own repo - testing/ # @openora/testing - dev/test harness (bootTestApp, seedDemoData) -docs/ - adr/ # architecture decision records - catalog.json # generated surface (routes/schemas/adapters/slots/events); read by @openora/mcp -tools/ # grouped: gen/ (gen.ts scaffolder, gen-catalog), lint/ (oxlint plugins, verify-module-shape), create/, db/ (seed), setup/ -extensions.config.ts # the single registry of enabled plugins -``` - -## Where does X go? (decision tree) - -- **New business domain** (eg "tournaments") -> `pnpm gen module ` creates `packages/core/src///`, wires the domain barrels + the `@openora/core` exports map, and registers it in `extensions.config.ts`. Every module owns its `drizzle.config.ts` + migration history. ADR-0024/0025/0027. -- **Extend/override an existing module** -> overlay plugin: `pnpm gen plugin ` -> `extensions//plugin.ts` (repo root here; the consumer's app when deployed). -- **New HTTP route** -> the module's `router/index.ts` via `pnpm gen route `. Player routes resolve the caller from `x-user-id`; admin routes MUST be guarded (next). -- **Admin-only route** -> `plugin.ts` resolves `AdminGuard` (`c.get(ADMIN_GUARD)`, seeded by `createApp`) and passes it into the router; `await adminGuard.assert(context)` is the handler's FIRST line. The single admin-enforcement point - never re-implement the role check. -- **New DB table** -> a Drizzle `pgTable` in the module's `schema/index.ts`; run `propose-table-change` (MCP) first, then `pnpm regen`. See `docs/standards/database.md`. -- **Reference / seed data** (roles, command configs, default tags, static lookup rows) -> `/seed/index.ts` exporting a `seed(db: DrizzleDb): Promise` function. Wire the subpath export (`@openora/core//seed/`) in `packages/core/package.json` + `tsconfig.json`, then import and call from `tools/db/seed.ts`. Use `onConflictDoNothing()` — seed is always idempotent. Never inline seed rows in a migration or in `tools/db/seed.ts` directly. Reference: `iam/seed/`, `pam/tag/seed/`, `engagement/chat-commands/seed/`. -- **Reusable Zod schema** -> `packages/core/src/contracts/schemas/.ts`. Module-local schemas in the module's `contract/`. -- **Enum / status value set** -> a values + schema + type triple on the contract surface (cross-domain: core `contracts/schemas/`; domain-local: the module's `contract/`), pgEnum derived from the tuple. `docs/standards/types.md` + `docs/standards/database.md` > Enums. -- **Cross-module event** -> declare the payload in `domainEventSchemas` (`packages/core/src/contracts/schemas/events.ts`), emit via `EventBus`, subscribe with `ctx.events.on(...)`. ADR-0010; detail in `messaging-and-microservices`. -- **Frontend UI** -> NOT here (headless). Consumer builds it over HTTP via `@openora/core/react`. -- **New data hook** -> `packages/core/src/react/hooks/` (domain-specific: that domain's `react/` dir). React Compiler owns memoization - `docs/standards/react-sdk.md`. -- **Operator config** (feature flags, brands, RG defaults) -> `platform-config.yaml`/`.json` via `loadPlatformConfig()` + `PlatformConfigSchema`, bound as `PLATFORM_CONFIG`. ADR-0013. -- **Third-party integration** (PSP, KYC, aggregator, chat) -> adapter interface + `createToken` in `packages/core/src/contracts/adapters/.ts`, impl in the owning module's `adapters//`, bound in `plugin.ts` via `ctx.provide(TOKEN, () => new Impl())`. Never inline `fetch`/SDK calls. -- **Background task** -> the `JOB_QUEUE` seam: `enqueue(queue('name'), payload, { idempotencyKey, delayMs, attempts, backoff, orderingKey })`; a worker overlay registers the handler via `ctx.jobs.worker(...)`. At-least-once: handlers idempotent (DB guard for money). ADR-0014. -- **Live client push** (chat, live odds, big-win feed) -> `REALTIME_TRANSPORT` seam + an oRPC `eventIterator(...)` served as SSE; client uses `useEventStream`. Separate from `MESSAGE_BROKER`. ADR-0007/0014; `chat` (engagement) is the reference vertical. - -## Naming - -Cross-cutting basics (kebab files, PascalCase types, `Schema` + inferred ``, predicate booleans, units in names) in `conventions`. OSS-specific: - -- Packages: `@openora/`. Public API = package/subpath entry + read-only `/schema`; internals are a lint error. -- oRPC routers namespaced by module (`wallet.transactions.list`). -- SQL / Drizzle identifiers: `docs/standards/database.md`. - -## Dependency rules (two-layer enforcement, both in `pnpm verify`) - -Two gates, kept in sync: **oxlint `oss-boundaries/*`** (`tools/lint/oxlint-boundaries-plugin.mjs`) matches import specifiers per edit, and **dependency-cruiser** (`.dependency-cruiser.cjs`, `pnpm check:boundaries`) resolves the whole graph - so it also catches transitive edges, barrel laundering, dynamic `import()`, and relative paths that dodge the prefix. ADR-0015. - -- A folded domain imports engine zones (`contracts`/`server`/`react`) + a sibling's read-only `/schema` only - never a sibling's internals (`no-cross-domain`). Couple via a command port, a domain event, or a shared contract. -- `contracts` is isomorphic: only other contracts + Zod (`no-contracts-to-runtime`). `react` never imports `server` or a module (`no-react-to-runtime`). -- Engine zones never import a domain (`no-core-to-domain`); wiring happens only in the consumer's composition root (+ `@openora/testing`). -- Import the package/subpath entry, never a deep `dist/` path, and never a sibling package's `src/` internals (`no-deep-package-import`; `@openora/core` is exempt - its subpath exports all resolve into `src/`). No cycles - break by inverting the dependency or moving the type to contracts. - -## Forbidden patterns - -Lint-enforced cross-cutting bans in `conventions`: `any` outside tests, `interface`, decorators, classes-for-reuse, hand-written duplicate types, cycles, inline `fetch`/`axios`, bare `TODO`s. OSS-specific: - -- Ad-hoc/duplicated Zod schemas in routers/services - schemas live in the module's `contract/` or core contracts; derive with `.pick/.omit/.partial/.extend/.merge`, don't re-type fields. -- Re-exporting types "to be nice" - import from where defined. -- SQL anti-patterns (bare `timestamp()`, CamelCase identifiers, hand-edited migrations) - `docs/standards/database.md`. - -## Run locally - -Scripts are grouped by prefix - `check:*` reports, `fix:*` rewrites, `gen:*` emits, `db:*` touches Postgres, `test:*` runs suites. All of them route through turbo, so repeat runs are cached. - -``` -pnpm setup # first time: docker + db + mcp + summary -pnpm dev # turbo dev (docs, mcp) -pnpm regen # tsconfig paths + drizzle generate + catalog -pnpm db:seed # demo data (idempotent; admin@oss.dev / password123) -pnpm verify # the full gate: every check:* + test:unit + test:integration + test:tools, in parallel -pnpm test:unit # infra-free suite (~4s); *.int.test.ts run in test:integration (docker pg + redis) -pnpm db:setup:test:fresh # recreate the shared e2e db after editing an already-applied migration -pnpm check:boundaries # just the whole-graph boundary + cycle gate -pnpm check:drift # catalog staleness (CI-only; not part of verify) -pnpm fix:lint # oxlint --fix; pair with fix:format -pnpm -F @openora/core vitest run # one test file/dir, eg src/iam/__tests__ -``` - -`docker compose up` starts only postgres; apps run on the host. PR only on green `pnpm verify`; CI adds `pnpm check:drift`. Pre-commit runs `pnpm check:boundaries` + `pnpm check:types`. - -## Definition of done - audit every new action - -Every new **state-changing action** (mutation route, admin op, money/KYC/config change) MUST leave an entry in the append-only, sha256 hash-chained `audit` module. Two ways: - -1. **Domain event (preferred).** Emit after the DB commit, declare the payload in `domainEventSchemas`, add the topic to `SUBSCRIBED_TOPICS` in `packages/core/src/audit/plugin.ts`. -2. **Direct record.** Resolve the `AUDIT_WRITER` port and `record({ actorId, actorType, action, resourceType, resourceId, before, after, ip })` - for admin actions / non-event outcomes. - -Capture actor, resource, and before/after on mutations. No audit entry = not done. Pure reads, docs, tests, chores need none. +Open-source, headless, plugin-based, AI-native igaming framework. Consumers extend modules, plugins, and adapters in their own repo; this repository ships backend contracts, runtime, SDK, tooling, and no consumer UI. -## Agent roster +## Load the right owner -For platform development (this repo); consumer agents ship in `tools/templates/consumer/__dot__rulesync/subagents/`. +- Universal engineering baseline and a topical routing table: `conventions`. +- Events, jobs, realtime, and outbox choices: `messaging-and-microservices`. +- Module boundaries, DI, ports, and shared helpers: `docs/standards/module-structure.md`. +- Money, compliance, and audit: read `docs/standards/{money,compliance,audit}.md` before changing those paths. +- The touched module's contract, schema, plugin, and catalog entry define its current surface. There are no nested module instructions. -| Agent | When to use | -| ------------------- | ------------------------------------------------------------ | -| `expert` | Fuzzy ask -> requirements + AC; regulatory/domain questions | -| `dev` | Implement a module/plugin/adapter from a given spec | -| `module-author` | Author a complete module end-to-end | -| `plugin-author` | Overlay plugin that extends without touching core | -| `operator` | Outside-in readiness audit; find launch blockers | -| `contract-reviewer` | Diff for breaking changes, boundary violations, schema drift | -| `security-reviewer` | Money/authz/secret-PII/auth-flow risks in changed files | -| `quality-reviewer` | Performance/duplication/simplification/conventions in a diff | -| `qa` | API-level tests + hands-on walkthrough; bug triage | -| `docs` | Sync prose docs to code, then `pnpm gen:agents` | +## Quick destinations -**Delegation is mandatory.** When a task matches a roster agent, spawn THAT agent - not `general-purpose`. A `preToolUse` hook (`guard-subagent.mjs`) rejects a generic Task that fits a roster agent. +- New module, route, table, seed, schema, enum, adapter, config, hook, or integration: use the matching generator and the topical standard named by `conventions`. +- Cross-module work: use a command port, domain event, shared contract, or read-only `/schema` subpath as defined in `docs/standards/module-structure.md`. +- Async or cross-process work: choose the channel in `messaging-and-microservices` before implementation. +- Docs or generated configuration: edit canonical source only. `pnpm regen` owns generated artifacts; `pnpm gen:agents` owns agent mirrors. -## Working rules for agents +## Root guardrails -- Use the `oss-dev` MCP server (`.mcp.json`, pre-approved) for read-only inspection: `read-agents-md`, `list-modules`, `describe-module`, `list-routes`, `list-extension-points`, `get-drizzle-schema`, `propose-table-change`, `schema-get`, `docs-search`, `db-query-readonly`. Faster than grep, reflects current state. -- Before a route: `list-routes`. Before a table: `propose-table-change`. After any change: `pnpm verify --filter `; fix failures before continuing. -- Read the touched module's `AGENTS.md` before editing it; keep it updated when invariants or extension seams change. Every `AGENTS.md` (this one included) stays lean: only what code can't say - invariants, rationale, gotchas, extension seams. Never route/table/event listings or "see `contract/`" pointers; agents already know to read `contract/`, `schema/`, `docs/catalog.json`. Claude Code loads them via generated per-module `CLAUDE.md` stubs (`tools/gen/gen-claude-stubs.mjs`, gitignored). -- Small PRs scoped to one module; cross-module changes need human approval. Never commit unless asked; never push without explicit per-action confirmation. -- ASCII only in code; short dashes (-) only, never long dashes. -- **Never run two agents that both call `pnpm regen` (or `pnpm sync:agents`) against the same working tree.** Both rewrite shared generated state - drizzle migration journals and `docs/catalog.json` - and the second run silently discards the first agent's freshly generated migration. The tree then holds a new enum value or table with NO migration: unit tests, lint and `boundaries` all still pass, so it surfaces at deploy, not in CI. Parallelise agents only across disjoint modules with regen serialised afterwards by one owner, and `git status -- '**/drizzle/migrations/**'` before handing off. +- Headless means no frontend UI belongs here; consumers build over `@openora/core/react`. +- Admin routes resolve the shared `AdminGuard` in `plugin.ts`; `await adminGuard.assert(context)` is the handler's first line. +- Serialize `pnpm regen` and `pnpm gen:agents` across agents. They rewrite shared generated state; one owner runs each command after parallel edits finish. +- Delegate a task to the matching named roster agent when one exists; do not use a generic agent for a roster task. diff --git a/.rulesync/subagents/contract-reviewer.md b/.rulesync/subagents/contract-reviewer.md index 3e75e165..123085c2 100644 --- a/.rulesync/subagents/contract-reviewer.md +++ b/.rulesync/subagents/contract-reviewer.md @@ -42,7 +42,7 @@ If the orchestrator passed a base ref + changed-file list, use them - do not re- - [ ] Services throw shared-factory domain errors (`makeNotFoundError` etc.), not HTTP exceptions; routers `mapErrors`. - [ ] No `any` outside tests, no `interface`, no `!` non-null assertions, no bare casts, no default exports, no inline `fetch`/`axios`. - [ ] No re-inferred/duplicated types outside the owning contract; id params typed via owning type. -- [ ] TODOs/FIXMEs carry context; module `AGENTS.md` updated if extension points/ports/routes changed. +- [ ] TODOs/FIXMEs carry context; extension points, ports, and routes match the module contract and catalog. - [ ] No frontend/UI code - headless repo. ### Tests + audit diff --git a/.rulesync/subagents/dev.md b/.rulesync/subagents/dev.md index 69fa702e..1992fd66 100644 --- a/.rulesync/subagents/dev.md +++ b/.rulesync/subagents/dev.md @@ -25,7 +25,7 @@ Your prompt contains requirements + acceptance criteria. Build to those. If the ## Before writing code 1. Read root `AGENTS.md` (decision tree, boundaries, forbidden patterns) and the sibling rules (`conventions`, `messaging-and-microservices`) plus the `docs/standards/` file matching what you are changing. Follow exactly. -2. Read the touched module's `AGENTS.md` and any related `docs/adr/`. +2. Read the touched module's `contract/`, `schema/`, `plugin.ts`, and any related `docs/adr/`. 3. Inspect current state via `oss-dev` MCP: `list-modules`, `describe-module`, `list-routes` (collision check), `get-drizzle-schema`, `propose-table-change` (before any table), `schema-get`. 4. Pick the home via the decision tree. Use the scaffolders (`pnpm gen module|route|plugin|adapter|job-worker`) - don't hand-write skeletons. 5. Library API in doubt (Hono, oRPC, Drizzle, Zod, better-auth)? Check current docs via context7/web search - don't code from memory. @@ -41,7 +41,7 @@ Your prompt contains requirements + acceptance criteria. Build to those. If the - `pnpm verify --filter ` exits 0; schema changes have a generated migration. - New module/plugin registered in `extensions.config.ts`; core contract slice composed in `tools/build-contract.ts`. -- Module `AGENTS.md` updated; new logic covered by co-located tests (authz negatives included). +- New logic covered by co-located tests (authz negatives included). - Every acceptance criterion satisfied - list them and confirm each. - Every state-changing action audited: domain event declared in `domainEventSchemas` + topic in `SUBSCRIBED_TOPICS` (`packages/core/src/audit/plugin.ts`), or `AUDIT_WRITER.record(...)`. No audit entry = not done. diff --git a/.rulesync/subagents/docs.md b/.rulesync/subagents/docs.md index 1a2c56ad..491c2235 100644 --- a/.rulesync/subagents/docs.md +++ b/.rulesync/subagents/docs.md @@ -34,7 +34,7 @@ You keep the OSS docs honest. Read the code first, write the docs second - never | Doc claim | Verify against | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| Repo map / "what lives where" | `ls apps/ packages/` - every named dir must exist and match its `package.json`/`AGENTS.md` | +| Repo map / "what lives where" | `ls apps/ packages/` - every named dir must exist and match its `package.json` | | Module roster / domain claims | `mcp__oss-dev__list-modules` + `extensions.config.ts` | | Route / adapter / extension claims | `list-routes`, `list-extension-points` | | Scaffolder flags + templates | `tools/gen/gen.ts`, `packages/core/generators/src/config.ts`, `tools/create/create-igaming-app.ts`, `ls tools/templates/` | @@ -44,7 +44,7 @@ You keep the OSS docs honest. Read the code first, write the docs second - never ## Scope -Edit directly: `.rulesync/rules/*.md` (canonical brief + rules), `.rulesync/subagents|commands|skills/`, root `README.md`, `docs/*.md`, `docs/adr/*.md` (Update blocks only), `packages/**/AGENTS.md`, `apps/**/AGENTS.md`, `tools/templates/consumer/__dot__rulesync/**`. +Edit directly: `.rulesync/rules/*.md` (canonical brief + rules), `.rulesync/subagents|commands|skills/`, root `README.md`, `docs/*.md`, `docs/adr/*.md` (Update blocks only), and `tools/templates/consumer/__dot__rulesync/**`. ADRs: never rewrite the original Context/Decision - add `> **Update (YYYY-MM-DD)**: ...` at the top. Obsolete docs get `Status: Superseded by ADR-XXXX`, not deletion. diff --git a/.rulesync/subagents/expert.md b/.rulesync/subagents/expert.md index 9161766a..afce7c7b 100644 --- a/.rulesync/subagents/expert.md +++ b/.rulesync/subagents/expert.md @@ -15,7 +15,7 @@ You are a senior iGaming product/domain expert who has shipped multiple real-mon ## Grounding (do this first) 1. Read root `AGENTS.md` (mission, pillars, decision tree) so requirements map onto how this platform is built. -2. Inventory what exists: `list-modules`, `list-routes`, `list-extension-points` via the `oss-dev` MCP; read active modules' `AGENTS.md`. Don't spec what already ships. +2. Inventory what exists: `list-modules`, `describe-module`, `list-routes`, `list-extension-points` via the `oss-dev` MCP. Don't spec what already ships. 3. Read `docs/catalog.json` for the adapter surface - which vendor ports exist, wired vs stubbed. ## How you work diff --git a/.rulesync/subagents/module-author.md b/.rulesync/subagents/module-author.md index eded94b2..46572a4c 100644 --- a/.rulesync/subagents/module-author.md +++ b/.rulesync/subagents/module-author.md @@ -4,7 +4,7 @@ targets: name: module-author description: >- Author a complete OSS module end-to-end from a name + brief: schema, contract, - service, router, plugin.ts, tests, AGENTS.md. + service, router, plugin.ts, and tests. claudecode: model: sonnet --- @@ -38,16 +38,15 @@ Creates the module as a standalone package with all required files and registers ## What to fill in -| File | What goes here | -| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `schema/index.ts` | Drizzle `pgTable`s (see `docs/standards/database.md`). `propose-table-change` first. | -| `contract/index.ts` | oRPC route contract + req/res Zod schemas - the source of truth. | -| `schemas/index.ts` | Local Zod helpers; types via `z.infer`, never hand-written. | -| `service/.service.ts` | Business logic as plain async methods. No HTTP concepts. Inject `DrizzleService` + `EventBus`. | -| `adapters//` | Impls of any adapter ports (port + token in `packages/core/src/contracts/adapters/`). | -| `router/index.ts` | Thin oRPC wiring; admin routes call `await adminGuard.assert(context)` first. | -| `plugin.ts` | `Plugin` object - DI wiring only. | -| `AGENTS.md` | ONLY what code can't say: invariants, rationale, gotchas, extension seams. No route/table/layout listings - they duplicate `contract/`/`schema/` and drift. | +| File | What goes here | +| --------------------------- | ---------------------------------------------------------------------------------------------- | +| `schema/index.ts` | Drizzle `pgTable`s (see `docs/standards/database.md`). `propose-table-change` first. | +| `contract/index.ts` | oRPC route contract + req/res Zod schemas - the source of truth. | +| `schemas/index.ts` | Local Zod helpers; types via `z.infer`, never hand-written. | +| `service/.service.ts` | Business logic as plain async methods. No HTTP concepts. Inject `DrizzleService` + `EventBus`. | +| `adapters//` | Impls of any adapter ports (port + token in `packages/core/src/contracts/adapters/`). | +| `router/index.ts` | Thin oRPC wiring; admin routes call `await adminGuard.assert(context)` first. | +| `plugin.ts` | `definePlugin` - DI wiring only. | Headless repo: build no UI. After filling in: `pnpm regen` (migration + catalog), then `pnpm verify` and fix everything. @@ -55,7 +54,7 @@ Headless repo: build no UI. After filling in: `pnpm regen` (migration + catalog) - `pnpm verify` exits 0; migration generated into the module's own `drizzle/migrations/` (ADR-0027). - Registered in `extensions.config.ts`; core contract slice composed in `tools/build-contract.ts`. -- `AGENTS.md` filled; at least one unit test in `__tests__/` (authz negatives for guarded routes). +- At least one unit test in `__tests__/` (authz negatives for guarded routes). - Every state-changing action audited: domain event in `domainEventSchemas` + topic in `SUBSCRIBED_TOPICS` (`packages/core/src/audit/plugin.ts`), or `AUDIT_WRITER.record(...)`. No audit entry = not done. ## Rules diff --git a/.rulesync/subagents/plugin-author.md b/.rulesync/subagents/plugin-author.md index 3ae2bb70..58e5c38b 100644 --- a/.rulesync/subagents/plugin-author.md +++ b/.rulesync/subagents/plugin-author.md @@ -61,5 +61,4 @@ No decorators, no controllers - `{ id, dependsOn, register } satisfies Plugin magic.** No auto-discovery, no decorators. Everything greppable; every wiring point a typed call. -6. **AI-friendly.** Every module has an `AGENTS.md`; every scaffold a command; contracts queryable via the `oss-dev` MCP server + generated `docs/catalog.json`. -7. **Functional & declarative.** Pure functions, immutable data, composition. A `class` only as a thin DI shell delegating to pure functions. Rationale + examples in `conventions`. - -## Repo map - -Ships `@openora/core` (domains + engine), the SDK, tooling. The API server lives in the consumer - this repo has no runnable server (`apps/api` removed 2026-06-22). - -``` -apps/ - docs/ # Fumadocs site - mcp-server-dev/ # MCP dev server (stdio) - agents connect via .mcp.json -packages/ - config/ # tsconfig, vitest, oxlint presets; boundary lint plugins - core/ # @openora/core - THE single published package (ADR-0025). Subpaths: - src/contracts/ # isomorphic: composeContract + healthContract, base zod schemas (schemas/), adapter interfaces + DI tokens (adapters/) - src/react/ # domain-agnostic SDK: createClient, typed client, auth, realtime. No UI. - src/server/ # node engine: kernel (logger, EventBus + EVENT_BUS, Container), plugin-host (Plugin, ModuleRegistry, loader), db (DrizzleService), auth (better-auth + AdminGuard), runtime (createApp - domain-agnostic, single-tenant). Subpaths: /orm, /migrate - src/compliance/ # sealed-token list + assertSealedServicesBound (engine); also the compliance domain (/contracts, /schema, /plugins) - src// # 9 folded domains (casino, cms, compliance, engagement, pam, wallet, iam, audit, admin-console), exposed as @openora/core//{contracts,schema,plugins,server,react}. The BARE root (@openora/core/) is the public consumer surface: an isomorphic contract barrel (schemas, enum triples, z.infer types; multi-slice domains namespace per slice, eg `import { chat } from '@openora/core/engagement'`) - never server code. Services/routers/plugin live under /server; tables under /schema. A domain imports engine zones + a sibling's read-only /schema only - never a sibling's internals. - src///drizzle/ # each module owns its drizzle.config.ts + migrations/ history (ADR-0027); scripts/generate-all.mjs runs them all - addons/ # gated @openora-addons/ packages - gating + extraction machinery (OSS_ADDONS, no-cross-addon, scaffolder) kept for future premium modules; none ship today. ADR-0025 - mcp/ # @openora/mcp - publishable MCP server consumers run against their own repo - testing/ # @openora/testing - dev/test harness (bootTestApp, seedDemoData) -docs/ - adr/ # architecture decision records - catalog.json # generated surface (routes/schemas/adapters/slots/events); read by @openora/mcp -tools/ # grouped: gen/ (gen.ts scaffolder, gen-catalog), lint/ (oxlint plugins, verify-module-shape), create/, db/ (seed, migrate-all), setup/ -extensions.config.ts # the single registry of enabled plugins -``` - -## Where does X go? (decision tree) - -- **New business domain** (eg "tournaments") -> `pnpm gen module ` creates `@openora-addons/` under `packages/addons//` and registers it in `extensions.config.ts` (no `kind` = core, `kind: 'addon'` = gated). Every module owns its `drizzle.config.ts` + migration history. ADR-0021/0024/0027. -- **Extend/override an existing module** -> overlay plugin: `pnpm gen plugin ` -> `extensions//plugin.ts` (repo root here; the consumer's app when deployed). -- **New HTTP route** -> the module's `router/index.ts` via `pnpm gen route `. Player routes resolve the caller from `x-user-id`; admin routes MUST be guarded (next). -- **Admin-only route** -> `plugin.ts` resolves `AdminGuard` (`c.get(ADMIN_GUARD)`, seeded by `createApp`) and passes it into the router; `await adminGuard.assert(context)` is the handler's FIRST line. The single admin-enforcement point - never re-implement the role check. -- **New DB table** -> a Drizzle `pgTable` in the module's `schema/index.ts`; run `propose-table-change` (MCP) first, then `pnpm regen`. See `db-conventions`. -- **Reusable Zod schema** -> `packages/core/src/contracts/schemas/.ts`. Module-local schemas in the module's `contract/`. -- **Enum / status value set** -> a values + schema + type triple on the contract surface (cross-domain: core `contracts/schemas/`; domain-local: the module's `contract/`), pgEnum derived from the tuple. `conventions` section 3 + `db-conventions` > Enums. -- **Cross-module event** -> declare the payload in `domainEventSchemas` (`packages/core/src/contracts/schemas/events.ts`), emit via `EventBus`, subscribe with `ctx.events.on(...)`. ADR-0010; detail in `messaging-and-microservices`. -- **Frontend UI** -> NOT here (headless). Consumer builds it over HTTP via `@openora/core/react`. -- **New data hook** -> `packages/core/src/react/hooks/` (domain-specific: that domain's `react/` dir). Hand-write `useMemo`/`useCallback` for stability-contract returns (consumer's React Compiler skips `node_modules`) - `conventions` section 7. -- **Operator config** (feature flags, brands, RG defaults) -> `platform-config.yaml`/`.json` via `loadPlatformConfig()` + `PlatformConfigSchema`, bound as `PLATFORM_CONFIG`. ADR-0013. -- **Third-party integration** (PSP, KYC, aggregator, chat) -> adapter interface + `createToken` in `packages/core/src/contracts/adapters/.ts`, impl in the owning module's `adapters//`, bound in `plugin.ts` via `ctx.provide(TOKEN, () => new Impl())`. Never inline `fetch`/SDK calls. -- **Background task** -> the `JOB_QUEUE` seam: `enqueue(queue('name'), payload, { idempotencyKey, delayMs, attempts, backoff, orderingKey })`; a worker overlay registers the handler via `ctx.jobs.worker(...)`. At-least-once: handlers idempotent (DB guard for money). ADR-0014. -- **Live client push** (chat, live odds, big-win feed) -> `REALTIME_TRANSPORT` seam + an oRPC `eventIterator(...)` served as SSE; client uses `useEventStream`. Separate from `MESSAGE_BROKER`. ADR-0007/0014; `chat` (engagement) is the reference vertical. - -## Naming - -Cross-cutting basics (kebab files, PascalCase types, `Schema` + inferred ``, predicate booleans, units in names) in `conventions`. OSS-specific: - -- Packages: `@openora/` (platform), `@openora-addons/` (gated add-ons). Public API = package/subpath entry + read-only `/schema`; internals are a lint error. -- oRPC routers namespaced by module (`wallet.transactions.list`). -- SQL / Drizzle identifiers: `db-conventions`. - -## Dependency rules (two-layer enforcement, both in `pnpm verify`) - -Two complementary gates, kept in sync: (1) **oxlint `oss-boundaries/*`** (`tools/lint/oxlint-boundaries-plugin.mjs`) - fast per-edit string matching on import specifiers (runs via `pnpm lint` + the post-edit hook); (2) **dependency-cruiser** (`.dependency-cruiser.cjs`, `pnpm boundaries`) - the resolved whole graph, so it also catches transitive edges, re-export/barrel laundering, dynamic `import()`, and relative paths that dodge the prefix. ADR-0015. - -- A folded domain imports engine zones (`contracts`/`server`/`react`) + a sibling's read-only `/schema` only - never a sibling's internals (`no-cross-domain`). Couple via a command port, a domain event, or a shared contract. Same rule for add-ons (`no-cross-addon`). -- `contracts` is isomorphic: only other contracts + Zod (`no-contracts-to-runtime`). `react` never imports `server` or a module (`no-react-to-runtime`). -- Engine zones never import a domain or add-on (`no-core-to-domain`/`no-core-to-addon`); wiring happens only in the consumer's composition root (+ `@openora/testing`). -- Import the package/subpath entry, never a deep `dist/` path. No cycles - break by inverting the dependency or moving the type to contracts. - -## Forbidden patterns - -Lint-enforced cross-cutting bans in `conventions`: `any` outside tests, `interface`, decorators, classes-for-reuse, hand-written duplicate types, cycles, inline `fetch`/`axios`, bare `TODO`s. OSS-specific: - -- Ad-hoc/duplicated Zod schemas in routers/services - schemas live in the module's `contract/` or core contracts; derive with `.pick/.omit/.partial/.extend/.merge`, don't re-type fields. -- Re-exporting types "to be nice" - import from where defined. -- SQL anti-patterns (bare `timestamp()`, CamelCase identifiers, hand-edited migrations) - `db-conventions`. - -## Run locally - -``` -pnpm setup:agent # first time: docker + db + mcp + summary -pnpm dev # turbo dev (docs, mcp) -pnpm regen # drizzle-kit generate + catalog -pnpm seed # demo data (idempotent; admin@oss.dev / password123) -pnpm boundaries # whole-graph boundary + cycle gate -pnpm -F @openora/core vitest run # one test file/dir, eg src/iam/__tests__ -pnpm verify # typecheck + test:unit + lint + module-shape + boundaries -``` - -`docker compose up` starts only postgres; apps run on the host. PR only on green `pnpm verify`; CI adds a no-drift check (`pnpm verify:drift`). Pre-commit runs `pnpm boundaries`. - -## Definition of done - audit every new action - -Every new **state-changing action** (mutation route, admin op, money/KYC/config change) MUST leave an entry in the append-only, sha256 hash-chained `audit` module. Two ways: - -1. **Domain event (preferred).** Emit after the DB commit, declare the payload in `domainEventSchemas`, add the topic to `SUBSCRIBED_TOPICS` in `packages/core/src/audit/plugin.ts`. -2. **Direct record.** Resolve the `AUDIT_WRITER` port and `record({ actorId, actorType, action, resourceType, resourceId, before, after, ip })` - for admin actions / non-event outcomes. - -Capture actor, resource, and before/after on mutations. No audit entry = not done. Pure reads, docs, tests, chores need none. - -## Agent roster - -For platform development (this repo); consumer agents ship in `tools/templates/consumer/__dot__rulesync/subagents/`. - -| Agent | When to use | -| ------------------- | ------------------------------------------------------------ | -| `expert` | Fuzzy ask -> requirements + AC; regulatory/domain questions | -| `dev` | Implement a module/plugin/adapter from a given spec | -| `module-author` | Author a complete module end-to-end | -| `plugin-author` | Overlay plugin that extends without touching core | -| `operator` | Outside-in readiness audit; find launch blockers | -| `contract-reviewer` | Diff for breaking changes, boundary violations, schema drift | -| `security-reviewer` | Money/authz/secret-PII/auth-flow risks in changed files | -| `quality-reviewer` | Performance/duplication/simplification/conventions in a diff | -| `qa` | API-level tests + hands-on walkthrough; bug triage | -| `docs` | Sync prose docs to code, then `pnpm sync:agents` | - -**Delegation is mandatory.** When a task matches a roster agent, spawn THAT agent - not `general-purpose`. A `preToolUse` hook (`guard-subagent.mjs`) rejects a generic Task that fits a roster agent. - -## Working rules for agents - -- Use the `oss-dev` MCP server (`.mcp.json`, pre-approved) for read-only inspection: `read-agents-md`, `list-modules`, `describe-module`, `list-routes`, `list-extension-points`, `get-drizzle-schema`, `propose-table-change`, `schema-get`, `docs-search`, `db-query-readonly`. Faster than grep, reflects current state. -- Before a route: `list-routes`. Before a table: `propose-table-change`. After any change: `pnpm verify --filter `; fix failures before continuing. -- Read the touched module's `AGENTS.md` before editing it; keep it updated when invariants or extension seams change. An `AGENTS.md` holds ONLY what code can't say: invariants, rationale, gotchas, extension seams, and where-to-look pointers - never route/table/layout/event listings (they duplicate `contract/`, `schema/`, `docs/catalog.json` and drift). Claude Code loads them via generated per-module `CLAUDE.md` stubs (`tools/gen/gen-claude-stubs.mjs`, gitignored). -- Small PRs scoped to one module; cross-module changes need human approval. Never commit unless asked; never push without explicit per-action confirmation. -- ASCII only in code; short dashes (-) only, never long dashes. diff --git a/README.md b/README.md index c8859114..2fdf1faf 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ The platform ships the backend surface (auth, wallet, player management, complia - **Explicit wiring** - a small functional DI container with typed tokens. No decorators, no auto-discovery; everything is greppable. - **Swappable vendor seams** - PSP, KYC, aggregator, chat, realtime transport, job queue, and message broker are ports with default in-process drivers and adapter overrides. - **Regulatory audit log** - append-only, sha256 hash-chained. Every state-changing action leaves a trail. -- **AI-native** - an `AGENTS.md` in every module, scaffolders as slash commands, a queryable MCP dev server, and a generated machine-readable `catalog.json`. +- **AI-native** - scaffolders as slash commands, a queryable MCP dev server, and a generated machine-readable `catalog.json`. ## Table of contents diff --git a/apps/mcp-server-dev/src/main.ts b/apps/mcp-server-dev/src/main.ts index e4a98d8f..036a6528 100644 --- a/apps/mcp-server-dev/src/main.ts +++ b/apps/mcp-server-dev/src/main.ts @@ -243,32 +243,7 @@ function readAdapterTokens(): string[] { return out.sort(); } -/** - * Resolves an AGENTS.md from a module name, a package name, or a repo-relative path - - * module docs live at packages/core/src/[/]/AGENTS.md, package docs at - * packages//AGENTS.md. - */ -function resolveAgentsMd(target: string): string | null { - const bare = target.replace(/^@openora(-[a-z]+)?\//, ''); - const moduleDir = findModuleDir(bare); - const candidates = [ - ...(moduleDir ? [join(moduleDir, 'AGENTS.md')] : []), - repoPath(target, 'AGENTS.md'), - repoPath('packages', bare, 'AGENTS.md'), - repoPath('apps', bare, 'AGENTS.md'), - ]; - return candidates.find(existsSync) ?? null; -} - -/** Named UI slot identifiers - the platform is headless; slots live in the consumer frontend. */ -function readSlots(): string[] { - return []; -} - -function buildPlaybook( - kind: IntentKind, - ctx: { modules: string[]; tokens: string[]; slots: string[] }, -): string { +function buildPlaybook(kind: IntentKind, ctx: { modules: string[]; tokens: string[] }): string { const moduleList = ctx.modules.length ? ctx.modules.map((m) => `- ${m}`).join('\n') : '- (none yet)'; @@ -312,8 +287,7 @@ function buildPlaybook( '', '## Playbook', '1. Implement the page in your frontend repo using `@openora/core/react` data hooks.', - '2. To extend an existing surface without forking it, fill a named slot via your frontend UI plugin (ADR-0006).', - '3. Run `run-verify`. Delegate backend routes to `dev`.', + '2. Run `run-verify`. Delegate backend routes to `dev`.', ].join('\n'); case 'route': return [ @@ -384,19 +358,13 @@ const server = new McpServer({ server.registerTool( 'read-agents-md', { - description: 'Read a section of AGENTS.md (or a package-level AGENTS.md) by heading name.', + description: 'Read a section of the root AGENTS.md by heading name.', inputSchema: { section: z.string().optional().describe('H2 heading to read (omit for the full file)'), - package: z - .string() - .optional() - .describe('Package name or path relative to repo root (omit for root AGENTS.md)'), }, }, - async ({ section, package: pkg }) => { - const filePath = pkg - ? (resolveAgentsMd(pkg) ?? repoPath(pkg, 'AGENTS.md')) - : repoPath('AGENTS.md'); + async ({ section }) => { + const filePath = repoPath('AGENTS.md'); const content = readFile(filePath); if (!content) { return { content: [{ type: 'text', text: `No AGENTS.md found at ${filePath}` }] }; @@ -435,14 +403,14 @@ server.registerTool( 'describe-module', { description: - 'Everything you need to edit a module in one call: its AGENTS.md, Drizzle tables, Zod schemas, and router surface. Prefer this over reading the files individually.', + 'Everything you need to edit a module in one call: Drizzle tables, Zod schemas, and router surface. Prefer this over reading the files individually.', inputSchema: { name: z.string().describe('Module name (kebab-case)'), response_format: z .enum(['concise', 'detailed']) .optional() .describe( - 'concise (default): AGENTS.md + table/schema/route names only. detailed: full source of every file.', + 'concise (default): table/schema/route names only. detailed: full source of every file.', ), }, }, @@ -465,10 +433,7 @@ server.registerTool( const schemaSrc = readFile(join(dir, 'schema', 'index.ts')); const zodSrc = readFile(join(dir, 'contract', 'index.ts')); const routerSrc = readFile(join(dir, 'router', 'index.ts')); - const parts: string[] = [ - `=== Module: ${name} ===\n`, - readFile(join(dir, 'AGENTS.md')) || '(no AGENTS.md)', - ]; + const parts: string[] = [`=== Module: ${name} ===\n`]; if (detailed) { parts.push('\n--- Drizzle tables (src/schema/index.ts) ---', schemaSrc || '(no tables)'); @@ -817,7 +782,7 @@ server.registerTool( 'docs-search', { description: - 'Search markdown docs (docs/, README, AGENTS.md, ADRs, per-package AGENTS.md) for a keyword. Returns matching lines with locations.', + 'Search markdown docs, README, and root AGENTS.md for a keyword. Returns matching lines with locations.', inputSchema: { query: z.string().describe('Case-insensitive substring to search for'), limit: z.number().optional().describe('Max matching lines to return (default 60)'), @@ -949,7 +914,7 @@ server.registerTool( 'enhance-intent', { description: - 'Turn a fuzzy "what I want to build" ask into a grounded, structured brief. Classifies the intent against the platform decision tree, injects LIVE repo context (existing modules, adapter tokens, UI slots), and returns an exact step-by-step playbook (which scaffold-* tool to run, which agent to delegate to, propose-table-change + run-verify reminders) plus an acceptance-criteria stub. Call this from the /start onboarding flow, or any time a user describes a feature in vague terms.', + 'Turn a fuzzy "what I want to build" ask into a grounded, structured brief. Classifies the intent against the platform decision tree, injects live repo context (existing modules and adapter tokens), and returns an exact step-by-step playbook (which scaffold-* tool to run, which agent to delegate to, propose-table-change + run-verify reminders) plus an acceptance-criteria stub. Call this from the /start onboarding flow, or any time a user describes a feature in vague terms.', inputSchema: { ask: z .string() @@ -967,7 +932,6 @@ server.registerTool( const ctx = { modules: listAllModules().map((m) => `${m.group}/${m.name}`), tokens: readAdapterTokens(), - slots: readSlots(), }; const tree = parseAgentsMdSection( readFile(repoPath('AGENTS.md')), @@ -1076,7 +1040,7 @@ server.registerTool( if (ask) { const resolved = classifyIntent(ask); - const playbook = buildPlaybook(resolved, { modules, tokens, slots: readSlots() }); + const playbook = buildPlaybook(resolved, { modules, tokens }); const text = [ '# Onboarding', `The user opened with: **${ask}** (looks like: ${resolved})`, diff --git a/docs/adapters/kyc.md b/docs/adapters/kyc.md index 90ada3f7..c5da344a 100644 --- a/docs/adapters/kyc.md +++ b/docs/adapters/kyc.md @@ -19,7 +19,7 @@ fetches vendor-neutral device/IP risk signals (`vpnOrTorDetected`, `dataCenterIp id. These only exist as part of a vendor's hosted verification session - never at signup - so only a hosted-session vendor implements it; document-forwarding vendors and `MockKycAdapter` omit it. The `kyc-decision-sync` job calls it alongside `resolveDecision` -and persists the result on the `kyc_verification` row; see `compliance/AGENTS.md` for the +and persists the result on the `kyc_verification` row; see the compliance contract and schema for the storage and auto-tagging rule. ## Webhook verifier @@ -40,7 +40,7 @@ implementation ALSO enforces replay protection - a signature already accepted wi 10-minute window (via the `CACHE` seam) is rejected, since HMAC alone has no expiry and a captured valid body+signature would otherwise be replayable forever. A vendor overlay rebinding `KYC_WEBHOOK_VERIFIER` should carry the same replay guard unless the vendor's -own delivery protocol already provides one; see `compliance/AGENTS.md` > Replay protection +own delivery protocol already provides one; see the compliance service's replay protection for the full rationale (including why a signed-timestamp check alone is not enough for a vendor like Didit that signs only the body). diff --git a/docs/adapters/payment.md b/docs/adapters/payment.md index 468bf9ce..3d11feca 100644 --- a/docs/adapters/payment.md +++ b/docs/adapters/payment.md @@ -128,4 +128,4 @@ The address-based/async recipe is the same shape regardless of vendor: (idempotent - a replayed or stray webhook for an already-terminal/unmatched row no-ops). -See `wallet/AGENTS.md` for the exact route/service contracts. +See the wallet contract and service for the exact route behavior. diff --git a/docs/agent-quickstart.md b/docs/agent-quickstart.md index 4c365f29..892145de 100644 --- a/docs/agent-quickstart.md +++ b/docs/agent-quickstart.md @@ -89,7 +89,7 @@ list-routes module= ## Step 8: Wire the plugin -Edit `packages/core/src///plugin.ts`. Confirm the service is added to `ctx.providers` and the router is added to `ctx.routers`. The registry surface is: `providers`, `controllers`, `routers`, `slots`, `events`, `mcp`, `imports`. +Edit `packages/core/src///plugin.ts`. Confirm the service is registered and the router is added through the plugin host. ## Step 9: Add frontend consumption layer @@ -97,18 +97,7 @@ The platform is headless backend only - pages, components, and styling live in t - **A new data hook** (eg `useAdminUsers`, `usePlayerWallet`) -> `packages/core/src/react/src/hooks/`. `@openora/core/react` is the supported frontend consumption surface (data hooks, auth, realtime transport - no components). -## Step 10: Update AGENTS.md - -Edit `packages/core/src///AGENTS.md`. It holds ONLY what code can't say: - -- What the module does (one paragraph) + where-to-look pointers (`contract/`, `schema/index.ts`, `domainEventSchemas`). -- Invariants and rationale: fail-closed branches, DB guards, race windows accepted by design, "X is the single writer of Y". -- Extension seams: ports provided/consumed, what an overlay can rebind. -- Module-specific don'ts (global rules are lint-enforced - don't repeat them). - -Never route/table/layout/event listings - they duplicate `contract/`/`schema/`/`docs/catalog.json` and drift. - -## Step 11: Verify +## Step 10: Verify ``` /verify --filter @openora/core @@ -116,7 +105,7 @@ Never route/table/layout/event listings - they duplicate `contract/`/`schema/`/` Fix any typecheck, lint, boundary, or test failures before considering the work done. -## Step 12: Integration check +## Step 11: Integration check Start the full stack: diff --git a/docs/architecture.md b/docs/architecture.md index 669b0e2a..ef63ee67 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -74,7 +74,7 @@ flowchart TB subgraph ai["AI dev surface"] mcp["mcp-server-dev
stdio, via .mcp.json"] scaffold["tools/gen/gen.ts
+ slash commands"] - agentsmd["AGENTS.md
(per package)"] + agentsmd["root AGENTS.md
+ docs/catalog.json"] end mcp -. inspects .-> orpc scaffold -. generates .-> mod @@ -115,7 +115,7 @@ Solid arrows are runtime/build dependencies; dashed arrows are **adapter seams** - **mcp-server-dev** - a stdio MCP server (registered in `.mcp.json`, not a port) exposing read-only inspection (`list-modules`, `list-routes`, `get-drizzle-schema`, ...) and write tools that delegate to the scaffolder. - **tools/gen/gen.ts** (-> `@openora/core/generators`) - deterministic code-mods behind the `/scaffold-*` slash commands (module, plugin, route). -- **AGENTS.md** - per-package brief; the first thing an agent reads. +- **Root AGENTS.md + docs/catalog.json** - platform rules plus the generated module surface. ## Adapter / bridge seams diff --git a/docs/catalog.json b/docs/catalog.json index d5dd3e01..54b47cc7 100644 --- a/docs/catalog.json +++ b/docs/catalog.json @@ -763,7 +763,6 @@ "wallet.withdrawal.rejected", "wallet.withdrawal.requested" ], - "uiSlots": [], "schemas": [ { "name": "ActivateCoolingOffInputSchema", diff --git a/docs/downstream-consumer.md b/docs/downstream-consumer.md index 0736b741..cc42f16e 100644 --- a/docs/downstream-consumer.md +++ b/docs/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 [`catalog.json`](./catalog.json) for the machine-readable surface (routes, schemas, adapter -tokens, slots, events, config schema) an agent reads instead of grepping `node_modules`. +tokens, events, config schema) an agent reads instead of grepping `node_modules`. ## Fastest path: scaffold the repo @@ -162,7 +162,7 @@ consumer's own physical copy; drizzle's protected-member classes then fail nomin against `DrizzleService.db` (which uses `@openora/core/server`'s copy). `@openora/core/server/orm` re-exports the framework-free drizzle surface from the single shared instance. -Full hooks guide: `packages/core/src/react/AGENTS.md`. +Full hooks guide: `docs/standards/react-sdk.md`. ## Local dev linking to a sibling consumer diff --git a/docs/mcp-setup.md b/docs/mcp-setup.md index 277cad51..306fea9b 100644 --- a/docs/mcp-setup.md +++ b/docs/mcp-setup.md @@ -21,7 +21,7 @@ It is idempotent and does three things against the current repo: Then restart your editor (or run `/mcp`) and run **`/start`**: it asks what you want to build, calls the `enhance-intent` tool to turn your fuzzy ask into a grounded spec (classified against -the decision tree, with live module/adapter/slot context), and drives the right scaffold flow. +the decision tree, with live module and adapter context), and drives the right scaffold flow. A `create:app` consumer ships the same script (`pnpm setup:mcp`, delegating to this checkout) - run it once in the generated repo so its own agents get the toolbelt. @@ -58,15 +58,15 @@ The server uses stdio transport (no port). Add this to your editor's MCP config: | Tool | What it returns | | ----------------------- | ------------------------------------------------------------------------------------------ | -| `read-agents-md` | A named section from root or per-module AGENTS.md | +| `read-agents-md` | A named section from root AGENTS.md | | `list-modules` | All registered modules + their group, tables, routes | -| `describe-module` | Full module surface: AGENTS.md + tables + schemas + routes in one call | +| `describe-module` | Full module surface: tables + schemas + routes in one call | | `list-routes` | oRPC route namespaces (filter by module name) | -| `list-extension-points` | UI slots, exported events, adapter port interfaces | +| `list-extension-points` | Exported events and adapter port interfaces | | `get-drizzle-schema` | pgTable definitions across all modules (filter by module) | | `propose-table-change` | Collision-check a new table name before adding it | | `schema-get` | Find a Zod schema by name with its file location | -| `docs-search` | Full-text search across all docs/ and AGENTS.md files | +| `docs-search` | Full-text search across docs, README, and root AGENTS.md | | `db-query-readonly` | Run a read-only SQL query against the dev database | | `list-slash-commands` | List available slash commands (scaffold shortcuts) | | `enhance-intent` | Turn a fuzzy "build X" ask into a classified, grounded brief + step-by-step playbook | diff --git a/docs/standards/audit.md b/docs/standards/audit.md new file mode 100644 index 00000000..aff34a58 --- /dev/null +++ b/docs/standards/audit.md @@ -0,0 +1,9 @@ +# Audit + +Read this before adding a state-changing action or changing audit storage, events, or exports. + +- 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. +- Audit rows are append-only. 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/compliance.md b/docs/standards/compliance.md new file mode 100644 index 00000000..82cbbb52 --- /dev/null +++ b/docs/standards/compliance.md @@ -0,0 +1,11 @@ +# Compliance + +Read this before changing KYC, responsible gambling, or compliance integrations. + +- `KYC_STATUS_WRITER` is the only writer of a player's KYC status. Keep the status change, verification history, and audit-visible event in one transaction. Normalize the deprecated `verified` status before comparing an approval state. +- Manual KYC decisions require authorization and a non-empty reason. A manual approval is stored as `manually_overridden`; repeated decisions at the current status create neither history nor another event. +- Verify KYC webhooks before accepting them. Queue vendor resolution off the request path, deduplicate the exact delivery, and reject a decision older than the last accepted delivery for that verification. Retry a decision whose player does not yet exist; do not drop it. +- A vendor approval passes only when every supplied verification check is approved. Missing checks mean the adapter has no check granularity; an empty supplied list or an unknown/non-approved check requires resubmission. Derive checks from the vendor decision, not a duplicated workflow configuration. +- Only duplicate-device and high-risk-country signals trigger the high-risk tag. VPN/Tor and datacenter-IP signals alone do not. +- Responsible-gambling restrictions must block login, revoke active sessions, and reject a new wager server-side. Do not block settlement of an already started round. Cooling-off expiry is evaluated at enforcement time, not by an unblock job. +- Do not write player compliance state directly or bind sealed national-register tokens. diff --git a/docs/standards/money.md b/docs/standards/money.md new file mode 100644 index 00000000..ec2dcc30 --- /dev/null +++ b/docs/standards/money.md @@ -0,0 +1,12 @@ +# Money + +Read this before changing a balance, ledger, payment, wager settlement, or money-moving command. + +- Amounts are decimal strings backed by Postgres `NUMERIC`; never use JavaScript floating-point arithmetic for stored or compared money. Keep arithmetic and conditional balance updates in the database transaction. +- A money mutation needs a durable database idempotency/concurrency guard inside its transaction. Cache-only reservations, preflight reads, and events are not correctness guards. A replay returns the original result and does not create another ledger entry, event, or audit record. +- 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. +- 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/docs/standards/testing.md b/docs/standards/testing.md index d376f383..81b3288e 100644 --- a/docs/standards/testing.md +++ b/docs/standards/testing.md @@ -7,6 +7,7 @@ Detail for the testing lines in `conventions`. Read this before adding or restru - **Co-locate as `__tests__/.test.ts` (Vitest).** - **A file that calls `createTestDb`/`createTestRedis` is named `.int.test.ts`** and runs in `test:integration` (needs docker Postgres + Redis); `test:unit` is the infra-free suite and stays a ~4s loop. Lint-enforced by `oss-module-shape/int-test-file-naming`. - The end-to-end tier lives in `@openora/testing` (`bootTestApp` against a shared test db). Recreate that db with `pnpm db:setup:test:fresh` if a migration was edited after it was applied locally - drizzle hashes each migration file's bytes, so a stale hash re-runs an applied migration. +- Integration Vitest configs using `@openora/testing` run with `poolOptions.threads.singleThread = true`: the harness shares one test database. Build before `pnpm test:integration`, because extension loading resolves compiled plugins. ## What is real, what is doubled @@ -36,4 +37,5 @@ Detail for the testing lines in `conventions`. Read this before adding or restru - **Cover new logic as part of the change** - unit for pure fns; always include authz negatives. - **Deterministic and isolated:** no shared mutable state, no real network, seedable data. Real-infra suites stay parallel-safe - own your database and Redis keys, never assume an empty shared one. +- `@openora/testing` is test-only. Never import it from production code or point `TEST_DATABASE_URL` at a real or development database: its cleanup truncates data. - Run one file or dir with `pnpm -F @openora/core vitest run `; find the tests touching a file with `pnpm -F @openora/core vitest related `. diff --git a/package.json b/package.json index 6ab316fd..e5b16eb8 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "gen:catalog": "tsx tools/gen/gen-catalog.ts", "gen:drizzle": "pnpm -F @openora/core gen:drizzle", "gen:tsconfig": "tsx tools/gen/sync-tsconfig-paths.ts", - "gen:agents": "rulesync generate && node tools/gen/gen-claude-stubs.mjs", + "gen:agents": "rulesync generate", "regen": "pnpm run gen:tsconfig && pnpm run gen:drizzle && pnpm run gen:catalog", "db:migrate": "openora-migrate", "db:seed": "tsx tools/db/seed.ts", diff --git a/packages/core/README.md b/packages/core/README.md index 7cb71fc3..4f002b04 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -38,7 +38,7 @@ Everything enters through typed plugin objects - `{ id, dependsOn, register } sa - **Zod-first** - every wire shape is a schema; types are inferred, never hand-written. - **Swappable vendor seams** - PSP, KYC, game aggregator, realtime, job queue, and message broker are ports with in-process defaults and adapter overrides. - **Regulatory-grade audit** - append-only, sha256 hash-chained log; every state-changing action leaves a trail. -- **AI-native** - machine-readable catalog, per-module agent docs, and a queryable MCP server ([`@openora/mcp`](https://www.npmjs.com/package/@openora/mcp)). +- **AI-native** - machine-readable catalog and a queryable MCP server ([`@openora/mcp`](https://www.npmjs.com/package/@openora/mcp)). ## Getting started diff --git a/packages/core/generators/src/config.ts b/packages/core/generators/src/config.ts index bf318ad9..6c823c52 100644 --- a/packages/core/generators/src/config.ts +++ b/packages/core/generators/src/config.ts @@ -7,7 +7,7 @@ import type { PlopTypes } from '@turbo/gen'; // dir - used by both the OSS monorepo and downstream consumer repos (a consumer's // `turbo/generators/config.ts` re-exports this default). Humans run `pnpm gen // ...`; AI agents call the same via the MCP `scaffold-*` tools. See the -// "How to add ..." sections in AGENTS.md. +// Root instructions document the available generators. // // pnpm gen module - business module (schema/service/router/plugin) // pnpm gen route

- oRPC procedure + contract entry @@ -289,7 +289,6 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { file('index.ts', 'module/index.hbs'), file('migrate.ts', 'module/migrate.hbs'), file('drizzle.config.ts', 'module/drizzle.config.hbs'), - file('AGENTS.md', 'module/agents.hbs'), () => wireDomainBarrels(domain, name), () => wireCoreExports(domain, name), () => registerExtension(name, `./packages/core/dist/${domain}/${name}/plugin.js`), diff --git a/packages/core/generators/src/templates/module/agents.hbs b/packages/core/generators/src/templates/module/agents.hbs deleted file mode 100644 index 30ad379c..00000000 --- a/packages/core/generators/src/templates/module/agents.hbs +++ /dev/null @@ -1,17 +0,0 @@ -# {{kebabCase name}} - - - -## Invariants - - - -## Extension points - - - -## Don't - - diff --git a/packages/core/src/admin-console/AGENTS.md b/packages/core/src/admin-console/AGENTS.md deleted file mode 100644 index 3c1fcdef..00000000 --- a/packages/core/src/admin-console/AGENTS.md +++ /dev/null @@ -1,7 +0,0 @@ -# admin-console - -HTTP API for backoffice admin operations: user management, transaction viewing, platform stats, game/player analytics. Owns NO tables - read-only over data owned by identity, wallet, gaming, and iam, reached via ports (`ADMIN_USER_DIRECTORY`, `ADMIN_WALLET_REPORTING`, `ADMIN_GAME_REPORTING`, `ADMIN_PLAYER_ACTIVITY`) and `/schema` subpath reads (`import { user } from '@openora/core/pam/schema/identity'`). The backoffice SPA lives in the downstream consumer (headless platform). Routes: `contract/index.ts` (or `list-routes module=admin-console`). - -- Guard every route: `await adminGuard.assert(context, resource, action)` as the handler's first line. -- Record admin actions via `AUDIT_WRITER` - no `backoffice.*` topics exist in `domainEventSchemas`, never invent one. -- Don't add DB tables here (read-only module); don't import another module's service/internals. diff --git a/packages/core/src/analytics/AGENTS.md b/packages/core/src/analytics/AGENTS.md deleted file mode 100644 index eba09a7c..00000000 --- a/packages/core/src/analytics/AGENTS.md +++ /dev/null @@ -1,9 +0,0 @@ -# analytics - -Read-only reporting domain for the backoffice: financial breakdowns and the registration-to-first-bet conversion funnel. Owns NO tables - it is the CQRS read side over wallet, identity, and profile data, reached only via their published `/schema` subpaths (`@openora/core/wallet/schema`, `@openora/core/pam/schema/identity`, `@openora/core/pam/schema/profile`). Never import a sibling module's service. - -- Money is always grouped by currency, never summed across currencies - the platform has no FX rates anywhere. A currency filter narrows the same grouped shape; it never collapses it to a scalar. -- `wallet_transaction` has no `userId` column - every query that needs the player joins through `wallet.userId`. -- The conversion funnel is a registration-cohort funnel, not an event-in-range funnel: the cohort is users whose `createdAt` falls in the requested range, and each later stage is a strict subset of the one before it (email-verified users who registered in range, of those who also ever deposited, of those who also ever bet). A stage's underlying deposit/bet activity is NOT date-ranged - only the registration cohort is. This keeps drop-off rates meaningful (never negative, never over 100%). -- `game_round.betAmount` is now persisted (`casino/gaming` debits the stake via `WALLET_COMMANDS` at round start), but `winAmount` still stays `'0'` for every row - crediting a win is game-outcome/RTP territory gated by the sealed, unimplemented `GAME_OUTCOME_AUTHORITY` token (see `casino/gaming/AGENTS.md`). GGR is therefore computed from `wallet_transaction` (`bet` minus `win`, completed only) rather than from `game_round`, and will stay that way until a certified outcome authority exists to credit wins. -- Every route is guarded on the `analytics` resource (`await adminGuard.assert(context, 'analytics', 'view')`) and reads are cached briefly (`CACHE` port) - these are dashboards refreshed on an interval, not a real-time feed. diff --git a/packages/core/src/audit/AGENTS.md b/packages/core/src/audit/AGENTS.md deleted file mode 100644 index a47d98ac..00000000 --- a/packages/core/src/audit/AGENTS.md +++ /dev/null @@ -1,30 +0,0 @@ -# audit - -Append-only, tamper-evident audit log - a regulatory requirement (MGA/UKGC: immutable 5-year record of financial transactions, admin actions, game results, logins, config/permission changes). Owns `audit_log`, exposes admin-guarded `audit.list`/`audit.exportCsv`, auto-records subscribed domain events, and binds the `AUDIT_WRITER` port for explicit writes. Subscribed topics: `SUBSCRIBED_TOPICS` in `plugin.ts` - only topics declared in `domainEventSchemas`, never invented ones. - -## Sealed token - -`AUDIT_WRITER` is a `SealedToken` - AML/SAR audit writes are a regulator-mandated invariant operators must not override. This module binds it via `ctx.provideSealed()` in `plugin.ts`, the only legitimate bind path: `ctx.provide()` rejects sealed tokens outright and `provideSealed()` refuses a second bind, so no overlay can rebind it. Rationale: `@openora/core/contracts` `adapters/token.ts`; canonical sealed list: `@openora/core/compliance` `sealed.ts`. - -## Hash chain - -Each `record()` runs in a single transaction serialized by a pg advisory lock: - -1. Read the latest row's `hash` (null for the first row) as `prevHash`. -2. Compute `sha256(JSON.stringify(...))` over the full row INCLUDING `before`/`after`/`result` - the mutation payload and outcome must be tamper-evident, not just who/what/where - with stable top-level key order. -3. Insert `prevHash` + the real `hash` in ONE statement - no read-back UPDATE, so a crash can never leave a placeholder hash. - -Gotcha: `before`/`after` are `jsonb` and Postgres reorders nested object keys on read-back (length-then-lex), so `computeHash` deep-sorts those keys (arrays keep order) before stringifying - otherwise a freshly-inserted row and the same row read back would hash differently despite identical content. `verifyChain()` re-derives every hash from rows read back from Postgres and reports the first broken link; run it from a scheduled job or admin tool for tamper detection. - -## Query semantics - -`list`/`exportCsv` filters combine with AND; the single search param `q` is a grouped OR that EXACT-matches `actorId` OR `resourceId` - exact only, to keep those indexes usable. `exportCsv` is capped at `EXPORT_MAX_ROWS` (50k) so it cannot be used for unbounded bulk extraction / OOM - narrow the date range for larger windows. The RG activity log / change history reuses this module via the `actionPrefix` filter (`like(action, 'rg.%')`) - no separate history table. - -## Event -> row mapping - -`wallet.*` events record `actorType='player'`, `resourceType='transaction'`, `resourceId=transactionId` (a transaction reference is searchable, not buried in `after`). Otherwise a payload with `userId` maps to `actorType='player'`; failing that, `system`. The topic becomes the `action` column. The four admin RG events map to `actorType='admin'`, `resourceType='player'`, `resourceId=userId`; `rg.exclusion.login_blocked` maps to a system `result='failure'` entry. - -## Don't - -- Expose update or delete on `audit_log` - append-only by regulatory requirement. Writes happen only via `record()` (event subscribers or `AUDIT_WRITER` callers). -- Invent event topics not in `domainEventSchemas`. diff --git a/packages/core/src/casino/gaming/AGENTS.md b/packages/core/src/casino/gaming/AGENTS.md deleted file mode 100644 index e1a148be..00000000 --- a/packages/core/src/casino/gaming/AGENTS.md +++ /dev/null @@ -1,9 +0,0 @@ -# Gaming - -Game catalog and play sessions. Owns `GAME_ADAPTER` (game list) and `RNG_ADAPTER` (random number generation) ports; default mocks. Tables: `game` (provider, category, `gameType`, metadata), `gameRound` (per-user session with bet/win amounts, currency, status). - -Also binds `ADMIN_GAME_REPORTING` (`admin-reporting.ts`) - a read-only query port over `game`/`gameRound` for the back-office games-performance report. admin-console depends only on the port, never on this module's schema (ADR-0017/0025). `game.gameType` (`original`/`casino`/`sportsbook`) is declared on the core contract surface (`@openora/core/contracts` `schemas/game.ts`), not module-locally like `GAME_ROUND_STATUSES` - it has to be, since the isomorphic `ADMIN_GAME_REPORTING` port and admin-console's contract both need the same type and neither can import a domain module. - -Routes serve both public reads (`listGames`, `getGame`) and player writes (`startRound`, `endRound`, `listRounds`). Each round tracks bet/win as decimals (to match wallet precision). Rounds stay tied to the initiating user via `userId` - no FK cross-module reference to wallet, just plain ID. - -Round lifecycle: status moves `'active'` -> `'completed'` on end. A round without an `endedAt` is still in play; a finished round carries final bet/win for accounting. diff --git a/packages/core/src/casino/lobby/AGENTS.md b/packages/core/src/casino/lobby/AGENTS.md deleted file mode 100644 index 33f2d34d..00000000 --- a/packages/core/src/casino/lobby/AGENTS.md +++ /dev/null @@ -1,5 +0,0 @@ -# Lobby - -Read-only aggregation over `gaming` games: categories (`lobby.listCategories`, `lobby.getCategoryBySlug`), featured slots (`lobby.getFeatured`), and search (`lobby.search`). - -Caching: `listCategories` and `getFeatured` cache through the `CACHE` port (30s TTL, TTL-only - no invalidation wiring). These feeds tolerate up to 30s staleness after an admin edits categories/featured slots. diff --git a/packages/core/src/cms/AGENTS.md b/packages/core/src/cms/AGENTS.md deleted file mode 100644 index b93fea6e..00000000 --- a/packages/core/src/cms/AGENTS.md +++ /dev/null @@ -1,7 +0,0 @@ -# CMS - -Public content: pages (`cms.pages`) and placement-keyed banners (`cms.banners`). Admin CRUD guarded by `AdminGuard` (`content` resource). - -`listPages`/`getPage` are unauthenticated and HTTP-cacheable (see `PUBLIC_HTTP_CACHE_PATHS`), so the service filters both to `publishedAt IS NOT NULL` - a draft is invisible to the public reads (an unpublished slug 404s the same as a nonexistent one, no draft-existence leak). There is no admin listing/get-by-id for drafts yet; `updatePage`/`deletePage` take the page `id` returned by `createPage`. - -Caching: `getPage` and `listBannersByPlacement` cache through the `CACHE` port (60s TTL). Page/banner create/update/delete invalidate the affected slug/placement key(s) in the same service method. The default `CACHE` binding is in-process (per replica) - a multi-instance deployment needs a Redis-backed (or event-driven) `CACHE` overlay for cross-instance invalidation, or reads on other replicas can serve a stale page/banner for up to the TTL after a mutation. diff --git a/packages/core/src/compliance/AGENTS.md b/packages/core/src/compliance/AGENTS.md deleted file mode 100644 index 52bfb160..00000000 --- a/packages/core/src/compliance/AGENTS.md +++ /dev/null @@ -1,306 +0,0 @@ -# compliance - -Regulatory surface: player limits, KYC verification, geo rules, Responsible Gambling (RG). Headless - the back-office UI lives in the consumer. - -## KYC - -Submission, vendor/webhook reconciliation, deposit-threshold re-KYC. Vendor-agnostic: real providers bind against `KYC_ADAPTER` / `KYC_STATUS_WRITER` / `KYC_WEBHOOK_VERIFIER` (`contracts/adapters/kyc.ts`; vendor shapes in `docs/adapters/kyc.md`); this repo ships only `MockKycAdapter` (auto-approves). `kyc_verification` is an append-only history. `kycWebhook` is an unauthenticated M2M route verified via `KYC_WEBHOOK_VERIFIER`; `reconcile` applies vendor decisions idempotently. `CumulativeDepositReKycTrigger` (`service/re-kyc-trigger.ts`) is pure, DB-free threshold-band logic. - -Invariant: `KYC_STATUS_WRITER` is the SINGLE writer for `player.kycStatus` - pam owns and binds the only implementation; every status change (submit, webhook reconcile, threshold re-KYC, admin override) routes through it so the `compliance.kyc.updated` audit emit can never be skipped. Compliance calls the port, never writes `player` directly. - -### Admin KYC actions (resubmit / override / bulk-approve) - -All three take a **mandatory, non-empty `reason`**, are guarded by `compliance:override-limit` -(whichever roles a deployment grants that permission - eg Super Admin, Compliance Manager), -and route through `KycVerificationService` (`requestResubmission`/`overrideStatus`/`bulkApprove` -in `service/kyc.service.ts`), which - unlike `submit`/`reconcile` - both inserts a -`kyc_verification` history row (`triggeredBy: 'manual'`, `provider: 'manual'`, -synthetic `referenceId`) AND calls `KYC_STATUS_WRITER.setStatus(..., { source: 'manual' })` -in the SAME transaction, so an admin action leaves the same audit-visible history a -vendor/webhook decision does (`getPlayerKyc` shows it). Idempotent on a repeat call -that resolves to the status the player is already at - no duplicate history row, no -duplicate `compliance.kyc.updated` emit (checked via an upfront `player.kycStatus` -read, not just relying on the writer's own conditional-UPDATE no-op). - -That idempotency read is `requirePlayerRowForUpdate`, and its `FOR UPDATE` must run -INSIDE the method's own transaction, never as a separate pre-check select: under READ -COMMITTED a plain read-then-decide lets two concurrent calls for the same player both -pass the check before either commits, double-inserting history. Locking the row first -serializes them - the same reason `WalletService.withdraw` locks the wallet row before -its own idempotency check. This is also what closes the same-userId-twice-in-one-call -race inside `bulkApprove`, since it fans out through `overrideStatus`. - -`submit` and `reconcile` write the `kyc_verification` row and call -`KYC_STATUS_WRITER.setStatus` in one transaction for the same reason the admin actions -do: a crash between the two would leave the append-only history and the player's live -status disagreeing. - -**`manually_overridden` representation.** The product spec wants both "the operator's -chosen status" and "flagged as manually overridden" out of `overrideKycStatus`, but -`player.kycStatus` is a single enum column - it cannot hold both simultaneously. The -codebase already established the answer before this task: the wallet KYC gate -(`KYC_PASS_STATUSES`) and `pam/tag`'s tag-evaluation service both treat -`manually_overridden` as an approved-equivalent terminal status, kept distinct from a -vendor `approved` for reporting/audit. So `overrideStatus`/`bulkApprove` route an -`approved` choice through `resolveManualStatus`, which rewrites it to -`manually_overridden` before writing; every OTHER choice (`rejected`, `pending`, -`resubmission_requested`, `not_started`) is written verbatim - no ambiguity there, since -only `approved` collides with the vendor-decision meaning. The "this was manual" fact -for every other choice already lives orthogonally in `triggeredBy: 'manual'` + -`source: 'manual'`, so nothing is lost by NOT also overloading the status for them. -`OverrideKycStatusInputSchema`'s `status` field (`KycOverrideStatusSchema`) excludes -`manually_overridden` from the operator-facing choices for this reason - it is a -derived value the service computes, never one an admin types in directly. This applies -uniformly to `bulkApproveKyc` too (each item resolves through the same -`overrideStatus('approved', ...)` call), even though the product brief's literal text -for bulk-approve just says "sets approved" - writing a bare `approved` there would -silently reintroduce the exact ambiguity the override route was designed to avoid. - -**Supersedes `playerContract.update`'s old `kycStatus` field.** Before this change, an -admin could also flip `player.kycStatus` via `PATCH /players/{playerId}` (gated by the -same `compliance:override-limit` permission) - but that path took no reason and wrote -no `kyc_verification` history row, a real compliance gap for a regulated action. That -field has been REMOVED from `playerContract.update` (see -`pam/player-management/contract/index.ts`); `overrideKycStatus` is now the only way to -change a player's KYC status by hand. `PlayerService` no longer depends on -`KycStatusWriter` at all. - -**Notification (resubmission request).** `requestKycResubmission` and an -`overrideKycStatus` call landing on `resubmission_requested` both emit the same -`compliance.kyc.updated` event (status + source, no new event type needed) - the -`notifications` module subscribes to it (filtered to `status: 'resubmission_requested'`, -`source: 'manual'`) and dispatches the player email + in-app notification through -`JOB_QUEUE` (never inline in the admin request path). Compliance never imports -`notifications` - the domain event is the only coupling. See -`engagement/notifications/AGENTS.md`. - -Compliance defines its own `PlayerNotFoundError` (`makeNotFoundError('Player')` in -`service/kyc.service.ts`) for these three routes' existence pre-check - it cannot import -pam's own `PlayerNotFoundError` class (cross-module internals are off-limits), so it -follows the same pattern as `identity`'s and `admin-console`'s independently-defined -`UserNotFoundError`. `bulkApprove` catches per-player errors (not just this one) and -reports `{ userId, success, error }` per item via `mapConcurrent` (bounded fan-out, -never `Promise.all`) rather than letting one bad id fail the whole batch. The per-item -`error` returned on the wire is a fixed string, never the raw exception text - the full -error is logged server-side (with userId) for ops, but a driver/constraint error's exact -text must not reach the admin-facing response body. The input schema caps `userIds` at -100 (`BulkApproveKycInputSchema`) - an unbounded array is a DoS vector - and dedupes -them, since a duplicate id is always a caller bug, never a legitimate "approve twice". -A failed item never reaches `overrideStatus`, so it leaves no `compliance.kyc.updated` -trail of its own - the router additionally records the WHOLE attempted batch as one -`AUDIT_WRITER` entry (`compliance.kyc.bulk_approve`, `after: { reason, results }`) after -`bulkApprove` returns, so an id probe (existing vs not) is still visible in the audit -log even when nothing downstream changed for that id. `requestResubmission` and -`overrideStatus` share their transaction body (insert the manual history row + call -`KYC_STATUS_WRITER.setStatus`) via a private `applyManualDecision` helper - the two -methods differ only in target status, `referenceId` prefix, and whether `decidedAt` is -stamped, extracted so the locking/idempotency rules above cannot diverge between them. - -`KYC_ADAPTER`, `KYC_STATUS_WRITER`, `KYC_WEBHOOK_VERIFIER` (all in -`packages/core/src/contracts/adapters/kyc.ts`). `KYC_STATUS_WRITER` is the single writer -for `player.kycStatus` - pam owns it and binds the only implementation; every status -change (submit, webhook reconcile, threshold re-KYC, admin override) routes through it so -the `compliance.kyc.updated` audit emit never gets skipped. Compliance calls the port, -never writes `player` directly. The write path (`PlayerKycStatusWriter.setStatus`) is a -single conditional `UPDATE ... WHERE kyc_status <> $new` (not select-then-update) so two -concurrent callers across ECS instances can't both pass a stale guard and double-emit; -zero matched rows means either "already at target" (no-op) or the player row doesn't -exist yet, which the writer distinguishes with a follow-up existence check and throws -`PlayerNotFoundError` for the latter rather than dropping the decision silently. - -`KycAdapter.resolveDecision(referenceId)` is an optional port method: a hosted-session -vendor (eg Didit) implements it to fetch the full decision (status, `documentTypes`, -`decisionReason`) by reference id off the request path; document-forwarding vendors and -`MockKycAdapter` have nothing to resolve and omit it. - -The success status is `approved` (`KYC_STATUSES`, `@openora/core/contracts`); `verified` -is a deprecated alias kept additive for the expand/contract migration (rows/instances may -still hold it). Any code comparing a KYC status for the approved state must go through -`normalizeKycStatus` first - never a scattered `=== 'verified' || === 'approved'`. The -`compliance.kyc.updated` payload (v4) also carries `reason` (nullable) and `source` -(`vendor` | `manual` | `webhook` | `reverify`) so the audit trail records why a -transition happened, not just the before/after status. Both ride in the audit record's -`after` (see `audit/plugin.ts`). Since v3, `actorId` is nullable: null marks a -system-driven flip (vendor/webhook/reverify), which the audit writer records as -`actorType: 'system'`. - -### Webhook -> job flow (`kyc-decision-sync`) - -`kycWebhook` verifies the signature (see Replay protection below) and `parseWebhook`s the -body, then enqueues a `kyc-decision-sync` job and returns 2xx immediately - it never -awaits a vendor call in the request path (Didit's own webhook SLA is ~5s with 2 retries; -the route must not inherit that latency). `idempotencyKey` is a SHA-256 hash of the -verbatim raw delivery bytes (`kyc-decision-sync:`), never `:`: -the vendor-neutral `KycResult` carries no delivery/event id, so the byte hash is the only -signal that reliably tells "the SAME decision resent" (a retry storm inside the vendor's -own SLA - correctly collapse it) apart from "a genuinely NEW decision that happens to -land on a status seen before" (eg rejected -> approved -> rejected again - must run). -Keying on `referenceId:status` let BullMQ's permanent-by-default `jobId` dedup (a -completed job's id can never be reused unless retention is bounded - see -`bullmq-job-queue.ts`) silently drop the later, real decision forever; the driver now -also bounds `removeOnComplete`/`removeOnFail` as a systemic safety net. `orderingKey: -referenceId`, 5 attempts with exponential backoff. The `kyc-decision-sync` worker -(registered in `plugin.ts`) calls `KycVerificationService.syncDecision`, which resolves -the full decision via `KYC_ADAPTER.resolveDecision` when the bound adapter implements it -(persisting `documentTypes`/`decisionReason` through `reconcile`), or falls back to the -status-only `reconcile` when it doesn't. - -A `PlayerNotFoundError` out of `KYC_STATUS_WRITER.setStatus` (the player row is created -lazily and a decision can legitimately arrive first) is left to propagate all the way out -of the worker handler - no try/catch swallows it - so the job queue's `attempts`/`backoff` -retries the decision instead of dropping it. - -**Decision monotonicity.** The BullMQ driver ignores `orderingKey` (no ordering groups in -OSS BullMQ), so two decisions for the same reference can run out of arrival order after a -retry. The router stamps `receivedAt` (webhook-arrival wall-clock time) on the job payload -BEFORE enqueue - immune to job-processing reordering - and `KycVerificationService. -reconcile` persists it as `kyc_verification.decisionReceivedAt`, refusing to apply an -incoming decision older than the one already on file for that reference (logs and returns -the current row unchanged; no `KYC_STATUS_WRITER` call, no re-emit). A caller with no -`receivedAt` (a direct `reconcile()`/`syncDecision()` call outside the job path) skips the -guard - always applies, same as before this existed. - -### Replay protection (`HmacKycWebhookVerifier`) - -A vendor may sign ONLY the body (Didit-style), never a timestamp, so a signed-freshness -check is not available - the signature itself is the only authenticated, replay-detectable -value. The default verifier rejects a signature already accepted within a 10-minute -window (`CACHE` seam, `kyc-webhook-seen:` key with a matching TTL) - -comfortably covers a vendor's own legitimate retry burst while closing most of the -"capture a valid body+signature and resend it later" window. `CACHE` is the same port -already bound cross-instance when `REDIS_URL` is set (ADR-0028), so replay detection -coordinates across replicas with zero extra wiring; the in-process default degrades to -per-instance-only protection. A `CACHE` failure degrades OPEN (accepts, logs a warning) -rather than blocking every webhook on an unrelated infra blip - the signature check -remains the fail-closed primary control. Beyond the 10-minute window, a decision replay -still hits the monotonicity guard above if a newer decision has since landed. - -### Device/IP risk signals - -The vendor cannot screen device/IP at signup - the signals only exist as part of a -hosted verification session - so screening happens at first KYC instead, via the same -`kyc-decision-sync` flow above. `KycAdapter.resolveRiskSignals(referenceId)` is a second, -independent optional port method (`contracts/adapters/kyc.ts`): a hosted-session vendor -(eg Didit) extracts the vendor-neutral shape `{ vpnOrTorDetected, dataCenterIpDetected, -duplicateDeviceDetected, highRiskCountryDetected, deviceFingerprints }` from its own -session decision; document-forwarding vendors and `MockKycAdapter` omit it. -`KycVerificationService.syncDecision` calls it alongside `resolveDecision` (independently - -- an adapter can implement either, both, or neither) and passes the result into the same - `reconcile` call. `reconcile` persists it on the `kyc_verification` row's `riskSignals` - jsonb column (nullable, preserved when a later reconcile carries none - same rule as - `documentTypes`), which `getPlayerKyc` surfaces on `current`/`history` for compliance - admins. Storage lives on `kyc_verification` rather than a separate table or the `player` - row: the signals belong to a specific verification session (referenceId), the same - append-only history that already carries `documentTypes`/`decisionReason` for that - session, and "must not be silently lost" compliance evidence is exactly what that - history exists for. - -**Auto-tagging rule.** `warrantsHighRiskTag` (`kyc.service.ts`) fires -`compliance.kyc.high_risk_signal_detected` only when `duplicateDeviceDetected` or -`highRiskCountryDetected` is true - never for `vpnOrTorDetected` or -`dataCenterIpDetected` alone or combined. A VPN/Tor exit or a datacenter IP is weak, -common evidence on its own (privacy tooling, corporate proxies, mobile carrier NAT) and -stacking two weak IP-reputation signals doesn't compound into strong evidence without a -base-rate model this platform doesn't have. A duplicate device fingerprint (the same -device already tied to another account) and a high-risk country (an AML/FATF-relevant -jurisdiction flag) are each independently strong, standalone fraud/compliance signals. -`pam/tag` subscribes to the event and applies `high_risk` as a pure label (same -precedent as `advanced_kyc_needed` reacting to `compliance.kyc.reverify_required` below) - -- it does not re-derive which signals qualify. The event fires (and is emitted) only - inside `reconcile`'s non-idempotent branch, so a redelivered no-op `kyc-decision-sync` - job never double-emits. - -### Workflow completeness (per-step checks) - -Completeness is ours, quality is theirs. A vendor's session-level `approved` says the -overall workflow finished; it says nothing about whether every expected step (ID check, -liveness, face match, AML, proof of address, ...) individually reached a terminal -successful state. A hosted-session vendor can report `Approved` while one step sits at -`Not Started` or `Expired` (a workflow-graph bug, a skipped branch, a document that -expired mid-review) - this platform must not release withdrawals against that. Compliance -does NOT re-derive the vendor's own judgement calls (document expiry thresholds, face -similarity scores, AML hit relevance) - only whether the vendor itself marked each step -`approved`. - -`KycCheckStatus`/`KycCheckResult` (`contracts/adapters/kyc.ts`) are the vendor-neutral -shape: `{ step: string; status: KycCheckStatus }`, where `status` is one of -`not_started | in_progress | in_review | approved | declined | expired | unknown`. -`unknown` covers a vendor status string this platform doesn't recognize - it is never -treated as `approved`, so an unrecognized vendor value fails safe into the manual queue -rather than crashing or silently passing. `KycResult.checks` (optional) carries the full -per-step array for the CURRENT session. Only `undefined` means "this adapter structurally -has no step-level granularity" (document-forwarding vendors, `MockKycAdapter`) - the gate -below is a no-op for that vendor only in that case. A supplied-but-empty `checks: []` -means "zero steps resolved" and is treated as INCOMPLETE, same as any other non-`approved` -entry (`findIncompleteCheck`) - an adapter that has step granularity must never conflate -"nothing to report" with "everything passed". - -**Which steps are "expected" is derived from the decision payload itself, never a -separately maintained list.** A hosted-session vendor's workflow graph is edited in the -vendor's own dashboard with no code deploy on this side, so any config mirroring "the -current step list" drifts the moment an operator changes the graph. Didit's session -decision already reports its own `features` array on every call - the definitive, -self-describing list of steps THAT session's workflow actually ran - so -`DiditKycAdapter.resolveDecision` (betfeel `apps/api/src/extensions/didit/`) builds -`checks` from `decision.features` via `resolveKycChecks`/`buildKycChecks` -(`didit-decision-mapper.ts`), never from a hardcoded or platform-config feature list. A -future vendor adapter follows the same rule: derive expected steps from whatever the -vendor's own decision response says applied to that session. Didit is a hosted-session -vendor with a real workflow graph, so it always has step granularity: on an `approved` -decision with a genuinely empty/missing `features` list, `resolveKycChecks` returns a -single `unknown` check rather than `undefined` - `undefined` is reserved for adapters -that structurally cannot report steps at all, which Didit never is. `IP_ANALYSIS` is -excluded from the feature -> check-block table: it is a risk signal (surfaced separately -via `resolveRiskSignals`/`extractKycRiskSignals`), not a pass/fail verification step, and -must never itself downgrade an otherwise-complete approval. Where a check block carries -multiple items (eg two `id_verifications`), `aggregateCheckStatus` reports the worst by -an explicit severity ranking (`declined` > `expired` > `unknown` > `in_review` > -`in_progress` > `not_started` > `approved`), not the first non-`approved` item by array -order - a co-present `declined` must never be hidden behind an earlier, less severe -`in_review`. - -`KycVerificationService.reconcile` and `submit` (`kyc.service.ts`) both run the same -completeness gate: when the mapped vendor status is `approved`, the checks THIS CALL is -about to persist - `opts.checks` on `reconcile` (falling back to the existing row's -`checks` when the caller supplies none, so a later checks-less reconcile can never -silently clear an earlier downgrade), or `result.checks` on `submit` for a vendor with an -instant decision - are checked for any non-`approved` entry (`findIncompleteCheck`). When -one is found, the status actually written is `resubmission_requested` instead - the -existing "needs player/operator attention" queue, not a new enum value. The -`decisionReason` column is overwritten to name the incomplete step and its status -(`describeIncompleteCheck`), so `getPlayerKyc` tells an operator WHY a vendor-approved -session landed in the queue instead of a clean `approved`. The `reason` forwarded to -`KYC_STATUS_WRITER.setStatus` (and from there into the `compliance.kyc.updated` audit -event) is ONLY a reason this call itself produced - the gate's `describeIncompleteCheck` -or an explicit `opts.reason` - never a prior reason preserved on the row, so a bare -status reconcile that supplies no reason of its own never re-surfaces stale reviewer text -on an unrelated transition. `syncDecision` passes `decision.checks` through unchanged; -`checks` is absent entirely when the vendor/adapter has no step-level granularity, in -which case the gate never fires (back-compat with every adapter that predates this). - -`checks` is persisted on the `kyc_verification` row (nullable jsonb, same -preserved-when-absent rule as `documentTypes`/`riskSignals`) - audit evidence for why a -decision was downgraded, not just the resulting status. - -## RG - -Write surface (limits incl. a session-time limit, cooling-off 24h-6wk, self-exclusion >=6mo or permanent, server-enforced lift) plus read/monitoring surface (flags, audit-backed history). The session limit reuses `user_limit` polymorphically by `type`: money limits carry `amount` (a `decimal()`), the session-time limit carries `minutes`. - -Enforcement depth = block login + revoke all active sessions + refuse the wager itself. The login block alone is NOT sufficient: a launched game's provider token outlives our session and aggregator settlement is inbound, so revoking sessions does not stop a round already in play (ADR-0032). Pending withdrawals are untouched (funds are not locked). - -Enforcement crosses the module boundary through two non-sealed ports in `@openora/core/contracts`, both owned + bound by identity. Compliance drives them only - never the identity schema. - -- `LOGIN_ENFORCEMENT` (push): `block` sets `user.rgBlocked`/`rgBlockedUntil` and revokes all sessions, `unblock` clears them. -- `PLAY_ELIGIBILITY` (read): gaming's `startRound` and wallet's `debit` (only `type: 'bet'`) refuse a restricted player, fail-closed, unknown user included. `win`/`loss` stay ungated - they settle an already-staked round rather than opening a new one. - -Cooling-off auto-expires by the `now >= rgBlockedUntil` compare - there is no unblock job. An admin can also end one early via `liftCoolingOff` (mandatory reason, NO confirm gate - unlike self-exclusion, a cooling-off is a support action that must stay reversible); `syncEnforcement` then recomputes and keeps the block if a self-exclusion is still active. - -Monitoring is queue-based: `wallet.deposit.completed` / `gaming.round.ended` / `rg.exclusion.login_blocked` enqueue per-player `rg-eval` jobs (idempotencyKey + orderingKey:userId) off the hot path; a worker upserts/clears `rg_flag` rows at the 80% band; a recurring `rg-monitor` job (everyMs 60_000 + cron for a durable overlay) raises session-time flags. Pure eval helpers (`periodWindow`/`thresholdPct`/`isAtThreshold`) live in `service/rg-eval.ts`, DB-free. Cross-domain reads go through `/schema` subpaths only (wallet, casino/gaming for spend aggregation, pam/identity `session` for the sweep). -RG change history / activity log / CSV = the audit module filtered by `actionPrefix: 'rg.'` (`resourceId` = subject player) - no new history table. - -## Don't - -- Bind the sealed RG / national-register tokens (GamStop is out of scope, stays UNBOUND). -- Write `player` directly - go through `KYC_STATUS_WRITER` / `LOGIN_ENFORCEMENT`. diff --git a/packages/core/src/compliance/service/kyc.service.ts b/packages/core/src/compliance/service/kyc.service.ts index 68c568a0..134ca0ff 100644 --- a/packages/core/src/compliance/service/kyc.service.ts +++ b/packages/core/src/compliance/service/kyc.service.ts @@ -220,7 +220,7 @@ export class KycVerificationService { * call is about to persist (`opts.checks`, falling back to the existing row's `checks` * when the caller supplies none - the SAME value written below, so the gate can never * evaluate a different set of checks than the one that ends up on the row) contain any - * non-`approved` entry - see `compliance/AGENTS.md` > KYC workflow completeness. + * non-`approved` entry. */ // referenceId is the KYC vendor's own reference, not an internal Uuid - stays a plain string. async reconcile( @@ -313,8 +313,8 @@ export class KycVerificationService { * `kyc-decision-sync` job handler, run off the webhook request path. Enriches the * reconcile through `resolveDecision`/`resolveRiskSignals` when the bound adapter * implements them, falling back to a status-only reconcile when it does not. Errors - * (notably `PlayerNotFoundError`) propagate so the job queue retries the decision - - * see `compliance/AGENTS.md` > Webhook -> job flow. `receivedAt` is the webhook's + * (notably `PlayerNotFoundError`) propagate so the job queue retries the decision. + * `receivedAt` is the webhook's * arrival time (stamped by the router before enqueue, carried on the job payload) - * passed through to `reconcile` as the monotonicity watermark so a job that runs * out of arrival order (the driver ignores `orderingKey`) can't overwrite a newer @@ -419,9 +419,8 @@ export class KycVerificationService { /** * Shared transaction body for `requestResubmission`/`overrideStatus`: inserts the * manual `kyc_verification` history row and calls `KYC_STATUS_WRITER.setStatus` in - * the SAME transaction (extracted so the two documented locking/idempotency rules - * in `compliance/AGENTS.md` > Admin KYC actions cannot silently diverge between the - * two call sites). Caller has already run the idempotency pre-check + * the SAME transaction so the locking and idempotency rules cannot diverge between + * the two call sites. Caller has already run the idempotency pre-check * (`requirePlayerRowForUpdate` + a status compare) before calling this. */ private async applyManualDecision( @@ -463,8 +462,7 @@ export class KycVerificationService { /** * Admin-initiated: requests the player resubmit documents. Idempotent on a repeat * call while the player is already at `resubmission_requested` - no duplicate history - * row, no duplicate `compliance.kyc.updated` emit. See `compliance/AGENTS.md` > - * Admin KYC actions for why the `FOR UPDATE` lock has to sit inside the transaction. + * row or `compliance.kyc.updated` emit. */ async requestResubmission(userId: User['id'], reason: string, actorId: User['id']) { return this.drizzle.db.transaction(async (trx) => { @@ -488,8 +486,7 @@ export class KycVerificationService { * Admin-initiated: forces the player to an operator-chosen status, guarded by the * router's `compliance:override-limit`. `resolveManualStatus` remaps an `approved` * choice to `manually_overridden`; every other choice is written verbatim. Idempotent - * on a repeat call resolving to the status the player already holds. Rationale for - * both: `compliance/AGENTS.md` > Admin KYC actions. + * on a repeat call resolving to the status the player already holds. */ async overrideStatus( userId: User['id'], @@ -520,7 +517,7 @@ export class KycVerificationService { * `overrideStatus('approved', ...)` path, so a bulk approval is recorded identically * to a single one. Bounded fan-out with per-player error isolation - one failure is * captured as a result row rather than aborting the batch, and the returned message - * is a fixed string, never the raw exception text (`compliance/AGENTS.md`). + * is a fixed string, never the raw exception text. */ async bulkApprove( userIds: User['id'][], diff --git a/packages/core/src/contracts/adapters/admin-player-activity.ts b/packages/core/src/contracts/adapters/admin-player-activity.ts index 7a902649..c6608cad 100644 --- a/packages/core/src/contracts/adapters/admin-player-activity.ts +++ b/packages/core/src/contracts/adapters/admin-player-activity.ts @@ -2,8 +2,8 @@ import { createToken, type Token } from './token.js'; /** * Admin/back-office reporting over player registration + engagement activity. - * Owned + bound by iam (see iam/AGENTS.md for why - it centralizes admin reporting - * concerns even though the underlying `user`/`session` tables belong to identity, + * Owned + bound by iam; it centralizes admin reporting concerns even though the + * underlying `user`/`session` tables belong to identity, * read via that module's read-only /schema subpath); the back-office depends only * on this port. A query port like ADMIN_WALLET_REPORTING/ADMIN_GAME_REPORTING. * See ADR-0017/0025. diff --git a/packages/core/src/engagement/chat-commands/AGENTS.md b/packages/core/src/engagement/chat-commands/AGENTS.md deleted file mode 100644 index 3f4ea7ea..00000000 --- a/packages/core/src/engagement/chat-commands/AGENTS.md +++ /dev/null @@ -1,92 +0,0 @@ -# chat-commands - -Player-facing slash commands (`/profile`, `/gift`, `/rain`) and `@mention` for the chat UI. Commands are stored in `chat_command_config` keyed by command type; operators can toggle or reconfigure any command via `adminUpdateCommand` without a deploy. - -## DB-backed command registry - -Each row in `chat_command_config` holds `enabled`, `label`, `description`, and a `config` jsonb column (`maxAmount`, `minAmount`, `maxRecipients`). The service checks the row before dispatching — a missing or disabled row throws `CommandDisabledError` (404). Seed data lives in `seed/index.ts` (`seedChatCommands`), called from `tools/db/seed.ts`. Four default commands: `mention`, `profile`, `gift`, `rain`. - -`mention` is special: it does not go through `POST /chat-command/execute`. The `@username` pattern is typed inline in a message; `GET /chat-command/mention-search` powers the type-ahead. The registry entry exists so operators can disable @mentions platform-wide. - -`profile` is also special like `mention` - it does not go through `POST /chat-command/execute`; `GET /chat-command/player-search` powers the search step and `GET /chat-command/player-profile/{userId}` (lookup by userId only, never username) returns the full profile card. Neither route posts to chat. - -`/block` and `/ignore` write to separate tables (`chatUserBlock` vs `chatUserIgnore`, owned by `chat`) via `CHAT_BLOCK_WRITER.blockUser`/`ignoreUser` respectively - they used to both call `blockUser`, now `handleBlockAction` dispatches on `input.type`. Both self-actions (blocking/ignoring yourself) are rejected via `SelfModerationActionError` (409), checked after the target username resolves. `searchPlayers`/`searchMentions` both take a `viewerId` and exclude any player the caller has blocked or ignored via `CHAT_BLOCK_WRITER.getExcludedUserIds(viewerId)` - a blocked/ignored player will not surface in player search or @mention autocomplete. - -## Idempotency for money-moving commands (gift/rain/donate) - -`gift`/`rain`/`donate` require a client `idempotencyKey` (uuid). The `CACHE` port reserves -`chat-command:idempotency:{actorId}:{commandType}:{idempotencyKey}` atomically with a five-minute -TTL, stores the full-request fingerprint, and then stores the completed `ChatSystemMessage` under -the same key. A matching fingerprint replays without another wallet call; a different fingerprint -throws `ChatCommandIdempotencyKeyReuseError`; a concurrent in-flight request throws -`ConcurrentCommandReplayError`. Failed money transactions release the reservation. The cache must -provide atomic `setIfAbsent` - a plain get-then-set is not safe for money. - -## Publish-after-commit for gift/rain/donate - -`ChatSystemWriter.postSystemMessage` only auto-publishes to realtime when it owns the write (no -`tx` argument). `handleGift`/`handleRain`/`handleDonate` all pass their own `tx`, so they own the -commit boundary and must call `this.transport.publish(chatChannel(...), msg)` themselves, AFTER -their `db.transaction(...)` call resolves - never before, or a client could see a gift/rain/donate -message for money that was never actually moved (transaction rolled back after the publish). -`handleBlockAction` does not pass `tx`, so it still relies on `postSystemMessage`'s own auto-publish. - -## Claimable gift mechanic - -`/gift ` is a two-step flow: the sender is debited immediately and a `chat_gift` row (status: unclaimed) is created atomically with the system message. Any other player calls `POST /chat-command/gift/:id/claim` to win the credit. The atomic claim uses `UPDATE ... WHERE claimed_by IS NULL RETURNING *` — first caller wins, zero balance goes unreturned. Realtime push fires on claim via `CHAT_REALTIME_TRANSPORT.publish(chatChannel(roomId), ...)` so frontends can update the card live. - -`chatGift.messageId` is a plain UUID with no FK — cross-module boundary rule applies. - -## Rain split has a remainder - debit the distributed amount, not the typed amount - -`/rain ` splits `floor(amount / recipientCount)` to each recipient - `perRecipient * -recipientCount` can be LESS than the player-typed `amount` (eg `10.99` split 10 ways credits -`10.00` total). `handleRain` debits `totalDistributed` (`floor(amount/n)*n`, computed in the same -SQL statement as `perRecipient`), never the raw `input.amount` - otherwise the undistributed -remainder is silently taken from the sender and never credited anywhere. The metadata, audit -`after.amount`, and the `chat.rain.distributed` event's `totalAmount` all report `totalDistributed` -too, since that is what actually left the sender's wallet. The pre-transaction `maxAmount`/ -`minAmount`/`amountUnits` limit checks still validate against the player-typed `input.amount` - -that happens before the split is known and is correct as-is. - -## Exact username resolution for `/donate`, `/block`, `/ignore` - -These three commands resolve an EXACT, already-known username (typed in full or picked from -autocomplete, never a partial search term) via the shared `resolveExactPlayer` helper, which calls -`ADMIN_USER_DIRECTORY.getPlayerByUsername` - a real exact, case-insensitive match. They do NOT use -`findPlayerIds` (that method is a capped, unordered `ILIKE '%query%'` substring search meant for -autocomplete-style fuzzy search): a short/common username can substring-collide with more than the -20-row cap of unrelated accounts, so the real target could fall outside the first 20 rows Postgres -happens to return and get a false `ChatPlayerNotFoundError`. `searchMentions`/`searchPlayers` still -use `findPlayerIds` correctly - those genuinely are partial-query autocomplete search. - -## Ports consumed - -- `CHAT_SYSTEM_WRITER` — posts system messages into the chat stream (bound by the `chat` plugin; implemented by `ChatService.sendSystemMessage`). -- `CACHE` — atomic Redis-backed idempotency reservation and short-lived replay result storage. -- `WALLET_COMMANDS` — debits the actor and credits recipient(s) within a single transaction; money never flows over events. -- `ADMIN_USER_DIRECTORY` — `findPlayerIds` for autocomplete-style username search, `lookupPlayers` for batch profile resolution, `getPlayerByUsername` for exact-match resolution of an already-known username (`/donate`, `/block`, `/ignore`). -- `ADMIN_GAME_REPORTING` — `getPlayerStats` for total-wagered/total-bets on the profile card (owned by casino/gaming). -- `AUDIT_WRITER` — transactional `recordInTransaction()` for gift/rain/donate money paths, so the - audit row commits or rolls back with the wallet move; non-money command configuration still uses - `record()`. -- `CHAT_REALTIME_TRANSPORT` — `getOnlineUserIds(channel)` for rain recipient discovery; `publish(channel, event)` for gift-claimed push. The chat-scoped token, not the generic `REALTIME_TRANSPORT` - this module publishes on the same `chat:*` channels chat itself uses, so it must ride whatever transport chat is bound to (see `chat/AGENTS.md`), never a different one. - -## Extension points - -Add a new command type by: - -1. Adding the key to `CHAT_COMMAND_TYPES` in `contract/index.ts`. -2. Adding a handler method in `ChatCommandsService`. -3. Adding a dispatch branch in `executeCommand`. -4. Inserting a seed row in `tools/db/seed.ts`. - -## Invariants - -- Gift send: debit + `chatGift` insert + system message are atomic in one transaction. `messageId` is back-filled in the same transaction after `postSystemMessage` returns. -- Gift claim: the `UPDATE ... WHERE claimed_by IS NULL` is the idempotency guard — no separate lock needed. Self-claim is rejected before the update attempt. -- Rain recipients are capped by `config.maxRecipients` (default 50) and filtered to exclude the actor. -- All money movement is transactional: debit + credits + system message happen atomically. -- Money-moving audit rows are written through the same transaction as the debit/credit and system message. -- `mapConcurrent` (limit 10) is used for rain credits — never `Promise.all` on an unbounded recipient list. -- `adminUpdateCommand` uses an upsert so a missing config row is created on first admin call. diff --git a/packages/core/src/engagement/chat/AGENTS.md b/packages/core/src/engagement/chat/AGENTS.md deleted file mode 100644 index fa5abbb7..00000000 --- a/packages/core/src/engagement/chat/AGENTS.md +++ /dev/null @@ -1,25 +0,0 @@ -# Chat - -Room-based and global messaging. Global chat has `roomId: null` and no `chatRoom` row or category. `chatRoom` stores name, unique slug, required category (`games-sports`, `regions`, `languages`, or `private-channels`), `isPublic`, nullable unique joinCode for private rooms, nullable creatorId, and soft-delete via `deletedAt`. Other tables: `chatMessage` (soft-delete via `isDeleted`, indexed by room + createdAt), `chatUserBlock` (directional mute, blocker-keyed), `chatRoomMember` (role: member/moderator, unique per room+user), and `chatRoomBan` (unique per room+user). - -Routes (player-facing): `listRooms` (public rooms + private rooms the caller is a member of), `getRoomMessages`/`sendRoomMessage` (public read, authenticated send; private rooms require membership), `getGlobalMessages`/`sendGlobalMessage` (player-only, global scope), `deleteMessage` (player, ownership-enforced), `getConnection` (issues a per-player realtime grant covering global + all public rooms + all private rooms where caller is a member), and `streamMessages` (SSE, roomId null = global; private-room access is enforced at router level). - -Realtime: by default, the bindings are `InProcessRealtimeTransport` and `SseClientAuthorizer`. The browser receives through the first-party SSE `streamMessages` route, authorized by its session cookie; this in-process fan-out and presence are limited to one API process. `extensions/ably/` is an optional infrastructure overlay. It rebinds both `REALTIME_TRANSPORT` and `REALTIME_CLIENT_AUTHORIZER` only when both `ABLY_API_KEY` and `ABLY_BROWSER_REALTIME_ENABLED=true` are set, after the browser adapter is deployed and selected. This explicit adapter flag prevents an API key alone from disabling SSE. Openora publishes to Ably and counts provider-backed presence, while the consumer realtime adapter connects the browser directly to Ably. The adapter obtains `tokenRequest` from `getConnection`, subscribes only to the returned `chat:global` / `chat:room:{id}` channels, enters/leaves presence for each connection, and refreshes authorization after membership changes. Ably grants are bound to the authenticated Openora user ID, scoped to exact channels, and permit only `subscribe` and `presence`; never expose `ABLY_API_KEY`, grant browser `publish`, or let the browser bypass Openora message routes. Persist and authorize messages in Openora before publishing; realtime delivery is best-effort, not the system of record. - -Presence and FE adoption: call `getOnlineCount({ roomId })` to display the current unique online count (`roomId: null` is global chat). Opening the SSE `streamMessages` route enters presence and closing it leaves; authenticated tabs are de-duplicated per user, while anonymous global viewers count separately. A managed realtime adapter must enter and leave presence on the same `chat:global` / `chat:room:{id}` channel for each connection and refresh grants after a room membership change. - -Routes (private-room lifecycle): `createPrivateRoom` (a player creates up to 15 active `private-channels` rooms, is auto-joined as moderator, and receives a join code), `joinRoom` (join by code; 404 if invalid, 403 if banned), `leaveRoom` (idempotent), `getRoom` (room detail; joinCode populated for members of private rooms), `kickMember` (moderator removes member - can rejoin), `banMember` (moderator bans member - cannot rejoin even with code; idempotent), and `listRoomMembers` (members only for private rooms). - -Routes (admin-only, AdminGuard-enforced): `createRoom` (POST /backoffice/chat/rooms - creates a public room by slug and required category), `listAdminRooms` (GET /backoffice/chat/rooms - paginated public rooms, sortable by name or creation time), `updateRoom` (PATCH /backoffice/chat/rooms/{id} - changes name, slug, and/or category), and `deleteRoom` (DELETE /backoffice/chat/rooms/{id} - soft-deletes the room while preserving messages, memberships, and bans). - -Per-viewer block filtering: message list filters out senders the viewer has blocked; the blocked player is unaffected and can still send. Filters apply to both room and global streams. Username resolves from the verified user row (falls back to header, then `anonymous`); userId resolves from auth. Realtime push uses `REALTIME_TRANSPORT`. - -Access model: public rooms are open to all; private rooms require membership. Deleted rooms are excluded from all room reads, lists, joins, streams, and moderation operations. `verifyRoomAccess(roomId, viewerId?)` is the single authority - called by `getRoomMessages`, `sendRoomMessage`, `getRoom`, `listRoomMembers`, `leaveRoom`, `kickMember`, `banMember`, and, via router, `streamMessages`. Moderator checks in `kickMember`/`banMember` require `role = 'moderator'` in `chatRoomMember`. - -Audit events: `chat.private_room.created`, `chat.room.member.kicked`, and `chat.room.member.banned` are subscribed by the audit module. `chat.user.blocked`/`chat.user.unblocked` are also audited. `chat.message.sent` is not audited because it is high-volume. - -Join code: 6 chars, 31-character alphabet (no ambiguous 0/1/I/O/L), crypto `randomInt`, globally unique at the DB layer. The private-room slug is generated independently as `private-{randomUUID()}` and must never contain the join code. - -Channel name convention (mirrors `chatChannel()` in the service): `chat:global` for null roomId and `chat:room:{roomId}` for rooms. - -Admin role requires the `chat-room` resource declared in `server/auth/permissions.ts`. diff --git a/packages/core/src/engagement/notifications/AGENTS.md b/packages/core/src/engagement/notifications/AGENTS.md deleted file mode 100644 index e6244300..00000000 --- a/packages/core/src/engagement/notifications/AGENTS.md +++ /dev/null @@ -1,14 +0,0 @@ -# Notifications - -In-app notification log with optional email delivery, driven by the wallet withdrawal approved/rejected events and by compliance's KYC resubmission requests. `type` is the `NOTIFICATION_TYPES` contract triple (`withdrawal.approved`/`withdrawal.rejected`/`kyc.resubmission_requested`) - only values a creation site actually emits belong in it. Player emails resolve through `ADMIN_USER_DIRECTORY` (owned by identity), never by reading identity tables. - -## Invariants - -- The in-app record is authoritative and always lands; email through `NOTIFICATION_DELIVERY_ADAPTER` is best-effort - a missing user or failed delivery is logged with the userId for ops, never thrown. -- The event handler fires the email on a detached, caught promise so a hung delivery can't stall event processing. -- `create` emits `notifications.created` for the audit module: a system-generated record, recipient in `after.userId`, no actor. -- `markRead`/`markAllRead` are a deliberate audit exception - a player flipping `readAt` on their own row has no money/KYC/config effect. - -## KYC resubmission notification (JOB_QUEUE-backed) - -Subscribes to compliance's `compliance.kyc.updated`, filtered to `status: 'resubmission_requested'` + `source: 'manual'` - covering both `compliance.requestKycResubmission` and an `overrideKycStatus` call that lands on the same target status, since both write through the same `KYC_STATUS_WRITER` emit. Unlike the withdrawal handlers, this one enqueues a `kyc-resubmission-notify` job (`JOB_QUEUE`) instead of dispatching inline: an admin action must never block on a slow SMTP endpoint (ADR-0014). The worker (registered in this same `plugin.ts`) creates the in-app notification and sends the email. The job's `idempotencyKey` is the envelope's `eventId` - unique per emit, stable across a redelivery of the SAME event once a durable broker is bound; never `Date.now()`, which would make every enqueue unique and defeat dedup. Compliance never imports this module - the domain event is the only coupling. diff --git a/packages/core/src/iam/AGENTS.md b/packages/core/src/iam/AGENTS.md deleted file mode 100644 index b0b09aec..00000000 --- a/packages/core/src/iam/AGENTS.md +++ /dev/null @@ -1,36 +0,0 @@ -# iam - -Identity & Access Management for the backoffice: dynamic DB-backed RBAC (a `(role x module) -> level` matrix), super-admin semantics, predefined roles seeded by script, admin onboarding via invitation tokens. Binds `ADMIN_PERMISSION_RESOLVER` so `AdminGuard` authorizes against DB grants: the resolver EXPANDS each stored `(module, level)` cell into the action grants the `AdminGuard.assert(ctx, resource, action)` call sites check - the guard and its call sites never change with the level model. - -## Level model - -Three totally ordered levels: `no_access` < `read` < `read_write`. Storage is sparse - only non-`no_access` cells exist; a missing module means `no_access`, deleting a row downgrades. Helpers live in `@openora/core/server` `permission-levels.ts` (`levelToActions`, `actionsToLevel`, `readActions`, `isLevelSufficient`) - always derive modules/levels from `statement` + these helpers, never hardcode strings. Gotchas: `content` has no `view` action, so `read` expands to `[]` (read-or-nothing); single-action modules (`report`, `analytics`) expand `read` and `read_write` identically. - -## Security invariants - -- Super-admin = a role with `isSuperAdmin=true`; the resolver returns ALL grants for its holders. -- Static-role fallback: for ANY user with NO assignment row, `AdminGuard` (`server/auth/admin-guard.ts`) falls back to the static better-auth role check - DB revocation is NOT authoritative while a static role still grants; revoke the static role to fully deny. This is how the bootstrap admin (`user.role='admin'`, no assignment row) counts as super-admin and can never be locked out. -- Role/permission/assignment mutations require super-admin (`NotSuperAdminError` -> FORBIDDEN). -- No escalation: `setRolePermissions` rejects granting a level above the caller's own effective level per module (`GrantEscalationError` -> FORBIDDEN; super-admin caller passes). -- Protected roles: `isSystem` roles cannot be DELETED, but predefined non-super roles MAY be renamed and re-permissioned. The super-admin role cannot be edited or deleted at all (`ProtectedRoleError` -> CONFLICT). The final user holding any super-admin role cannot be unassigned (`LastSuperAdminError` -> CONFLICT) - the count + delete run in one transaction with the holder rows locked `FOR UPDATE`, so concurrent unassigns cannot both strip the last super admin. -- The `admin` module (role/admin management) is NOT operator-editable: omitted from `listCatalog`, grants targeting it rejected (`InvalidGrantError` -> BAD_REQUEST). Admin capability comes ONLY from `isSuperAdmin`. -- `acceptInvitation` is one atomic conditional UPDATE - a replay updates zero rows and emits no event. `assignRole` dedupes on the unique index (returns the existing row, no 500). -- Grant freshness: `getGrants` resolves in one indexed join, cached per user (`admin-grants:`, 10s TTL) behind `CACHE`. Revocation stays effectively immediate because the plugin purges keys on `iam.role.assigned`/`revoked` (user-keyed) and `iam.role.permissions.changed` (fanned out to every current holder via `invalidateRole`); the TTL is only the safety floor. Unbound `CACHE` (some tests) = always hit the DB. - -## Seeding - -`DEFAULT_ADMIN_ROLES` (`seed/data/default-admin-roles.ts`) is the canonical spec; `seedRoles` is a convergent upsert keyed on `key` - re-seeding reconciles names/levels back to the spec but does not drop grants removed from it. Roles are seeded by script, never by migration. - -## Extension points - -- Override invitation email: `ctx.provide(SEND_EMAIL, ...)` in a later-loading overlay. -- React to onboarding: `ctx.events.on('iam.invitation.accepted', ...)`. -- Frontend route guards are NOT audited - a client-side redirect never sends the underlying request, so it is not a reliable signal that the user attempted to access protected data (only a real backend request that hits `AdminGuard.assert()` is). `assertSuperAdmin` and the no-escalation check in `setRolePermissions` emit the same `identity.user.unauthorized_access` event before throwing `NotSuperAdminError`/`GrantEscalationError`, so service-level denials that never reach `AdminGuard.assert()` are audited too. -- Add permission modules: edit `statement` in `packages/core/src/server/auth/permissions.ts` - catalog, levels, and validation all derive from it. -- Each `iam.role.*` payload carries an explicit `actorId` (the envelope does not); the audit module subscribes to all of them. -- Binds `ADMIN_PLAYER_ACTIVITY` (`adapters/admin-player-activity.ts`) - the back-office player-activity report (registrations over time, DAU/WAU/MAU trend, 7d/30d retention cohorts). It reads the `user`/`session` tables via identity's read-only `/schema` subpath rather than identity binding the port itself - iam already `dependsOn: ['identity']` and centralizes the other admin-reporting-style ports, so this keeps that a single seam for admin-console. "Active" is defined as a session row whose `updatedAt` falls in the window (better-auth refreshes it on continued use) - see the one-line comment on `getActiveUsersTrend` before changing it, this is a deliberate simplification, not a hard requirement. - -## Don't - -- Write `no_access` rows (sparse storage) or hardcode module/level strings. -- Move user creation or money operations into this module. diff --git a/packages/core/src/pam/identity/AGENTS.md b/packages/core/src/pam/identity/AGENTS.md deleted file mode 100644 index b8b78399..00000000 --- a/packages/core/src/pam/identity/AGENTS.md +++ /dev/null @@ -1,27 +0,0 @@ -# identity - -better-auth-backed authentication and account lifecycle (register, login, logout, 2FA, password reset, email verification, profile). The engine's `SessionResolver`/`AdminGuard` verify sessions against this module's tables by injection, never by importing them (ADR-0019/0025). - -## Login lockout - -Per-account credential-failure counter (`user.failed_login_attempts` + `lockout_until`): after `maxAttempts` (default 5) the account locks, and a repeat lockout inside a rolling 24h window escalates 1min -> 5min -> 15min (`lockout_count` + `last_lockout_at`; the window resets once `last_lockout_at` ages out, `IDENTITY_OPTIONS.lockout.durationMs` is the tier-3+ fallback). `identity.user.login.failed` carries `attemptsRemaining` so a client can prompt a reset as the count runs down. Per-account correctness, distinct from rate limiting below. - -Anti-enumeration: a nonexistent email has no row to hold that state, so the same `computeLockoutState`/`computeLockoutTier` functions run against a `CACHE`-backed shadow record (`login-shadow:`, 7d TTL) - repeated wrong passwords on a fake email eventually return the identical `ACCOUNT_LOCKED` response, with no `identity.user.lockout.triggered` (no real userId to attach). `cache` is an optional constructor dep: unbound or erroring, the mirroring silently no-ops and that path degrades to a static reply. - -## Phone login (SMS OTP) - -A standalone method for players with a verified phone (`user.phone_number` E.164 + `phone_verified`; phone management routes are a separate story). Request sends a 6-digit code via the `SMS_ADAPTER` port (default mock logs to stdout; an overlay rebinds Twilio/SNS) and returns the SAME `{ expiresAt, resendAfter }` whether or not the phone is registered - 60s resend cooldown, one live code per user, SHA-256 hashed in `sms_otp_session`, 5min TTL. Verify mints the session DIRECTLY in the `session` table, bypassing better-auth's TOTP plugin chain: TOTP 2FA does NOT apply to phone login, by design. The RG login block is enforced after the OTP verifies, same as password login. - -Wrong codes increment `failed_attempts` (5-attempt cap, then the session cancels) and throw `OtpInvalidError` (`UNPROCESSABLE_CONTENT`) with `data: { attemptsRemaining, reason }` so the caller can tell `wrong_code` from `expired` instead of inferring it. Unknown, unverified, or just-cancelled phones are mirrored through the same shadow-record trick (`phone-otp-shadow:`, 7d physical TTL, logical 5min expiry derived from the stored `createdAt`), so cooldown/attempt/expiry behavior is indistinguishable from a real session - same optional-cache, silent-degrade contract as lockout. - -`IDENTITY_READER` is the sanctioned read port for other PAM modules. Besides inactive-player lookups and user->player resolution, it exposes the current player KYC status and a shared-login-IP lookup used by tag evaluation for BF-317 multi-account/bonus-abuse signals. Keep raw IP addresses inside identity; consumers receive only matched user ids and must not persist the IP in tag/audit reasons. - -## Rate limiting - -Rate limiting: `login`, `register`, `requestPasswordReset`, `verifyPasswordResetOtp`, `resetPassword`, `verify2fa`, and `sendEmailVerification` consume a per-identifier budget via the `RATE_LIMITER` port (keyed by normalized email/token/session, never IP) before doing work - exceeding it throws a 429 (`TOO_MANY_REQUESTS`) with `retryAfterMs`. Defaults are named constants in `identity.service.ts` (login 10/5min, register 5/15min, reset-request 3/15min, reset-verify 5/5min, reset 5/15min, verify2fa 5/5min, email-verification 3/15min); an overlay rebinds `RATE_LIMITER` to a Redis backend to change policy backend, not the numbers. - -Auth-sensitive routes consume a per-identifier budget via `RATE_LIMITER` (keyed by normalized email/token/session, NEVER IP) before doing any work, then throw 429 with `retryAfterMs`. The numbers are named constants in `identity.service.ts`; an overlay rebinds `RATE_LIMITER` to change the backend, not the policy. - -## Password reset - -Two-step OTP: request emails it, `verifyPasswordResetOtp` lets the client check it up front for immediate feedback WITHOUT consuming it, and `resetPassword` is the sole authoritative call that validates and sets. Both read the same better-auth verification row (`forget-password:`), so splitting the flow grants no extra guesses beyond better-auth's `allowedAttempts`. diff --git a/packages/core/src/pam/player-management/AGENTS.md b/packages/core/src/pam/player-management/AGENTS.md deleted file mode 100644 index ad9ad3b9..00000000 --- a/packages/core/src/pam/player-management/AGENTS.md +++ /dev/null @@ -1,16 +0,0 @@ -# Player Management - -Operator-facing player CRUD, search, and analytics over the `player` table (lifetime wagered/deposit totals live here). Search leans on a trigram GIN index on `display_name` for ILIKE - the DB needs `pg_trgm`. - -## Invariants - -- Owns and binds `KYC_STATUS_WRITER`; compliance drives every KYC transition through that port (submit, webhook reconcile, threshold re-KYC, admin resubmit/override/bulk-approve), never by writing `player`. -- `update` does NOT accept `kycStatus`. A KYC transition is a regulated compliance action needing a mandatory reason and an append-only `kyc_verification` history row, neither of which a general-purpose patch captures - `compliance.overrideKycStatus` (`POST /compliance/players/{userId}/kyc/override`, `compliance:override-limit`) supersedes it. `PlayerService` takes no `KycStatusWriter` and never writes `player.kycStatus`. -- `remove` bans the player; players are never hard-deleted. -- `update`/`remove` emit no domain event (no `player.updated`/`player.removed` topic exists) - the router records `admin.player.updated`/`admin.player.removed` directly through `AUDIT_WRITER`, fetching the pre-mutation row for the `before` snapshot. No audit call happens when `adminGuard.assert` rejects the caller first. - -`PlayerKycStatusWriter.setStatus` (`service/kyc-status-writer.ts`) is a single conditional `UPDATE player SET kyc_status = $new FROM (SELECT kyc_status FROM player WHERE user_id = $1 FOR UPDATE) AS prev WHERE ... AND prev.kyc_status <> $new RETURNING prev.kyc_status` - not select-then-update - so concurrent callers across instances can't both pass a stale guard and double-emit `compliance.kyc.updated`. Zero rows back is ambiguous (already at target vs missing player row), disambiguated by a follow-up existence check that throws `PlayerNotFoundError` for the latter rather than silently no-opping. The `FROM (... FOR UPDATE)` subquery captures the pre-update value inside that same atomic statement, so `previousStatus` on the emitted event can never be stale. - -`update()` emits `player.level.changed` (`{ userId, previousLevel, newLevel, actorId }`) after the transaction commits whenever `data.level` is provided and differs from the existing row's level - a best-effort fan-out (unlike the `KYC_STATUS_WRITER` emit, which runs inside the transaction as a regulated single-writer seam). The `tag` module subscribes to it to keep its single mutable `level` tag in sync. - -Known pre-existing gap (not introduced or fixed here): `playerContract.update`'s `email` field is accepted by the contract and by `PlayerService.update`'s signature, but the router never forwards `input.email` into the service call - an admin-submitted email change is silently a no-op. diff --git a/packages/core/src/pam/player-management/service/kyc-status-writer.ts b/packages/core/src/pam/player-management/service/kyc-status-writer.ts index a48b6953..6b964977 100644 --- a/packages/core/src/pam/player-management/service/kyc-status-writer.ts +++ b/packages/core/src/pam/player-management/service/kyc-status-writer.ts @@ -12,7 +12,7 @@ type ConditionalUpdateRow = { previous_status: KycStatus }; * change - admin override, vendor decision, webhook, threshold re-KYC - flows through * here, so there is exactly one write path + one `compliance.kyc.updated` emit. The * guard-then-write is a single conditional UPDATE, never a select-then-update, so - * concurrent callers cannot double-emit - see `pam/player-management/AGENTS.md`. + * concurrent callers cannot double-emit. */ export class PlayerKycStatusWriter implements KycStatusWriter { constructor( diff --git a/packages/core/src/pam/player-note/AGENTS.md b/packages/core/src/pam/player-note/AGENTS.md deleted file mode 100644 index 2fffb849..00000000 --- a/packages/core/src/pam/player-note/AGENTS.md +++ /dev/null @@ -1,7 +0,0 @@ -# Player Note - -Admin-only player annotations - an operator scratchpad, not a compliance record. Every write stamps `actorId` so notes carry authorship; no domain events, no cross-module coupling. Notes are permanent once written (no soft-delete). - -## Don't - -- Use this as the compliance/audit trail - that's the `audit` module. diff --git a/packages/core/src/pam/profile/AGENTS.md b/packages/core/src/pam/profile/AGENTS.md deleted file mode 100644 index c0b8df0c..00000000 --- a/packages/core/src/pam/profile/AGENTS.md +++ /dev/null @@ -1,3 +0,0 @@ -# Profile - -Player self-service read/write over the `player` table - the counterpart to player-management (operator CRUD) on the SAME schema. Access is player-only by userId match rather than a guard, and there is no admin override by design: profile edits are user-initiated only. diff --git a/packages/core/src/pam/tag/AGENTS.md b/packages/core/src/pam/tag/AGENTS.md deleted file mode 100644 index e286fdd9..00000000 --- a/packages/core/src/pam/tag/AGENTS.md +++ /dev/null @@ -1,118 +0,0 @@ -# Tag - -Rule-based player tagging. A tag lands on a player either manually (admin, actor stamped) or by rule evaluation on subscribed wallet/identity/compliance events; `playerTag` keeps the full assign/removal history rather than a current-state row, so removals stay auditable. - -Tag keys: `high_roller`, `vip`, `bonus_abuser`, `high_risk`, `inactive`, `large_depositor`, `self_excluded`, `kyc_pending`, `kyc_rejected`, `basic_kyc_needed`, `advanced_kyc_needed`, `test_account`, `dormant_high_roller`, `withdrawal_review`, `multi_account`, `level`. - -Routes: `createTag`/`deleteTag`/`listPlayerTags`/`listAssignableTags`/`assignPlayerTag`/`removePlayerTag` (all `adminGuard`-gated on the `tag` resource: `view`/`create`/`delete`), admin rule CRUD (`listTagRules`, `upsertTagRule`, gated on `tag-rule`). Players are never notified of tags applied to their account - no player-facing route exists. - -Subscribes to wallet.deposit.completed, wallet.withdrawal.completed, wallet.withdrawal.requested, identity.user.login, identity.user.phone_login, compliance.kyc.submitted, compliance.kyc.updated, compliance.kyc.reverify_required, compliance.kyc.high_risk_signal_detected, rg.self*exclusion.activated, rg.self_exclusion.lifted, player.level.changed; evaluates rules on each event. `withdrawal_review` triggers on the \_requested* event (the attempt, before funds move), not `completed` - by the time a withdrawal completes the review purpose is defeated. `rg.self_exclusion.activated`/`.lifted` are a direct 1:1 mapping onto `self_excluded` (`onSelfExclusionActivated` -> `tryAssignTag`, `onSelfExclusionLifted` -> `tryRemoveTag`) - no threshold, no rule row, just a tag mirror of the RG exclusion lifecycle. `player.level.changed` (emitted by player-management's `PlayerService.update()` after commit, only when an admin edits `player.level`) drives `onPlayerLevelChanged`: an ATOMIC same-key swap via `TagService.replacePlayerTag` - removal of the prior active `level` row (silent no-op when absent) + insert of the new one with the level value in a fresh `assignReason`, both on ONE transaction. Never the two-transaction `tryRemoveTag`/`tryAssignTag` pair: a mid-swap failure there would strand the player with the old row removed and no new one committed (the handler's rejection is swallowed by `EventBus.on`, no retry). The loser of a genuine concurrent replace serializes on the row lock + `player_tag_active_key` and throws `TagAlreadyInUseError` with nothing committed - the handler swallows it as the usual idempotent no-op. Residual (accepted, cosmetic): under two rapid interleaved level edits the surviving row's `assignReason` may reflect the race winner rather than the last emit - `player.level` on the player row stays the source of truth; the tag text is a display convenience. - -`level` is a single mutable tag key, `isSticky: false`, NOT one enum value per level number - the numeric level lives only in `assignReason` text (`` `player level set to ${newLevel}` ``), never in the tag key itself. It is assigned/replaced exclusively via the `player.level.changed` event above; there is no player-creation-time bootstrap/hook, so a player an admin has never edited the level of has no `level` tag at all. Like every event-driven tag, its `player_tag` rows carry `assignActor: 'scheduled'` / `assignActorUserId: SYSTEM_ACTOR_ID` even though the triggering event names the real admin `actorId` - the per-row trail does NOT identify the admin; the platform audit log (which subscribes to `player.level.changed` directly) is what records the real actor. - -`multi_account` is a sticky automatic risk tag backed by an enabled, no-threshold `tagRule`: on password or phone login, `TagEvaluationService` asks the `IDENTITY_READER` port for other player users that authenticated from the same login IP. When at least one match exists, it assigns `multi_account` to the current player and every matched player. The tag reason deliberately names only the identity signal; it must not copy the IP address or other raw PII into `player_tag.assign_reason`. - -`bonus_abuser` remains manually assignable/removable through the generic admin routes, and is also automatically assigned by the shared-login-IP multi-account risk path above when its enabled, no-threshold rule exists. That coupling is intentional for BF-317: the concrete risk signal is multi-account behavior, while the bonus-abuse designation stays sticky until an admin clears it. - -`withdrawal_review` evaluation has TWO paths, both idempotent against each other via `TagAlreadyInUseError`: (1) the async `wallet.withdrawal.requested` subscription above (`onWithdrawalRequested` -> `tryAssignTag`, best-effort fan-out for other consumers), and (2) the synchronous `TAG_EVALUATION_COMMANDS` command port (`evaluateWithdrawalRequested`, bound in `plugin.ts`) that wallet's `withdraw()` calls and awaits, on its OWN withdrawal transaction handle, before its auto-approval decision reads risk tags. Money/AML-adjacent decisions never depend on an `EventBus` emit alone (`EventBus.emit` is never awaited by the emitter - see `messaging-and-microservices` "money never flows over events"), so path (2) is the one that actually gates auto-approval; path (1) exists only for other subscribers. `evaluateWithdrawalRequested` writes through `TagService.assignPlayerTagInTx(trx, args)` - the caller-supplied-tx counterpart to `assignPlayerTag` (same idempotent-insert core, factored into a shared private `_assignPlayerTagOnTx`) - so the assignment commits atomically with the caller's own write. Any unexpected error from `evaluateWithdrawalRequested` propagates and aborts the caller's withdrawal (fail-closed: a review-gate evaluation failure must block the withdrawal, not silently skip review). - -Daily scheduled job (cron 0 2 \* \* \*, idempotent, queue `tag.daily-evaluation`) runs three independent sweeps: - -- **inactive**: assigns to players inactive for `thresholdDays`; removed on next login (`onUserLogin`). -- **dormant_high_roller**: a co-occurrence check, not a history lookup - `high_roller` is only re-evaluated on deposit, so a dormant player never loses it that way. Assigns when a player is both currently inactive (its own `thresholdDays`, independent of the `inactive` rule's) AND currently holds an active `high_roller` tag. Removed on next login; `high_roller` itself is left untouched. -- **high_risk resweep (removal only, frequency dimension only)**: `high_risk` is assign-only via `onWithdrawalCompleted`; a rolling withdrawal-count window can drop back under threshold with no new withdrawal, so this sweep batch-rechecks every current holder's windowed count in one `getWithdrawalCountsInWindow` call and removes the tag once the count falls below `thresholdCount`. `getWithdrawalCountsInWindow` is optional on the `WalletReader` port (so an external/consumer implementation that predates it still satisfies the port) - when absent, the resweep falls back to `mapConcurrent`-bounded fan-out over the singular `getWithdrawalCountInWindow`, one call per holder. Deliberately does NOT recheck the amount dimension: assignment's amount check has no time bound (a single withdrawal, ever), so a windowed recheck would silently de-designate a still-risky player once their triggering withdrawal ages out of the window - an AML false negative. Skipped entirely when the rule's `thresholdDays` OR `thresholdCount` is null (an amount-only rule isn't resweepable by the cron; stays until an admin clears it or a future withdrawal re-triggers assignment). `playerTag.assignMetadata` records which breach condition(s) fired at assignment (`amountBreach`, `countBreach`, or both) - the resweep additionally skips any holder whose metadata shows an amount breach (same amount-dimension-is-never-resweepable rule, now enforced per-holder instead of only at the rule level) or whose metadata is `null` (pre-migration/legacy row, or a row the resweep can't attribute to a known breach) - a null-metadata holder stays tagged until an admin manually clears it or a future `onWithdrawalCompleted` re-assignment populates metadata. A subsequent breach discovered while `high_risk` is already active is not silently dropped by the idempotent no-op: `_assignPlayerTagOnTx` merges it into the existing row's `assignMetadata`, filling only a dimension that was previously null and never overwriting one already recorded - this is what keeps the resweep's amount-dimension gate accurate over the tag's whole active lifetime, not just at first assignment. - -`withdrawal_review` is sticky (isSticky=true, seeded disabled) - assign-only, never auto-removed by any code path; requires manual admin removal via `removePlayerTag`. - -Provides `PLAYER_TAGS` (read active tags) and `TAG_EVALUATION_COMMANDS` (synchronous withdrawal_review evaluation, see above) ports for other modules. `listActiveHoldersByTagKey` on `TagService` is internal to this module (used only by the high_risk resweep) - deliberately not exposed on the `PLAYER_TAGS` port. Depends on wallet and identity modules for reader ports (`WALLET_READER`, `IDENTITY_READER`, `ADMIN_USER_DIRECTORY` - the last for a live KYC-status read, since the tag module owns no KYC data of its own) - this is why wallet can't `dependsOn: ['tag']` in return (it would cycle); wallet resolves both `PLAYER_TAGS` and `TAG_EVALUATION_COMMANDS` lazily via `c.has(...)` in its router factory instead, same as every other optional cross-domain port in this repo. - -KYC tags are lifecycle-managed but sticky where required: `kyc_pending` is assigned only while the profile status is `pending` or `resubmission_requested`; `kyc_rejected` is assigned on `rejected` and is not removed by resubmission/submission. It is cleared only once KYC reaches `verified` or `manually_overridden`. Cleanup on terminal approval runs even if a rule is disabled so stale rows cannot strand. - -Sticky tags (isSticky=true) are not auto-removed except where a lifecycle explicitly defines the removal (`kyc_rejected` on approval, `self_excluded` on lift). Manual assignment/removal always works. Event-driven assignment is rule-threshold-based or deterministic per signal type. - -`assignPlayerTag`'s "at most one active assignment per (tag, player)" guarantee is DB-enforced, not just app-level: a partial unique index (`player_tag_active_key` on `(tagId, playerId) WHERE removedAt IS NULL`) backs an `onConflictDoNothing` insert. The service's pre-check SELECT is a fast/friendly path only; the loser of a genuine concurrent race (or the same at-least-once event redelivered) hits the index and throws `TagAlreadyInUseError` from the insert itself, closing the race window completely. `tryAssignTag` (`TagEvaluationService`) already treats that error as an idempotent no-op, so redelivery stays correctly silent while no longer producing a duplicate active row. - -`migrate.ts`'s `preSql` (the `runMigrations` escape hatch for raw pre-migration SQL, alongside `extensions`) runs a dedupe `UPDATE` before every migration apply, not just once: a database that predates `player_tag_active_key` could have accumulated duplicate active `(tagId, playerId)` rows from the pre-fix race described above, and `CREATE UNIQUE INDEX` aborts outright on a live violation. The dedupe keeps the earliest row (by `createdAt`) per pair and soft-removes the rest via the same `removedAt`/`removalReason`/`removalActor` columns `removePlayerTag` uses, so the cleanup is audit-visible rather than a silent `DELETE`. It's guarded by `to_regclass('public.player_tag')` so it no-ops on a fresh install (where `preSql` runs before migration `0000` even creates the table) and is idempotent on every subsequent run (nothing left to dedupe once at most one active row remains per pair). - -Router handlers wire `mapErrors` (`createTag` -> `CONFLICT: TagKeyConflictError` on a duplicate `tag.key`; `deleteTag` -> `NOT_FOUND: TagNotFoundError` when the key does not exist (no row deleted) / `CONFLICT: TagInUseError` on an FK-restrict violation (a `playerTag`/`tagRule` row still references the key); `assignPlayerTag` -> `NOT_FOUND: TagNotFoundError` / `CONFLICT: TagAlreadyInUseError`; `removePlayerTag` -> `NOT_FOUND: [TagNotFoundError, TagAssignmentNotFoundError]`; `upsertTagRule` -> `NOT_FOUND: TagRuleNotFoundError`) so domain errors surface as the correct HTTP status instead of leaking as a raw 500. `deleteTag` checks `.returning()` on the delete before emitting `tag.deleted` - a no-op delete of a missing key must not produce a false "deleted" audit entry. `TagKeyConflictError`/`TagInUseError` are named factories from `server/kernel/domain-error.ts` (the sanctioned per-entity pattern every other module uses), not the generic `AppError` classes - `TagService` detects the specific Postgres constraint violation (23505 for `createTag`, 23503 for `deleteTag`) before falling back to the module's generic `mapDbError` for anything else. `TagInUseError` is distinct from `TagAlreadyInUseError`: the former means "tag key still referenced, can't delete the catalog entry"; the latter means "player already holds this tag active". - -`createTag`/`deleteTag` emit `tag.created`/`tag.deleted` (actor = the admin caller, threaded from the router via `getUserId(context)`) after the DB commit, subscribed by the audit module alongside `tag.player.assigned`/`removed`/`tag.rule.upserted` - these routes became real, accountable admin mutations once they were gated by `adminGuard` in this same change, so they inherit the audit obligation. - -**Migrations never seed data (this module included)** - the `tag`/`tagRule` catalog rows (including `dormant_high_roller`/`withdrawal_review`) are created by `seedTag()` (`seed/index.ts`, wired into `pnpm seed` via `tools/db/seed.ts`), not baked into a migration, consistent with every other module. Both `seedTags` and `seedTagRules` are idempotent (`onConflictDoUpdate`), safe to re-run against a database that already has rows. Migration `0002_silent_skaar.sql` (adds the `dormant_high_roller`/`withdrawal_review` enum values + supporting indexes) does NOT create their `tag`/`tagRule` rows - **on an already-migrated database, re-run `pnpm seed` after applying that migration** so those two tags exist before the daily sweep/resweep (or any admin `assignPlayerTag` call) references them by key. - -## KYC filter tags (`kyc_pending`, `kyc_rejected`, `basic_kyc_needed`, `advanced_kyc_needed`) - -Four backoffice filter tags over the player's KYC state, all driven by `TagEvaluationService` -and gated by their own `tagRule.isEnabled` row (all four ship enabled by default, no -threshold - the condition is the KYC status itself, not a configurable amount/count). - -- `kyc_pending` (not sticky) - KYC submitted, awaiting a decision. -- `kyc_rejected` (sticky) - most recent decision was a rejection. -- `basic_kyc_needed` (not sticky) - **best-guess semantics, not a product spec**: KYC - status is `not_started` or `rejected` AND the player has at least one completed - deposit. An untouched, deposit-free account is not worth chasing. Two independent - triggers implement the OR/AND, since no single event carries both halves: - - `TagEvaluationService.evaluateBasicKycNeededOnDeposit` (called from - `onDepositCompleted`) - catches "still not_started" or "still rejected" the moment - a (further) deposit lands. Reads the LIVE status via `ADMIN_USER_DIRECTORY.lookupPlayers` - (normalized through `normalizeKycStatus` first). - - `TagEvaluationService.evaluateBasicKycNeededOnRejection` (called from - `onKycStatusUpdated`'s `rejected` branch) - catches the case where the deposit - predates the rejection (submit -> deposit already happened -> later rejected), via - `WALLET_READER.getLifetimeDeposit(userId) > 0`. - - Removed on `compliance.kyc.submitted` (fresh submission - no longer `not_started`) - and on `compliance.kyc.updated` `approved`/`manually_overridden` (resolved). - - **Known limitation**: the `not_started` arm has no direct signal for "this player - was manually approved (`source: 'manual'`) without ever submitting" - it infers - `not_started` purely from the live `player.kycStatus` read, which is correct in - that case too (a manual approval sets `kycStatus: 'approved'`, so the live read - already reflects it). The proxy that WOULD be wrong - inferring from tag **history** - (ever held `kyc_pending`) - was deliberately not used; the live `ADMIN_USER_DIRECTORY` - read is exact. No known gap remains for this arm. -- `advanced_kyc_needed` (sticky) - the player's cumulative deposits crossed the - re-KYC threshold band, i.e. the same condition that fires compliance's - `reverify_threshold` re-KYC path (`KycVerificationService.handleDeposit`). This - module does NOT re-derive that threshold check - it subscribes to - `compliance.kyc.reverify_required` (compliance's own signal for "this fired") and - applies the tag as a pure label, so the two can never drift. Removed on - `compliance.kyc.submitted` (re-verification submitted) and on - `compliance.kyc.updated` approved/manually_overridden (resolved). Marked sticky - - same rationale as `kyc_rejected` (see below): a materially higher-stakes, - AML-relevant signal that should stay visible for backoffice review rather than - silently clear the instant a follow-up event fires; it is still explicitly - removed on the deliberate resolution transitions above (same precedent as - `kyc_rejected`'s explicit clears), never by a blanket sweep. - -**Stickiness decisions for the two new tags** (the brief left this open; existing -precedent - `kyc_pending` not sticky, `kyc_rejected` sticky - guided the calls above): -`basic_kyc_needed` mirrors `kyc_pending` (a transient funnel label that should vanish -automatically the moment the condition no longer holds, so operators don't chase an -already-fixed player); `advanced_kyc_needed` mirrors `kyc_rejected` (a compliance-review -signal worth keeping visible, not silently swallowed by routine event churn). - -**Vendor-workflow caveat (important - read before treating these as enforcement):** -the platform ships exactly ONE vendor KYC workflow (`MockKycAdapter`/a real vendor's -single hosted flow) that performs ID + liveness + face match + proof-of-address + AML -screening all in a single pass - see `compliance/AGENTS.md`. "Basic" (ID + liveness + -face match) vs "Advanced" (Basic + proof of address + AML) is therefore a LABELLING -convention layered over that one pass, driven by deposit-threshold triggers - it is -NOT two separate, independently enforced verification tiers. Nothing in the platform -gates a player's ability to deposit/withdraw differently based on which of these two -tags they carry; they only drive the backoffice player-list filter. A real tier split -(a player who cleared "basic" but not "advanced" being unable to withdraw past some -limit, for instance) would need two separate vendor workflows/decisions and is a -product decision out of scope here. - -## `high_risk` - second trigger: KYC device/IP risk signals - -`high_risk` (sticky) has two independent triggers, both assign-only (never -auto-removed - risk designation requires an explicit admin clear): - -- `onWithdrawalCompleted` - existing withdrawal amount/frequency threshold rule. -- `onKycHighRiskSignalDetected` - compliance.kyc.high_risk_signal_detected, fired when - the player's KYC device/IP screening (see `compliance/AGENTS.md`) trips compliance's - own `warrantsHighRiskTag` rule (duplicate device or high-risk country; never a bare - VPN/Tor or datacenter-IP signal). This module does not re-derive which of the four - booleans qualify - it subscribes to compliance's own decision and applies the tag as - a pure label, the same precedent as `advanced_kyc_needed` reacting to - `compliance.kyc.reverify_required`. diff --git a/packages/core/src/wallet/AGENTS.md b/packages/core/src/wallet/AGENTS.md deleted file mode 100644 index b0440938..00000000 --- a/packages/core/src/wallet/AGENTS.md +++ /dev/null @@ -1,49 +0,0 @@ -# wallet - -User balances, deposits, withdrawals. One wallet per user. Default `PAYMENT_ADAPTER` binding is `MockPaymentAdapter` (always returns terminal `completed`). - -## Money model (ADR-0029) - -Money columns are `decimal({ precision: 18, scale: 8 })` - Postgres `NUMERIC`, a decimal STRING in TS, never a float, never a scaled integer. Scale 8 covers fiat (2dp) and crypto (BTC-level 8dp) without a per-asset rescale table. Balance mutations run as SQL numeric arithmetic (`` sql`${wallet.balance} + ${amount}::numeric` ``), never JS math. `moneyToNumber()` is the ONE sanctioned decimal->number conversion point, for heuristics only - never for a ledger write. - -## Payment seam - -`PAYMENT_ADAPTER` covers two vendor shapes (`docs/adapters/payment.md`): a synchronous PSP (`processDeposit`/`processWithdrawal` return an already-terminal status) and an address-based/async custody vendor (optional `issueDepositAddress` + `parseWebhook`, driving inbound deposits and delayed settlement through `POST /wallet/webhook`). Implement under `adapters//` and rebind in an overlay loading after wallet - last registration wins. `PAYMENT_WEBHOOK_VERIFIER` default (`HmacPaymentWebhookVerifier`) checks HMAC-SHA256 over the raw body against `x-payment-signature` (case-insensitive, `sha256=` prefix tolerated, timing-safe), keyed by `PAYMENT_WEBHOOK_SECRET`; fails closed on a missing secret, header, or raw body. - -## Withdrawal lifecycle - -Player `withdraw` HOLDS funds (balance debited at request time) -> `pending` -> admin approves (`processing`, sent to the rail - derived from currency: `BTC`/`ETH`/`USDT` -> `fireblocks`, else `psp`) or rejects with a mandatory reason (`rejected`, funds returned) -> `completed`/`failed`. A synchronous PSP finalizes immediately in `settleApproved` (PSP call outside the tx; refund on failure). An async vendor returns a non-terminal status - the row stays `processing` and the webhook-driven `reconcileWithdrawalStatus` does the eventual transition; it is idempotent and no-ops on any row not currently `processing`. -Before the `PLAYER_TAGS` risk-flag read above, `withdraw()` (still inside its own withdrawal transaction, right after the guarded debit) calls `await this.tagEvaluationCommands.evaluateWithdrawalRequested(txn, { userId, amount })` when the `TAG_EVALUATION_COMMANDS` port is bound (optional, resolved lazily via `c.has(...)` in `plugin.ts` - same reason as `PLAYER_TAGS`: wallet can't `dependsOn: ['tag']`, that would cycle). This is what makes the risk-flag exclusion gate correct: `EventBus.emit('wallet.withdrawal.requested', ...)` is fire-and-forget and never awaited by the emitter, so without this synchronous call `maybeAutoApprove` could read `PLAYER_TAGS` before the tag module's async event handler has assigned `withdrawal_review`, auto-approving a withdrawal that should have been excluded. `evaluateWithdrawalRequested` runs on the withdrawal's own `txn`, so a `withdrawal_review` assignment it makes commits atomically with the withdrawal request row - guaranteed visible by the time the transaction returns and the risk-tag read runs. An unexpected error from it propagates and aborts the whole withdrawal (fail-closed on the review gate itself, separate from `maybeAutoApprove`'s own fail-closed-to-pending behavior). See `pam/tag/AGENTS.md` for the port's tag-module side. - -KYC gate: when `platformConfig.kyc.gateWithdrawals` is true, `withdraw()` fails closed unless the player's KYC status (normalized via `normalizeKycStatus` - a raw DB read may still hold the deprecated `verified` value) is in the pass-set (`approved` or `manually_overridden`), throwing `KycRequiredError` (maps to CONFLICT). The status is read through the existing `ADMIN_USER_DIRECTORY.lookupPlayers` port - no new cross-domain coupling. Off by default. - -Queue `riskTags` are DB-backed heuristics, not a risk engine: `large_amount` (>= 5000) and `high_frequency` (>= 3 withdrawals per wallet in trailing 24h, one batched query). - -## Auto-approval (off by default) - -After the hold commits, `maybeAutoApprove` decides WHO approves - system or manual queue - never whether the request succeeds. Strictly fail-closed: it NEVER throws out of `withdraw()`; any error or ambiguous branch leaves the row `pending`. - -- Gates (ALL must hold): `autoWithdrawal.enabled`; a resolved positive threshold for the withdrawal's RAIL (per-player `auto_withdrawal_rule` wins over the global `wallet_auto_withdrawal_config` singleton row - DB-backed, Super-Admin-editable at runtime via `autoWithdrawalConfig.set`, no redeploy needed, BF-211) with `amount <= threshold`; KYC status in the pass-set INDEPENDENT of `kyc.gateWithdrawals` (missing directory/summary => pending); no active tag intersecting the effective exclusion set (via `PLAYER_TAGS`; port unbound while the set is non-empty => pending); neither risk heuristic; trailing-24h `dailyCapAmount`/`dailyCapCount` not exceeded. The per-player rule overrides only the THRESHOLD gate - it never bypasses the tag-exclusion gate. -- Tag-exclusion set (BF-319): `wallet_auto_withdrawal_config.excludeRiskFlags` (DB column, Super-Admin-editable via `autoWithdrawalConfig.set`, admin submits the full replacement array every call) is the entire, sole source of truth for the exclusion set - no tag is hardcoded as permanently excluded, and a Super Admin can clear it to `[]` to disable all tag-based exclusion. `PLAYER_TAGS` is only required when the exclusion set is non-empty - `autoApprovalRiskTags` short-circuits to `[]` (skipping the port call) when `excludeRiskFlags` is empty, so an unbound port only fails closed while exclusions are actually configured. The migration-level column `DEFAULT` (`withdrawal_review`, `kyc_rejected`, `multi_account`, `high_risk`, `bonus_abuser`) is a starting value for upgraded installs, not an enforced floor - it is exactly as editable as any tag an admin adds later. -- Only the daily-cap check runs under the per-user advisory lock (atomic with the `processing` flip); the other gates run before it. -- System-actor marker: `reviewedBy = null`, `reviewReason = 'auto-approved'`. Reuses `flipToProcessing`/`settleApproved` - the same two-phase sequence as manual approve. -- Every auto-approval writes an `AUDIT_WRITER` entry (`actorType: 'system'`) capturing the full rationale BEFORE the PSP call, so the AML/SAR trail survives a PSP failure. -- Crypto uses the SAME mechanism and gates as fiat (BF-211), safe because `wallet_auto_withdrawal_config.cryptoThreshold` defaults to `'0'` via the seed (same for `fiatThreshold`) - zero resolves to "never auto-approve", so an upgrade never silently activates it. The per-player rule is a single global column, not rail-aware. -- `wallet_auto_withdrawal_config` is a true singleton (`singletonKey` DB-unique, always `'global'`), normally created by `seedAutoWithdrawalConfig()`. The READ path (`getAutoWithdrawalConfig()`, used by `resolveAutoThreshold`/`maybeAutoApprove`) throws `AutoWithdrawalConfigNotFoundError` if the row is somehow absent rather than silently defaulting - `maybeAutoApprove`'s existing outer try/catch treats that the same as any other unexpected error (fail closed to pending); a withdrawal must never silently create config. The admin WRITE path (`setAutoWithdrawalConfig`, `PUT /wallet/auto-withdrawal-config`) is an upsert (`onConflictDoUpdate` on `singletonKey`), so a Super Admin can self-heal a missing row on an unseeded install through the existing route with zero new surface; `GET`/`.getAutoWithdrawalConfig()` never creates it, only `.set`/`PUT` does. - -## Idempotency and races - -- Client-supplied `idempotencyKey` (optional uuid) on deposit/withdraw: partial unique index on `(wallet_id, idempotency_key)`. A matching key returns the ORIGINAL stored transaction (same shape, no second insert, no re-emitted event); a reused key with a DIFFERENT amount -> `IdempotencyKeyReuseError` (CONFLICT). -- The insert is `onConflictDoNothing()` - a concurrent loser no-ops (never aborts the tx) and re-reads the winner's committed row. -- `deposit` pre-checks replay BEFORE the PSP call so a client retry never re-charges. That pre-check is NOT race-safe by design - two concurrent first attempts can both reach the PSP, but the ledger guard still never double-credits. -- Webhook deposit credits are idempotent on the vendor `externalId` (`providerRefId`, partial UNIQUE + `onConflictDoNothing` + re-read - a DB guard, per the money-critical-path convention). An unknown deposit address logs and no-ops - never throws past the webhook boundary. `wallet.deposit.completed` emits only on the actual credit, never on a replay. -- Deposit addresses are get-or-create, unique on `(userId, currency)`; `DepositAddressUnsupportedError` (CONFLICT) when the bound adapter lacks `issueDepositAddress`. -- Rate limiting: deposit/withdraw consume 30/min per user (`RATE_LIMITER`, key `wallet-mutation:`) before any work -> 429 with `retryAfterMs`. Guards a runaway client, not fraud - idempotency + the ledger guard cover correctness. - -## WALLET_COMMANDS (provided port) - -Other modules move money WITHIN their own db transaction via `WALLET_COMMANDS.debit/credit(tx, { userId, amount, type })` - never by importing wallet tables (ADR-0010/0016). Both write a `completed` ledger row (internal settlement) so gameplay shows in transaction history. Both reject a non-positive `amount` EXCEPT `type: 'loss'`, which is informational: the stake already left at `bet` time, so a loss writes a 0-amount row and never touches the balance. `debit` returns `{ ok: false, available }` on a shortfall (guarded conditional UPDATE, concurrency-safe); `credit` fails closed on a missing wallet rather than creating one. - -## Events note - -`wallet.withdrawal.failed` is emitted only when `adminId` is set (manual/auto-approve settlement); a webhook-driven failure refunds and marks `failed` but has no admin to attribute, so it skips the event. The notifications module subscribes to `approved`/`rejected` to notify the player. diff --git a/packages/mcp/docs/catalog.json b/packages/mcp/docs/catalog.json index d367268e..3a6143b5 100644 --- a/packages/mcp/docs/catalog.json +++ b/packages/mcp/docs/catalog.json @@ -187,52 +187,6 @@ "wallet.deposit.completed", "wallet.withdrawal.completed" ], - "uiSlots": [ - { - "name": "append", - "description": "" - }, - { - "name": "dashboard:tiles", - "description": "Stat cards in the dashboard grid. Subject: void" - }, - { - "name": "games:columns", - "description": "Games list page." - }, - { - "name": "player:detail:actions", - "description": "Action buttons in the player detail page header. Subject: Player" - }, - { - "name": "player:detail:sections", - "description": "Collapsible sections in the player detail body. Subject: Player" - }, - { - "name": "players:columns", - "description": "" - }, - { - "name": "players:toolbar", - "description": "Toolbar controls above the players DataTable. Subject: void" - }, - { - "name": "user:detail:actions", - "description": "Action buttons in the user detail page header. Subject: AdminUser" - }, - { - "name": "user:detail:sections", - "description": "Sections in the user detail body. Subject: AdminUser" - }, - { - "name": "users:columns", - "description": "" - }, - { - "name": "users:toolbar", - "description": "Toolbar controls above the users DataTable. Subject: void" - } - ], "schemas": [ { "name": "AdminTransactionSchema", @@ -479,7 +433,6 @@ "imports", "mcp", "providers", - "routers", - "slots" + "routers" ] } diff --git a/packages/testing/AGENTS.md b/packages/testing/AGENTS.md deleted file mode 100644 index f0cacd81..00000000 --- a/packages/testing/AGENTS.md +++ /dev/null @@ -1,15 +0,0 @@ -# @openora/testing - -Shared harness for integration suites (ours and downstream consumers'): boots the real Hono + oRPC app in-process against a real Postgres test database - no mocks, no network listener. `setupTestDb` migrates and hands back `truncateAll`/`dispose`; `bootTestApp` returns `{ app, container, close }` you drive with `app.request()`; `asPlayer`/`asAdmin` log in through `/identity/login` for a real session cookie (no `x-user-id` trust, ADR-0019); `seedMinimal` wraps `seedDemoData`. - -## Requirements - -- A test Postgres must exist: CI provisions `postgres:16`, locally `pnpm db:setup:test` against docker-compose postgres. -- Integration vitest configs MUST set `poolOptions.threads.singleThread = true` - every suite shares one database, so they cannot run in parallel. -- `bootTestApp` needs the platform BUILT (`loadExtensions()` resolves compiled `dist/**/plugin.js`) - run `pnpm build` before `pnpm test:integration`. -- Isolate with unique ids per test, or `truncateAll()` between files. (A per-test transaction rollback would be faster but requires handlers to take the txn - not wired here.) - -## Don't - -- Import this package from production code - it is test-only. -- Point `TEST_DATABASE_URL` at a real or dev database: `truncateAll()` wipes it. diff --git a/packages/testing/src/__tests__/kyc.e2e.test.ts b/packages/testing/src/__tests__/kyc.e2e.test.ts index cd66d632..321f3942 100644 --- a/packages/testing/src/__tests__/kyc.e2e.test.ts +++ b/packages/testing/src/__tests__/kyc.e2e.test.ts @@ -483,8 +483,7 @@ describe('KYC status writer concurrency (real Postgres FOR UPDATE)', () => { // Two real HTTP requests racing through the full router -> service -> Postgres // stack, genuinely concurrent transactions - not a mocked DB scripted to return // zero rows on the second call. Exercises the FOR UPDATE row lock + - // conditional-UPDATE semantics documented in compliance/AGENTS.md and - // pam/player-management/AGENTS.md. + // conditional-UPDATE semantics documented in docs/standards/compliance.md. const [resA, resB] = await Promise.all([ admin.post(`/compliance/players/${userId}/kyc/override`, { status: 'approved', diff --git a/tools/create/create-service.ts b/tools/create/create-service.ts index 9a23129c..ed38e09c 100644 --- a/tools/create/create-service.ts +++ b/tools/create/create-service.ts @@ -126,5 +126,5 @@ console.log(` pnpm -F ${pkgName} dev # boots the baked-in manifest`); console.log(` SERVICE_MANIFEST=... pnpm -F ${pkgName} dev # override at runtime`); console.log('To exclude these modules from the monolith, drop them from extensions.config.ts'); console.log( - 'and run them only via this host (events flow once a durable broker is set: AMQP_URL).', + 'and run them only via this host (set REDIS_URL or bind MESSAGE_BROKER in an overlay; AMQP_URL only enables the outbox).', ); diff --git a/tools/gen/gen-catalog.ts b/tools/gen/gen-catalog.ts index 33742d4d..febc817c 100644 --- a/tools/gen/gen-catalog.ts +++ b/tools/gen/gen-catalog.ts @@ -5,10 +5,10 @@ * docs/catalog.json - structured, consumed at runtime by the published @openora/mcp * server (a consumer's node_modules has no platform source). * Human/agent-readable access is the MCP dev server (describe-module, list-routes) - * and each module's AGENTS.md - no monolithic markdown dump. + * plus each module's contract, schema, and plugin - no monolithic markdown dump. * * It captures: modules (+ tables + routes), adapter seams (+ wired-vs-stub - * status), domain events, UI slots, Zod schema index, the igaming-config shape, + * status), domain events, Zod schema index, the igaming-config shape, * and the plugin-contract surface. * * Pure filesystem parsing - no package imports - so it is robust and DETERMINISTIC @@ -167,26 +167,6 @@ function collectEvents(): string[] { return [...set].sort(); } -function collectSlots(): Array<{ name: string; description: string }> { - const file = join(repoRoot, 'packages', 'sdks', 'react-sdk', 'src', 'ui-plugin', 'slots.ts'); - const src = read(file); - const out: Array<{ name: string; description: string }> = []; - let pending = ''; - for (const line of src.split('\n')) { - const jsdoc = line.match(/\/\*\*\s*(.+?)\s*\*\//); - if (jsdoc) { - pending = (jsdoc[1] ?? '').trim(); - continue; - } - const slot = line.match(/:\s*'([a-z][a-z:]+)'/); - if (slot) { - out.push({ name: slot[1] ?? '', description: pending }); - pending = ''; - } - } - return out.sort((a, b) => a.name.localeCompare(b.name)); -} - // Each module owns its route contract under contract/, so the schema index spans both the cross-cutting core contracts zone and every module contract dir. See ADR-0021. function collectSchemas(): Array<{ name: string; file: string }> { const out: Array<{ name: string; file: string }> = []; @@ -250,7 +230,6 @@ const catalog = { modules: collectModules(), adapters: collectAdapters(), events: collectEvents(), - uiSlots: collectSlots(), schemas: collectSchemas(), config: { token: 'IGAMING_CONFIG', @@ -266,6 +245,6 @@ writeFileSync(join(docsDir, 'catalog.json'), JSON.stringify(catalog, null, 2) + 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.uiSlots.length} slots, ${catalog.schemas.length} schemas`, + `${catalog.events.length} events, ${catalog.schemas.length} schemas`, ); console.log('[catalog] wrote docs/catalog.json'); diff --git a/tools/gen/gen-claude-stubs.mjs b/tools/gen/gen-claude-stubs.mjs deleted file mode 100644 index 957a6ff1..00000000 --- a/tools/gen/gen-claude-stubs.mjs +++ /dev/null @@ -1,34 +0,0 @@ -import { existsSync, globSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; - -const STUB = '@AGENTS.md\n'; -const noNodeModules = (p) => p.includes('node_modules'); - -const stubs = globSync('{packages,extensions,apps}/**/AGENTS.md', { exclude: noNodeModules }).map( - (agentsMd) => join(dirname(agentsMd), 'CLAUDE.md'), -); - -for (const stub of stubs) { - let current; - try { - current = readFileSync(stub, 'utf8'); - } catch { - current = null; - } - if (current !== STUB) { - writeFileSync(stub, STUB); - } -} - -// A deleted/renamed AGENTS.md leaves its gitignored stub behind on every machine - -// remove orphans, but only files that are exactly the generated stub, never hand-written ones. -const orphans = globSync('{packages,extensions,apps}/**/CLAUDE.md', { - exclude: noNodeModules, -}).filter( - (stub) => !existsSync(join(dirname(stub), 'AGENTS.md')) && readFileSync(stub, 'utf8') === STUB, -); -orphans.forEach(unlinkSync); - -console.log( - `claude stubs: ${stubs.length} module CLAUDE.md files in sync${orphans.length ? `, ${orphans.length} orphan(s) removed` : ''}`, -); diff --git a/tools/lint/verify-module-shape.ts b/tools/lint/verify-module-shape.ts index 2098e392..64ccb0fc 100644 --- a/tools/lint/verify-module-shape.ts +++ b/tools/lint/verify-module-shape.ts @@ -57,19 +57,7 @@ function checkDomain(dir: string, id: string): Check[] { const hasRuntime = keys.some( (k) => k === `./${id}/plugin` || k === `./${id}/server` || k.startsWith(`./${id}/plugins/`), ); - const moduleDirs = has('plugin.ts') - ? [{ rel: '', dir }] - : readdirSync(dir) - .sort() - .map((m) => ({ rel: `${m}/`, dir: join(dir, m) })) - .filter(({ dir: m }) => statSync(m).isDirectory() && existsSync(join(m, 'plugin.ts'))); - return [ - ...moduleDirs.map(({ rel }) => ({ - label: `${rel}AGENTS.md`, - ok: existsSync(join(dir, rel, 'AGENTS.md')), - hint: 'every module ships an AGENTS.md (what it does, extension points, do/dont)', - })), { label: 'index.ts (slice root)', ok: has('index.ts'), diff --git a/tools/templates/consumer/__dot__rulesync/commands/scaffold-module.md b/tools/templates/consumer/__dot__rulesync/commands/scaffold-module.md index 0f635a07..10991986 100644 --- a/tools/templates/consumer/__dot__rulesync/commands/scaffold-module.md +++ b/tools/templates/consumer/__dot__rulesync/commands/scaffold-module.md @@ -1,5 +1,5 @@ --- -description: 'Generate a self-contained local add-on package (schema, service, router, plugin.ts, AGENTS.md) and register it. Args: . Consumers rarely need this - prefer an overlay plugin.' +description: 'Generate a self-contained local add-on package (schema, service, router, plugin.ts) and register it. Args: . Consumers rarely need this - prefer an overlay plugin.' --- > A consumer extends the platform with **overlay plugins** (`scaffold-plugin`) almost always. Reach diff --git a/tools/templates/consumer/__dot__rulesync/rules/overview.md b/tools/templates/consumer/__dot__rulesync/rules/overview.md index 387bc455..0e0cf55e 100644 --- a/tools/templates/consumer/__dot__rulesync/rules/overview.md +++ b/tools/templates/consumer/__dot__rulesync/rules/overview.md @@ -94,7 +94,6 @@ 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 -- `list-slots` - named UI slots you can fill from a UI plugin - `describe-module ` - one module's group, tables, routes - `schema-get ` - locate a Zod contract schema's file - `get-config-schema` - the igaming-config fields a consumer can set diff --git a/tools/templates/consumer/__dot__rulesync/skills/create-plugin/SKILL.md b/tools/templates/consumer/__dot__rulesync/skills/create-plugin/SKILL.md index 130d77c0..8faed2aa 100644 --- a/tools/templates/consumer/__dot__rulesync/skills/create-plugin/SKILL.md +++ b/tools/templates/consumer/__dot__rulesync/skills/create-plugin/SKILL.md @@ -32,7 +32,7 @@ Hand off via the `add-feature` skill's `handoff.md`. Do not patch the linked OSS - Read `.claude/rules/overview.md` (what you may and may not touch) and `docs/standards/database.md` (if the extension owns tables). - Inspect what already exists with the `oss` MCP: `catalog-overview`, `list-adapters` (token + - default binding to swap), `list-routes` (collision check), `list-slots`, `list-events`. + default binding to swap), `list-routes` (collision check), `list-events`. - For a domain rule you can't safely assume (a limit, a KYC threshold, a jurisdiction behavior), spawn `expert` before scaffolding.