Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
355 changes: 312 additions & 43 deletions README.md

Large diffs are not rendered by default.

132 changes: 132 additions & 0 deletions RUNBOOK.md
Original file line number Diff line number Diff line change
@@ -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/`.
9 changes: 8 additions & 1 deletion platform/.env.example
Original file line number Diff line number Diff line change
@@ -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
37 changes: 24 additions & 13 deletions platform/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
16 changes: 10 additions & 6 deletions platform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
20 changes: 20 additions & 0 deletions platform/migrations/0001_core.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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, -- "<operator_player_id>:<currency>"
Expand Down
45 changes: 35 additions & 10 deletions platform/scripts/dev-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<void> {
Expand Down Expand Up @@ -138,6 +143,19 @@ async function main(): Promise<void> {
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
Expand All @@ -150,22 +168,29 @@ async function main(): Promise<void> {
[
"",
"─────────────────────────────────────────────────────────────────────",
` 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 ?? "<round_ref>"} \\`,
Expand Down
12 changes: 10 additions & 2 deletions platform/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions platform/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading