diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..181bf8e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,82 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this repo is + +A server-authoritative slot game ("Banana X", `5x4`, symbols-pay-anywhere, profile-driven RTP) plus a provider platform (RGS + operator control plane). It contains **two distinct backends** and **one shared math engine** — understanding which is which is the key to working here. + +## The single source of truth for slot math + +`client/engine/*.js` is the ONLY implementation of the slot math. The files are browser IIFEs that register onto `globalThis.SlotEngine` and **must be loaded in a fixed order** (`rng, config, symbols, multipliers, matrix, payouts, tumble, near-miss, crazy-mode, session-store, spin-engine, simulator`). + +Three consumers load these exact same files, in this exact order, so results are provably identical ("parity"): +- The **browser client** (`client/index.html` → `client/main.js` = canvas renderer + UI). +- The **platform RGS** — `platform/src/lib/engine-loader.ts` reads the JS files and executes them in Node. +- **`tools/rtp-parity.js`** — runs the engine's simulator and asserts measured RTP is within tolerance of the profile's theoretical RTP. + +When changing math, change it in `client/engine/`, then run `npm run test:rtp-parity`. If you add/remove/reorder an engine file, update the `ENGINE_FILES` array in **both** `tools/rtp-parity.js` and `platform/src/lib/engine-loader.ts`. + +> Exception: `backend/server.js` (the MVP, below) does NOT use the shared engine — it re-implements the math directly from the rules JSON. Treat it as a separate, legacy path. + +## Game rules config + +`game-rules-v2.json` drives layout, symbols, RTP profiles, and features. It exists in **two locations** that must be kept in sync: `math/game-rules-v2.json` (canonical) and `client/math/game-rules-v2.json` (served copy). Both `backend/server.js` and the platform engine-loader **prefer the `client/math/` copy** when it exists. Related config: `math/paytable-v1.json`, `math/reel-strips-v1.json`. + +## The two backends + +**1. MVP server — `backend/server.js`** (plain Node `http`, no deps, in-memory sessions). Serves the static client and `/api/v1/*` (`session/init`, `spin`, `buy-free-spins`, `simulate`, `simulate/stream`, `game-rules`, `health`). This is what `tools/api-test.js` targets. Self-contained math re-implementation. + +**2. Platform — `platform/`** (Fastify + TypeScript; the real provider stack). This is the RGS + control plane: +- `src/app.ts` — HTTP composition root; registers health, static assets, admin console, `/play` page, operator API, game API, admin API. +- `src/container.ts` — domain composition root. All persistence is **in-memory** (`memory-repositories.ts`, `sandbox-wallet.ts`, `transaction-store.ts`), designed to be swapped for Postgres/real adapters without touching call sites (`migrations/0001_core.sql` exists for that). +- **Operator API** (`src/http/operator.routes.ts`) is HMAC-signed over exact request bytes (`src/lib/security/hmac.ts`, `nonce-store.ts`, `rate-limiter.ts`); the raw body is preserved in `app.ts` for signature verification. **Game API** (`game.routes.ts`) is session-scoped via launch tokens. +- `round-orchestrator.ts` ties engine resolution + wallet + ledger together; `round-ledger.service.ts` + `hash-chain.ts` provide an immutable, hash-chained audit trail. `authoritative-resolver.ts` + `seeded-rng.ts` give deterministic, replayable spins. +- Flow: casino signs `POST /operator/v1/launch` → gets a launch token → player opens `/play?lt=`. See `platform/GUIDE.md` for the click-by-click operator walkthrough and `platform/scripts/e2e-operator.ts` for a working end-to-end example. + +## Commands + +### Root (client + MVP) +```bash +npm start # static-serve client/ on :3000 (no API) +npm run start:dev-server # run backend/server.js MVP API on :3000 (PORT env to change) +npm run simulate # math/simulate.js +npm run test:api # tools/api-test.js — requires the MVP server running +npm run test:rtp-parity # validate client engine RTP vs. theoretical +``` +`rtp-parity` honors env vars: `STEPS`, `BET`, `GAME_ID`, `ANTE=1`, `BONUS_ONLY=1`, `TOLERANCE`. Exits non-zero on FAIL. + +### Platform (`cd platform` first; Node 20+) +```bash +npm install +npm run dev # tsx watch on :8080 +npm run dev:seed # dev server + seed a demo operator, spins, and print admin tokens + a /play URL +npm run build # tsc → dist/ +npm start # node dist/server.js +npm run typecheck # tsc (no emit) via tsconfig.typecheck.json +npm test # vitest run (test/*.test.ts) +npm run test:watch # vitest watch +npm run e2e # scripts/e2e-operator.ts (full HMAC operator lifecycle) +npm run smoke:rtp # scripts/rtp-smoke.ts +npm run migrate # apply migrations/*.sql (needs DATABASE_URL) +``` +Run a single platform test: `npx vitest run test/engine.service.test.ts` (or add `-t "test name"`). + +### Optional Postgres persistence (`platform/`) +State is **in-memory by default** (all tests rely on this — never require a DB to run them). Setting `DATABASE_URL` turns on durable persistence via a **memory-first write-through** design (`src/persistence/`): the in-memory stores stay the read source of truth, hydrate from Postgres on boot (`hydrate.ts`), and enqueue an upsert on every mutation that an `onSend` hook (`app.ts`) flushes per request. So the whole codebase stays synchronous — do NOT convert services to async for the DB. Money is `numeric(14,2)` (decimals, matching the engine). The schema is `migrations/0001_core.sql` (app-aligned, append-only WORM triggers on the ledgers); the runner is `src/db/migrate.ts`. Bring it up: +```bash +docker compose -f docker-compose.yml up -d +DATABASE_URL=postgres://bananax:bananax@127.0.0.1:5432/bananax npm run migrate +DATABASE_URL=postgres://bananax:bananax@127.0.0.1:5432/bananax npm run dev:seed # idempotent: reuses the demo operator if already seeded +``` +When adding a persisted field/table: update the entity, `migrations/0001_core.sql`, the `TABLES` registry in `src/persistence/persistence.ts`, and the relevant store's `save*`/`hydrate` calls. + +## Deployment + +Static-client hosting only (`netlify.toml`, `vercel.json`): both publish `client/` as a SPA with a catch-all rewrite to `index.html`. These deploy the front end **without** an API backend. + +## Environment notes + +- Windows + PowerShell is the primary shell; a Bash tool is also available for POSIX scripts. +- Node 18+ for root scripts; Node 20+ for the platform. +- `docs/` is a phased program playbook (01–15 + specs); `ROADMAP.md` indexes it. `qa/` holds the certification package, test plans, and screenshots. diff --git a/platform/docker-compose.yml b/platform/docker-compose.yml index be3d9a6..56a1045 100644 --- a/platform/docker-compose.yml +++ b/platform/docker-compose.yml @@ -1,12 +1,13 @@ -# Optional Postgres for the production-shaped data path. The in-memory repos -# remain the default (tests rely on them); this compose file is here so a real -# Postgres is one command away when you need to wire up the persistent path: +# Optional Postgres for the durable data path. In-memory stays the default (tests +# rely on it); this brings a real Postgres up in one command: # # docker compose -f platform/docker-compose.yml up -d -# psql "postgres://bananax:bananax@127.0.0.1:5432/bananax" \ -# -f platform/migrations/0001_core.sql +# cd platform +# DATABASE_URL=postgres://bananax:bananax@127.0.0.1:5432/bananax npm run migrate +# DATABASE_URL=postgres://bananax:bananax@127.0.0.1:5432/bananax npm run dev:seed # -# See platform/GUIDE.md → "Postgres path" for the swap-in steps. +# The migration runner (src/db/migrate.ts) applies migrations/*.sql, so this compose +# does NOT auto-apply them via initdb — there is a single migration path. services: postgres: image: postgres:16-alpine @@ -19,7 +20,6 @@ services: - "5432:5432" volumes: - bananax-pg:/var/lib/postgresql/data - - ./migrations:/docker-entrypoint-initdb.d:ro volumes: bananax-pg: diff --git a/platform/migrations/0001_core.sql b/platform/migrations/0001_core.sql index 6c033da..6ace869 100644 --- a/platform/migrations/0001_core.sql +++ b/platform/migrations/0001_core.sql @@ -1,231 +1,183 @@ --- 0001_core.sql — Banana X platform core schema (plan §3) --- PostgreSQL. Money is stored as integer minor units (+ currency); time is UTC. --- Append-only tables (rounds, wallet_transactions, audit_records) are protected by --- triggers below AND by NOT granting UPDATE/DELETE to the application role in prod. +-- 0001_core.sql — Banana X platform schema (app-aligned). -- --- This migration is the Phase 2/3 foundation. Later phases add reports/invoices and --- jurisdiction/RG tables. Apply with your migration runner (e.g. node-pg-migrate / Prisma). - -BEGIN; +-- This schema matches exactly what the application persists (see src/persistence). +-- The platform is MEMORY-FIRST: the in-memory stores are the read source of truth, +-- and every mutation is written through to these tables (loaded back on boot). So: +-- • money is numeric(14,2) — the same decimal amounts the engine/reporting use +-- (a real-money production build would move to integer minor units) +-- • append-only ledgers (rounds, audit_records, round_adjustments, +-- wallet_transactions) are protected by an UPDATE/DELETE-deny trigger; the app +-- only ever INSERTs (re-flush uses ON CONFLICT DO NOTHING, which never UPDATEs) +-- • foreign keys are intentionally omitted so a per-request write-through batch can +-- never fail on ordering; integrity is enforced by the application layer +-- +-- PostgreSQL. Time is stored UTC (ISO strings from the app). +-- NOTE: the migration runner (src/db/migrate.ts) wraps each file in a transaction +-- and owns the schema_migrations bookkeeping, so this file has no BEGIN/COMMIT. CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid() -- ── Tenancy & identity ──────────────────────────────────────────────────────── CREATE TABLE operators ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + id text PRIMARY KEY, name text NOT NULL, slug text NOT NULL UNIQUE, - status text NOT NULL DEFAULT 'sandbox' CHECK (status IN ('sandbox','live','suspended')), + status text NOT NULL DEFAULT 'sandbox', default_currency text NOT NULL DEFAULT 'GEL', - timezone text NOT NULL DEFAULT 'UTC', - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now() + created_at timestamptz NOT NULL ); CREATE TABLE operator_domains ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - operator_id uuid NOT NULL REFERENCES operators(id), + id text PRIMARY KEY, + operator_id text NOT NULL, domain text NOT NULL, - environment text NOT NULL CHECK (environment IN ('sandbox','prod')), - status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','disabled')), - created_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (operator_id, domain, environment) + environment text NOT NULL, + status text NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL ); +CREATE INDEX ix_domains_operator ON operator_domains (operator_id); CREATE TABLE operator_api_credentials ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - operator_id uuid NOT NULL REFERENCES operators(id), - api_key_id text NOT NULL UNIQUE, - hmac_secret_ref text NOT NULL, -- pointer into secret manager; never the raw secret - environment text NOT NULL CHECK (environment IN ('sandbox','prod')), - status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','rotating','revoked')), - ip_allowlist text[] NOT NULL DEFAULT '{}', - created_at timestamptz NOT NULL DEFAULT now(), - expires_at timestamptz, - last_used_at timestamptz -); -CREATE INDEX ix_cred_operator_status ON operator_api_credentials (operator_id, status); - -CREATE TABLE admin_users ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - operator_id uuid REFERENCES operators(id), -- NULL = provider/internal staff - email text NOT NULL UNIQUE, - password_hash text NOT NULL, - mfa_secret_ref text, - status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','disabled')), - last_login_at timestamptz, - created_at timestamptz NOT NULL DEFAULT now() -); - -CREATE TABLE roles ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - scope text NOT NULL CHECK (scope IN ('provider','operator')), - key text NOT NULL UNIQUE, - name text NOT NULL -); -CREATE TABLE permissions ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - key text NOT NULL UNIQUE -); -CREATE TABLE role_permissions ( - role_id uuid NOT NULL REFERENCES roles(id), - permission_id uuid NOT NULL REFERENCES permissions(id), - PRIMARY KEY (role_id, permission_id) -); -CREATE TABLE user_roles ( - user_id uuid NOT NULL REFERENCES admin_users(id), - role_id uuid NOT NULL REFERENCES roles(id), - PRIMARY KEY (user_id, role_id) -); + id text PRIMARY KEY, + operator_id text NOT NULL, + api_key_id text NOT NULL UNIQUE, + secret_hash text NOT NULL, + secret_last4 text NOT NULL, + -- Raw HMAC secret. DEV/SKELETON ONLY — production keeps this in a secret manager + -- and stores only a reference here. + hmac_secret text, + environment text NOT NULL, + status text NOT NULL DEFAULT 'active', + ip_allowlist jsonb NOT NULL DEFAULT '[]'::jsonb, + created_at timestamptz NOT NULL, + expires_at timestamptz +); +CREATE INDEX ix_cred_operator ON operator_api_credentials (operator_id); -- ── Games & math configs ────────────────────────────────────────────────────── CREATE TABLE games ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - code text NOT NULL UNIQUE, - title text NOT NULL, - provider_studio text, - status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','certified','live','retired')), - layout jsonb NOT NULL DEFAULT '{}'::jsonb, - created_at timestamptz NOT NULL DEFAULT now() -); - -CREATE TABLE game_versions ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - game_id uuid NOT NULL REFERENCES games(id), - semver text NOT NULL, - client_bundle_hash text, - engine_hash text, - status text NOT NULL DEFAULT 'draft' - CHECK (status IN ('draft','candidate','certified','live','retired')), - certified_by text, - certified_at timestamptz, - created_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (game_id, semver) + id text PRIMARY KEY, + code text NOT NULL UNIQUE, + title text NOT NULL, + status text NOT NULL DEFAULT 'draft', + created_at timestamptz NOT NULL ); CREATE TABLE math_configs ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - game_id uuid NOT NULL REFERENCES games(id), - version text NOT NULL, - rtp_profile_key text NOT NULL, - theoretical_rtp numeric(6,3) NOT NULL, - config_jsonb jsonb NOT NULL, - config_hash text NOT NULL, -- sha256 of canonical config - signature text, -- provider signing key - status text NOT NULL DEFAULT 'draft' - CHECK (status IN ('draft','in_review','approved','active','archived')), - approved_by text, - approved_at timestamptz, - created_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (game_id, version, rtp_profile_key) + id text PRIMARY KEY, + game_id text NOT NULL, + version text NOT NULL, + rtp_profile_key text NOT NULL, + theoretical_rtp numeric(7,3) NOT NULL, + config_hash text NOT NULL, + status text NOT NULL DEFAULT 'draft', + approved_by text, + approved_at timestamptz, + created_at timestamptz NOT NULL ); CREATE TABLE operator_games ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - operator_id uuid NOT NULL REFERENCES operators(id), - game_id uuid NOT NULL REFERENCES games(id), - math_config_id uuid NOT NULL REFERENCES math_configs(id), - min_bet bigint NOT NULL, - max_bet bigint NOT NULL, - allowed_bets jsonb NOT NULL, + id text PRIMARY KEY, + operator_id text NOT NULL, + game_id text NOT NULL, + math_config_id text NOT NULL, currency text NOT NULL, jurisdiction text NOT NULL DEFAULT 'GE', - status text NOT NULL DEFAULT 'enabled' CHECK (status IN ('enabled','disabled')), - created_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (operator_id, game_id, currency, jurisdiction) + allowed_bets jsonb NOT NULL, + status text NOT NULL DEFAULT 'enabled', + created_at timestamptz NOT NULL ); +CREATE INDEX ix_opgames_operator ON operator_games (operator_id); --- ── Play & money (system of record) ─────────────────────────────────────────── +-- ── Play & money (durable mirror of the in-memory system of record) ──────────── CREATE TABLE player_sessions ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - operator_id uuid NOT NULL REFERENCES operators(id), - game_id uuid NOT NULL REFERENCES games(id), - math_config_id uuid NOT NULL REFERENCES math_configs(id), + id text PRIMARY KEY, + operator_id text NOT NULL, + game_id text NOT NULL, + game_code text NOT NULL, + math_config_id text NOT NULL, operator_player_id text NOT NULL, currency text NOT NULL, - locale text NOT NULL DEFAULT 'en', - launch_token_id text, - status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','expired','closed')), + allowed_bets jsonb NOT NULL, + status text NOT NULL DEFAULT 'active', free_spins_left int NOT NULL DEFAULT 0, - free_spin_multiplier_carry numeric(12,2) NOT NULL DEFAULT 0, - bonus_round_win bigint NOT NULL DEFAULT 0, - created_at timestamptz NOT NULL DEFAULT now(), - expires_at timestamptz, - closed_at timestamptz + free_spin_multiplier_carry numeric(14,2) NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL ); -CREATE INDEX ix_sessions_operator_created ON player_sessions (operator_id, created_at DESC); -CREATE INDEX ix_sessions_status_expiry ON player_sessions (status, expires_at); +CREATE INDEX ix_sessions_operator ON player_sessions (operator_id); --- Immutable round ledger (hash-chained). See platform memory-repositories.ts for the --- same semantics; here the chain is enforced + protected against UPDATE/DELETE. +-- Immutable, hash-chained round ledger (see src/modules/ledger). CREATE TABLE rounds ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - seq bigserial NOT NULL, + id text PRIMARY KEY, + seq bigint NOT NULL, round_ref text NOT NULL UNIQUE, - session_id uuid NOT NULL REFERENCES player_sessions(id), - operator_id uuid NOT NULL REFERENCES operators(id), - game_id uuid NOT NULL REFERENCES games(id), - math_config_id uuid NOT NULL REFERENCES math_configs(id), - bet_amount bigint NOT NULL, - bet_charged bigint NOT NULL, + session_id text NOT NULL, + operator_id text NOT NULL, + operator_player_id text NOT NULL, + game_id text NOT NULL, + math_config_id text NOT NULL, + bet_amount numeric(14,2) NOT NULL, + bet_charged numeric(14,2) NOT NULL, is_free_spin boolean NOT NULL DEFAULT false, ante_enabled boolean NOT NULL DEFAULT false, - rng_seed_ref text NOT NULL, -- encrypted seed pointer (or sealed seed) + rng_seed_hex text NOT NULL, rng_algo text NOT NULL, rng_bytes_drawn int NOT NULL, - outcome_jsonb jsonb NOT NULL, outcome_hash text NOT NULL, - total_win bigint NOT NULL, - multiplier_applied numeric(12,2) NOT NULL DEFAULT 1, - status text NOT NULL DEFAULT 'resolved' CHECK (status IN ('resolved','settled','void')), + total_win numeric(14,2) NOT NULL, + multiplier_applied numeric(14,2) NOT NULL DEFAULT 1, + status text NOT NULL DEFAULT 'resolved', + pre_state jsonb, + outcome_jsonb jsonb NOT NULL, prev_hash text NOT NULL, payload_hash text NOT NULL, chain_hash text NOT NULL, - created_at timestamptz NOT NULL DEFAULT now() + created_at timestamptz NOT NULL ); -CREATE INDEX ix_rounds_operator_created ON rounds (operator_id, created_at DESC); -CREATE INDEX ix_rounds_session ON rounds (session_id); -CREATE INDEX ix_rounds_status ON rounds (status); +CREATE INDEX ix_rounds_operator ON rounds (operator_id, seq); +CREATE INDEX ix_rounds_player ON rounds (operator_id, operator_player_id); +CREATE INDEX ix_rounds_session ON rounds (session_id); CREATE TABLE wallet_transactions ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - operator_id uuid NOT NULL REFERENCES operators(id), - session_id uuid NOT NULL REFERENCES player_sessions(id), - round_id uuid REFERENCES rounds(id), - type text NOT NULL CHECK (type IN ('DEBIT','CREDIT','ROLLBACK')), - amount bigint NOT NULL, + id text PRIMARY KEY, + seq bigint NOT NULL, + operator_id text NOT NULL, + session_id text NOT NULL, + round_ref text NOT NULL, + type text NOT NULL CHECK (type IN ('DEBIT','CREDIT','ROLLBACK','ADJUSTMENT')), + amount numeric(14,2) NOT NULL, currency text NOT NULL, idempotency_key text NOT NULL UNIQUE, operator_tx_ref text, - status text NOT NULL DEFAULT 'pending' - CHECK (status IN ('pending','confirmed','failed','rolled_back')), - attempt_count int NOT NULL DEFAULT 0, - request_jsonb jsonb, - response_jsonb jsonb, + status text NOT NULL, error_code text, - created_at timestamptz NOT NULL DEFAULT now(), - confirmed_at timestamptz -); -CREATE INDEX ix_tx_round ON wallet_transactions (round_id); -CREATE INDEX ix_tx_status_created ON wallet_transactions (status, created_at); -CREATE UNIQUE INDEX ux_tx_operator_ref ON wallet_transactions (operator_id, operator_tx_ref) - WHERE operator_tx_ref IS NOT NULL; - -CREATE TABLE rollback_records ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - original_tx_id uuid NOT NULL REFERENCES wallet_transactions(id), - round_id uuid REFERENCES rounds(id), - reason text NOT NULL, - status text NOT NULL DEFAULT 'pending', - idempotency_key text NOT NULL UNIQUE, - created_at timestamptz NOT NULL DEFAULT now() -); + created_at timestamptz NOT NULL +); +CREATE INDEX ix_tx_round ON wallet_transactions (round_ref); +CREATE INDEX ix_tx_operator ON wallet_transactions (operator_id); + +-- Append-only overlay recording admin void/settle actions on rounds. +CREATE TABLE round_adjustments ( + id text PRIMARY KEY, + seq bigint NOT NULL, + round_ref text NOT NULL, + operator_id text NOT NULL, + operator_player_id text NOT NULL, + kind text NOT NULL, + effective_status text NOT NULL, + reason text NOT NULL, + actor_id text NOT NULL, + wallet_tx_refs jsonb NOT NULL DEFAULT '[]'::jsonb, + created_at timestamptz NOT NULL +); +CREATE INDEX ix_adj_round ON round_adjustments (round_ref); +CREATE INDEX ix_adj_operator ON round_adjustments (operator_id); CREATE TABLE audit_records ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - seq bigserial NOT NULL, - operator_id uuid REFERENCES operators(id), - actor_type text NOT NULL CHECK (actor_type IN ('admin','system','operator')), + id text PRIMARY KEY, + seq bigint NOT NULL, + operator_id text, + actor_type text NOT NULL, actor_id text NOT NULL, action text NOT NULL, target_type text NOT NULL, @@ -233,48 +185,50 @@ CREATE TABLE audit_records ( payload_hash text NOT NULL, prev_hash text NOT NULL, chain_hash text NOT NULL, - created_at timestamptz NOT NULL DEFAULT now() + created_at timestamptz NOT NULL ); -CREATE INDEX ix_audit_operator_created ON audit_records (operator_id, created_at DESC); -CREATE INDEX ix_audit_action ON audit_records (action); +CREATE INDEX ix_audit_operator ON audit_records (operator_id); +CREATE INDEX ix_audit_action ON audit_records (action); -CREATE TABLE api_request_logs ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - operator_id uuid REFERENCES operators(id), - api_key_id text, - endpoint text NOT NULL, - method text NOT NULL, - request_id text NOT NULL, - idempotency_key text, - ip inet, - signature_valid boolean, - status_code int, - latency_ms int, - error_code text, - created_at timestamptz NOT NULL DEFAULT now() +-- Operator↔provider dispute queue (mutable: status transitions on resolve). +CREATE TABLE disputes ( + id text PRIMARY KEY, + operator_id text NOT NULL, + round_ref text NOT NULL, + kind text NOT NULL, + reason text NOT NULL, + status text NOT NULL DEFAULT 'open', + requested_by text NOT NULL, + resolved_by text, + resolution_note text, + created_at timestamptz NOT NULL, + resolved_at timestamptz ); -CREATE INDEX ix_reqlog_operator_created ON api_request_logs (operator_id, created_at DESC); -CREATE INDEX ix_reqlog_request_id ON api_request_logs (request_id); +CREATE INDEX ix_disputes_operator ON disputes (operator_id); --- ── Append-only protection ──────────────────────────────────────────────────── --- Block UPDATE/DELETE on the immutable ledgers at the DB layer (defense in depth; --- the app role should also lack UPDATE/DELETE grants on these tables). +-- ── Sandbox wallet (demo player funds; a real build uses the operator's wallet) ── +CREATE TABLE wallet_balances ( + balance_key text PRIMARY KEY, -- ":" + amount numeric(14,2) NOT NULL +); +CREATE TABLE wallet_applied ( -- idempotency: keyed results of applied ops + idempotency_key text PRIMARY KEY, + result_jsonb jsonb NOT NULL +); +CREATE TABLE wallet_debit_refs ( -- lets a rollback reverse the exact debit + operator_tx_ref text PRIMARY KEY, + balance_key text NOT NULL, + amount numeric(14,2) NOT NULL +); + +-- ── Append-only protection (defense in depth) ───────────────────────────────── CREATE OR REPLACE FUNCTION deny_mutation() RETURNS trigger AS $$ BEGIN RAISE EXCEPTION 'Table % is append-only', TG_TABLE_NAME; END; $$ LANGUAGE plpgsql; -CREATE TRIGGER trg_rounds_no_mutate - BEFORE UPDATE OR DELETE ON rounds - FOR EACH ROW EXECUTE FUNCTION deny_mutation(); - -CREATE TRIGGER trg_audit_no_mutate - BEFORE UPDATE OR DELETE ON audit_records - FOR EACH ROW EXECUTE FUNCTION deny_mutation(); - --- NOTE: wallet_transactions transitions status (pending→confirmed/…); it is append-only --- in spirit but needs controlled status updates, so it is NOT covered by deny_mutation. --- Restrict it via a stored-proc / row-state-machine + column-level grants instead. - -COMMIT; +CREATE TRIGGER trg_rounds_no_mutate BEFORE UPDATE OR DELETE ON rounds FOR EACH ROW EXECUTE FUNCTION deny_mutation(); +CREATE TRIGGER trg_audit_no_mutate BEFORE UPDATE OR DELETE ON audit_records FOR EACH ROW EXECUTE FUNCTION deny_mutation(); +CREATE TRIGGER trg_adjustments_no_mutate BEFORE UPDATE OR DELETE ON round_adjustments FOR EACH ROW EXECUTE FUNCTION deny_mutation(); +CREATE TRIGGER trg_tx_no_mutate BEFORE UPDATE OR DELETE ON wallet_transactions FOR EACH ROW EXECUTE FUNCTION deny_mutation(); diff --git a/platform/package-lock.json b/platform/package-lock.json index 0999cf5..44eaafa 100644 --- a/platform/package-lock.json +++ b/platform/package-lock.json @@ -10,11 +10,13 @@ "license": "UNLICENSED", "dependencies": { "fastify": "^5.2.1", + "pg": "^8.22.0", "pino": "^9.6.0", "zod": "^3.24.1" }, "devDependencies": { "@types/node": "^22.10.7", + "@types/pg": "^8.20.0", "pino-pretty": "^13.0.0", "tsx": "^4.19.2", "typescript": "^5.7.3", @@ -996,6 +998,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -1693,6 +1707,95 @@ "node": ">= 14.16" } }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1801,6 +1904,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -2754,6 +2896,15 @@ "dev": true, "license": "ISC" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/platform/package.json b/platform/package.json index 791f7df..44f03ed 100644 --- a/platform/package.json +++ b/platform/package.json @@ -14,18 +14,21 @@ "typecheck": "tsc -p tsconfig.typecheck.json", "test": "vitest run", "test:watch": "vitest", - "smoke:rtp": "tsx scripts/rtp-smoke.ts" + "smoke:rtp": "tsx scripts/rtp-smoke.ts", + "migrate": "tsx scripts/migrate.ts" }, "engines": { "node": ">=20" }, "dependencies": { "fastify": "^5.2.1", + "pg": "^8.22.0", "pino": "^9.6.0", "zod": "^3.24.1" }, "devDependencies": { "@types/node": "^22.10.7", + "@types/pg": "^8.20.0", "pino-pretty": "^13.0.0", "tsx": "^4.19.2", "typescript": "^5.7.3", diff --git a/platform/scripts/dev-seed.ts b/platform/scripts/dev-seed.ts index ddd6911..66182fa 100644 --- a/platform/scripts/dev-seed.ts +++ b/platform/scripts/dev-seed.ts @@ -21,6 +21,10 @@ import { buildContainer } from "../src/container"; import { buildApp } from "../src/app"; import { createLogger } from "../src/lib/logger"; import { SandboxWallet } from "../src/modules/wallet/sandbox-wallet"; +import { NullPersistence, PostgresPersistence, type Persistence } from "../src/persistence/persistence"; +import { hydrateContainer } from "../src/persistence/hydrate"; +import { createPool, getPool } from "../src/db/pool"; +import { runMigrations } from "../src/db/migrate"; const PORT = Number(process.env.PORT ?? 8080); const ADMIN_SECRET = process.env.ADMIN_TOKEN_SECRET ?? "dev-admin-secret-change-me"; @@ -37,48 +41,71 @@ const cfg = { }; async function main(): Promise { + // Optional Postgres durability. Re-running against an existing DB reuses the + // already-seeded operator instead of erroring on the duplicate slug. + let persistence: Persistence = NullPersistence; + const dbUrl = process.env.DATABASE_URL; + if (dbUrl) { + const pool = createPool(dbUrl); + await runMigrations(pool); + } + const wallet = new SandboxWallet(); - const c = buildContainer(cfg, { wallet }); + if (dbUrl) persistence = new PostgresPersistence(getPool()); + const c = buildContainer(cfg, { wallet, persistence }); + if (dbUrl) await hydrateContainer(c, persistence); - // Provision: operator → domain → approved math config → game assignment. - const op = c.mgmt.createOperator({ name: "Demo Casino", slug: "demo" }); - c.mgmt.addDomain(op.id, "casino.example.com", "prod"); - const game = c.mgmt.registerGame({ code: "bananax", title: "Banana X" }); - let mc = c.mgmt.createMathConfig({ - game_id: game.id, - version: "3.0.0", - rtp_profile_key: "bananax", - theoretical_rtp: 96.38, - config_hash: "sha256:demo" - }); - mc = c.mgmt.approveMathConfig(c.mgmt.submitMathConfigForReview(mc.id).id, "math-lead"); - c.mgmt.assignGame({ - operator_id: op.id, - game_id: game.id, - math_config_id: mc.id, - currency: "GEL", - allowed_bets: [1, 5, 10, 50, 500] - }); - wallet.setBalance("p1", "GEL", 100_000); + // Idempotent provisioning: only seed if the demo operator doesn't already exist. + let op = c.mgmt.listOperators().find((o) => o.slug === "demo") ?? null; + let inspectorRef: string | null = null; - // Open a session and play a handful of spins so the ledger has rows. - const launchToken = c.sessions.createLaunchToken({ - operator_id: op.id, - game_code: "bananax", - operator_player_id: "p1", - currency: "GEL", - origin: "casino.example.com" - }); - const session = c.sessions.initSession(launchToken); + if (!op) { + op = c.mgmt.createOperator({ name: "Demo Casino", slug: "demo" }); + c.mgmt.addDomain(op.id, "casino.example.com", "prod"); + const game = c.mgmt.registerGame({ code: "bananax", title: "Banana X" }); + let mc = c.mgmt.createMathConfig({ + game_id: game.id, + version: "3.0.0", + rtp_profile_key: "bananax", + theoretical_rtp: 96.38, + config_hash: "sha256:demo" + }); + mc = c.mgmt.approveMathConfig(c.mgmt.submitMathConfigForReview(mc.id).id, "math-lead"); + c.mgmt.assignGame({ + operator_id: op.id, + game_id: game.id, + math_config_id: mc.id, + currency: "GEL", + allowed_bets: [1, 5, 10, 50, 500] + }); + wallet.setBalance("p1", "GEL", 100_000); - let firstWinRef: string | null = null; - let firstRef: string | null = null; - for (let i = 0; i < 50; i += 1) { - const out = await c.orchestrator.spin(session.id, { bet_amount: 5, idempotency_key: `seed-${i}` }); - if (i === 0) firstRef = out.round_ref; - if (!firstWinRef && out.total_win > 0) firstWinRef = out.round_ref; + // Open a session and play a handful of spins so the ledger has rows. + const launchToken = c.sessions.createLaunchToken({ + operator_id: op.id, + game_code: "bananax", + operator_player_id: "p1", + currency: "GEL", + origin: "casino.example.com" + }); + const session = c.sessions.initSession(launchToken); + + let firstWinRef: string | null = null; + let firstRef: string | null = null; + for (let i = 0; i < 50; i += 1) { + const out = await c.orchestrator.spin(session.id, { bet_amount: 5, idempotency_key: `seed-${i}` }); + if (i === 0) firstRef = out.round_ref; + if (!firstWinRef && out.total_win > 0) firstWinRef = out.round_ref; + } + inspectorRef = firstWinRef ?? firstRef; + } else { + // Reuse the persisted operator; pick an existing round for the inspector. + const rounds = c.reporting.listRounds(op.id); + inspectorRef = rounds.find((r) => r.total_win > 0)?.round_ref ?? rounds[0]?.round_ref ?? null; } - const inspectorRef = firstWinRef ?? firstRef; + + // Persist everything the seed just created before we start serving. + await persistence.flush(); // Mint a fresh launch token for the player demo (the one above was consumed). const playerToken = c.sessions.createLaunchToken( diff --git a/platform/scripts/migrate.ts b/platform/scripts/migrate.ts new file mode 100644 index 0000000..a1d9352 --- /dev/null +++ b/platform/scripts/migrate.ts @@ -0,0 +1,27 @@ +/** + * Apply pending Postgres migrations. + * + * DATABASE_URL=postgres://bananax:bananax@127.0.0.1:5432/bananax npm run migrate + */ +import { createPool, closePool } from "../src/db/pool"; +import { runMigrations } from "../src/db/migrate"; + +async function main(): Promise { + const url = process.env.DATABASE_URL; + if (!url) { + // eslint-disable-next-line no-console + console.error("DATABASE_URL is required (e.g. postgres://bananax:bananax@127.0.0.1:5432/bananax)"); + process.exit(2); + } + const pool = createPool(url); + const res = await runMigrations(pool); + // eslint-disable-next-line no-console + console.log( + res.applied.length + ? `Applied: ${res.applied.join(", ")}` + (res.alreadyApplied.length ? ` (skipped ${res.alreadyApplied.length} already applied)` : "") + : `Nothing to apply (${res.alreadyApplied.length} already applied).` + ); + await closePool(); +} + +void main(); diff --git a/platform/src/app.ts b/platform/src/app.ts index 83f95fd..ad6d941 100644 --- a/platform/src/app.ts +++ b/platform/src/app.ts @@ -57,6 +57,18 @@ export function buildApp(deps: BuildAppDeps): FastifyInstance { app.decorate("container", deps.container); + // Durable write-through: after handling each request, flush any queued mutations + // to Postgres in one transaction. No-op when persistence is disabled (in-memory). + // A flush failure is logged, not surfaced — the batch is re-queued for the next + // request so a transient DB hiccup never fails an otherwise-successful spin. + app.addHook("onSend", async (req) => { + try { + await deps.container.persistence.flush(); + } catch (err) { + req.log.error({ err }, "persistence_flush_failed"); + } + }); + app.setErrorHandler((err: FastifyError, req, reply) => { req.log.error({ err }, "request_failed"); const status = err.statusCode && err.statusCode >= 400 ? err.statusCode : 500; diff --git a/platform/src/config/env.ts b/platform/src/config/env.ts index 7acf4bc..9ff5571 100644 --- a/platform/src/config/env.ts +++ b/platform/src/config/env.ts @@ -20,6 +20,9 @@ const EnvSchema = z.object({ // Operator HMAC request signing: max allowed clock skew, and per-key rate limit. HMAC_SKEW_SECONDS: z.coerce.number().int().positive().max(300).default(30), RATE_LIMIT_PER_MIN: z.coerce.number().int().positive().default(600), + // Optional Postgres persistence. When set, state is durably mirrored to Postgres + // (loaded on boot, written through per request). Unset → pure in-memory (default). + DATABASE_URL: z.string().url().optional(), // Optional overrides; default resolution lives in the engine loader. ENGINE_DIR: z.string().optional(), ENGINE_RULES_PATH: z.string().optional() diff --git a/platform/src/container.ts b/platform/src/container.ts index f32e7ce..897e1e4 100644 --- a/platform/src/container.ts +++ b/platform/src/container.ts @@ -10,8 +10,13 @@ import { ReportingService } from "./modules/reporting/reporting.service"; import { AdminAuthService } from "./modules/admin/admin-auth"; import { RoundLedgerService } from "./modules/ledger/round-ledger.service"; import { InMemoryRoundRepository, InMemoryAuditRepository } from "./modules/ledger/memory-repositories"; +import { InMemoryRoundAdjustmentStore } from "./modules/rounds/round-adjustment.store"; +import { RoundLifecycleService } from "./modules/rounds/round-lifecycle.service"; +import { RoundVerificationService } from "./modules/rounds/round-verification.service"; +import { DisputeService } from "./modules/disputes/dispute.service"; import { NonceStore } from "./lib/security/nonce-store"; import { RateLimiter } from "./lib/security/rate-limiter"; +import { NullPersistence, type Persistence } from "./persistence/persistence"; export interface PlatformConfig { launchSecret: string; @@ -37,23 +42,40 @@ export interface Container { transactions: InMemoryTransactionStore; orchestrator: RoundOrchestrator; reporting: ReportingService; + adjustments: InMemoryRoundAdjustmentStore; + lifecycle: RoundLifecycleService; + verification: RoundVerificationService; + disputes: DisputeService; adminAuth: AdminAuthService; nonceStore: NonceStore; rateLimiter: RateLimiter; + // Durable-persistence plumbing (NullPersistence when Postgres is disabled). + persistence: Persistence; + roundsRepo: InMemoryRoundRepository; + auditRepo: InMemoryAuditRepository; } -export function buildContainer(config: PlatformConfig, overrides: { wallet?: WalletAdapter } = {}): Container { +export function buildContainer( + config: PlatformConfig, + overrides: { wallet?: WalletAdapter; persistence?: Persistence } = {} +): Container { + const persistence = overrides.persistence ?? NullPersistence; const engine = new EngineService(); - const audit = new InMemoryAuditRepository(); - const mgmt = new ManagementService(audit); - const rounds = new InMemoryRoundRepository(); + const audit = new InMemoryAuditRepository(persistence); + const mgmt = new ManagementService(audit, persistence); + const rounds = new InMemoryRoundRepository(persistence); const ledger = new RoundLedgerService(rounds, audit); - const sessions = new SessionService(mgmt, config.launchSecret); + const sessions = new SessionService(mgmt, config.launchSecret, persistence); const wallet = overrides.wallet ?? new SandboxWallet(); + if (wallet instanceof SandboxWallet) wallet.usePersistence(persistence); const resolver = new AuthoritativeResolver(engine); - const transactions = new InMemoryTransactionStore(); + const transactions = new InMemoryTransactionStore(persistence); const orchestrator = new RoundOrchestrator(engine, resolver, sessions, mgmt, wallet, ledger, transactions); - const reporting = new ReportingService(rounds, transactions, engine); + const adjustments = new InMemoryRoundAdjustmentStore(persistence); + const reporting = new ReportingService(rounds, transactions, engine, adjustments); + const lifecycle = new RoundLifecycleService(ledger, wallet, transactions, adjustments, audit); + const verification = new RoundVerificationService(ledger, resolver); + const disputes = new DisputeService(lifecycle, ledger, audit, persistence); return { config, @@ -66,8 +88,15 @@ export function buildContainer(config: PlatformConfig, overrides: { wallet?: Wal transactions, orchestrator, reporting, + adjustments, + lifecycle, + verification, + disputes, adminAuth: new AdminAuthService(config.adminSecret), nonceStore: new NonceStore(config.hmacSkewSeconds * 4 * 1000), - rateLimiter: new RateLimiter(config.rateLimitPerMin) + rateLimiter: new RateLimiter(config.rateLimitPerMin), + persistence, + roundsRepo: rounds, + auditRepo: audit }; } diff --git a/platform/src/db/migrate.ts b/platform/src/db/migrate.ts new file mode 100644 index 0000000..7014c8a --- /dev/null +++ b/platform/src/db/migrate.ts @@ -0,0 +1,68 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { Pool } from "pg"; + +/** + * Tiny forward-only migration runner. Applies every migrations/NNNN_*.sql not yet + * recorded in schema_migrations, each inside its own transaction, in filename order. + * Idempotent: re-running applies nothing new. Migration files contain no BEGIN/COMMIT + * (this runner owns the transaction) and no schema_migrations bookkeeping. + */ +export interface MigrateResult { + applied: string[]; + alreadyApplied: string[]; +} + +function migrationsDir(): string { + // Resolves from both src (tsx) and dist (compiled) layouts. + const candidates = [ + path.resolve(__dirname, "../../migrations"), + path.resolve(__dirname, "../../../migrations"), + path.resolve(process.cwd(), "migrations") + ]; + for (const c of candidates) if (fs.existsSync(c)) return c; + throw new Error(`migrations dir not found (tried: ${candidates.join(", ")})`); +} + +export async function runMigrations(pool: Pool): Promise { + await pool.query( + `CREATE TABLE IF NOT EXISTS schema_migrations ( + version text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + )` + ); + const done = new Set( + (await pool.query<{ version: string }>("SELECT version FROM schema_migrations")).rows.map((r) => r.version) + ); + + const dir = migrationsDir(); + const files = fs + .readdirSync(dir) + .filter((f) => f.endsWith(".sql")) + .sort(); + + const applied: string[] = []; + const alreadyApplied: string[] = []; + for (const file of files) { + const version = file.replace(/\.sql$/, ""); + if (done.has(version)) { + alreadyApplied.push(version); + continue; + } + const sql = fs.readFileSync(path.join(dir, file), "utf8"); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query(sql); + await client.query("INSERT INTO schema_migrations (version) VALUES ($1)", [version]); + await client.query("COMMIT"); + applied.push(version); + } catch (err) { + await client.query("ROLLBACK"); + throw new Error(`migration ${version} failed: ${(err as Error).message}`); + } finally { + client.release(); + } + } + return { applied, alreadyApplied }; +} diff --git a/platform/src/db/pool.ts b/platform/src/db/pool.ts new file mode 100644 index 0000000..60d3e2f --- /dev/null +++ b/platform/src/db/pool.ts @@ -0,0 +1,36 @@ +import { Pool, types } from "pg"; + +/** + * Postgres connection pool + result-type coercion so DB rows come back in the exact + * shapes the in-memory stores already use: + * • numeric (OID 1700) → JS number (node-pg returns strings by default) + * • int8/bigint (OID 20) → JS number (safe for our seq/counter scale) + * • timestamptz/timestamp → ISO-8601 string (the app stores created_at as strings) + * jsonb is already parsed to JS objects by node-pg. + */ +types.setTypeParser(1700, (v) => (v === null ? null : Number(v))); // numeric +types.setTypeParser(20, (v) => (v === null ? null : Number(v))); // int8 +types.setTypeParser(1184, (v) => (v === null ? null : new Date(v).toISOString())); // timestamptz +types.setTypeParser(1114, (v) => (v === null ? null : new Date(v + "Z").toISOString())); // timestamp + +let pool: Pool | null = null; + +/** Lazily create the shared pool from a connection string. */ +export function createPool(connectionString: string): Pool { + if (!pool) { + pool = new Pool({ connectionString, max: 10 }); + } + return pool; +} + +export function getPool(): Pool { + if (!pool) throw new Error("DB pool not initialized — call createPool(DATABASE_URL) first"); + return pool; +} + +export async function closePool(): Promise { + if (pool) { + await pool.end(); + pool = null; + } +} diff --git a/platform/src/http/admin-console.ts b/platform/src/http/admin-console.ts index 145c5a1..2976050 100644 --- a/platform/src/http/admin-console.ts +++ b/platform/src/http/admin-console.ts @@ -112,7 +112,10 @@ const PAGE = /* html */ ` @@ -168,9 +171,67 @@ const PAGE = /* html */ ` + + +
+

Legitimacy & actions

+

Verify recomputes the outcome from the stored server seed and checks the ledger hash-chain. Provider admins can void/settle directly; operator admins raise a request the provider approves.

+
+ + + + + + +
+
+
+
+ + + + + + + + +