diff --git a/README.md b/README.md index 376eb62..d76a32b 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,331 @@ -# Provider Slot MVP (Banana X Profile Build) +# Banana X — Slot Game & Provider Platform -Runnable server-authoritative slot prototype with Banana X-style profile wiring. +A server-authoritative slot game (**Banana X** — 5×4, symbols-pay-anywhere, profile-driven RTP) +**plus** a full provider platform (RGS + operator control plane) built around it. -## Requirements +The repository contains **two distinct backends** and **one shared math engine**. Understanding +which is which is the key to working here: -- Node.js 18+ +- a lightweight **MVP server** that serves the browser game and a simple `/api/v1/*`, and +- a production-shaped **platform** (Fastify + TypeScript) — the real RGS + control plane, with a + server-to-server operator API, launch-token player sessions, an immutable hash-chained ledger, + and two separate admin consoles behind real logins (password + TOTP 2FA). -## Run +Both consumers — and the browser client — load the **exact same engine files in the exact same +order**, so their spin results are provably identical ("parity"). + +--- + +## Table of contents + +- [The mental model: two backends, one engine](#the-mental-model-two-backends-one-engine) +- [Repository layout](#repository-layout) +- [Quick start](#quick-start) +- [The shared slot engine](#the-shared-slot-engine) +- [Game rules & math config](#game-rules--math-config) +- [Backend 1 — the MVP server](#backend-1--the-mvp-server) +- [Backend 2 — the platform (RGS + control plane)](#backend-2--the-platform-rgs--control-plane) + - [The two admin consoles + login](#the-two-admin-consoles--login) + - [APIs](#platform-apis) + - [Optional Postgres persistence](#optional-postgres-persistence) +- [Commands reference](#commands-reference) +- [Deployment](#deployment) +- [Testing & verification](#testing--verification) +- [Security notes](#security-notes) +- [Documentation](#documentation) + +--- + +## The mental model: two backends, one engine + +``` + ┌─────────────────────────────────────────────┐ + │ client/engine/*.js (THE math) │ + │ browser IIFEs on globalThis.SlotEngine, │ + │ loaded in a FIXED order → identical results │ + └───────────────┬───────────────┬─────────────┘ + loads the same files ┌───┘ └───┐ loads the same files + ▼ ▼ + ┌─────────────────────────────────┐ ┌──────────────────────────────────────┐ + │ Browser client │ │ Platform RGS (Node) │ + │ client/index.html + main.js │ │ platform/src/lib/engine-loader.ts │ + │ (canvas renderer + UI) │ │ executes the JS in Node │ + └─────────────────────────────────┘ └──────────────────────────────────────┘ + ▲ ▲ + └──── tools/rtp-parity.js (asserts measured + RTP ≈ the profile's theoretical RTP) + + Separate, legacy path: backend/server.js re-implements the math from JSON + (does NOT use the shared engine). +``` + +- **`client/engine/*.js` is the ONLY real implementation of the slot math.** When you change math, + change it there, then run `npm run test:rtp-parity`. +- The **platform** and **`tools/rtp-parity.js`** read those same files and run them in Node. +- **`backend/server.js`** (the MVP) is a separate, self-contained re-implementation of the math + from the rules JSON — treat it as a legacy path. + +--- + +## Repository layout + +| Path | What it is | +|------|-----------| +| `client/` | Browser game: `index.html` + `main.js` (canvas renderer + UI), `styles.css`, `assets/`. | +| `client/engine/` | **The shared slot math** (browser IIFEs; the single source of truth). | +| `client/math/` | Served copy of the rules JSON (preferred by the backends when present). | +| `backend/server.js` | **MVP server** — dependency-free Node `http`, in-memory sessions, `/api/v1/*`. | +| `math/` | Canonical rules JSON (`game-rules-v2.json`, `paytable-v1.json`, `reel-strips-v1.json`) + `simulate.js`. | +| `platform/` | **The provider platform** — Fastify + TypeScript RGS + control plane (see below). | +| `tools/` | `rtp-parity.js` (engine RTP validation) and `api-test.js` (hits the MVP API). | +| `docs/` | Phased program playbook (01–15 + specs, GDD, ADR, compliance…). Indexed by `ROADMAP.md`. | +| `qa/` | Certification package, test plans, screenshots. | +| `RUNBOOK.md` | How to run **everything** (every command + where to open it). | +| `CLAUDE.md` | Working guide for this repo (architecture invariants). | + +--- + +## Quick start + +**Requirements:** Node 18+ for the root scripts; Node 20+ for the platform. Windows/PowerShell is +the primary shell; a Bash tool is also available. + +### Play the game against the MVP server ```bash -npm start +npm install # (root has no runtime deps, but installs dev tooling if any) +npm run start:dev-server +# open http://localhost:3000 (game at /, API at /api/v1/*) ``` -Open: +`npm start` alone static-serves `client/` on `:3000` **without** an API. + +### Run the full platform (RGS + both admin consoles) + +```bash +cd platform +npm install +npm run dev:seed +# then open the two consoles printed in the terminal: +# http://127.0.0.1:8080/provider → Provider Control Plane (our admin) +# http://127.0.0.1:8080/admin → Provider Games · Operator Portal (the casino's admin) +``` + +`dev:seed` also seeds logins, plays 50 spins, and prints a player launch URL. See +[the two admin consoles](#the-two-admin-consoles--login) for the sign-in flow. + +--- + +## The shared slot engine + +`client/engine/*.js` are browser IIFEs that register onto `globalThis.SlotEngine` and **must load +in a fixed order**: + +``` +rng, config, symbols, multipliers, matrix, payouts, tumble, +near-miss, crazy-mode, session-store, spin-engine, simulator +``` + +(The client also loads presentation-only modules — `audio.js`, `ambiance.js` — that the backends +ignore.) Three consumers load these exact files in this exact order so results are identical: + +1. the browser client (`client/index.html` → `client/main.js`), +2. the platform RGS (`platform/src/lib/engine-loader.ts`), and +3. `tools/rtp-parity.js`. + +> 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`. + +**Active game profile (Banana X):** + +- Layout `5×4`, symbols pay anywhere (minimum **8** matches), **High** volatility, max win cap + **20000×** bet. +- RTP modes: `bananax` **96.38%**, `bananax_94` **94.40%**, `bananax_92` **92.38%**. +- Server-authoritative RNG, tumble/cascade flow, free spins + retriggers, multiplier progression. + +--- + +## Game rules & math 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 +- `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`. + +--- + +## Backend 1 — the MVP server + +`backend/server.js` — plain Node `http`, **no dependencies**, in-memory sessions. It serves the +static client and a simple API, and **re-implements the math directly from the rules JSON** (it does +NOT use the shared engine). This is what `tools/api-test.js` targets. + +**Endpoints** (base `http://localhost:3000/api/v1`): + +- `POST /session/init` · `POST /spin` · `POST /buy-free-spins` +- `POST /simulate` · `GET /simulate/stream` (SSE) +- `GET /game-rules` · `GET /health` + +Simulate example: `POST /api/v1/simulate` with `{ "steps": 1000000, "bet_amount": 1, "game_id": "bananax" }`, +or stream via `GET /api/v1/simulate/stream?steps=1000000&bet_amount=1&game_id=bananax`. + +--- + +## Backend 2 — the platform (RGS + control plane) + +`platform/` is the real provider stack (Fastify + TypeScript). Composition roots: + +- `src/app.ts` — HTTP composition root: registers health, static assets, the shared console + assets, the two consoles, the `/play` page, and the operator / game / admin APIs. +- `src/container.ts` — domain composition root. All persistence is **in-memory by default** + (`memory-repositories.ts`, `sandbox-wallet.ts`, `transaction-store.ts`), designed to swap for + Postgres without touching call sites. +- `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. + +**Player launch flow:** a casino signs `POST /operator/v1/launch` → gets a launch token → the +player opens `/play?lt=`. See `platform/GUIDE.md` for the click-by-click walkthrough and +`platform/scripts/e2e-operator.ts` for a working end-to-end example. + +### The two admin consoles + login + +For security, the provider's admin and the casino-client's admin are **two separate consoles with +separate logins** (not one page toggled by a pasted token): + +| Console | URL | Who / what | +|---------|-----|-----------| +| **Provider Control Plane** | `/provider` | **Our** admin: onboarding, all games + math configs, disputes (approve/reject), reports, round inspector, and **admin-account management**. | +| **Provider Games · Operator Portal** | `/admin` | The **casino** admin: only their **assigned games** + their own rounds, reports, players, round inspector, and raising disputes. Multi-game, provider-neutral branding. | + +`/` redirects to `/provider`. Both consoles share one served shell (`/console/app.css` + +`/console/app.js`). + +**Authentication — username + password + TOTP authenticator (2FA):** + +- Passwords are hashed with **scrypt**; 2FA is **RFC 6238 TOTP** — both hand-rolled on `node:crypto` + (no external deps): `src/lib/security/password.ts`, `src/lib/security/totp.ts`. +- Login flow (`src/http/admin-auth.routes.ts`, `/admin/v1/auth/*`): **password → authenticator + code → session token**. First sign-in forces a password set (operator accounts) + authenticator + enrollment. The issued bearer token is the same one the Admin API already verifies. +- Accounts live in `src/modules/admin/admin-account.ts` (`admin_accounts` store). A **bootstrap + provider super-admin** is seeded from `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD`; + provider admins then create operator-admin accounts in-console (provider-only + `/admin/v1/admin-accounts`). RBAC roles: `provider_*` and `operator_*` + (`src/modules/admin/admin-auth.ts`). +- `dev:seed` prints the seeded logins: provider `admin` / `change-me-admin`, and operator + `demo-operator` + a one-time password. + +### Platform APIs + +- **Operator API** `/operator/v1/*` — server-to-server, **HMAC-signed over the 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/v1/*` — session-scoped via launch/session bearer tokens. +- **Admin API** `/admin/v1/*` — RBAC + tenant-scoped. Includes `/auth/*` (login), rounds + `/verify` `/transactions` `/void` `/settle`, `/disputes` (+approve/reject), `/players/:id/rounds`, + `/operator-games` (+`/status` toggle), operators `/status` + credential revoke/rotate, and + provider-only `/admin-accounts`. + +### Optional Postgres persistence + +State is **in-memory by default** (all tests rely on this — a DB is never required 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 flushes per +request — so the whole codebase stays **synchronous** (do NOT convert services to async for the DB). +Money is `numeric(14,2)`. Schema: `migrations/0001_core.sql` (append-only WORM triggers on the +ledgers); runner: `src/db/migrate.ts`. + +```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 +``` + +--- + +## Commands reference + +### Root (client + MVP) — Node 18+ + +```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 (MVP math Monte-Carlo) +npm run test:api # tools/api-test.js — requires the MVP server running +npm run test:rtp-parity # validate the shared engine's 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 run dev # tsx watch on :8080 +npm run dev:seed # dev server + seed a demo operator/spins + the two console logins + a /play URL +npm run build # tsc → dist/ +npm start # node dist/server.js +npm run typecheck # tsc (no emit) +npm test # vitest run (test/*.test.ts) +npm run e2e # scripts/e2e-operator.ts (full HMAC operator lifecycle) +npm run smoke:rtp # scripts/rtp-smoke.ts (engine RTP smoke) +npm run migrate # apply migrations/*.sql (needs DATABASE_URL) +``` + +Run a single platform test: `npx vitest run test/engine.service.test.ts` (add `-t "name"` to filter). + +> A complete "run everything + where to open it" guide lives in [`RUNBOOK.md`](./RUNBOOK.md). + +--- + +## Deployment -`http://localhost:3000` (or next free port if 3000 is busy) +`netlify.toml` and `vercel.json` publish **`client/`** as a SPA with a catch-all rewrite to +`index.html`. These deploy the **front end only, without an API backend**. The platform is a +long-running Node service (deploy separately; optional Postgres for durability). -## Active Game Profile +--- -- Game title: `Banana X` -- Default game id: `bananax` -- RTP modes: - - `bananax` (slug `banana_x_fantasma`) - `96.38%` - - `bananax_94` (slug `banana_x_94_fantasma`) - `94.40%` - - `bananax_92` (slug `banana_x92_fantasma`) - `92.38%` -- Layout: `5 reels x 4 rows` -- Win model: symbols pay anywhere (minimum `8` matches) -- Volatility: `High` -- Max win cap: `20000x` bet +## Testing & verification -## Math Simulation +- **Engine RTP parity** — `npm run test:rtp-parity` (root) and `npm run smoke:rtp` (platform) run + the shared engine and assert measured RTP is within tolerance of the profile's theoretical RTP. + This is the most important correctness gate when touching math. +- **MVP API** — `npm run test:api` (needs `start:dev-server` running). +- **Platform** — `npm test` (vitest), `npm run typecheck`, `npm run e2e` (full HMAC operator + lifecycle). -- UI button: `Imitate 1M Spins` -- API: `POST /api/v1/simulate` with: - - `{ "steps": 1000000, "bet_amount": 1, "game_id": "bananax" }` -- Stream API: - - `GET /api/v1/simulate/stream?steps=1000000&bet_amount=1&game_id=bananax` +> **Known issue:** the shared engine currently measures ~1–1.75% **under** its profile's theoretical +> RTP, so `test:rtp-parity` and `smoke:rtp` FAIL. The MVP's separate math (`math/simulate.js`) is +> fine (~96.52%), so the two paths have diverged; the shortfall is isolated to `client/engine/`. -## Implemented APIs +--- -- `POST /api/v1/session/init` -- `POST /api/v1/spin` -- `POST /api/v1/buy-free-spins` -- `POST /api/v1/simulate` -- `GET /api/v1/simulate/stream` -- `GET /api/v1/game-rules` -- `GET /api/v1/health` +## Security notes -## Runtime Features +- The provider admin and the operator (client) admin are **separate consoles with separate + logins**; real auth is **username + password (scrypt) + TOTP 2FA**, and the backend enforces RBAC + + tenant scoping on every Admin API route regardless of the console. +- The Operator API is HMAC-signed over exact request bytes with nonce + skew + rate limiting. +- The round ledger is **immutable and hash-chained**; void/settle never edit a round — they append + a compensating adjustment + wallet transaction + audit record. +- See `SECURITY_INCIDENT.md` for a documented API-token exposure incident and recovery steps, and + `docs/07-security.md` / `docs/incident-response.md` for the broader posture. -- Server-authoritative RNG outcomes -- Tumble flow with animated drop + explode -- Free spins, retriggers, and multiplier progression -- Per-profile RTP configuration via `math/game-rules-v2.json` -- In-client canvas renderer with futuristic dark/glass visual theme -- Last 10 Spins log + event feed merged in one panel +--- -## Important Notes +## Documentation -- Sessions are in-memory (no persistent DB yet). -- RTP modes should be validated with repeated 1M runs before certification sign-off. +- [`RUNBOOK.md`](./RUNBOOK.md) — run everything, and where to open each thing. +- [`platform/GUIDE.md`](./platform/GUIDE.md) — operator's click-by-click walkthrough (login, + onboarding, launch, inspector). +- [`platform/README.md`](./platform/README.md) — platform-specific detail. +- [`ROADMAP.md`](./ROADMAP.md) — index into the phased program playbook in `docs/` (01–15 + specs, + GDD, ADR, compliance matrix, integration kit, and more). +- [`CLAUDE.md`](./CLAUDE.md) — architecture invariants and how to work in this repo. diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 0000000..5852f36 --- /dev/null +++ b/RUNBOOK.md @@ -0,0 +1,132 @@ +# RUNBOOK — how to run everything + +Every runnable command in this repo, what it does, and where to open the result. +There are **two independent stacks**: the **root** (browser client + MVP API) and the +**platform** (the real RGS + control plane, under `platform/`). + +- Root scripts need **Node 18+**. Platform needs **Node 20+**. +- Windows/PowerShell is primary; a Bash tool is also available. +- Run root commands from the repo root; run platform commands from `platform/` (`cd platform` first). + +--- + +## 1. Root — browser client + MVP API + +The MVP (`backend/server.js`) is a dependency-free Node server with in-memory sessions and its +**own** re-implementation of the slot math (the legacy path — not the shared engine). + +| Command | What it does | Access / verify | +|---|---|---| +| `npm start` | Static-serves `client/` on **:3000** (no API). Opens a browser. | http://127.0.0.1:3000 — playable canvas UI, but spins have no server. | +| `npm run start:dev-server` | Runs the MVP API on **:3000** (`PORT=4000 npm run start:dev-server` to change). Serves the client **and** `/api/v1/*`. | Game: http://127.0.0.1:3000 · Health: http://127.0.0.1:3000/api/v1/health | +| `npm run simulate` | Offline Monte-Carlo of the MVP math (`math/simulate.js`). | Prints JSON: `rtp_percent`, `hit_frequency_percent`. | +| `npm run test:api` | Hits every MVP endpoint (`tools/api-test.js`). **Requires the MVP server running** (`start:dev-server`) in another terminal. | Exit 0 = pass. | +| `npm run test:rtp-parity` | Runs the **shared engine** (`client/engine/*`) and asserts measured RTP ≈ profile theoretical. | Prints a PASS/FAIL block; exits non-zero on FAIL. | + +**MVP API endpoints** (base `http://127.0.0.1:3000/api/v1`): `session/init`, `spin`, +`buy-free-spins`, `simulate`, `simulate/stream`, `game-rules`, `health`. + +**`test:rtp-parity` env vars:** `STEPS`, `BET`, `GAME_ID`, `ANTE=1`, `BONUS_ONLY=1`, `TOLERANCE`. +Example: `STEPS=200000 TOLERANCE=0.5 npm run test:rtp-parity`. + +--- + +## 2. Platform — RGS + control plane (`platform/`) + +Fastify + TypeScript. In-memory persistence by default (no DB needed). Default host/port: +**0.0.0.0 : 8080** (override with `PORT` / `HOST` env). + +```bash +cd platform +npm install # first time only +``` + +| Command | What it does | Access / verify | +|---|---|---| +| `npm run dev` | tsx watch dev server on **:8080**. | See "Where to open" below. | +| `npm run dev:seed` | Dev server **+** seeds a demo operator/spins, seeds the two console logins, and prints a ready `/play?lt=…` URL. | Copy the printed console URLs + logins into a browser. | +| `npm run build` | `tsc` → `dist/`. | Compiles; no runtime. | +| `npm start` | Runs the compiled server (`node dist/server.js`). Needs `npm run build` first. | http://127.0.0.1:8080 | +| `npm run typecheck` | Type-checks, no emit. | Clean = pass. | +| `npm test` | Full vitest suite (`test/*.test.ts`). | All green = pass. | +| `npm run test:watch` | Vitest in watch mode. | Interactive. | +| `npm run e2e` | Full HMAC operator lifecycle: launch → play → spins → reports → round inspector (`scripts/e2e-operator.ts`). | Prints each step; ends with `DONE`. | +| `npm run smoke:rtp` | 100k-spin RTP check of the shared engine. | PASS/FAIL block. | +| `npm run migrate` | Applies `migrations/*.sql`. **Requires `DATABASE_URL`** (see §3). | — | + +Run a single test: `npx vitest run test/engine.service.test.ts` (add `-t "name"` to filter). + +### Where to open (while `npm run dev` / `dev:seed` is running) + +There are **two separate admin consoles** with **separate logins** (username + +password + TOTP authenticator 2FA), for security: + +| URL | What | +|---|---| +| http://127.0.0.1:8080/health | Health check | +| http://127.0.0.1:8080/provider | **Provider Control Plane** (our admin: onboarding, all games, disputes, accounts) | +| http://127.0.0.1:8080/admin | **Provider Games · Operator Portal** (the casino admin: their assigned games + data only) | +| http://127.0.0.1:8080/play?lt=… | Player game (launch token from `dev:seed` output or an operator `launch` call) | + +`/` redirects to `/provider`. Seeded logins are printed by `dev:seed`: provider +`admin` / `change-me-admin`, and operator `demo-operator` + a one-time password. +On first sign-in each account sets a password (operator only) and enrolls a TOTP +authenticator. Bootstrap creds are configurable via `BOOTSTRAP_ADMIN_USERNAME` / +`BOOTSTRAP_ADMIN_PASSWORD` (see `platform/.env.example`). + +**API surfaces:** Operator API `/operator/v1/*` (HMAC-signed over raw bytes) · Game API +`/game/v1/*` (launch/session bearer tokens) · Admin API `/admin/v1/*` (RBAC + tenant-scoped; +`/admin/v1/auth/*` is the login flow, `/admin/v1/admin-accounts` is provider-only account mgmt). +Full click-by-click walkthrough: `platform/GUIDE.md`. + +--- + +## 3. Optional: Postgres persistence (platform) + +In-memory is the default and all tests rely on it — you do **not** need this to run anything above. +Setting `DATABASE_URL` turns on durable, memory-first write-through persistence. + +```bash +cd platform +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 +``` + +--- + +## 4. Suggested "run everything" order + +```bash +# Root +npm run simulate +npm run test:rtp-parity +npm run start:dev-server # leave running in terminal A +npm run test:api # terminal B (needs A up) + +# Platform +cd platform && npm install +npm run typecheck +npm test +npm run e2e +npm run smoke:rtp +npm run dev:seed # then open the printed /play URL + /admin +``` + +--- + +## 5. Last full-run status (2026-07-14) + +| Suite | Result | +|---|---| +| `simulate` (MVP math) | ✅ RTP 96.52%, hit 30.78% | +| `test:api` | ✅ exit 0 | +| platform `typecheck` | ✅ clean | +| platform `test` | ✅ 68/68 (13 files) | +| platform `e2e` | ✅ full HMAC lifecycle | +| `test:rtp-parity` (shared engine) | ❌ **FAIL** — 95.23% vs 96.38% target (−1.15%, tol ±0.20%) | +| platform `smoke:rtp` (shared engine) | ❌ **FAIL** — 94.63% vs 96.38% target (−1.75%, tol ±1.00%) | + +> **Known issue:** the shared engine (`client/engine/*`) pays ~1–1.75% under its profile's +> theoretical RTP — both RTP checks fail. The MVP's separate math (`math/simulate.js`) is fine at +> 96.52%, so the two paths have diverged. The shortfall is isolated to `client/engine/`. diff --git a/platform/.env.example b/platform/.env.example index 29f1bf8..cbb6a1a 100644 --- a/platform/.env.example +++ b/platform/.env.example @@ -1,8 +1,15 @@ -# Banana X platform — environment template (Phase 1) +# Provider platform — environment template (Phase 1) NODE_ENV=local HOST=0.0.0.0 PORT=8080 LOG_LEVEL=info +# Bootstrap provider super-admin, seeded on first boot when no provider account +# exists. Change these for anything but local dev. The seeded admin still has to +# enroll a TOTP authenticator (2FA) on first sign-in at /provider. +BOOTSTRAP_ADMIN_USERNAME=admin +BOOTSTRAP_ADMIN_PASSWORD=change-me-admin +# Issuer name shown in the authenticator app during TOTP enrollment. +TOTP_ISSUER=Provider Platform # Optional overrides (default to the repo's existing engine + rules): # ENGINE_DIR=../client/engine # ENGINE_RULES_PATH=../client/math/game-rules-v2.json diff --git a/platform/GUIDE.md b/platform/GUIDE.md index c33aa52..15cbc73 100644 --- a/platform/GUIDE.md +++ b/platform/GUIDE.md @@ -20,30 +20,41 @@ That single command: - boots the platform on `http://127.0.0.1:8080` - creates a demo casino (operator slug `demo`) with an approved math config - plays 50 spins so the **Round Inspector** has something to show -- prints **PROVIDER** and **OPERATOR** admin tokens you'll need to sign in +- seeds a **PROVIDER** login (`admin` / `change-me-admin`) and a demo **OPERATOR** + login (`demo-operator` + a printed one-time password) - prints a sample **Round ID** (e.g. `r_seed-3`) to paste into the inspector - prints a **player launch URL** at `/play?lt=…` -Leave that terminal running. Open these in your browser: +Leave that terminal running. There are **two separate consoles** (different logins, +for security): -| What | URL | -|---------------------------|---------------------------------------------------| -| Admin Portal | `http://127.0.0.1:8080/admin` | -| Player demo (launch URL) | shown in the terminal — open it in another tab | +| What | URL | +|-----------------------------------|----------------------------------| +| Provider Control Plane (our admin)| `http://127.0.0.1:8080/provider` | +| Operator Portal (the casino admin)| `http://127.0.0.1:8080/admin` | +| Player demo (launch URL) | shown in the terminal | > If you've already started the platform with `npm run dev` (no seed), the -> portal still works but starts empty. +> consoles still work but start empty (the provider `admin` login is still seeded). --- -## 2. Sign in to the Admin Portal - -1. Open `http://127.0.0.1:8080/admin`. -2. Paste the **PROVIDER** token from the terminal into the sign-in box → **Sign in**. -3. You'll land on the **Dashboard** showing the seeded operator, RTP, GGR, and the +## 2. Sign in (username + password + authenticator 2FA) + +1. Open `http://127.0.0.1:8080/provider`. +2. Enter the **PROVIDER** username + password from the terminal → **Continue**. +3. First sign-in only: you'll be asked to enroll a **TOTP authenticator** — add the + shown secret to Google Authenticator / Authy / 1Password (or paste it manually), + then enter the 6-digit code → **Verify & finish**. (A brand-new operator account + is also asked to set a new password first.) +4. On later sign-ins you enter username + password, then just the current 6-digit + authenticator code. +5. You'll land on the **Dashboard** showing the seeded operator, RTP, GGR, and the latest rounds. -You can sign out at any time from the **Token** tab (right-most nav button). +The client (casino) admin signs in the same way at `http://127.0.0.1:8080/admin` +with the **OPERATOR** login — that portal shows only their own games and data. +Sign out at any time from the **Sign out** button in the header. --- diff --git a/platform/README.md b/platform/README.md index adca094..680e15d 100644 --- a/platform/README.md +++ b/platform/README.md @@ -98,18 +98,22 @@ npm run build # tsc -> dist/ The engine + rules are resolved from the repo's existing `client/engine` and `client/math/game-rules-v2.json`; override with `ENGINE_DIR` / `ENGINE_RULES_PATH`. -### One-command run (Admin Portal + Round Inspector + player demo) +### One-command run (two consoles + Round Inspector + player demo) ```bash npm run dev:seed ``` -This provisions a demo operator, plays 50 spins, prints both **PROVIDER** and **OPERATOR** admin -tokens, prints a sample `round_ref` to paste into the Round Inspector, and prints a launch URL -for the player demo. Then open: +This provisions a demo operator, plays 50 spins, seeds the two console logins, prints a sample +`round_ref` to paste into the Round Inspector, and prints a launch URL for the player demo. There +are **two separate admin consoles** with **separate logins** (username + password + TOTP 2FA): -- **`http://127.0.0.1:8080/admin`** — the Admin Portal (Dashboard / Round Inspector / Onboarding - / Reports). Sign in by pasting the printed bearer token. +- **`http://127.0.0.1:8080/provider`** — the **Provider Control Plane** (our admin: Dashboard, + Onboarding, all games, Disputes, Reports, Round Inspector, Admin Accounts). Seeded login + `admin` / `change-me-admin`. +- **`http://127.0.0.1:8080/admin`** — the **Provider Games · Operator Portal** (the casino admin: + only their assigned games + data). Seeded login `demo-operator` + the printed one-time password. +- Each account sets a password (operator) and enrolls a TOTP authenticator on first sign-in. - The player launch URL printed in the terminal — opens `/play?lt=…`, the minimal player demo that shows the **Round ID** after each spin. diff --git a/platform/migrations/0001_core.sql b/platform/migrations/0001_core.sql index 6ace869..01a258f 100644 --- a/platform/migrations/0001_core.sql +++ b/platform/migrations/0001_core.sql @@ -206,6 +206,26 @@ CREATE TABLE disputes ( ); CREATE INDEX ix_disputes_operator ON disputes (operator_id); +-- ── Admin accounts (the identities behind the two consoles; MUTABLE, not WORM) ── +-- password_hash is scrypt; totp_secret is the authenticator shared secret (a real +-- build encrypts it at rest via KMS). Login (password → TOTP) is app logic. +CREATE TABLE admin_accounts ( + id text PRIMARY KEY, + username text NOT NULL UNIQUE, + scope text NOT NULL, -- 'provider' | 'operator' + operator_id text, -- null for provider scope + role text NOT NULL, + password_hash text NOT NULL, + totp_secret text, -- null until enrolled + status text NOT NULL DEFAULT 'active', + must_set_password boolean NOT NULL DEFAULT true, + totp_enrolled boolean NOT NULL DEFAULT false, + failed_attempts integer NOT NULL DEFAULT 0, + locked_until timestamptz, + created_at timestamptz NOT NULL +); +CREATE INDEX ix_admin_accounts_operator ON admin_accounts (operator_id); + -- ── Sandbox wallet (demo player funds; a real build uses the operator's wallet) ── CREATE TABLE wallet_balances ( balance_key text PRIMARY KEY, -- ":" diff --git a/platform/scripts/dev-seed.ts b/platform/scripts/dev-seed.ts index 66182fa..da29ffd 100644 --- a/platform/scripts/dev-seed.ts +++ b/platform/scripts/dev-seed.ts @@ -17,7 +17,7 @@ * * State is in-memory; restarting the process clears it. */ -import { buildContainer } from "../src/container"; +import { buildContainer, seedBootstrapAdmin } from "../src/container"; import { buildApp } from "../src/app"; import { createLogger } from "../src/lib/logger"; import { SandboxWallet } from "../src/modules/wallet/sandbox-wallet"; @@ -32,12 +32,17 @@ const ADMIN_SECRET = process.env.ADMIN_TOKEN_SECRET ?? "dev-admin-secret-change- // session. The printed URLs/tokens are useless if they expire before you click. const DEMO_TTL_SECONDS = Number(process.env.DEMO_TOKEN_TTL_SECONDS ?? 3600); +const BOOTSTRAP_USER = process.env.BOOTSTRAP_ADMIN_USERNAME ?? "admin"; +const BOOTSTRAP_PASS = process.env.BOOTSTRAP_ADMIN_PASSWORD ?? "change-me-admin"; + const cfg = { launchSecret: process.env.LAUNCH_TOKEN_SECRET ?? "dev-launch-secret-change-me", sessionSecret: process.env.SESSION_TOKEN_SECRET ?? "dev-session-secret-change-me", adminSecret: ADMIN_SECRET, hmacSkewSeconds: 30, - rateLimitPerMin: 10_000 + rateLimitPerMin: 10_000, + bootstrapAdminUsername: BOOTSTRAP_USER, + bootstrapAdminPassword: BOOTSTRAP_PASS }; async function main(): Promise { @@ -138,6 +143,19 @@ async function main(): Promise { DEMO_TTL_SECONDS ); + // Real logins for the two consoles: seed the bootstrap provider super-admin and + // create a demo operator (casino) admin. Both must set a new password + enroll a + // TOTP authenticator on first sign-in. + seedBootstrapAdmin(c); + let operatorAdmin: { username: string; temp_password: string } | null = null; + if (!c.adminAccounts.getByUsername("demo-operator")) { + const created = c.adminAccounts.create( + { username: "demo-operator", scope: "operator", operator_id: op.id, role: "operator_admin" }, + "dev-seed" + ); + operatorAdmin = { username: created.account.username, temp_password: created.temp_password }; + } + const app = buildApp({ logger: createLogger({ level: "info", pretty: true, env: "local" }), container: c @@ -150,22 +168,29 @@ async function main(): Promise { [ "", "─────────────────────────────────────────────────────────────────────", - ` Banana X platform · ready on ${base}`, + ` Provider platform · ready on ${base}`, "─────────────────────────────────────────────────────────────────────", "", - ` Admin Portal: ${base}/admin`, - ` Player demo (launch): ${base}/play?lt=${playerToken}`, + ` Provider Control Plane (our admin): ${base}/provider`, + ` Operator Portal (client admin): ${base}/admin`, + ` Player demo (launch): ${base}/play?lt=${playerToken}`, + "", + " Sign in with username + password, then enroll an authenticator (2FA)", + " on first login. Seeded logins:", + "", + ` PROVIDER → username: ${BOOTSTRAP_USER} password: ${BOOTSTRAP_PASS} (at /provider)`, + operatorAdmin + ? ` OPERATOR → username: ${operatorAdmin.username} one-time password: ${operatorAdmin.temp_password} (at /admin)` + : " OPERATOR → already seeded (reuse your existing password)", "", ` Seeded operator: '${op.slug}' (${op.id})`, ` Plays: 50 × bet 5 GEL`, ` Sample round_ref: ${inspectorRef ?? "(none)"}`, ` → paste it in the Round Inspector to see the visual playback`, "", - " PROVIDER admin token (see everything + onboard):", - ` ${providerToken}`, - "", - " OPERATOR admin token (scoped to the demo operator):", - ` ${operatorToken}`, + " Script-only admin tokens (bypass the login UI, e.g. for curl):", + ` PROVIDER: ${providerToken}`, + ` OPERATOR: ${operatorToken}`, "", " Quick curl:", ` curl ${base}/admin/v1/rounds/${inspectorRef ?? ""} \\`, diff --git a/platform/src/app.ts b/platform/src/app.ts index ad6d941..d76c871 100644 --- a/platform/src/app.ts +++ b/platform/src/app.ts @@ -5,7 +5,11 @@ import healthRoutes from "./modules/health/health.routes"; import operatorRoutes from "./http/operator.routes"; import gameRoutes from "./http/game.routes"; import adminRoutes from "./http/admin.routes"; -import adminConsoleRoutes from "./http/admin-console"; +import adminAuthRoutes from "./http/admin-auth.routes"; +import adminAccountRoutes from "./http/admin-accounts.routes"; +import adminAssetsRoutes from "./http/admin-assets"; +import providerConsoleRoutes from "./http/provider-console"; +import operatorConsoleRoutes from "./http/operator-console"; import playPageRoutes from "./http/play-page"; import staticAssetsRoutes from "./http/static-assets"; import type { AdminClaims } from "./modules/admin/admin-auth"; @@ -83,10 +87,14 @@ export function buildApp(deps: BuildAppDeps): FastifyInstance { app.register(healthRoutes); app.register(staticAssetsRoutes); - app.register(adminConsoleRoutes); + app.register(adminAssetsRoutes); + app.register(providerConsoleRoutes); + app.register(operatorConsoleRoutes); app.register(playPageRoutes); app.register(operatorRoutes); app.register(gameRoutes); + app.register(adminAuthRoutes); + app.register(adminAccountRoutes); app.register(adminRoutes); return app; diff --git a/platform/src/config/env.ts b/platform/src/config/env.ts index 9ff5571..f30e8a1 100644 --- a/platform/src/config/env.ts +++ b/platform/src/config/env.ts @@ -17,6 +17,12 @@ const EnvSchema = z.object({ LAUNCH_TOKEN_SECRET: z.string().min(8).default("dev-launch-secret-change-me"), SESSION_TOKEN_SECRET: z.string().min(8).default("dev-session-secret-change-me"), ADMIN_TOKEN_SECRET: z.string().min(8).default("dev-admin-secret-change-me"), + // Bootstrap provider super-admin, seeded on boot when no provider account exists. + // Local/dev defaults only; production MUST override (and the seeded admin still + // enrolls TOTP on first login). + BOOTSTRAP_ADMIN_USERNAME: z.string().min(1).default("admin"), + BOOTSTRAP_ADMIN_PASSWORD: z.string().min(8).default("change-me-admin"), + TOTP_ISSUER: z.string().min(1).default("Provider Platform"), // 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), diff --git a/platform/src/container.ts b/platform/src/container.ts index 897e1e4..b5abb09 100644 --- a/platform/src/container.ts +++ b/platform/src/container.ts @@ -8,6 +8,7 @@ import { InMemoryTransactionStore } from "./modules/wallet/transaction-store"; import { RoundOrchestrator } from "./modules/rounds/round-orchestrator"; import { ReportingService } from "./modules/reporting/reporting.service"; import { AdminAuthService } from "./modules/admin/admin-auth"; +import { AdminAccountService } from "./modules/admin/admin-account"; import { RoundLedgerService } from "./modules/ledger/round-ledger.service"; import { InMemoryRoundRepository, InMemoryAuditRepository } from "./modules/ledger/memory-repositories"; import { InMemoryRoundAdjustmentStore } from "./modules/rounds/round-adjustment.store"; @@ -24,6 +25,11 @@ export interface PlatformConfig { adminSecret: string; hmacSkewSeconds: number; rateLimitPerMin: number; + // Bootstrap provider super-admin (seeded on boot if no provider account exists). + // Optional so tests that mint tokens directly need not supply them. + bootstrapAdminUsername?: string; + bootstrapAdminPassword?: string; + totpIssuer?: string; } /** @@ -47,6 +53,7 @@ export interface Container { verification: RoundVerificationService; disputes: DisputeService; adminAuth: AdminAuthService; + adminAccounts: AdminAccountService; nonceStore: NonceStore; rateLimiter: RateLimiter; // Durable-persistence plumbing (NullPersistence when Postgres is disabled). @@ -93,6 +100,7 @@ export function buildContainer( verification, disputes, adminAuth: new AdminAuthService(config.adminSecret), + adminAccounts: new AdminAccountService(audit, persistence, config.totpIssuer ?? "Provider Platform"), nonceStore: new NonceStore(config.hmacSkewSeconds * 4 * 1000), rateLimiter: new RateLimiter(config.rateLimitPerMin), persistence, @@ -100,3 +108,16 @@ export function buildContainer( auditRepo: audit }; } + +/** + * Seed the bootstrap provider super-admin if the platform has no provider account + * yet. Call AFTER hydration (so an existing DB account is not duplicated). The + * seeded admin has a known password but must still enroll TOTP on first login. + */ +export function seedBootstrapAdmin(c: Container): { username: string; seeded: boolean } { + const username = c.config.bootstrapAdminUsername ?? "admin"; + const password = c.config.bootstrapAdminPassword ?? "change-me-admin"; + if (c.adminAccounts.hasAnyProviderAccount()) return { username, seeded: false }; + c.adminAccounts.seedProviderSuperAdmin(username, password); + return { username, seeded: true }; +} diff --git a/platform/src/http/admin-accounts.routes.ts b/platform/src/http/admin-accounts.routes.ts new file mode 100644 index 0000000..574a56d --- /dev/null +++ b/platform/src/http/admin-accounts.routes.ts @@ -0,0 +1,124 @@ +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { envelope } from "./errors"; +import { adminBearerAuth } from "./auth"; +import { can, type AdminClaims, type AdminRole } from "../modules/admin/admin-auth"; + +/** + * Admin-account management (provider-only). Only a provider_super_admin holds + * `admin_accounts.manage` (via the "*" grant), so operator admins can never mint + * or escalate accounts. Operator-scoped accounts are pinned to an operator_id and + * may only carry operator_* roles. + */ + +const PROVIDER_ROLES: AdminRole[] = ["provider_super_admin", "provider_finance", "provider_read_only"]; +const OPERATOR_ROLES: AdminRole[] = ["operator_admin", "operator_finance", "operator_viewer"]; + +const createBody = z.object({ + username: z.string().min(3).max(64), + scope: z.enum(["provider", "operator"]), + operator_id: z.string().optional(), + role: z.enum([ + "provider_super_admin", + "provider_finance", + "provider_read_only", + "operator_admin", + "operator_finance", + "operator_viewer" + ]) +}); + +function forbid(reply: FastifyReply, req: FastifyRequest, message: string): FastifyReply { + return reply.code(403).send(envelope("FORBIDDEN", message, req.id)); +} + +const adminAccountRoutes: FastifyPluginAsync = async (app) => { + const auth = adminBearerAuth(app.container); + + // Every route here is provider-super-admin only. + const guard = (req: FastifyRequest, reply: FastifyReply): AdminClaims | null => { + const claims = req.adminClaims as AdminClaims; + if (claims.scope !== "provider" || !can(claims, "admin_accounts.manage")) { + forbid(reply, req, "admin_accounts.manage (provider super-admin) required"); + return null; + } + return claims; + }; + + app.get("/admin/v1/admin-accounts", { preHandler: auth }, async (req, reply) => { + if (!guard(req, reply)) return reply; + const q = req.query as { scope?: "provider" | "operator"; operator_id?: string }; + return { accounts: app.container.adminAccounts.list({ scope: q.scope, operator_id: q.operator_id }) }; + }); + + app.post("/admin/v1/admin-accounts", { preHandler: auth }, async (req, reply) => { + const claims = guard(req, reply); + if (!claims) return reply; + const parsed = createBody.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send(envelope("BAD_REQUEST", "username (3-64), scope, and a matching role are required", req.id)); + } + const { username, scope, operator_id, role } = parsed.data; + + // Role must match scope; operator accounts must name an existing operator. + const allowed = scope === "provider" ? PROVIDER_ROLES : OPERATOR_ROLES; + if (!allowed.includes(role)) { + return reply.code(400).send(envelope("ROLE_SCOPE_MISMATCH", `role ${role} is not valid for ${scope} scope`, req.id)); + } + if (scope === "operator") { + if (!operator_id) return reply.code(400).send(envelope("OPERATOR_ID_REQUIRED", "operator scope needs operator_id", req.id)); + try { + app.container.mgmt.getOperator(operator_id); + } catch { + return reply.code(404).send(envelope("OPERATOR_NOT_FOUND", "no such operator", req.id)); + } + } + + try { + const result = app.container.adminAccounts.create( + { username, scope, operator_id: scope === "operator" ? operator_id : null, role }, + claims.admin_id + ); + return reply.code(201).send(result); + } catch (err) { + const code = err instanceof Error ? err.message : "BAD_REQUEST"; + const status = code === "DUPLICATE_USERNAME" ? 409 : 400; + return reply.code(status).send(envelope(code, code, req.id)); + } + }); + + const withAccount = (req: FastifyRequest, reply: FastifyReply): string | null => { + const { id } = req.params as { id: string }; + if (!app.container.adminAccounts.getById(id)) { + reply.code(404).send(envelope("ACCOUNT_NOT_FOUND", "no such admin account", req.id)); + return null; + } + return id; + }; + + app.post("/admin/v1/admin-accounts/:id/disable", { preHandler: auth }, async (req, reply) => { + const claims = guard(req, reply); + if (!claims) return reply; + const id = withAccount(req, reply); + if (!id) return reply; + return { account: app.container.adminAccounts.disable(id, claims.admin_id) }; + }); + + app.post("/admin/v1/admin-accounts/:id/reset-password", { preHandler: auth }, async (req, reply) => { + const claims = guard(req, reply); + if (!claims) return reply; + const id = withAccount(req, reply); + if (!id) return reply; + return app.container.adminAccounts.resetPassword(id, claims.admin_id); + }); + + app.post("/admin/v1/admin-accounts/:id/reset-totp", { preHandler: auth }, async (req, reply) => { + const claims = guard(req, reply); + if (!claims) return reply; + const id = withAccount(req, reply); + if (!id) return reply; + return { account: app.container.adminAccounts.resetTotp(id, claims.admin_id) }; + }); +}; + +export default adminAccountRoutes; diff --git a/platform/src/http/admin-assets.ts b/platform/src/http/admin-assets.ts new file mode 100644 index 0000000..51f0ede --- /dev/null +++ b/platform/src/http/admin-assets.ts @@ -0,0 +1,382 @@ +import type { FastifyPluginAsync } from "fastify"; + +/** + * Shared shell for the two consoles (provider-console.ts and operator-console.ts). + * Served once as /console/app.css + /console/app.js so both pages share one + * stylesheet and one application script. Each page sets window.__CONSOLE__ to + * declare its kind ('provider' | 'operator'), title, and expected login scope; the + * script renders the login/2FA flow, gates the scope, and drives whatever nav + * buttons + view sections that page includes. + */ + +export const CONSOLE_CSS = /* css */ ` +:root { color-scheme: dark; --bg:#0b1020; --panel:#111935; --panel-2:#0e1430; + --line:#1f2a4d; --accent:#4f8cff; --accent-2:#7aa7ff; --text:#e6ecff; --muted:#8a98c8; + --ok:#5fe3a1; --err:#ff8b8b; --warn:#ffb56b; } +* { box-sizing: border-box; } +html, body { margin:0; } +body { font:14px/1.5 ui-sans-serif,system-ui,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--text); min-height:100vh; } +a { color: var(--accent-2); text-decoration: none; } +button { background:var(--accent); border:0; color:#00122e; font-weight:700; padding:8px 14px; border-radius:8px; cursor:pointer; font:inherit; } +button:hover { filter: brightness(1.08); } +button.ghost { background:transparent; border:1px solid var(--line); color:var(--text); font-weight:600; } +button.danger { background:#c84455; color:#fff; } +button:disabled { opacity:.5; cursor:not-allowed; } +input, select, textarea { background:var(--bg); border:1px solid var(--line); color:var(--text); padding:8px 10px; border-radius:8px; font:inherit; } +input:focus, select:focus, textarea:focus { outline:2px solid var(--accent); outline-offset:1px; } +label { display:flex; flex-direction:column; gap:4px; font-size:13px; color:var(--muted); } +label > input, label > select, label > textarea { color:var(--text); } +.muted { color:var(--muted); } +.ok { color:var(--ok); } +.err { color:var(--err); } +header { display:flex; gap:14px; align-items:center; flex-wrap:wrap; padding:12px 18px; background:var(--panel); border-bottom:1px solid var(--line); position:sticky; top:0; z-index:2; } +header h1 { margin:0; font-size:16px; font-weight:700; } +header .pill { padding:2px 10px; border:1px solid var(--line); border-radius:20px; font-size:12px; color:var(--muted); } +header .brandtag { font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--muted); } +nav { display:flex; gap:6px; margin-left:auto; flex-wrap:wrap; align-items:center; } +nav button { background:transparent; border:1px solid var(--line); color:var(--text); font-weight:600; padding:6px 12px; } +nav button.active { background:var(--accent); color:#00122e; border-color:var(--accent); } +main { padding:18px; max-width:1240px; margin:0 auto; } +.panel { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:14px 16px; margin-bottom:14px; } +.panel h2 { margin:0 0 10px; font-size:15px; } +.cards { display:grid; grid-template-columns: repeat(auto-fill, minmax(160px,1fr)); gap:10px; } +.card { background:var(--panel-2); border:1px solid var(--line); border-radius:10px; padding:10px 12px; } +.card span { display:block; color:var(--muted); font-size:12px; } +.card b { display:block; font-size:18px; margin-top:2px; } +table { width:100%; border-collapse:collapse; } +th, td { text-align:left; padding:8px 10px; border-bottom:1px solid var(--line); font-size:13px; } +th { color:var(--muted); font-weight:600; } +tr.clickable { cursor:pointer; } +tr.clickable:hover td { background: rgba(79,140,255,.08); } +.row { display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; } +.row > * { flex: 1 1 220px; } +.grid-2 { display:grid; grid-template-columns: 1fr 1fr; gap:14px; } +@media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } } +.status { padding:6px 10px; border-radius:8px; font-size:13px; } +.status.ok { background:#10301f; color:var(--ok); border:1px solid #1b4f33; } +.status.err { background:#2a1320; color:var(--err); border:1px solid #5c2236; } +.login { max-width:460px; margin:56px auto; padding:24px; background:var(--panel); border:1px solid var(--line); border-radius:14px; } +.login h2 { margin-top:0; } +.login .field { margin-top:12px; } +.login .actions { margin-top:16px; display:flex; gap:8px; } +.secretbox { font-family: ui-monospace,Menlo,Consolas,monospace; font-size:15px; letter-spacing:.06em; word-break:break-all; + background:#070b1a; border:1px solid var(--line); padding:10px 12px; border-radius:8px; margin:8px 0; } +.steps { display:flex; gap:6px; margin-bottom:12px; } +.steps .st { flex:1; text-align:center; font-size:11px; padding:5px; border:1px solid var(--line); border-radius:6px; color:var(--muted); } +.steps .st.on { background:var(--accent); color:#00122e; border-color:var(--accent); font-weight:700; } +pre.code { background:#070b1a; border:1px solid var(--line); padding:8px 10px; border-radius:8px; font-size:12px; overflow:auto; max-height:160px; white-space:pre-wrap; word-break:break-all; } + +.inspector { display:grid; gap:14px; grid-template-columns: 1fr 1fr; } +@media (max-width: 1100px) { .inspector { grid-template-columns: 1fr; } } +.inspector .panel { margin:0; } +.kv { display:grid; grid-template-columns: 140px 1fr; gap:6px 12px; font-size:13px; } +.kv span { color: var(--muted); } +.kv code { color: var(--accent-2); font-family: ui-monospace,Menlo,Consolas,monospace; word-break: break-all; } +.matrix { display:grid; gap:6px; padding:10px; background:#070b1a; border:1px solid var(--line); border-radius:10px; } +.matrix .cell { aspect-ratio:1; background:#0b1530; border:1px solid #1c2747; border-radius:8px; + position:relative; display:flex; align-items:center; justify-content:center; overflow:hidden; transition: box-shadow .25s ease; } +.matrix .cell img { width:80%; height:80%; object-fit:contain; } +.matrix .cell.win { box-shadow: 0 0 0 2px var(--accent), 0 0 14px rgba(79,140,255,.55) inset; background:#12224a; } +.matrix .cell.scatter::after { content: 'S'; position:absolute; top:3px; left:5px; font-size:10px; color:#fff; + background: #c84455; padding:1px 4px; border-radius:5px; font-weight:700; } +.matrix .cell .mbadge { position:absolute; bottom:3px; right:4px; font-size:11px; color:#00122e; background:var(--accent); + padding:1px 5px; border-radius:6px; font-weight:700; } +.matrix .cell .lbl { position:absolute; bottom:2px; left:4px; font-size:9px; color:#aebbe6; } +.step-ctrl { display:flex; gap:8px; align-items:center; margin-top:8px; flex-wrap:wrap; } +.totals { display:flex; gap:14px; flex-wrap:wrap; margin-top:8px; } +.totals .t { padding:6px 12px; border:1px solid var(--line); border-radius:8px; background:var(--panel-2); font-size:13px; } +.banner { padding:8px 12px; border-radius:8px; border:1px solid var(--line); margin-top:8px; font-weight:600; } +.banner.fs { background: #28203a; color:#d8c4ff; border-color:#3e3270; } +.banner.bonus { background:#3a2a10; color:#ffd089; border-color:#6e4d20; } +.breakdown { font-size:13px; } +.breakdown details { background:var(--panel-2); border:1px solid var(--line); border-radius:8px; padding:8px 10px; margin-bottom:6px; } +.breakdown summary { cursor:pointer; font-weight:600; } +.breakdown ul { margin:6px 0 0 18px; } +.toolbar { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:10px; } +.toolbar input.search { min-width: 280px; } +`; + +// The application script. Kept as one served file so both consoles share it. +export const CONSOLE_JS = /* js */ ` +"use strict"; +const CONSOLE = window.__CONSOLE__ || { kind: "provider", expectedScope: "provider", title: "Console" }; +const TOKEN_KEY = "adminToken:" + CONSOLE.kind; +const SCOPE_KEY = "adminScope:" + CONSOLE.kind; +const state = { token: localStorage.getItem(TOKEN_KEY) || "", scope: localStorage.getItem(SCOPE_KEY) || null, operators: [], games: [], mathConfigs: [] }; + +const $ = (id) => document.getElementById(id); +const fmtMoney = (n) => Number(n ?? 0).toFixed(2); +const fmt = (v) => v === undefined || v === null ? "" : (typeof v === "object" ? JSON.stringify(v) : String(v)); +const setHTML = (id, html) => { const e = $(id); if (e) e.innerHTML = html; }; +function esc(s){ return String(s==null?"":s).replace(/&/g,"&").replace(//g,">"); } +function flash(el, msg, ok) { if (!el) return; el.hidden = false; el.className = "status " + (ok ? "ok" : "err"); el.textContent = msg; } +function clearFlash(el) { if (el) { el.hidden = true; el.textContent = ""; } } + +// ── API (bearer) ─────────────────────────────────────────────────────────────── +async function api(path, opts = {}) { + const headers = { "authorization": "Bearer " + state.token, ...(opts.headers || {}) }; + if (opts.body && typeof opts.body !== "string") { headers["content-type"] = "application/json"; opts.body = JSON.stringify(opts.body); } + const res = await fetch(path, { ...opts, headers }); + const text = await res.text(); + let body; try { body = text ? JSON.parse(text) : null; } catch { body = text; } + if (res.status === 401) { handleAuthExpired(); const err = new Error("session invalid or expired"); err.status = 401; err.body = body; throw err; } + if (!res.ok) { const msg = (body && body.error && body.error.message) || ("HTTP " + res.status); const err = new Error(msg); err.status = res.status; err.body = body; throw err; } + return body; +} +let _authExpiredFlashed = false; +function handleAuthExpired() { + signOut(); + if (_authExpiredFlashed) return; _authExpiredFlashed = true; + const err = $("authErr"); if (err) flash(err, "Your session expired — please sign in again.", false); + setTimeout(() => { _authExpiredFlashed = false; }, 1500); +} +// Unauthenticated POST (login flow). +async function authPost(path, body) { + const res = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + const text = await res.text(); let data; try { data = text ? JSON.parse(text) : null; } catch { data = null; } + return { status: res.status, data }; +} + +// ── AUTH: username + password → TOTP 2FA (with first-login enrollment) ────────── +let auth = { step: "login", ticket: null, mfaToken: null, secret: null, otpauth: null, needsPassword: false }; + +function renderAuth() { + const root = $("authRoot"); if (!root) return; + if (state.token) { root.hidden = true; return; } + root.hidden = false; + let inner = ""; + if (auth.step === "login") inner = loginFormHtml(); + else if (auth.step === "mfa") inner = mfaFormHtml(); + else if (auth.step === "setpw") inner = setPwFormHtml(); + else if (auth.step === "totp") inner = totpFormHtml(); + root.innerHTML = ''; + wireAuth(); +} +function stepBar(active) { + const steps = [["login","1 · Password"],["setpw","2 · New password"],["totp","3 · Authenticator"]]; + return '
' + steps.map(s => '
' + s[1] + '
').join("") + '
'; +} +function loginFormHtml() { + return '

Sign in

' + + '

' + esc(CONSOLE.title) + ' — sign in with your username, password, and authenticator code.

' + + '
' + + '
' + + '
'; +} +function mfaFormHtml() { + return '

Two-factor code

' + + '

Enter the 6-digit code from your authenticator app.

' + + '
' + + '
'; +} +function setPwFormHtml() { + return stepBar("setpw") + '

Set a new password

' + + '

First sign-in: choose a new password (at least 8 characters).

' + + '
' + + '
' + + '
'; +} +function totpFormHtml() { + return stepBar("totp") + '

Set up your authenticator

' + + '

Add this secret to an authenticator app (Google Authenticator, Authy, 1Password…), then enter the 6-digit code it shows.

' + + '
' + esc(auth.secret || "") + '
' + + (auth.otpauth ? '

Open in authenticator (or paste the secret above manually)

' : "") + + '
' + + '
'; +} +function wireAuth() { + const err = $("authErr"); + const on = (id, fn) => { const b = $(id); if (b) b.onclick = fn; }; + on("btnCancel", () => { auth = { step: "login", ticket: null, mfaToken: null, secret: null, otpauth: null, needsPassword: false }; renderAuth(); }); + on("btnLogin", async () => { + clearFlash(err); + const username = ($("inUser").value || "").trim(), password = $("inPass").value || ""; + if (!username || !password) return flash(err, "enter username and password", false); + const { status, data } = await authPost("/admin/v1/auth/login", { username, password }); + if (status === 429) return flash(err, "too many attempts — wait a moment and retry", false); + if (status === 423) return flash(err, "account locked after failed attempts — try again later", false); + if (status === 403) return flash(err, "this account is disabled", false); + if (status !== 200) return flash(err, (data && data.error && data.error.message) || "invalid username or password", false); + if (data.setup_required) { + auth.ticket = data.ticket; auth.needsPassword = data.needs_password; + if (data.needs_password) { auth.step = "setpw"; renderAuth(); } + else { await beginTotp(); } + return; + } + auth.mfaToken = data.mfa_token; auth.step = "mfa"; renderAuth(); + }); + on("btnMfa", async () => { + clearFlash(err); + const code = ($("inCode").value || "").trim(); + const { status, data } = await authPost("/admin/v1/auth/login/mfa", { mfa_token: auth.mfaToken, code }); + if (status !== 200) return flash(err, status === 401 ? "invalid authenticator code" : "verification failed", false); + finishAuth(data.token, data.scope); + }); + on("btnSetPw", async () => { + clearFlash(err); + const pw = $("inNewPw").value || "", pw2 = $("inNewPw2").value || ""; + if (pw.length < 8) return flash(err, "password must be at least 8 characters", false); + if (pw !== pw2) return flash(err, "passwords do not match", false); + const { status, data } = await authPost("/admin/v1/auth/first-login/password", { ticket: auth.ticket, new_password: pw }); + if (status !== 200) return flash(err, (data && data.error && data.error.message) || "could not set password", false); + if (data.done) return finishAuth(data.token, data.scope); + await beginTotp(); + }); + on("btnConfirmTotp", async () => { + clearFlash(err); + const code = ($("inTotpCode").value || "").trim(); + const { status, data } = await authPost("/admin/v1/auth/first-login/totp/confirm", { ticket: auth.ticket, code }); + if (status !== 200) return flash(err, status === 401 ? "invalid authenticator code" : "verification failed", false); + finishAuth(data.token, data.scope); + }); +} +async function beginTotp() { + const { status, data } = await authPost("/admin/v1/auth/first-login/totp/begin", { ticket: auth.ticket }); + if (status !== 200) { auth.step = "login"; renderAuth(); flash($("authErr"), "could not start authenticator setup — sign in again", false); return; } + auth.secret = data.secret; auth.otpauth = data.otpauth_uri; auth.step = "totp"; renderAuth(); +} +function finishAuth(token, scope) { + if (scope !== CONSOLE.expectedScope) { + auth = { step: "login", ticket: null, mfaToken: null, secret: null, otpauth: null, needsPassword: false }; + renderAuth(); + flash($("authErr"), "This login is for the " + CONSOLE.expectedScope + " console. Use the correct portal.", false); + return; + } + state.token = token; state.scope = scope; + localStorage.setItem(TOKEN_KEY, token); localStorage.setItem(SCOPE_KEY, scope); + auth = { step: "login", ticket: null, mfaToken: null, secret: null, otpauth: null, needsPassword: false }; + afterSignIn(); +} +function afterSignIn() { + const pill = $("scopePill"); if (pill) pill.textContent = (state.scope || "?") + " · signed in"; + const nav = $("nav"); if (nav) nav.hidden = false; + const so = $("signOutBtn"); if (so) so.hidden = false; + renderAuth(); + applyScopeVisibility(); + const initial = (location.hash || "#dashboard").slice(1); + show(VIEWS.includes(initial) ? initial : "dashboard"); +} +function applyScopeVisibility() { + const isProvider = state.scope === "provider"; + document.querySelectorAll("[data-provider]").forEach(el => { el.style.display = isProvider ? "" : "none"; }); + document.querySelectorAll("[data-operator]").forEach(el => { el.style.display = isProvider ? "none" : ""; }); +} +function signOut() { + state.token = ""; state.scope = null; localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(SCOPE_KEY); + const pill = $("scopePill"); if (pill) pill.textContent = "not signed in"; + const nav = $("nav"); if (nav) nav.hidden = true; + const so = $("signOutBtn"); if (so) so.hidden = true; + hideAllViews(); + auth = { step: "login", ticket: null, mfaToken: null, secret: null, otpauth: null, needsPassword: false }; + renderAuth(); +} + +// ── views ──────────────────────────────────────────────────────────────────── +const VIEWS = Array.from(document.querySelectorAll("#nav button[data-view]")).map(b => b.dataset.view); +function hideAllViews() { VIEWS.forEach(v => { const s = $(v + "View"); if (s) s.hidden = true; }); } +function show(view) { + if (!VIEWS.includes(view)) view = VIEWS[0]; + hideAllViews(); + const s = $(view + "View"); if (s) s.hidden = false; + document.querySelectorAll("#nav button").forEach(b => b.classList.toggle("active", b.dataset.view === view)); + if (view === "dashboard") refreshDashboard(); + if (view === "inspector") { const i = $("roundRefInput"); if (i) i.focus(); } + if (view === "games") loadGames(); + if (view === "disputes") loadDisputes(); + if (view === "players") { const i = $("plPlayer"); if (i) i.focus(); } + if (view === "onboarding") refreshOnboardingDropdowns(); + if (view === "reports") loadReports(); + if (view === "accounts") loadAccounts(); + history.replaceState(null, "", "#" + view); +} +document.querySelectorAll("#nav button[data-view]").forEach(b => b.addEventListener("click", () => show(b.dataset.view))); +const _signOutBtn = $("signOutBtn"); if (_signOutBtn) _signOutBtn.addEventListener("click", signOut); + +function cardHtml(label, value) { return '
'+label+''+fmt(value)+'
'; } +function tableHtml(rows, cols) { + if (!rows || !rows.length) return '

no rows

'; + const head = ''+cols.map(c => ''+c+'').join('')+''; + const body = rows.map(r => ''+cols.map(c => ''+fmt(r[c])+'').join('')+'').join(''); + return ''+head+body+'
'; +} + +// ── dashboard ──────────────────────────────────────────────────────────────── +async function refreshDashboard() { + try { + const summary = await api("/admin/v1/reports/summary"); + const rtp = await api("/admin/v1/reports/rtp"); + const recon = await api("/admin/v1/reports/reconciliation"); + setHTML("overviewCards", + cardHtml("rounds", summary.rounds) + cardHtml("total bet", fmtMoney(summary.total_bet)) + + cardHtml("total win", fmtMoney(summary.total_win)) + cardHtml("GGR", fmtMoney(summary.ggr)) + + cardHtml("hold %", summary.hold_percent) + cardHtml("RTP % (actual)", rtp.actual_rtp_percent) + + cardHtml("reconciliation", recon.ok ? "✅ ok" : "⚠ drift")); + } catch (e) { setHTML("overviewCards", ''+e.message+''); } + if (state.scope === "provider" && $("operatorsList")) { + try { const data = await api("/admin/v1/operators"); state.operators = data.operators || []; renderOperators(); } + catch (e) { setHTML("operatorsList", ''+e.message+''); } + } + await loadRounds(); +} +async function loadRounds() { + if (!$("roundsList")) return; + const of = $("filterOp"); const opFilter = of ? of.value.trim() : ""; + const qs = opFilter ? ("?operator_id=" + encodeURIComponent(opFilter)) : ""; + try { + const data = await api("/admin/v1/rounds" + qs); + const rows = data.rounds || []; + if (!rows.length) { setHTML("roundsList", '

no rounds yet

'); return; } + const head = 'round_refoperatorbetwinstatuscreated_at'; + const body = rows.slice().reverse().slice(0, 50).map((r) => + '' + + '' + r.round_ref + '' + + '' + (r.operator_id ? r.operator_id.slice(0, 8) + '…' : '') + '' + + '' + fmtMoney(r.bet_charged ?? r.bet_amount) + '' + + '' + (r.total_win > 0 ? '+' + fmtMoney(r.total_win) + '' : fmtMoney(r.total_win)) + '' + + '' + r.status + '' + + '' + (r.created_at || '').replace('T', ' ').slice(0, 19) + '').join(''); + setHTML("roundsList", '' + head + body + '
'); + $("roundsList").querySelectorAll('tr.clickable').forEach(tr => tr.addEventListener('click', () => { $("roundRefInput").value = tr.dataset.ref; show('inspector'); lookupRound(); })); + } catch (e) { setHTML("roundsList", ''+e.message+''); } +} + +__INSPECTOR__ +__ONBOARDING__ +__REPORTS__ +__ROUNDACTIONS__ +__GAMES__ +__DISPUTES__ +__PLAYERS__ +__OPERATORS__ +__ACCOUNTS__ + +// ── boot ───────────────────────────────────────────────────────────────────── +if (state.token && state.scope) { afterSignIn(); } else { renderAuth(); } +`; + +// The inspector/onboarding/etc. blocks are big and unchanged in behavior from the +// original single console; kept in separate template pieces for readability. +import { INSPECTOR_JS, ONBOARDING_JS, REPORTS_JS, ROUNDACTIONS_JS, GAMES_JS, DISPUTES_JS, PLAYERS_JS, OPERATORS_JS, ACCOUNTS_JS } from "./admin-console-views"; + +function buildAppJs(): string { + return CONSOLE_JS + .replace("__INSPECTOR__", INSPECTOR_JS) + .replace("__ONBOARDING__", ONBOARDING_JS) + .replace("__REPORTS__", REPORTS_JS) + .replace("__ROUNDACTIONS__", ROUNDACTIONS_JS) + .replace("__GAMES__", GAMES_JS) + .replace("__DISPUTES__", DISPUTES_JS) + .replace("__PLAYERS__", PLAYERS_JS) + .replace("__OPERATORS__", OPERATORS_JS) + .replace("__ACCOUNTS__", ACCOUNTS_JS); +} + +const adminAssetsRoutes: FastifyPluginAsync = async (app) => { + const js = buildAppJs(); + app.get("/console/app.css", async (_req, reply) => + reply.type("text/css").header("cache-control", "no-store").send(CONSOLE_CSS)); + app.get("/console/app.js", async (_req, reply) => + reply.type("application/javascript").header("cache-control", "no-store").send(js)); +}; + +export default adminAssetsRoutes; diff --git a/platform/src/http/admin-auth.routes.ts b/platform/src/http/admin-auth.routes.ts new file mode 100644 index 0000000..6af8475 --- /dev/null +++ b/platform/src/http/admin-auth.routes.ts @@ -0,0 +1,167 @@ +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { envelope } from "./errors"; +import { signToken, verifyToken, type TokenClaims } from "../lib/tokens"; +import type { Container } from "../container"; +import type { AdminAccount } from "../modules/admin/admin-account"; + +/** + * Admin login (both consoles): username + password → TOTP 2FA → session token. + * Unauthenticated endpoints (they MINT the bearer token that admin.routes verifies). + * + * Two short-lived, signed intermediate tokens gate the multi-step flows so no step + * can be skipped: + * • mfa_token — proves the password step passed; spend it on /login/mfa. + * • setup ticket — proves password step during first-login; drives the forced + * password-set + TOTP-enrollment sequence. + * Both are signed with the same admin secret and carry a `purpose` claim. + */ + +const MFA_TTL_SECONDS = 120; +const SETUP_TTL_SECONDS = 600; +const MIN_PASSWORD_LEN = 8; + +const loginBody = z.object({ username: z.string().min(1), password: z.string().min(1) }); +const mfaBody = z.object({ mfa_token: z.string().min(1), code: z.string().min(1) }); +const setPwBody = z.object({ ticket: z.string().min(1), new_password: z.string().min(MIN_PASSWORD_LEN) }); +const beginBody = z.object({ ticket: z.string().min(1) }); +const confirmBody = z.object({ ticket: z.string().min(1), code: z.string().min(1) }); + +interface MfaClaims extends TokenClaims { purpose: "mfa"; admin_id: string } +interface SetupClaims extends TokenClaims { purpose: "setup"; admin_id: string } + +function bad(reply: FastifyReply, req: FastifyRequest, status: number, code: string, message: string): FastifyReply { + return reply.code(status).send(envelope(code, message, req.id)); +} + +const adminAuthRoutes: FastifyPluginAsync = async (app) => { + const c = app.container; + const secret = c.config.adminSecret; + + // Issue the final admin session token from an account (same shape admin.routes verifies). + const mintSession = (a: AdminAccount): { token: string; scope: string; role: string } => { + c.adminAccounts.recordSuccessfulLogin(a.id); + const token = c.adminAuth.mintToken({ admin_id: a.id, scope: a.scope, operator_id: a.operator_id, role: a.role }); + return { token, scope: a.scope, role: a.role }; + }; + + // Re-fetch the live account behind a ticket/mfa token (flags may have changed mid-flow). + const accountFor = (adminId: string): AdminAccount | null => c.adminAccounts.getById(adminId); + + // Step 1 — password. Returns either an mfa challenge or a first-login setup ticket. + app.post("/admin/v1/auth/login", async (req, reply) => { + const parsed = loginBody.safeParse(req.body); + if (!parsed.success) return bad(reply, req, 400, "BAD_REQUEST", "username and password required"); + const { username, password } = parsed.data; + + if (!c.rateLimiter.allow(`login:${username.toLowerCase()}`)) { + return bad(reply, req, 429, "RATE_LIMITED", "too many attempts, slow down"); + } + + const account = c.adminAccounts.getByUsername(username); + // Generic failure for unknown user / wrong password — never reveal which. + if (!account) return bad(reply, req, 401, "INVALID_CREDENTIALS", "invalid username or password"); + if (account.status === "disabled") return bad(reply, req, 403, "ACCOUNT_DISABLED", "account disabled"); + if (c.adminAccounts.isLocked(account)) { + return bad(reply, req, 423, "ACCOUNT_LOCKED", "account temporarily locked after failed attempts"); + } + if (!c.adminAccounts.verifyPassword(account, password)) { + c.adminAccounts.recordFailedLogin(account.id); + return bad(reply, req, 401, "INVALID_CREDENTIALS", "invalid username or password"); + } + + // First login: force password reset and/or TOTP enrollment before any session. + if (account.must_set_password || !account.totp_enrolled) { + const ticket = signToken(secret, { purpose: "setup", admin_id: account.id }, SETUP_TTL_SECONDS); + return reply.send({ + setup_required: true, + ticket, + needs_password: account.must_set_password, + needs_totp: !account.totp_enrolled + }); + } + + const mfa_token = signToken(secret, { purpose: "mfa", admin_id: account.id }, MFA_TTL_SECONDS); + return reply.send({ mfa_required: true, mfa_token }); + }); + + // Step 2 — TOTP. Spends the mfa_token, returns the session token. + app.post("/admin/v1/auth/login/mfa", async (req, reply) => { + const parsed = mfaBody.safeParse(req.body); + if (!parsed.success) return bad(reply, req, 400, "BAD_REQUEST", "mfa_token and code required"); + let claims: MfaClaims; + try { + claims = verifyToken(secret, parsed.data.mfa_token); + } catch { + return bad(reply, req, 401, "MFA_TOKEN_INVALID", "session expired, sign in again"); + } + if (claims.purpose !== "mfa") return bad(reply, req, 401, "MFA_TOKEN_INVALID", "wrong token"); + const account = accountFor(claims.admin_id); + if (!account || account.status === "disabled") return bad(reply, req, 401, "MFA_TOKEN_INVALID", "account unavailable"); + if (!c.adminAccounts.verifyTotpCode(account, parsed.data.code)) { + c.adminAccounts.recordFailedLogin(account.id); + return bad(reply, req, 401, "INVALID_2FA_CODE", "invalid authenticator code"); + } + return reply.send(mintSession(account)); + }); + + // ── First-login setup (ticket-gated) ──────────────────────────────────────── + const setupAccount = (reply: FastifyReply, req: FastifyRequest, ticket: string): AdminAccount | null => { + let claims: SetupClaims; + try { + claims = verifyToken(secret, ticket); + } catch { + bad(reply, req, 401, "SETUP_TICKET_INVALID", "setup session expired, sign in again"); + return null; + } + if (claims.purpose !== "setup") { + bad(reply, req, 401, "SETUP_TICKET_INVALID", "wrong token"); + return null; + } + const account = accountFor(claims.admin_id); + if (!account || account.status === "disabled") { + bad(reply, req, 401, "SETUP_TICKET_INVALID", "account unavailable"); + return null; + } + return account; + }; + + // 1a — set the new password (required before TOTP enrollment). + app.post("/admin/v1/auth/first-login/password", async (req, reply) => { + const parsed = setPwBody.safeParse(req.body); + if (!parsed.success) return bad(reply, req, 400, "BAD_REQUEST", `new_password must be at least ${MIN_PASSWORD_LEN} characters`); + const account = setupAccount(reply, req, parsed.data.ticket); + if (!account) return reply; + c.adminAccounts.setPassword(account.id, parsed.data.new_password); + const updated = accountFor(account.id)!; + // Password done. If TOTP already enrolled (unusual on first login), finish now. + if (updated.totp_enrolled) return reply.send({ done: true, ...mintSession(updated) }); + return reply.send({ done: false, needs_totp: true }); + }); + + // 1b — begin TOTP enrollment (must have set password first). + app.post("/admin/v1/auth/first-login/totp/begin", async (req, reply) => { + const parsed = beginBody.safeParse(req.body); + if (!parsed.success) return bad(reply, req, 400, "BAD_REQUEST", "ticket required"); + const account = setupAccount(reply, req, parsed.data.ticket); + if (!account) return reply; + if (account.must_set_password) return bad(reply, req, 409, "PASSWORD_SET_REQUIRED", "set a new password first"); + const { secret: totpSecret, otpauth_uri } = c.adminAccounts.beginTotpEnrollment(account.id, account.username); + return reply.send({ secret: totpSecret, otpauth_uri }); + }); + + // 1c — confirm TOTP enrollment with a live code; issues the session token. + app.post("/admin/v1/auth/first-login/totp/confirm", async (req, reply) => { + const parsed = confirmBody.safeParse(req.body); + if (!parsed.success) return bad(reply, req, 400, "BAD_REQUEST", "ticket and code required"); + const account = setupAccount(reply, req, parsed.data.ticket); + if (!account) return reply; + if (account.must_set_password) return bad(reply, req, 409, "PASSWORD_SET_REQUIRED", "set a new password first"); + if (!c.adminAccounts.confirmTotpEnrollment(account.id, parsed.data.code)) { + return bad(reply, req, 401, "INVALID_2FA_CODE", "invalid authenticator code"); + } + return reply.send({ done: true, ...mintSession(accountFor(account.id)!) }); + }); +}; + +export default adminAuthRoutes; diff --git a/platform/src/http/admin-console-sections.ts b/platform/src/http/admin-console-sections.ts new file mode 100644 index 0000000..59be308 --- /dev/null +++ b/platform/src/http/admin-console-sections.ts @@ -0,0 +1,267 @@ +/** + * Static HTML section fragments shared by the two console pages + * (provider-console.ts and operator-console.ts). Operator-only vs provider-only + * controls are tagged data-provider / data-operator and toggled at runtime by the + * shared script's applyScopeVisibility(). The operator page simply omits the + * sections it does not offer (onboarding, operators, accounts). + */ + +export const SECTION_INSPECTOR = /* html */ ` +`; + +export const SECTION_GAMES = /* html */ ` +`; + +export const SECTION_DISPUTES = /* html */ ` +`; + +export const SECTION_PLAYERS = /* html */ ` +`; + +export const SECTION_REPORTS = /* html */ ` +`; + +export const SECTION_ONBOARDING = /* html */ ` +`; + +export const SECTION_ACCOUNTS = /* html */ ` +`; + +export const SECTION_DASHBOARD_PROVIDER = /* html */ ` +`; + +export const SECTION_DASHBOARD_OPERATOR = /* html */ ` +`; + +/** Assemble a full console page (head + header + auth root + sections + script). */ +export function renderConsolePage(opts: { + title: string; + brandtag: string; + consoleKind: "provider" | "operator"; + expectedScope: "provider" | "operator"; + nav: Array<{ view: string; label: string }>; + sections: string; +}): string { + const navHtml = opts.nav.map((n) => ``).join("\n "); + const consoleCfg = JSON.stringify({ kind: opts.consoleKind, expectedScope: opts.expectedScope, title: opts.title }); + return ` + + + +${opts.title} + + + +
+

${opts.title}

+ ${opts.brandtag} + not signed in + +
+
+
+ ${opts.sections} +
+ + +`; +} diff --git a/platform/src/http/admin-console-views.ts b/platform/src/http/admin-console-views.ts new file mode 100644 index 0000000..42243d2 --- /dev/null +++ b/platform/src/http/admin-console-views.ts @@ -0,0 +1,414 @@ +/** + * View-logic blocks for the shared console script (admin-assets.ts). These are the + * per-view functions (inspector, onboarding, reports, games, disputes, players, + * operators, admin-accounts) concatenated into /console/app.js. They run in the + * same scope as the shell, so they reference its helpers ($, api, show, state, + * cardHtml, tableHtml, flash, esc, fmtMoney, fmt, setHTML) directly. + * + * Behaviour is unchanged from the original single-file console except the new + * accounts block. Each loader only runs when its view is present in the page, so + * the operator console (which omits onboarding/operators/accounts) is unaffected. + */ + +export const INSPECTOR_JS = /* js */ ` +let inspectorRound = null; let stepIdx = 0; let playTimer = null; +const SYMBOL_LABELS = { + TOP_CROWN:'crown', HOURGLASS:'hourglass', RING:'ring', CHALICE:'chalice', RED_GEM:'gem', + PURPLE_TRIANGLE:'purple', YELLOW_HEX:'yellow', GREEN_TRIANGLE:'green', BLUE_DIAMOND:'diamond', SCATTER:'scatter' +}; +function symbolImageUrl(code) { + if (!code) return null; + const c = String(code).toUpperCase(); + return '/assets/symbols/' + encodeURIComponent(c) + '.png'; +} +function multiValueFromCode(code) { const m = /MULTI(\\d+)/i.exec(String(code || '')); return m ? Number(m[1]) : null; } +async function lookupRound() { + const ref = $('roundRefInput').value.trim(); + $('inspectorContent').innerHTML = ''; clearFlash($('inspectorStatus')); + if (!ref) { flash($('inspectorStatus'), 'paste a Round ID first', false); return; } + $('inspectorStatus').className = 'muted'; $('inspectorStatus').textContent = 'looking up…'; $('inspectorStatus').hidden = false; + try { + const data = await api('/admin/v1/rounds/' + encodeURIComponent(ref)); + inspectorRound = data; stepIdx = 0; renderInspector(); + $('inspectorStatus').textContent = ''; $('inspectorStatus').className = 'ok'; + } catch (e) { + inspectorRound = null; + if (e.status === 404) renderInspectorEmpty(ref); else flash($('inspectorStatus'), e.message, false); + } +} +function renderInspectorEmpty(ref) { + $('inspectorContent').innerHTML = '

Not found

' + + '

No round matches '+esc(ref)+' — check that you copied the full ID (it looks like r_…) ' + + 'and that you have permission to view this round.

'; + $('inspectorStatus').textContent = ''; +} +function renderInspector() { + if (!inspectorRound) return; + const r = inspectorRound; const outcome = r.outcome || {}; + const steps = Array.isArray(outcome.tumble_steps) ? outcome.tumble_steps : []; + const initialMatrix = outcome.matrix; + const totalStages = 1 + steps.length; + if (stepIdx >= totalStages) stepIdx = totalStages - 1; if (stepIdx < 0) stepIdx = 0; + const wins = Array.isArray(outcome.ways_wins) ? outcome.ways_wins : []; + const totalSymbolsCaught = wins.reduce((s, w) => s + (Number(w.count) || 0), 0); + const biggest = wins.slice().sort((a,b) => (Number(b.amount)||0) - (Number(a.amount)||0))[0]; + const multipliers = Array.isArray(outcome.multipliers) ? outcome.multipliers : []; + const text = '' + + '

Round details

' + + kv('Round ID', ''+r.round_ref+'') + + kv('Operator', r.operator ? (esc(r.operator.name) + ' ('+esc(r.operator.slug)+')') : r.operator_id) + + kv('Game', r.game ? (esc(r.game.title) + ' '+esc(r.game.code)+'') : r.game_id) + + kv('Math config', r.math_config ? (r.math_config.version + ' · RTP ' + r.math_config.theoretical_rtp + '% · ' + r.math_config.status) : r.math_config_id) + + kv('Currency', r.currency || '—') + + kv('Bet', fmtMoney(r.bet_amount) + (r.ante_enabled ? ' (ante)' : '')) + + kv('Bet charged', fmtMoney(r.bet_charged)) + + kv('Total win', ' 0 ? ' class="ok"' : '')+'>'+fmtMoney(r.total_win)+'') + + kv('Multiplier applied', r.multiplier_applied + '×') + + kv('Free spin?', r.is_free_spin ? 'yes' : 'no') + + kv('Free spins awarded', outcome.free_spins_awarded ?? 0) + + kv('Scatters', outcome.scatter_count ?? 0) + + kv('Status', r.status) + kv('Created at', r.created_at) + + kv('Outcome hash', ''+r.outcome_hash+'') + + kv('RNG', r.rng_algo + ' (seed ref)') + kv('Seq', r.chain?.seq) + + '
' + + '

What the player caught

' + + cardHtml('symbols caught', totalSymbolsCaught) + cardHtml('clusters', wins.length) + + cardHtml('biggest cluster', biggest ? (biggest.symbol+' ×'+biggest.count+' = '+fmtMoney(biggest.amount)) : '—') + + cardHtml('multipliers', multipliers.length) + cardHtml('tumble steps', steps.length) + + '
' + + (outcome.free_spins_awarded ? '' : '') + + (r.is_free_spin ? '' : '') + + '

Per-step breakdown

' + buildBreakdown(steps) + '
'; + const visual = '

Visual playback

' + renderStageHtml(outcome, initialMatrix, steps, stepIdx) + renderStepControls(totalStages) + '
'; + $('inspectorContent').innerHTML = '
'+text+visual+'
'; + bindStepControls(totalStages); +} +function kv(k, v) { return ''+k+'
'+(v ?? '')+'
'; } +function buildBreakdown(steps) { + if (!steps.length) return '

no tumble steps (no win on initial board)

'; + return steps.map((s, i) => { + const wins = (s.ways_wins || []).map(w => '
  • '+w.symbol+' × '+w.count+' → '+fmtMoney(w.payout)+'× = '+fmtMoney(w.amount)+'
  • ').join(''); + const mults = (s.multipliers || []).length ? ('
  • multipliers caught: ' + (s.multipliers || []).map(m => (multiValueFromCode((m).code) || m.value)+'× @ ['+m.row+','+m.col+']').join(', ') + '
  • ') : ''; + const winPos = (s.winning_positions || []).length; + return '
    Step '+(i+1)+' · win '+fmtMoney(s.win_total)+' · '+winPos+' cells highlighted
      '+wins+mults+'
    '; + }).join(''); +} +function renderStageHtml(outcome, initialMatrix, steps, idx) { + let matrix, winningPositions = [], stepWin = 0, stepMultipliers = [], stepLabel = ''; + if (idx === 0) { matrix = initialMatrix; stepLabel = 'Initial board'; } + else { const s = steps[idx - 1] || {}; matrix = s.matrix; winningPositions = s.winning_positions || []; stepWin = s.win_total || 0; stepMultipliers = s.multipliers || []; stepLabel = 'Tumble step ' + idx + ' / ' + steps.length; } + const rows = matrix?.length ?? 5, cols = matrix?.[0]?.length ?? 6; + const winSet = new Set(winningPositions.map(p => p.row+','+p.col)); + const multiMap = new Map(); + for (const m of stepMultipliers) { if (m && typeof m.row === 'number' && typeof m.col === 'number') { multiMap.set(m.row+','+m.col, (m.value ?? multiValueFromCode(m.code) ?? '×')); } } + let html = '
    ' + + '
    Stage: '+stepLabel+'
    ' + + (idx > 0 ? '
    Step win: +'+fmtMoney(stepWin)+'
    ' : '') + + '
    Running total: '+fmtMoney(runningTotal(steps, idx))+'
    ' + + '
    Final win: '+fmtMoney(outcome.total_win || 0)+'
    ' + + (outcome.multiplier_applied && outcome.multiplier_applied !== 1 ? '
    Multiplier applied: '+outcome.multiplier_applied+'×
    ' : '') + '
    '; + html += '
    '; + for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { + const sym = matrix?.[r]?.[c]; const isWin = winSet.has(r+','+c); + const isScatter = String(sym||'').toUpperCase() === 'SCATTER'; + const multiBadge = multiMap.get(r+','+c) ?? (String(sym||'').toUpperCase().startsWith('MULTI') ? multiValueFromCode(sym) : null); + const url = sym ? symbolImageUrl(sym) : null; + html += '
    '; + if (url) html += ''+sym+''; + html += ''+(SYMBOL_LABELS[String(sym||'').toUpperCase()] || sym || '')+''; + if (multiBadge != null) html += ''+multiBadge+'×'; + html += '
    '; + } } + html += '
    '; return html; +} +function runningTotal(steps, idx) { if (idx === 0) return 0; let s = 0; for (let i = 0; i < idx; i++) s += Number(steps[i]?.win_total || 0); return s; } +function renderStepControls(totalStages) { + return '
    ' + + 'stage '+(stepIdx+1)+' / '+totalStages+'
    '; +} +function bindStepControls(totalStages) { + const goto = (i) => { stepIdx = Math.max(0, Math.min(totalStages-1, i)); renderInspector(); }; + $('prevStep').onclick = () => { stopPlay(); goto(stepIdx - 1); }; + $('nextStep').onclick = () => { stopPlay(); goto(stepIdx + 1); }; + $('playStep').onclick = () => togglePlay(totalStages); + document.addEventListener('keydown', handleKeyNav); +} +function handleKeyNav(e) { + const iv = $('inspectorView'); if (!iv || iv.hidden) return; + if (e.target && /(INPUT|TEXTAREA|SELECT)/.test(e.target.tagName)) return; + if (e.key === 'ArrowLeft') document.getElementById('prevStep')?.click(); + if (e.key === 'ArrowRight') document.getElementById('nextStep')?.click(); + if (e.key === ' ') { e.preventDefault(); document.getElementById('playStep')?.click(); } +} +function stopPlay() { if (playTimer) { clearInterval(playTimer); playTimer = null; const b = $('playStep'); if (b) b.textContent = '▶ Play'; } } +function togglePlay(totalStages) { + if (playTimer) { stopPlay(); return; } + const b = $('playStep'); if (b) b.textContent = '⏸ Pause'; + playTimer = setInterval(() => { if (stepIdx >= totalStages - 1) { stopPlay(); return; } stepIdx += 1; renderInspector(); }, 900); +} +`; + +export const ONBOARDING_JS = /* js */ ` +async function refreshOnboardingDropdowns() { + try { if (state.scope === 'provider') { const ops = await api('/admin/v1/operators'); state.operators = ops.operators || []; } } catch {} + try { const data = await api('/admin/v1/games'); state.games = data.games || []; state.mathConfigs = data.math_configs || []; } catch {} + const fillOps = (selId) => { const sel = $(selId); if (!sel) return; sel.innerHTML = state.operators.map(o => '').join(''); }; + ['domainOp','credOp','asnOp','launchOp','acctOp'].forEach(fillOps); + const fillGames = (selId) => { const sel = $(selId); if (!sel) return; sel.innerHTML = state.games.map(g => '').join(''); }; + ['mcGame','asnGame'].forEach(fillGames); + const sel = $('asnConfig'); if (sel) sel.innerHTML = state.mathConfigs.map(c => '').join(''); +} +async function createOperator() { + clearFlash($('opResult')); + const name = $('opName').value.trim(), slug = $('opSlug').value.trim(), default_currency = $('opCurrency').value.trim() || 'GEL'; + if (!name || !slug) return flash($('opResult'), 'name and slug are required', false); + try { const data = await api('/admin/v1/operators', { method: 'POST', body: { name, slug, default_currency } }); flash($('opResult'), 'created operator '+data.operator.slug+' ('+data.operator.id+')', true); await refreshOnboardingDropdowns(); } + catch (e) { flash($('opResult'), e.message, false); } +} +async function addDomain() { + clearFlash($('domainResult')); + const operatorId = $('domainOp').value, domain = $('domainHost').value.trim(), environment = $('domainEnv').value; + if (!operatorId || !domain) return flash($('domainResult'), 'operator + domain required', false); + try { const data = await api('/admin/v1/operators/'+operatorId+'/domains', { method: 'POST', body: { domain, environment } }); flash($('domainResult'), 'allowlisted '+data.domain.domain+' ('+data.domain.environment+')', true); } + catch (e) { flash($('domainResult'), e.message, false); } +} +async function issueCredential() { + clearFlash($('credResult')); + const operatorId = $('credOp').value, environment = $('credEnv').value; + if (!operatorId) return flash($('credResult'), 'operator required', false); + try { const data = await api('/admin/v1/operators/'+operatorId+'/credentials', { method: 'POST', body: { environment } }); + $('credResult').hidden = false; $('credResult').className = 'status ok'; + $('credResult').innerHTML = 'issued credential (secret shown ONCE — copy now):
    api_key_id: '+data.credential.api_key_id+'\\napi_secret: '+data.api_secret+'
    '; + } catch (e) { flash($('credResult'), e.message, false); } +} +async function registerGame() { + clearFlash($('gameResult')); + const code = $('gameCode').value.trim(), title = $('gameTitle').value.trim(); + if (!code || !title) return flash($('gameResult'), 'code + title required', false); + try { const data = await api('/admin/v1/games', { method: 'POST', body: { code, title } }); flash($('gameResult'), 'registered game '+data.game.code+' ('+data.game.id+')', true); await refreshOnboardingDropdowns(); } + catch (e) { flash($('gameResult'), e.message, false); } +} +async function createMathConfig() { + clearFlash($('mcResult')); + const body = { game_id: $('mcGame').value, version: $('mcVersion').value.trim(), rtp_profile_key: $('mcKey').value.trim(), theoretical_rtp: Number($('mcRtp').value), config_hash: $('mcHash').value.trim() }; + if (!body.game_id || !body.version || !body.rtp_profile_key || !body.config_hash) return flash($('mcResult'), 'all fields required', false); + try { const created = await api('/admin/v1/math-configs', { method: 'POST', body }); + const approved = await api('/admin/v1/math-configs/'+created.math_config.id+'/approve', { method: 'POST', body: {} }); + flash($('mcResult'), 'approved math config '+approved.math_config.id+' ('+approved.math_config.status+')', true); await refreshOnboardingDropdowns(); + } catch (e) { flash($('mcResult'), e.message, false); } +} +async function assignGame() { + clearFlash($('asnResult')); + const operatorId = $('asnOp').value, game_id = $('asnGame').value, math_config_id = $('asnConfig').value; + const currency = $('asnCurrency').value.trim() || 'GEL'; + const allowed_bets = $('asnBets').value.split(',').map(s => Number(s.trim())).filter(n => !isNaN(n)); + if (!operatorId || !game_id || !math_config_id || !allowed_bets.length) return flash($('asnResult'), 'operator, game, config + at least one bet required', false); + try { const data = await api('/admin/v1/operators/'+operatorId+'/assignments', { method: 'POST', body: { game_id, math_config_id, currency, allowed_bets } }); flash($('asnResult'), 'assigned ('+data.operator_game.id+', allowed bets: '+data.operator_game.allowed_bets.join(', ')+')', true); } + catch (e) { flash($('asnResult'), e.message, false); } +} +async function launchDemo() { + clearFlash($('launchResult')); + const operatorId = $('launchOp').value; + const body = { game_code: $('launchGame').value.trim(), operator_player_id: $('launchPlayer').value.trim(), currency: $('launchCurrency').value.trim(), origin: $('launchOrigin').value.trim() }; + if (!operatorId || !body.game_code || !body.operator_player_id || !body.currency || !body.origin) return flash($('launchResult'), 'all fields required (origin must be an allowlisted domain)', false); + try { const data = await api('/admin/v1/operators/'+operatorId+'/launch-demo', { method: 'POST', body }); + $('launchResult').hidden = false; $('launchResult').className = 'status ok'; + $('launchResult').innerHTML = 'launch ready — open player ↗'; + } catch (e) { flash($('launchResult'), e.message, false); } +} +`; + +export const REPORTS_JS = /* js */ ` +async function loadReports() { + const ro = $('repOp'); const opId = ro ? ro.value.trim() : ''; + const qs = opId ? ('?operator_id=' + encodeURIComponent(opId)) : ''; + try { const s = await api('/admin/v1/reports/summary'+qs); + setHTML('repSummary', '
    ' + cardHtml('rounds', s.rounds) + cardHtml('total bet', fmtMoney(s.total_bet)) + cardHtml('total win', fmtMoney(s.total_win)) + cardHtml('GGR', fmtMoney(s.ggr)) + cardHtml('hold %', s.hold_percent) + '
    '); + } catch (e) { setHTML('repSummary', ''+e.message+''); } + try { const r = await api('/admin/v1/reports/rtp'+qs); + setHTML('repRtp', '
    ' + cardHtml('rounds', r.rounds) + cardHtml('total bet', fmtMoney(r.total_bet)) + cardHtml('total win', fmtMoney(r.total_win)) + cardHtml('actual RTP %', r.actual_rtp_percent) + '
    '); + } catch (e) { setHTML('repRtp', ''+e.message+''); } + try { const g = await api('/admin/v1/reports/ggr'+qs); setHTML('repGgr', tableHtml(g.ggr_by_day || [], ['date','rounds','total_bet','total_win','ggr'])); } + catch (e) { setHTML('repGgr', ''+e.message+''); } + try { const re = await api('/admin/v1/reports/reconciliation'+qs); + setHTML('repRecon', '
    ' + cardHtml('ok', re.ok ? 'yes' : 'no') + cardHtml('rounds bet', fmtMoney(re.rounds_total_bet)) + cardHtml('rounds win', fmtMoney(re.rounds_total_win)) + cardHtml('debits (tx)', fmtMoney(re.tx_debits)) + cardHtml('credits (tx)', fmtMoney(re.tx_credits)) + cardHtml('rollbacks (tx)', fmtMoney(re.tx_rollbacks)) + cardHtml('bet vs debit diff', fmtMoney(re.bet_vs_debit_diff)) + cardHtml('win vs credit diff', fmtMoney(re.win_vs_credit_diff)) + '
    '); + } catch (e) { setHTML('repRecon', ''+e.message+''); } + try { const f = await api('/admin/v1/transactions/failed'+qs); setHTML('repFailed', tableHtml(f.failed_settlements || [], ['round_ref','type','amount','currency','status','error_code'])); } + catch (e) { setHTML('repFailed', ''+e.message+''); } +} +`; + +export const ROUNDACTIONS_JS = /* js */ ` +async function verifyRound() { + const ref = $('roundRefInput').value.trim(); const out = $('roundActionOut'); + if (!ref) { flash(out, 'paste a round id above first', false); return; } + out.hidden = false; out.className = 'status'; out.textContent = 'verifying…'; + try { const v = await api('/admin/v1/rounds/' + encodeURIComponent(ref) + '/verify'); + out.className = 'status ' + (v.verdict === 'tampered' ? 'err' : 'ok'); + out.innerHTML = 'Verdict: ' + v.verdict.toUpperCase() + '
    ' + + cardHtml('outcome ok', v.outcome_ok === null ? 'n/a' : (v.outcome_ok ? 'yes' : 'NO')) + cardHtml('chain ok', v.chain_ok ? 'yes' : 'NO') + '
    ' + + '
    stored:     ' + v.stored_hash + '\\nrecomputed: ' + (v.recomputed_hash || '(not replayed)') + '
    ' + + (v.notes && v.notes.length ? '

    ' + esc(v.notes.join(' · ')) + '

    ' : ''); + } catch (e) { flash(out, e.message, false); } +} +async function loadRoundTx() { + const ref = $('roundRefInput').value.trim(); const out = $('roundActionOut'); + if (!ref) { flash(out, 'paste a round id above first', false); return; } + try { const d = await api('/admin/v1/rounds/' + encodeURIComponent(ref) + '/transactions'); + out.hidden = false; out.className = 'status'; + out.innerHTML = '

    effective status: ' + d.effective_status + ' (recorded: ' + d.recorded_status + ')

    ' + tableHtml(d.transactions, ['type','amount','currency','status','error_code','idempotency_key']); + } catch (e) { flash(out, e.message, false); } +} +async function voidRoundAction() { await lifecycleAction('void'); } +async function settleRoundAction() { await lifecycleAction('settle'); } +async function lifecycleAction(kind) { + const ref = $('roundRefInput').value.trim(); const out = $('roundActionOut'); + if (!ref) { flash(out, 'paste a round id first', false); return; } + const reason = prompt('Reason for ' + kind + ' on ' + ref + ':'); if (reason === null || !reason.trim()) return; + try { const r = await api('/admin/v1/rounds/' + encodeURIComponent(ref) + '/' + kind, { method: 'POST', body: { reason: reason.trim() } }); flash(out, kind + ' done — effective status: ' + r.effective_status + (r.already ? ' (no-op)' : '') + '. ' + (r.notes || []).join('; '), true); } + catch (e) { flash(out, e.message, false); } +} +async function raiseDisputeAction(kind) { + const ref = $('roundRefInput').value.trim(); const out = $('roundActionOut'); + if (!ref) { flash(out, 'paste a round id first', false); return; } + const reason = prompt('Reason for requesting ' + kind + ' on ' + ref + ':'); if (reason === null || !reason.trim()) return; + try { const r = await api('/admin/v1/disputes', { method: 'POST', body: { round_ref: ref, kind, reason: reason.trim() } }); flash(out, 'dispute raised (' + r.dispute.id + ', ' + r.dispute.status + ') — a provider admin will review it.', true); } + catch (e) { flash(out, e.message, false); } +} +`; + +export const GAMES_JS = /* js */ ` +async function loadGames() { + const el = $('gamesList'); if (!el) return; + const go = $('gamesOp'); const opId = go ? go.value.trim() : ''; + const qs = opId ? ('?operator_id=' + encodeURIComponent(opId)) : ''; + try { const d = await api('/admin/v1/operator-games' + qs); const rows = d.operator_games || []; + if (!rows.length) { el.innerHTML = '

    no game assignments

    '; return; } + el.innerHTML = '' + + rows.map(g => '' + + '' + + '').join('') + '
    assignmentgame_idcurrencybetsstatus
    ' + g.id.slice(0,8) + '…' + g.game_id.slice(0,8) + '…' + g.currency + '' + g.allowed_bets.join(', ') + '' + (g.status === 'enabled' ? 'enabled' : 'disabled') + '
    '; + el.querySelectorAll('button[data-id]').forEach(b => b.addEventListener('click', () => toggleGame(b.dataset.id, b.dataset.to))); + } catch (e) { el.innerHTML = '' + e.message + ''; } +} +async function toggleGame(id, to) { try { await api('/admin/v1/operator-games/' + id + '/status', { method: 'POST', body: { status: to } }); loadGames(); } catch (e) { alert(e.message); } } +`; + +export const DISPUTES_JS = /* js */ ` +async function loadDisputes() { + const el = $('disputesList'); if (!el) return; + const dO = $('dspOp'); const opId = dO ? dO.value.trim() : ''; const stEl = $('dspStatus'); const st = stEl ? stEl.value : ''; + const params = []; if (opId) params.push('operator_id=' + encodeURIComponent(opId)); if (st) params.push('status=' + st); + const qs = params.length ? ('?' + params.join('&')) : ''; + try { const d = await api('/admin/v1/disputes' + qs); const rows = d.disputes || []; + if (!rows.length) { el.innerHTML = '

    no disputes

    '; return; } + const prov = state.scope === 'provider'; + el.innerHTML = '' + + rows.map(x => '' + + '' + + '').join('') + '
    idroundkindreasonstatusby
    ' + x.id + '' + x.round_ref + '' + x.kind + '' + esc(x.reason) + '' + x.status + '' + esc(x.requested_by) + '' + ((prov && x.status === 'open') ? (' ') : '') + '
    '; + el.querySelectorAll('button[data-app]').forEach(b => b.addEventListener('click', () => approveDispute(b.dataset.app))); + el.querySelectorAll('button[data-rej]').forEach(b => b.addEventListener('click', () => rejectDispute(b.dataset.rej))); + el.querySelectorAll('td[data-ref]').forEach(td => td.addEventListener('click', () => { $('roundRefInput').value = td.dataset.ref; show('inspector'); lookupRound(); })); + } catch (e) { el.innerHTML = '' + e.message + ''; } +} +async function approveDispute(id) { const note = prompt('Approval note (optional):') || ''; try { await api('/admin/v1/disputes/' + id + '/approve', { method: 'POST', body: { note } }); loadDisputes(); } catch (e) { alert(e.message); } } +async function rejectDispute(id) { const note = prompt('Rejection reason (required):'); if (note === null || !note.trim()) return; try { await api('/admin/v1/disputes/' + id + '/reject', { method: 'POST', body: { note: note.trim() } }); loadDisputes(); } catch (e) { alert(e.message); } } +`; + +export const PLAYERS_JS = /* js */ ` +async function loadPlayer() { + const el = $('playerRounds'); const st = $('plStatus'); + const plO = $('plOp'); const opId = plO ? plO.value.trim() : ''; const pid = $('plPlayer').value.trim(); + if (!pid) { flash(st, 'enter a player id', false); return; } + const qs = opId ? ('?operator_id=' + encodeURIComponent(opId)) : ''; + st.hidden = false; st.className = 'muted'; st.textContent = 'loading…'; + try { const d = await api('/admin/v1/players/' + encodeURIComponent(pid) + '/rounds' + qs); + st.textContent = d.count + ' rounds'; + if (!d.rounds.length) { el.innerHTML = '

    no rounds for this player

    '; return; } + el.innerHTML = '' + + d.rounds.map(r => '' + + '' + + '').join('') + '
    roundbetwinfree?statuscreated
    ' + r.round_ref + '' + fmtMoney(r.bet_charged) + '' + (r.total_win > 0 ? '+' + fmtMoney(r.total_win) + '' : fmtMoney(r.total_win)) + '' + (r.is_free_spin ? 'yes' : '') + '' + (r.effective_status !== r.status ? ('' + r.effective_status + '') : r.status) + '' + (r.created_at || '').replace('T',' ').slice(0,19) + '
    '; + el.querySelectorAll('tr.clickable').forEach(tr => tr.addEventListener('click', () => { $('roundRefInput').value = tr.dataset.ref; show('inspector'); lookupRound(); })); + } catch (e) { flash(st, e.message, false); el.innerHTML = ''; } +} +`; + +export const OPERATORS_JS = /* js */ ` +function renderOperators() { + const el = $('operatorsList'); if (!el) return; const rows = state.operators; + if (!rows.length) { el.innerHTML = '

    no operators

    '; return; } + el.innerHTML = '' + + rows.map(o => '' + + '').join('') + '
    slugnamestatusccyactions
    ' + esc(o.slug) + '' + esc(o.name) + '' + o.status + '' + o.default_currency + '' + (o.status === 'suspended' ? '' : '') + ' ' + + '
    '; + el.querySelectorAll('button[data-act]').forEach(b => b.addEventListener('click', () => setOperatorStatus(b.dataset.op, b.dataset.act))); + el.querySelectorAll('button[data-creds]').forEach(b => b.addEventListener('click', () => loadCredentials(b.dataset.creds))); +} +async function setOperatorStatus(id, status) { + if (status === 'suspended' && !confirm('Suspend this operator? New sessions & spins will be blocked immediately.')) return; + try { await api('/admin/v1/operators/' + id + '/status', { method: 'POST', body: { status } }); refreshDashboard(); } catch (e) { alert(e.message); } +} +async function loadCredentials(id) { + const out = $('opManageOut'); if (!out) return; + try { const d = await api('/admin/v1/operators/' + id + '/credentials'); const rows = d.credentials || []; + out.innerHTML = '

    Credentials

    ' + (rows.length + ? ('' + + rows.map(c => '' + + '').join('') + '
    api_key_idenvstatuslast4
    ' + c.api_key_id + '' + c.environment + '' + c.status + '' + c.secret_last4 + '' + (c.status !== 'revoked' ? (' ') : '') + '
    ') + : '

    no credentials

    ') + '
    '; + out.querySelectorAll('button[data-rev]').forEach(b => b.addEventListener('click', () => revokeCred(b.dataset.rev))); + out.querySelectorAll('button[data-rot]').forEach(b => b.addEventListener('click', () => rotateCred(b.dataset.rot))); + } catch (e) { out.innerHTML = '' + e.message + ''; } +} +async function revokeCred(pair) { const parts = pair.split('|'); const op = parts[0], key = parts[1]; if (!confirm('Revoke ' + key + '? This is immediate and permanent.')) return; try { await api('/admin/v1/operators/' + op + '/credentials/' + key + '/revoke', { method: 'POST', body: {} }); loadCredentials(op); } catch (e) { alert(e.message); } } +async function rotateCred(pair) { const parts = pair.split('|'); const op = parts[0], key = parts[1]; try { const d = await api('/admin/v1/operators/' + op + '/credentials/' + key + '/rotate', { method: 'POST', body: {} }); alert('New api_key_id: ' + d.credential.api_key_id + '\\napi_secret (shown once):\\n' + d.api_secret); loadCredentials(op); } catch (e) { alert(e.message); } } +`; + +export const ACCOUNTS_JS = /* js */ ` +const OPERATOR_ROLES = ['operator_admin','operator_finance','operator_viewer']; +const PROVIDER_ROLES = ['provider_super_admin','provider_finance','provider_read_only']; +function acctRoleOptions() { + const scope = $('acctScope') ? $('acctScope').value : 'operator'; + const roles = scope === 'provider' ? PROVIDER_ROLES : OPERATOR_ROLES; + const sel = $('acctRole'); if (sel) sel.innerHTML = roles.map(r => '').join(''); + const opWrap = $('acctOpWrap'); if (opWrap) opWrap.style.display = scope === 'operator' ? '' : 'none'; +} +async function loadAccounts() { + if ($('acctScope')) { $('acctScope').onchange = acctRoleOptions; acctRoleOptions(); } + try { const ops = await api('/admin/v1/operators'); state.operators = ops.operators || []; + const sel = $('acctOp'); if (sel) sel.innerHTML = state.operators.map(o => '').join(''); + } catch {} + const el = $('accountsList'); if (!el) return; + try { const d = await api('/admin/v1/admin-accounts'); const rows = d.accounts || []; + if (!rows.length) { el.innerHTML = '

    no admin accounts yet

    '; return; } + el.innerHTML = '' + + rows.map(a => '' + + '' + + '' + + '').join('') + '
    usernamescoperoleoperatorstatussetup
    ' + esc(a.username) + '' + a.scope + '' + a.role + '' + (a.operator_id ? a.operator_id.slice(0,8)+'…' : '—') + '' + (a.status === 'active' ? 'active' : 'disabled') + '' + (a.must_set_password ? 'needs pw · ' : '') + (a.totp_enrolled ? '2FA on' : 'needs 2FA') + ' ' + + (a.status === 'active' ? '' : '') + '
    '; + el.querySelectorAll('button[data-rpw]').forEach(b => b.addEventListener('click', () => resetAccountPassword(b.dataset.rpw))); + el.querySelectorAll('button[data-rtotp]').forEach(b => b.addEventListener('click', () => resetAccountTotp(b.dataset.rtotp))); + el.querySelectorAll('button[data-dis]').forEach(b => b.addEventListener('click', () => disableAccount(b.dataset.dis))); + } catch (e) { el.innerHTML = '' + e.message + ''; } +} +async function createAccount() { + clearFlash($('acctResult')); + const username = $('acctUser').value.trim(); const scope = $('acctScope').value; const role = $('acctRole').value; + const operator_id = scope === 'operator' ? $('acctOp').value : undefined; + if (!username) return flash($('acctResult'), 'username required', false); + try { const d = await api('/admin/v1/admin-accounts', { method: 'POST', body: { username, scope, role, operator_id } }); + $('acctResult').hidden = false; $('acctResult').className = 'status ok'; + $('acctResult').innerHTML = 'created ' + esc(d.account.username) + ' — one-time password (share securely, they set a new one + enroll 2FA on first login):
    ' + esc(d.temp_password) + '
    '; + loadAccounts(); + } catch (e) { flash($('acctResult'), e.message, false); } +} +async function resetAccountPassword(id) { + if (!confirm('Reset this admin\\'s password? They must set a new one on next login.')) return; + try { const d = await api('/admin/v1/admin-accounts/' + id + '/reset-password', { method: 'POST', body: {} }); alert('New one-time password (shown once):\\n' + d.temp_password); loadAccounts(); } catch (e) { alert(e.message); } +} +async function resetAccountTotp(id) { if (!confirm('Reset 2FA? They must re-enroll an authenticator on next login.')) return; try { await api('/admin/v1/admin-accounts/' + id + '/reset-totp', { method: 'POST', body: {} }); loadAccounts(); } catch (e) { alert(e.message); } } +async function disableAccount(id) { if (!confirm('Disable this admin account? They will be unable to sign in.')) return; try { await api('/admin/v1/admin-accounts/' + id + '/disable', { method: 'POST', body: {} }); loadAccounts(); } catch (e) { alert(e.message); } } +`; diff --git a/platform/src/http/admin-console.ts b/platform/src/http/admin-console.ts deleted file mode 100644 index 2976050..0000000 --- a/platform/src/http/admin-console.ts +++ /dev/null @@ -1,1095 +0,0 @@ -import type { FastifyPluginAsync } from "fastify"; - -/** - * Banana X Admin Portal — a single served SPA (no separate build step) covering - * everything an admin actually needs: - * - * • Dashboard — operators + rounds summary + reconciliation - * • Round Inspector — paste a round_ref and see TEXT + VISUAL playback of a - * stored spin, using the real symbol PNGs - * • Onboarding — create operator, allowlist a domain, issue a credential, - * register a game, create+approve a math config, assign it, - * and get a one-click launch link to a demo player - * • Reports — RTP / GGR / reconciliation / failed settlements - * - * Token-paste auth (the dev-seed script prints one); state is persisted to - * localStorage so a refresh keeps you signed in. Everything talks to /admin/v1/*. - */ -const PAGE = /* html */ ` - - - -Banana X · Admin - - - -
    -

    🍌 Banana X · Admin Portal

    - not signed in - -
    - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - - -`; - -const adminConsoleRoutes: FastifyPluginAsync = async (app) => { - // The portal HTML/JS is inlined, so never let a browser serve a stale copy — - // otherwise an old build's sign-in logic (or a fixed bug) lingers after a deploy. - const serve = async (_req: unknown, reply: import("fastify").FastifyReply) => - reply.type("text/html").header("cache-control", "no-store").send(PAGE); - app.get("/", serve); - app.get("/admin", serve); -}; - -export default adminConsoleRoutes; diff --git a/platform/src/http/auth.ts b/platform/src/http/auth.ts index cba0377..85ce7ec 100644 --- a/platform/src/http/auth.ts +++ b/platform/src/http/auth.ts @@ -50,6 +50,21 @@ export function operatorHmacAuth(container: Container): preHandlerHookHandler { }; } +/** Admin-console auth: validate an admin session bearer token → req.adminClaims. */ +export function adminBearerAuth(container: Container): preHandlerHookHandler { + return async (req, reply) => { + const auth = header(req, "authorization"); + if (!auth || !auth.startsWith("Bearer ")) { + return deny(reply, req, 401, "UNAUTHORIZED", "admin token required"); + } + try { + req.adminClaims = container.adminAuth.verify(auth.slice(7)); + } catch { + return deny(reply, req, 401, "UNAUTHORIZED", "invalid admin token"); + } + }; +} + /** Game-client auth: validate a session bearer token. */ export function gameBearerAuth(container: Container): preHandlerHookHandler { return async (req, reply) => { diff --git a/platform/src/http/operator-console.ts b/platform/src/http/operator-console.ts new file mode 100644 index 0000000..5b06ce1 --- /dev/null +++ b/platform/src/http/operator-console.ts @@ -0,0 +1,48 @@ +import type { FastifyPluginAsync } from "fastify"; +import { + renderConsolePage, + SECTION_DASHBOARD_OPERATOR, + SECTION_INSPECTOR, + SECTION_GAMES, + SECTION_DISPUTES, + SECTION_PLAYERS, + SECTION_REPORTS +} from "./admin-console-sections"; + +/** + * Provider Games — the CLIENT (operator/casino) portal, served at /admin. Shows + * only this operator's own data: their assigned games (turn on/off), their rounds + * + reports, player lookup, round inspector, and raising disputes to the provider. + * Operator-scoped login only. Multi-game, provider-neutral branding — NOT tied to + * any single game. + */ +const PAGE = renderConsolePage({ + title: "Provider Games · Operator Portal", + brandtag: "Operator · casino admin", + consoleKind: "operator", + expectedScope: "operator", + nav: [ + { view: "dashboard", label: "Dashboard" }, + { view: "games", label: "My Games" }, + { view: "inspector", label: "Round Inspector" }, + { view: "players", label: "Players" }, + { view: "disputes", label: "Disputes" }, + { view: "reports", label: "Reports" } + ], + sections: [ + SECTION_DASHBOARD_OPERATOR, + SECTION_GAMES, + SECTION_INSPECTOR, + SECTION_PLAYERS, + SECTION_DISPUTES, + SECTION_REPORTS + ].join("\n") +}); + +const operatorConsoleRoutes: FastifyPluginAsync = async (app) => { + const serve = async (_req: unknown, reply: import("fastify").FastifyReply) => + reply.type("text/html").header("cache-control", "no-store").send(PAGE); + app.get("/admin", serve); +}; + +export default operatorConsoleRoutes; diff --git a/platform/src/http/provider-console.ts b/platform/src/http/provider-console.ts new file mode 100644 index 0000000..e1e2d2e --- /dev/null +++ b/platform/src/http/provider-console.ts @@ -0,0 +1,55 @@ +import type { FastifyPluginAsync } from "fastify"; +import { + renderConsolePage, + SECTION_DASHBOARD_PROVIDER, + SECTION_INSPECTOR, + SECTION_GAMES, + SECTION_DISPUTES, + SECTION_PLAYERS, + SECTION_ONBOARDING, + SECTION_REPORTS, + SECTION_ACCOUNTS +} from "./admin-console-sections"; + +/** + * Provider Control Plane — OUR admin console (served at /provider). Full control: + * onboarding, operators, all games, math configs, disputes (approve/reject), + * reports, round inspector, and admin-account management. Provider-scoped login + * only. Neutral branding (no single-game identity). + */ +const PAGE = renderConsolePage({ + title: "Provider Control Plane", + brandtag: "Provider · internal", + consoleKind: "provider", + expectedScope: "provider", + nav: [ + { view: "dashboard", label: "Dashboard" }, + { view: "inspector", label: "Round Inspector" }, + { view: "games", label: "Games" }, + { view: "disputes", label: "Disputes" }, + { view: "players", label: "Players" }, + { view: "onboarding", label: "Onboarding" }, + { view: "reports", label: "Reports" }, + { view: "accounts", label: "Admin Accounts" } + ], + sections: [ + SECTION_DASHBOARD_PROVIDER, + SECTION_INSPECTOR, + SECTION_GAMES, + SECTION_DISPUTES, + SECTION_PLAYERS, + SECTION_ONBOARDING, + SECTION_REPORTS, + SECTION_ACCOUNTS + ].join("\n") +}); + +const providerConsoleRoutes: FastifyPluginAsync = async (app) => { + const serve = async (_req: unknown, reply: import("fastify").FastifyReply) => + reply.type("text/html").header("cache-control", "no-store").send(PAGE); + // Root redirects to the provider console (the operator portal lives at /admin). + app.get("/", async (_req, reply) => reply.code(302).header("location", "/provider").send()); + app.get("/provider", serve); +}; + +export default providerConsoleRoutes; diff --git a/platform/src/lib/security/password.ts b/platform/src/lib/security/password.ts new file mode 100644 index 0000000..f9d1284 --- /dev/null +++ b/platform/src/lib/security/password.ts @@ -0,0 +1,38 @@ +import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; + +/** + * Password hashing with scrypt (node:crypto — no external deps, matching the + * hand-rolled tokens.ts). Stored format is a single self-describing string: + * + * scrypt$$$ + * + * so the work factor travels with the hash and can be raised later without a + * migration. Verification is constant-time via timingSafeEqual. + */ + +const KEYLEN = 32; +const DEFAULT_COST = 16384; // scrypt N (2^14); r=8, p=1 defaults. + +export function hashPassword(password: string, cost = DEFAULT_COST): string { + const salt = randomBytes(16); + const hash = scryptSync(password, salt, KEYLEN, { N: cost }); + return `scrypt$${cost}$${salt.toString("hex")}$${hash.toString("hex")}`; +} + +export function verifyPassword(password: string, stored: string): boolean { + const parts = stored.split("$"); + if (parts.length !== 4 || parts[0] !== "scrypt") return false; + const cost = Number(parts[1]); + if (!Number.isInteger(cost) || cost <= 1) return false; + let salt: Buffer; + let expected: Buffer; + try { + salt = Buffer.from(parts[2], "hex"); + expected = Buffer.from(parts[3], "hex"); + } catch { + return false; + } + if (expected.length !== KEYLEN) return false; + const actual = scryptSync(password, salt, KEYLEN, { N: cost }); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} diff --git a/platform/src/lib/security/totp.ts b/platform/src/lib/security/totp.ts new file mode 100644 index 0000000..c4ede1f --- /dev/null +++ b/platform/src/lib/security/totp.ts @@ -0,0 +1,113 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +/** + * RFC 6238 TOTP (and its RFC 4226 HOTP core) for authenticator-app 2FA. + * Dependency-free (node:crypto only), consistent with tokens.ts. Defaults match + * Google Authenticator / Authy / 1Password: SHA-1, 30s step, 6 digits. + * + * Secrets are RFC 4648 base32 (no padding) so they paste into any authenticator + * and encode into an otpauth:// URI for QR enrollment. + */ + +const DIGITS = 6; +const PERIOD = 30; +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +/** Generate a random base32 secret (default 20 bytes = 160 bits, the RFC norm). */ +export function generateTotpSecret(bytes = 20): string { + return base32Encode(randomBytes(bytes)); +} + +/** Current 6-digit code for a secret (mainly for tests / dev tooling). */ +export function totpCode(secret: string, atMs = Date.now()): string { + const counter = Math.floor(atMs / 1000 / PERIOD); + return hotp(base32Decode(secret), counter); +} + +/** + * Verify a submitted code, allowing ±`window` steps of clock drift (default ±1, + * i.e. the previous/current/next 30s window). Constant-time per candidate. + */ +export function verifyTotp(secret: string, code: string, atMs = Date.now(), window = 1): boolean { + const trimmed = (code ?? "").replace(/\s/g, ""); + if (!/^\d{6}$/.test(trimmed)) return false; + let key: Buffer; + try { + key = base32Decode(secret); + } catch { + return false; + } + const base = Math.floor(atMs / 1000 / PERIOD); + for (let offset = -window; offset <= window; offset += 1) { + const candidate = hotp(key, base + offset); + const a = Buffer.from(candidate); + const b = Buffer.from(trimmed); + if (a.length === b.length && timingSafeEqual(a, b)) return true; + } + return false; +} + +/** Build the otpauth:// URI an authenticator app encodes as a QR code. */ +export function otpauthUri(secret: string, opts: { issuer: string; account: string }): string { + // Key URI format: the "issuer:account" label keeps a literal colon separator; + // each side is URL-encoded independently. + const label = `${encodeURIComponent(opts.issuer)}:${encodeURIComponent(opts.account)}`; + const params = new URLSearchParams({ + secret, + issuer: opts.issuer, + algorithm: "SHA1", + digits: String(DIGITS), + period: String(PERIOD) + }); + return `otpauth://totp/${label}?${params.toString()}`; +} + +// ── RFC 4226 HOTP core ──────────────────────────────────────────────────────── +function hotp(key: Buffer, counter: number): string { + const buf = Buffer.alloc(8); + // 64-bit big-endian counter (safe-integer range is plenty for time steps). + buf.writeBigUInt64BE(BigInt(counter)); + const hmac = createHmac("sha1", key).update(buf).digest(); + const offset = hmac[hmac.length - 1] & 0x0f; + const binary = + ((hmac[offset] & 0x7f) << 24) | + ((hmac[offset + 1] & 0xff) << 16) | + ((hmac[offset + 2] & 0xff) << 8) | + (hmac[offset + 3] & 0xff); + return (binary % 10 ** DIGITS).toString().padStart(DIGITS, "0"); +} + +// ── base32 (RFC 4648, no padding) ───────────────────────────────────────────── +function base32Encode(buf: Buffer): string { + let bits = 0; + let value = 0; + let out = ""; + for (const byte of buf) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + out += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) out += BASE32_ALPHABET[(value << (5 - bits)) & 31]; + return out; +} + +function base32Decode(secret: string): Buffer { + const clean = secret.replace(/=+$/, "").replace(/\s/g, "").toUpperCase(); + let bits = 0; + let value = 0; + const out: number[] = []; + for (const ch of clean) { + const idx = BASE32_ALPHABET.indexOf(ch); + if (idx === -1) throw new Error("INVALID_BASE32"); + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + out.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Buffer.from(out); +} diff --git a/platform/src/modules/admin/admin-account.ts b/platform/src/modules/admin/admin-account.ts new file mode 100644 index 0000000..ad96424 --- /dev/null +++ b/platform/src/modules/admin/admin-account.ts @@ -0,0 +1,265 @@ +import { randomUUID, randomBytes } from "node:crypto"; +import type { AuditRepository } from "../ledger/ledger.types"; +import { NullPersistence, type Persistence } from "../../persistence/persistence"; +import { hashPassword, verifyPassword } from "../../lib/security/password"; +import { generateTotpSecret, verifyTotp, otpauthUri } from "../../lib/security/totp"; +import type { AdminRole, AdminScope } from "./admin-auth"; + +/** + * Admin identity store: the real accounts behind the two consoles. Each account + * has a scrypt password hash and (once enrolled) a TOTP secret for authenticator + * 2FA. Login (password → TOTP) is orchestrated in http/admin-auth.routes.ts; this + * service owns the credential state machine and lockout. + * + * Storage is an in-memory Map (write-through to Postgres when persistence is + * attached), matching ManagementService. + */ + +const now = (): string => new Date().toISOString(); +const MAX_FAILED_ATTEMPTS = 5; +const LOCKOUT_MS = 15 * 60 * 1000; + +export interface AdminAccount { + id: string; + username: string; // canonical (lowercased) + scope: AdminScope; + operator_id: string | null; + role: AdminRole; + password_hash: string; + totp_secret: string | null; + status: "active" | "disabled"; + must_set_password: boolean; + totp_enrolled: boolean; + failed_attempts: number; + locked_until: string | null; + created_at: string; +} + +/** Public view — never leaks password_hash or totp_secret. */ +export type AdminAccountView = Omit; + +export function toView(a: AdminAccount): AdminAccountView { + const { password_hash: _p, totp_secret: _t, ...view } = a; + return view; +} + +function generateTempPassword(): string { + // URL-safe, human-transcribable one-time password handed to a new admin. + return randomBytes(12).toString("base64url"); +} + +export class AdminAccountService { + private readonly accounts = new Map(); + private readonly byUsername = new Map(); // username -> id + + constructor( + private readonly audit: AuditRepository, + private readonly persistence: Persistence = NullPersistence, + private readonly issuer = "Provider Platform" + ) {} + + hydrate(rows: AdminAccount[]): void { + for (const row of rows) { + this.accounts.set(row.id, row); + this.byUsername.set(row.username, row.id); + } + } + + private save(a: AdminAccount): void { + this.persistence.save("admin_accounts", a); + } + + private record(a: AdminAccount, action: string, actorId: string): void { + this.audit.append({ + operator_id: a.operator_id, + actor_type: "admin", + actor_id: actorId, + action, + target_type: "admin_account", + target_id: a.id + }); + } + + getByUsername(username: string): AdminAccount | null { + const id = this.byUsername.get(username.trim().toLowerCase()); + return id ? this.accounts.get(id) ?? null : null; + } + + getById(id: string): AdminAccount | null { + return this.accounts.get(id) ?? null; + } + + list(filter: { scope?: AdminScope; operator_id?: string } = {}): AdminAccountView[] { + return Array.from(this.accounts.values()) + .filter((a) => (filter.scope ? a.scope === filter.scope : true)) + .filter((a) => (filter.operator_id ? a.operator_id === filter.operator_id : true)) + .map(toView); + } + + hasAnyProviderAccount(): boolean { + for (const a of this.accounts.values()) if (a.scope === "provider") return true; + return false; + } + + /** + * Create an account with a one-time temp password. The account must set a new + * password and enroll TOTP on first login. Returns the temp password ONCE. + */ + create( + input: { username: string; scope: AdminScope; operator_id?: string | null; role: AdminRole }, + actorId = "system" + ): { account: AdminAccountView; temp_password: string } { + const username = input.username.trim().toLowerCase(); + if (!username) throw new Error("USERNAME_REQUIRED"); + if (this.byUsername.has(username)) throw new Error("DUPLICATE_USERNAME"); + if (input.scope === "operator" && !input.operator_id) throw new Error("OPERATOR_ID_REQUIRED"); + + const temp = generateTempPassword(); + const account: AdminAccount = { + id: randomUUID(), + username, + scope: input.scope, + operator_id: input.scope === "operator" ? input.operator_id ?? null : null, + role: input.role, + password_hash: hashPassword(temp), + totp_secret: null, + status: "active", + must_set_password: true, + totp_enrolled: false, + failed_attempts: 0, + locked_until: null, + created_at: now() + }; + this.accounts.set(account.id, account); + this.byUsername.set(username, account.id); + this.save(account); + this.record(account, "admin_account.create", actorId); + return { account: toView(account), temp_password: temp }; + } + + /** + * Seed a provider super-admin from a known password (env bootstrap). The + * password is already set, but TOTP still must be enrolled on first login. + * Idempotent-ish: caller checks hasAnyProviderAccount() first. + */ + seedProviderSuperAdmin(username: string, password: string): AdminAccountView { + const canonical = username.trim().toLowerCase(); + if (this.byUsername.has(canonical)) return toView(this.accounts.get(this.byUsername.get(canonical)!)!); + const account: AdminAccount = { + id: randomUUID(), + username: canonical, + scope: "provider", + operator_id: null, + role: "provider_super_admin", + password_hash: hashPassword(password), + totp_secret: null, + status: "active", + must_set_password: false, + totp_enrolled: false, + failed_attempts: 0, + locked_until: null, + created_at: now() + }; + this.accounts.set(account.id, account); + this.byUsername.set(canonical, account.id); + this.save(account); + this.record(account, "admin_account.seed", "bootstrap"); + return toView(account); + } + + isLocked(a: AdminAccount): boolean { + return a.locked_until !== null && Date.parse(a.locked_until) > Date.now(); + } + + verifyPassword(a: AdminAccount, password: string): boolean { + return verifyPassword(password, a.password_hash); + } + + recordFailedLogin(id: string): void { + const a = this.accounts.get(id); + if (!a) return; + a.failed_attempts += 1; + if (a.failed_attempts >= MAX_FAILED_ATTEMPTS) { + a.locked_until = new Date(Date.now() + LOCKOUT_MS).toISOString(); + } + this.save(a); + } + + recordSuccessfulLogin(id: string): void { + const a = this.accounts.get(id); + if (!a) return; + a.failed_attempts = 0; + a.locked_until = null; + this.save(a); + } + + setPassword(id: string, newPassword: string): void { + const a = this.accounts.get(id); + if (!a) throw new Error("ACCOUNT_NOT_FOUND"); + a.password_hash = hashPassword(newPassword); + a.must_set_password = false; + a.failed_attempts = 0; + a.locked_until = null; + this.save(a); + this.record(a, "admin_account.password_set", a.id); + } + + /** Begin TOTP enrollment: mint + store a secret (not yet active) and return the + * provisioning URI/secret for the authenticator app. */ + beginTotpEnrollment(id: string, account: string): { secret: string; otpauth_uri: string } { + const a = this.accounts.get(id); + if (!a) throw new Error("ACCOUNT_NOT_FOUND"); + const secret = generateTotpSecret(); + a.totp_secret = secret; + a.totp_enrolled = false; + this.save(a); + return { secret, otpauth_uri: otpauthUri(secret, { issuer: this.issuer, account }) }; + } + + confirmTotpEnrollment(id: string, code: string): boolean { + const a = this.accounts.get(id); + if (!a || !a.totp_secret) return false; + if (!verifyTotp(a.totp_secret, code)) return false; + a.totp_enrolled = true; + this.save(a); + this.record(a, "admin_account.totp_enrolled", a.id); + return true; + } + + verifyTotpCode(a: AdminAccount, code: string): boolean { + if (!a.totp_secret || !a.totp_enrolled) return false; + return verifyTotp(a.totp_secret, code); + } + + disable(id: string, actorId = "system"): AdminAccountView { + const a = this.accounts.get(id); + if (!a) throw new Error("ACCOUNT_NOT_FOUND"); + a.status = "disabled"; + this.save(a); + this.record(a, "admin_account.disable", actorId); + return toView(a); + } + + resetPassword(id: string, actorId = "system"): { account: AdminAccountView; temp_password: string } { + const a = this.accounts.get(id); + if (!a) throw new Error("ACCOUNT_NOT_FOUND"); + const temp = generateTempPassword(); + a.password_hash = hashPassword(temp); + a.must_set_password = true; + a.failed_attempts = 0; + a.locked_until = null; + this.save(a); + this.record(a, "admin_account.reset_password", actorId); + return { account: toView(a), temp_password: temp }; + } + + resetTotp(id: string, actorId = "system"): AdminAccountView { + const a = this.accounts.get(id); + if (!a) throw new Error("ACCOUNT_NOT_FOUND"); + a.totp_secret = null; + a.totp_enrolled = false; + this.save(a); + this.record(a, "admin_account.reset_totp", actorId); + return toView(a); + } +} diff --git a/platform/src/persistence/hydrate.ts b/platform/src/persistence/hydrate.ts index 1f15c9a..7b9d35e 100644 --- a/platform/src/persistence/hydrate.ts +++ b/platform/src/persistence/hydrate.ts @@ -8,6 +8,7 @@ import type { AdjustmentRecord } from "../modules/rounds/round-adjustment.store" import type { Session } from "../modules/session/session.service"; import type { DisputeRecord } from "../modules/disputes/dispute.service"; import type { Operator, OperatorDomain, ApiCredential, Game, MathConfig, OperatorGame } from "../modules/management/management.types"; +import type { AdminAccount } from "../modules/admin/admin-account"; import type { WalletTxResult, WalletRollbackResult } from "../modules/wallet/wallet.types"; /** @@ -16,7 +17,7 @@ import type { WalletTxResult, WalletRollbackResult } from "../modules/wallet/wal * chains and seq counters continue seamlessly. */ export async function hydrateContainer(c: Container, p: Persistence): Promise { - const [operators, domains, credentials, games, mathConfigs, operatorGames, sessions, rounds, txs, adjustments, audit, disputes, balances, applied, debitRefs] = + const [operators, domains, credentials, games, mathConfigs, operatorGames, sessions, rounds, txs, adjustments, audit, disputes, balances, applied, debitRefs, adminAccounts] = await Promise.all([ p.loadTable("operators"), p.loadTable("operator_domains"), @@ -32,7 +33,8 @@ export async function hydrateContainer(c: Container, p: Persistence): Promise = { ["id"], { orderBy: "created_at" } ), + admin_accounts: spec( + ["id", "username", "scope", "operator_id", "role", "password_hash", "totp_secret", "status", "must_set_password", "totp_enrolled", "failed_attempts", "locked_until", "created_at"], + ["id"], + { orderBy: "created_at" } + ), wallet_balances: spec(["balance_key", "amount"], ["balance_key"]), wallet_applied: spec(["idempotency_key", "result_jsonb"], ["idempotency_key"], { jsonb: ["result_jsonb"], rename: { result_jsonb: "result" } }), wallet_debit_refs: spec(["operator_tx_ref", "balance_key", "amount"], ["operator_tx_ref"]) diff --git a/platform/src/server.ts b/platform/src/server.ts index 776272c..165d127 100644 --- a/platform/src/server.ts +++ b/platform/src/server.ts @@ -1,6 +1,6 @@ import { loadEnv, isProd } from "./config/env"; import { createLogger } from "./lib/logger"; -import { buildContainer } from "./container"; +import { buildContainer, seedBootstrapAdmin } from "./container"; import { buildApp } from "./app"; import { NullPersistence, PostgresPersistence, type Persistence } from "./persistence/persistence"; import { hydrateContainer } from "./persistence/hydrate"; @@ -33,7 +33,10 @@ async function main(): Promise { sessionSecret: env.SESSION_TOKEN_SECRET, adminSecret: env.ADMIN_TOKEN_SECRET, hmacSkewSeconds: env.HMAC_SKEW_SECONDS, - rateLimitPerMin: env.RATE_LIMIT_PER_MIN + rateLimitPerMin: env.RATE_LIMIT_PER_MIN, + bootstrapAdminUsername: env.BOOTSTRAP_ADMIN_USERNAME, + bootstrapAdminPassword: env.BOOTSTRAP_ADMIN_PASSWORD, + totpIssuer: env.TOTP_ISSUER }, { persistence } ); @@ -46,6 +49,12 @@ async function main(): Promise { "hydrated_from_postgres" ); } + + // Seed the bootstrap provider super-admin (after hydration so a durable account + // is never duplicated). The seeded admin enrolls TOTP on first login. + const boot = seedBootstrapAdmin(container); + if (boot.seeded) logger.info({ username: boot.username }, "bootstrap_admin_seeded"); + logger.info({ rules_version: container.engine.rulesVersion() }, "container_ready"); const app = buildApp({ logger, container }); diff --git a/platform/test/admin-account.test.ts b/platform/test/admin-account.test.ts new file mode 100644 index 0000000..c76f020 --- /dev/null +++ b/platform/test/admin-account.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { AdminAccountService } from "../src/modules/admin/admin-account"; +import { InMemoryAuditRepository } from "../src/modules/ledger/memory-repositories"; +import { totpCode } from "../src/lib/security/totp"; + +let svc: AdminAccountService; + +beforeEach(() => { + svc = new AdminAccountService(new InMemoryAuditRepository()); +}); + +describe("AdminAccountService", () => { + it("seeds a provider super-admin that must enroll TOTP but not reset password", () => { + expect(svc.hasAnyProviderAccount()).toBe(false); + svc.seedProviderSuperAdmin("Admin", "s3cret-pw"); + expect(svc.hasAnyProviderAccount()).toBe(true); + const a = svc.getByUsername("admin"); // case-insensitive + expect(a).not.toBeNull(); + expect(a!.role).toBe("provider_super_admin"); + expect(a!.must_set_password).toBe(false); + expect(a!.totp_enrolled).toBe(false); + expect(svc.verifyPassword(a!, "s3cret-pw")).toBe(true); + expect(svc.verifyPassword(a!, "wrong")).toBe(false); + }); + + it("creates an operator account with a one-time temp password + forced setup", () => { + const { account, temp_password } = svc.create({ + username: "casino-admin", + scope: "operator", + operator_id: "op-1", + role: "operator_admin" + }); + expect(temp_password).toBeTruthy(); + expect(account).not.toHaveProperty("password_hash"); + expect(account).not.toHaveProperty("totp_secret"); + const a = svc.getByUsername("casino-admin")!; + expect(a.must_set_password).toBe(true); + expect(a.operator_id).toBe("op-1"); + expect(svc.verifyPassword(a, temp_password)).toBe(true); + }); + + it("rejects duplicate usernames and operator accounts without an operator_id", () => { + svc.create({ username: "dup", scope: "operator", operator_id: "op-1", role: "operator_admin" }); + expect(() => svc.create({ username: "dup", scope: "operator", operator_id: "op-1", role: "operator_admin" })).toThrow(/DUPLICATE/); + expect(() => svc.create({ username: "x", scope: "operator", role: "operator_admin" })).toThrow(/OPERATOR_ID_REQUIRED/); + }); + + it("enrolls TOTP: begin issues a secret, confirm requires a valid code", () => { + const a = svc.getByUsername(svc.create({ username: "u", scope: "provider", role: "provider_finance" }).account.username)!; + const { secret } = svc.beginTotpEnrollment(a.id, "u"); + expect(secret).toBeTruthy(); + expect(svc.confirmTotpEnrollment(a.id, "000000")).toBe(false); + expect(svc.confirmTotpEnrollment(a.id, totpCode(secret))).toBe(true); + const enrolled = svc.getById(a.id)!; + expect(enrolled.totp_enrolled).toBe(true); + expect(svc.verifyTotpCode(enrolled, totpCode(secret))).toBe(true); + }); + + it("locks an account after repeated failed logins and clears on success", () => { + const a = svc.getByUsername(svc.create({ username: "lockme", scope: "provider", role: "provider_read_only" }).account.username)!; + for (let i = 0; i < 5; i += 1) svc.recordFailedLogin(a.id); + expect(svc.isLocked(svc.getById(a.id)!)).toBe(true); + svc.recordSuccessfulLogin(a.id); + expect(svc.isLocked(svc.getById(a.id)!)).toBe(false); + }); + + it("reset-password forces a new setup; reset-totp clears enrollment", () => { + const a = svc.getByUsername(svc.create({ username: "r", scope: "provider", role: "provider_super_admin" }).account.username)!; + svc.setPassword(a.id, "brand-new-pw"); + expect(svc.getById(a.id)!.must_set_password).toBe(false); + const { temp_password } = svc.resetPassword(a.id); + expect(svc.getById(a.id)!.must_set_password).toBe(true); + expect(svc.verifyPassword(svc.getById(a.id)!, temp_password)).toBe(true); + + const { secret } = svc.beginTotpEnrollment(a.id, "r"); + svc.confirmTotpEnrollment(a.id, totpCode(secret)); + svc.resetTotp(a.id); + expect(svc.getById(a.id)!.totp_enrolled).toBe(false); + expect(svc.getById(a.id)!.totp_secret).toBeNull(); + }); +}); diff --git a/platform/test/admin-accounts.test.ts b/platform/test/admin-accounts.test.ts new file mode 100644 index 0000000..e605769 --- /dev/null +++ b/platform/test/admin-accounts.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { buildApp } from "../src/app"; +import { buildContainer, type Container } from "../src/container"; +import { createLogger } from "../src/lib/logger"; + +const CONFIG = { launchSecret: "s1", sessionSecret: "s2", adminSecret: "s3", hmacSkewSeconds: 30, rateLimitPerMin: 10_000 }; + +let app: FastifyInstance; +let container: Container; +let operatorId: string; + +function providerToken(): string { + return container.adminAuth.mintToken({ admin_id: "prov-1", scope: "provider", role: "provider_super_admin" }); +} +function operatorToken(): string { + return container.adminAuth.mintToken({ admin_id: "op-1", scope: "operator", operator_id: operatorId, role: "operator_admin" }); +} +async function post(url: string, token: string, body: object) { + return await app.inject({ method: "POST", url, payload: body, headers: { authorization: `Bearer ${token}`, "content-type": "application/json" } }); +} + +beforeEach(async () => { + container = buildContainer(CONFIG); + app = buildApp({ logger: createLogger({ level: "silent", pretty: false, env: "test" }), container }); + await app.ready(); + operatorId = container.mgmt.createOperator({ name: "LuckySpin", slug: "luckyspin" }).id; +}); + +describe("Admin-account management (provider-only)", () => { + it("lets a provider super-admin create an operator account with a one-time password", async () => { + const res = await post("/admin/v1/admin-accounts", providerToken(), { + username: "casino-admin", + scope: "operator", + operator_id: operatorId, + role: "operator_admin" + }); + expect(res.statusCode).toBe(201); + const body = res.json() as { account: { username: string; operator_id: string }; temp_password: string }; + expect(body.account.username).toBe("casino-admin"); + expect(body.account.operator_id).toBe(operatorId); + expect(body.temp_password).toBeTruthy(); + expect(body.account).not.toHaveProperty("password_hash"); + }); + + it("forbids an operator admin from managing accounts", async () => { + const res = await post("/admin/v1/admin-accounts", operatorToken(), { + username: "sneaky", scope: "operator", operator_id: operatorId, role: "operator_admin" + }); + expect(res.statusCode).toBe(403); + }); + + it("rejects a role that doesn't match the scope", async () => { + const res = await post("/admin/v1/admin-accounts", providerToken(), { + username: "mismatch", scope: "operator", operator_id: operatorId, role: "provider_super_admin" + }); + expect(res.statusCode).toBe(400); + expect((res.json() as { error: { code: string } }).error.code).toBe("ROLE_SCOPE_MISMATCH"); + }); + + it("404s when the named operator does not exist", async () => { + const res = await post("/admin/v1/admin-accounts", providerToken(), { + username: "orphan", scope: "operator", operator_id: "nope", role: "operator_admin" + }); + expect(res.statusCode).toBe(404); + }); + + it("409s on a duplicate username", async () => { + const body = { username: "dupe", scope: "provider", role: "provider_finance" }; + expect((await post("/admin/v1/admin-accounts", providerToken(), body)).statusCode).toBe(201); + expect((await post("/admin/v1/admin-accounts", providerToken(), body)).statusCode).toBe(409); + }); + + it("reset-password returns a fresh one-time password and re-arms first-login", async () => { + const created = (await post("/admin/v1/admin-accounts", providerToken(), { + username: "resetme", scope: "provider", role: "provider_read_only" + })).json() as { account: { id: string } }; + const reset = await post(`/admin/v1/admin-accounts/${created.account.id}/reset-password`, providerToken(), {}); + expect(reset.statusCode).toBe(200); + expect((reset.json() as { temp_password: string }).temp_password).toBeTruthy(); + expect(container.adminAccounts.getById(created.account.id)!.must_set_password).toBe(true); + }); + + it("lists accounts for the provider", async () => { + await post("/admin/v1/admin-accounts", providerToken(), { username: "analyst-1", scope: "provider", role: "provider_finance" }); + const list = await app.inject({ method: "GET", url: "/admin/v1/admin-accounts", headers: { authorization: `Bearer ${providerToken()}` } }); + expect(list.statusCode).toBe(200); + expect((list.json() as { accounts: unknown[] }).accounts.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/platform/test/admin-auth-login.test.ts b/platform/test/admin-auth-login.test.ts new file mode 100644 index 0000000..2131c63 --- /dev/null +++ b/platform/test/admin-auth-login.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { buildApp } from "../src/app"; +import { buildContainer, seedBootstrapAdmin, type Container } from "../src/container"; +import { createLogger } from "../src/lib/logger"; +import { totpCode } from "../src/lib/security/totp"; + +const CONFIG = { + launchSecret: "s1", + sessionSecret: "s2", + adminSecret: "s3", + hmacSkewSeconds: 30, + rateLimitPerMin: 10_000, + bootstrapAdminUsername: "root", + bootstrapAdminPassword: "bootstrap-pw-123" +}; + +let app: FastifyInstance; +let container: Container; + +async function post(url: string, body: object) { + return await app.inject({ method: "POST", url, payload: body, headers: { "content-type": "application/json" } }); +} + +beforeEach(async () => { + container = buildContainer(CONFIG); + seedBootstrapAdmin(container); + app = buildApp({ logger: createLogger({ level: "silent", pretty: false, env: "test" }), container }); + await app.ready(); +}); + +// Drive the full first-login: password step → set password → enroll TOTP → session. +async function firstLogin(username: string, password: string, newPassword: string): Promise<{ token: string; secret: string }> { + const login = await post("/admin/v1/auth/login", { username, password }); + expect(login.statusCode).toBe(200); + const { setup_required, ticket, needs_totp } = login.json() as { setup_required: boolean; ticket: string; needs_totp: boolean }; + expect(setup_required).toBe(true); + expect(needs_totp).toBe(true); + + const pw = await post("/admin/v1/auth/first-login/password", { ticket, new_password: newPassword }); + expect(pw.statusCode).toBe(200); + + const begin = await post("/admin/v1/auth/first-login/totp/begin", { ticket }); + expect(begin.statusCode).toBe(200); + const { secret } = begin.json() as { secret: string }; + + const confirm = await post("/admin/v1/auth/first-login/totp/confirm", { ticket, code: totpCode(secret) }); + expect(confirm.statusCode).toBe(200); + const { token, done } = confirm.json() as { token: string; done: boolean }; + expect(done).toBe(true); + expect(token).toBeTruthy(); + return { token, secret }; +} + +describe("Admin login (password + TOTP 2FA)", () => { + it("bootstrap admin does first-login setup, then the token works on the Admin API", async () => { + const { token } = await firstLogin("root", "bootstrap-pw-123", "new-strong-pw-1"); + const res = await app.inject({ method: "GET", url: "/admin/v1/operators", headers: { authorization: `Bearer ${token}` } }); + expect(res.statusCode).toBe(200); // provider scope reaches the provider-only route + }); + + it("after enrollment, subsequent logins require password THEN a valid TOTP code", async () => { + const { secret } = await firstLogin("root", "bootstrap-pw-123", "new-strong-pw-1"); + + const login = await post("/admin/v1/auth/login", { username: "root", password: "new-strong-pw-1" }); + const { mfa_required, mfa_token } = login.json() as { mfa_required: boolean; mfa_token: string }; + expect(mfa_required).toBe(true); + + const badCode = await post("/admin/v1/auth/login/mfa", { mfa_token, code: "000000" }); + expect(badCode.statusCode).toBe(401); + + const ok = await post("/admin/v1/auth/login/mfa", { mfa_token, code: totpCode(secret) }); + expect(ok.statusCode).toBe(200); + expect((ok.json() as { token: string }).token).toBeTruthy(); + }); + + it("rejects a wrong password with a generic error and does not leak user existence", async () => { + const wrong = await post("/admin/v1/auth/login", { username: "root", password: "nope" }); + expect(wrong.statusCode).toBe(401); + const unknown = await post("/admin/v1/auth/login", { username: "ghost", password: "whatever" }); + expect(unknown.statusCode).toBe(401); + expect((wrong.json() as { error: { code: string } }).error.code).toBe("INVALID_CREDENTIALS"); + expect((unknown.json() as { error: { code: string } }).error.code).toBe("INVALID_CREDENTIALS"); + }); + + it("locks the account after repeated failed passwords", async () => { + for (let i = 0; i < 5; i += 1) await post("/admin/v1/auth/login", { username: "root", password: "wrong" }); + const locked = await post("/admin/v1/auth/login", { username: "root", password: "bootstrap-pw-123" }); + expect(locked.statusCode).toBe(423); + }); + + it("blocks TOTP enrollment before the password is set", async () => { + const login = await post("/admin/v1/auth/login", { username: "root", password: "bootstrap-pw-123" }); + const { ticket } = login.json() as { ticket: string }; + // Seeded bootstrap admin has must_set_password=false, so enrollment is allowed + // directly — assert the negative case via a freshly created operator account below. + const created = container.adminAccounts.create({ username: "op", scope: "operator", operator_id: "op-1", role: "operator_admin" }); + const opLogin = await post("/admin/v1/auth/login", { username: "op", password: created.temp_password }); + const opTicket = (opLogin.json() as { ticket: string }).ticket; + const begin = await post("/admin/v1/auth/first-login/totp/begin", { ticket: opTicket }); + expect(begin.statusCode).toBe(409); // must set password first + expect(ticket).toBeTruthy(); + }); +}); diff --git a/platform/test/password.test.ts b/platform/test/password.test.ts new file mode 100644 index 0000000..52ea798 --- /dev/null +++ b/platform/test/password.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { hashPassword, verifyPassword } from "../src/lib/security/password"; + +describe("password hashing (scrypt)", () => { + it("verifies a correct password and rejects a wrong one", () => { + const stored = hashPassword("correct horse battery staple"); + expect(verifyPassword("correct horse battery staple", stored)).toBe(true); + expect(verifyPassword("wrong password", stored)).toBe(false); + }); + + it("uses a random salt so the same password hashes differently each time", () => { + const a = hashPassword("hunter2"); + const b = hashPassword("hunter2"); + expect(a).not.toBe(b); + expect(verifyPassword("hunter2", a)).toBe(true); + expect(verifyPassword("hunter2", b)).toBe(true); + }); + + it("embeds the scrypt cost in the self-describing format", () => { + const stored = hashPassword("x"); + expect(stored.startsWith("scrypt$16384$")).toBe(true); + expect(stored.split("$")).toHaveLength(4); + }); + + it("rejects malformed / tampered stored hashes without throwing", () => { + expect(verifyPassword("x", "")).toBe(false); + expect(verifyPassword("x", "not-a-hash")).toBe(false); + expect(verifyPassword("x", "scrypt$16384$zz$zz")).toBe(false); + const stored = hashPassword("x"); + expect(verifyPassword("x", stored.slice(0, -2) + "00")).toBe(false); + }); +}); diff --git a/platform/test/totp.test.ts b/platform/test/totp.test.ts new file mode 100644 index 0000000..e958f06 --- /dev/null +++ b/platform/test/totp.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { totpCode, verifyTotp, generateTotpSecret, otpauthUri } from "../src/lib/security/totp"; + +// RFC 6238 Appendix B reference secret ("12345678901234567890") in base32. +const RFC_SECRET = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; + +describe("TOTP (RFC 6238)", () => { + // Expected values are the last 6 digits of the RFC 8-digit SHA-1 vectors + // (truncation is modulo 10^digits, so 6 digits == last 6 of the 8-digit code). + const vectors: Array<[number, string]> = [ + [59, "287082"], + [1111111109, "081804"], + [1111111111, "050471"], + [1234567890, "005924"], + [2000000000, "279037"] + ]; + + it("matches the RFC 6238 SHA-1 test vectors", () => { + for (const [seconds, expected] of vectors) { + expect(totpCode(RFC_SECRET, seconds * 1000)).toBe(expected); + } + }); + + it("verifies the current code and rejects a wrong one", () => { + const now = Date.now(); + const code = totpCode(RFC_SECRET, now); + expect(verifyTotp(RFC_SECRET, code, now)).toBe(true); + expect(verifyTotp(RFC_SECRET, "000000", now)).toBe(false); + }); + + it("accepts codes within the ±1 step drift window and rejects beyond it", () => { + const t = 1111111111 * 1000; + expect(verifyTotp(RFC_SECRET, totpCode(RFC_SECRET, t - 30_000), t)).toBe(true); // prev step + expect(verifyTotp(RFC_SECRET, totpCode(RFC_SECRET, t + 30_000), t)).toBe(true); // next step + expect(verifyTotp(RFC_SECRET, totpCode(RFC_SECRET, t - 90_000), t)).toBe(false); // 3 steps away + }); + + it("rejects non-6-digit input without throwing", () => { + expect(verifyTotp(RFC_SECRET, "12345")).toBe(false); + expect(verifyTotp(RFC_SECRET, "abcdef")).toBe(false); + expect(verifyTotp(RFC_SECRET, "")).toBe(false); + }); + + it("round-trips a freshly generated secret", () => { + const secret = generateTotpSecret(); + const now = Date.now(); + expect(verifyTotp(secret, totpCode(secret, now), now)).toBe(true); + }); + + it("builds a scannable otpauth URI", () => { + const uri = otpauthUri(RFC_SECRET, { issuer: "Provider", account: "alice" }); + expect(uri).toContain("otpauth://totp/Provider:alice"); + expect(uri).toContain(`secret=${RFC_SECRET}`); + expect(uri).toContain("issuer=Provider"); + }); +});