SPB Git

spb/spinza

Public

Spinza v0.1.0 — fictional social casino: engine, 20 certified games, API, web, admin

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 8, 2026) Browse files

133 changed files +20,001 −0

133 changed files
added .env.example +12 −0
@@ -0,0 +1,12 @@
1 +# Spinza — local development
2 +DATABASE_URL=postgres://localhost:5432/spinza
3 +REDIS_URL=redis://127.0.0.1:6379
4 +API_PORT=8231
5 +API_HOST=127.0.0.1
6 +WEB_PORT=8230
7 +API_URL=http://127.0.0.1:8231
8 +NEXT_PUBLIC_SITE_URL=http://localhost:8230
9 +SESSION_SECRET=change-me-64-hex
10 +ADMIN_IP_ALLOWLIST=
11 +COOKIE_SECURE=0
12 +LOG_LEVEL=info
added .gitignore +18 −0
@@ -0,0 +1,18 @@
1 +node_modules/
2 +.next/
3 +dist/
4 +*.tsbuildinfo
5 +.env
6 +.env.*
7 +!.env.example
8 +logs/
9 +tmp/
10 +coverage/
11 +.DS_Store
12 +.turbo/
13 +.claude/
14 +qa/node_modules/
15 +qa/screens/
16 +deploy/spinza.mld.json
17 +apps/web/next-env.d.ts
18 +data/
added CLAUDE.md +48 −0
@@ -0,0 +1,48 @@
1 +# Spinza — repository guide
2 +
3 +Spinza (www.spinza.dev) is a premium **fictional** social casino. Virtual credits only: no deposits, no withdrawals, no purchases, no crypto, no cash value, no prizes. The full product brief is in `docs/SPEC-original.md`; this file is the condensed working guide.
4 +
5 +## Layout (pnpm workspaces + Turborepo)
6 +
7 +| Path | Package | Role |
8 +|---|---|---|
9 +| `packages/shared` | `@spinza/shared` | Economy constants (10,000 SC start, bet levels, daily rewards, XP curve), zod schemas, formatting (`formatSC` — never a currency sign), API DTO types. |
10 +| `packages/game-core` | `@spinza/game-core` | **Server-only game engine**: `CryptoRng` (crypto.randomBytes, rejection sampling — `Math.random` is banned for outcomes), reels, ways/lines evaluation, wild/scatter/cascade/hold-respin/pick-bonus/meter/heat/mystery/quantum/dynamic-grid features, `runSpin()`, `simulate()`, `certify()`, `validateDefinition()`, `defineGame()`. `./client` export = types only (safe for the browser). |
11 +| `games/` | `@spinza/games` | 20 original game definitions (`src/<slug>/index.ts`), `registry.ts`, `calibration.json` (payScale per slug+version, written by the simulator), `certifications/<slug>.json` (PASS/FAIL reports). |
12 +| `packages/database` | `@spinza/database` | Drizzle schema (25 tables, snake_case casing), migrations in `drizzle/`, `seed.ts` (games sync, achievements, missions, levels, flags, settings), `sync-games.ts`. |
13 +| `apps/api` | `@spinza/api` | Fastify on 127.0.0.1:8231. Auth (argon2id, opaque 256-bit sessions hashed, recovery codes), atomic spin (`services/spin.ts`), wallet ledger (`services/wallet.ts`), progression, rewards, leaderboards, admin (TOTP), health. |
14 +| `apps/simulator` | `@spinza/simulator` | CLI `pnpm sim <validate|run|calibrate|certify>` with worker threads; also used by the admin simulator. |
15 +| `apps/web` | `@spinza/web` | Next 16 (port 8230). `/api/*` is rewritten to the Fastify service. Game screen = `src/components/game/*` (PixiJS renderer, procedural symbols, WebAudio sound). Admin console under `/admin`. |
16 +
17 +## Non-negotiable rules
18 +
19 +- Outcomes are decided **only** in `apps/api` via `runSpin`. The browser animates `result.steps`; it never computes wins.
20 +- Every balance change goes through `lockWallet` → `applyCredit` (ledger row) → `saveWallet` inside one Postgres transaction. `sum(credit_transactions.amount) == wallets.balance` is an invariant checked in admin.
21 +- Spin idempotency: unique `(user_id, client_round_id)`; duplicates (even concurrent) return the stored round with `replayed: true`.
22 +- A game is `published` only if `games/certifications/<slug>.json` is `PASS` for its exact `version`. Changing pays/weights ⇒ bump `version`, re-run `pnpm sim calibrate <slug>` then `pnpm sim certify <slug>`.
23 +- Never log passwords, hashes, recovery codes, session tokens (pino redact list in `apps/api/src/app.ts`).
24 +- No payments, no `$`/`€` anywhere in UI copy. Credits are formatted `10,000 SC`.
25 +
26 +## Commands
27 +
28 +```bash
29 +pnpm install
30 +createdb spinza && pnpm db:migrate && pnpm db:seed # local Postgres 17 + Redis required
31 +pnpm dev:api # 8231 pnpm dev:web # 8230
32 +pnpm sim validate | pnpm sim run <slug> --spins 1000000 | pnpm sim calibrate all --spins 10000000 --threads 26 | pnpm sim certify all --spins 10000000
33 +pnpm --filter @spinza/game-core test # engine unit tests
34 +pnpm --filter @spinza/api test # integration tests (needs local DB/Redis)
35 +pnpm admin:create <username> [password] # prints TOTP secret once
36 +```
37 +
38 +## Deployment (MacLustr)
39 +
40 +`mld deploy spinza` from the laptop (`~/Desktop/cluster-skill/mld`). Manifest with secrets: `M1M32:~/dispatch/apps/spinza.json` (copy: `deploy/spinza.mld.json`, gitignored). Node M3U96a, PM2 `spinza-api` (8231 loopback) + `spinza-web` (8230) + `spinza-ngrok` (`www.spinza.dev`). Postgres db `spinza` + Redis local to the node. Hooks: install → migrate + seed → `next build`. Backups: `deploy/backup.sh` (daily launchd on the node).
41 +
42 +## Gotchas
43 +
44 +- drizzle-orm ≥0.44 wraps pg errors: use `pgCode(e)` (`apps/api/src/lib/pg.ts`), not `e.code`.
45 +- Way/line pays are in bet units (`betDivisor`: 25 for 5-reel ways, 50 for 6+ reels, `lines.length` for line games); scatter/bonus/coin values are × total bet. `payScale` from `calibration.json` scales all non-jackpot wins.
46 +- Certification tolerance is statistical: `max(0.4%, 3σ/√n)` capped at 1.5% — high-volatility games need ≥10M spins.
47 +- Turbopack: import workspace packages without `.js` extensions; browser code imports `@spinza/game-core/client` only.
48 +- PixiJS renderer must be created client-side only (`GameClient` is `"use client"`; `Application.init` is async).
added README.md +38 −0
@@ -0,0 +1,38 @@
1 +# Spinza
2 +
3 +**www.spinza.dev — The Virtual Casino Playground.**
4 +
5 +Spinza is a premium *fictional* social casino: twenty original games, a real server-authoritative game engine, cryptographically secure RNG, versioned game mathematics, a simulation & certification platform and an immutable wallet ledger — all running on **Spinza Credits (SC)**, a virtual currency with no monetary value.
6 +
7 +> Virtual credits only. No deposits. No withdrawals. No cash value. 18+.
8 +
9 +## What's inside
10 +
11 +- `packages/game-core` — the Spinza Game Engine (ways/lines, wilds, scatters, cascades, hold & respin, pick bonuses, persistent meters, heat, mystery, quantum split, dynamic grids, fictional jackpots), simulator and certification.
12 +- `games/` — 20 original definitions (`defineGame`), calibration and 10,000,000-spin certification reports.
13 +- `apps/api` — Fastify API: username/password accounts (Argon2id), recovery codes, opaque sessions, atomic spins with idempotency, ledger, XP/levels, achievements, missions, daily & rescue rewards, leaderboards, TOTP admin.
14 +- `apps/web` — Next.js 16 front-end: landing, lobby, PixiJS game screen with procedural art and WebAudio sound, rewards, leaderboards, profile, settings, admin console.
15 +- `apps/simulator` — multi-threaded CLI (`pnpm sim …`).
16 +
17 +See `CLAUDE.md` for the working guide and `docs/SPEC-original.md` for the product brief.
18 +
19 +## Quick start
20 +
21 +```bash
22 +pnpm install
23 +createdb spinza
24 +pnpm db:migrate && pnpm db:seed
25 +pnpm dev:api # http://127.0.0.1:8231
26 +pnpm dev:web # http://localhost:8230
27 +```
28 +
29 +Requires Node ≥ 22, pnpm 11, PostgreSQL 17 and Redis on localhost.
30 +
31 +## Certification
32 +
33 +Every game ships with `games/certifications/<slug>.json`; the seed publishes a game only when the report is `PASS` for the exact version. Re-certify after any math change:
34 +
35 +```bash
36 +pnpm sim calibrate <slug> --spins 10000000
37 +pnpm sim certify <slug> --spins 10000000
38 +```
added apps/api/package.json +34 −0
@@ -0,0 +1,34 @@
1 +{
2 + "name": "@spinza/api",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "scripts": {
7 + "dev": "tsx watch src/server.ts",
8 + "start": "tsx src/server.ts",
9 + "admin:create": "tsx src/admin-create.ts",
10 + "typecheck": "tsc -p tsconfig.json --noEmit",
11 + "test": "vitest run"
12 + },
13 + "dependencies": {
14 + "@fastify/cookie": "^11.0.2",
15 + "@spinza/database": "workspace:*",
16 + "@spinza/game-core": "workspace:*",
17 + "@spinza/games": "workspace:*",
18 + "@spinza/shared": "workspace:*",
19 + "@spinza/simulator": "workspace:*",
20 + "argon2": "^0.44.0",
21 + "fastify": "^5.4.0",
22 + "fastify-plugin": "^6.0.0",
23 + "ioredis": "^5.6.0",
24 + "pino": "^9.7.0",
25 + "pino-pretty": "^13.0.0",
26 + "tsx": "^4.20.0",
27 + "zod": "^4.0.0"
28 + },
29 + "devDependencies": {
30 + "@types/node": "^24.0.0",
31 + "typescript": "^5.9.3",
32 + "vitest": "^3.2.0"
33 + }
34 +}
added apps/api/src/admin-create.ts +36 −0
@@ -0,0 +1,36 @@
1 +/**
2 + * Create (or reset) an admin account with TOTP.
3 + * pnpm admin:create <username> [password]
4 + * Prints the TOTP secret + otpauth URL once. Never logs the password.
5 + */
6 +import { adminUsers, closeDb, db, eq } from "@spinza/database";
7 +import { encrypt, generateTotpSecret, hashPassword, otpauthUrl, randomToken } from "./lib/crypto";
8 +
9 +async function main() {
10 + const [username, passwordArg] = process.argv.slice(2);
11 + if (!username) {
12 + console.error("usage: admin:create <username> [password]");
13 + process.exit(1);
14 + }
15 + const password = passwordArg ?? randomToken(12);
16 + const secret = generateTotpSecret();
17 + const passwordHash = await hashPassword(password);
18 + const existing = await db.query.adminUsers.findFirst({ where: eq(adminUsers.username, username.toLowerCase()) });
19 + if (existing) {
20 + await db.update(adminUsers).set({ passwordHash, totpSecret: encrypt(secret), disabled: false }).where(eq(adminUsers.id, existing.id));
21 + console.log(`Admin "${username}" reset.`);
22 + } else {
23 + await db.insert(adminUsers).values({ username: username.toLowerCase(), passwordHash, totpSecret: encrypt(secret) });
24 + console.log(`Admin "${username}" created.`);
25 + }
26 + console.log(`Password: ${passwordArg ? "(as provided)" : password}`);
27 + console.log(`TOTP secret (base32): ${secret}`);
28 + console.log(`otpauth URL: ${otpauthUrl(username, secret)}`);
29 + console.log("Add the secret to an authenticator app now — it is not shown again.");
30 + await closeDb();
31 +}
32 +
33 +main().catch((e) => {
34 + console.error(e);
35 + process.exit(1);
36 +});
added apps/api/src/app.ts +75 −0
@@ -0,0 +1,75 @@
1 +import Fastify, { type FastifyInstance } from "fastify";
2 +import cookie from "@fastify/cookie";
3 +import { ZodError } from "zod";
4 +import { config } from "./config";
5 +import { ApiError } from "./lib/errors";
6 +import { authPlugin } from "./plugins/auth";
7 +import { authRoutes } from "./routes/auth";
8 +import { userRoutes } from "./routes/user";
9 +import { gameRoutes } from "./routes/games";
10 +import { rewardRoutes } from "./routes/rewards";
11 +import { healthRoutes } from "./routes/health";
12 +import { adminRoutes } from "./routes/admin";
13 +import { refreshSettings } from "./lib/settings";
14 +
15 +const REDACT = ["req.headers.cookie", "req.headers.authorization", "*.password", "*.newPassword", "*.currentPassword", "*.recoveryCode", "*.totp", "*.passwordHash", "*.tokenHash"];
16 +
17 +export async function buildApp(): Promise<FastifyInstance> {
18 + const app = Fastify({
19 + logger: {
20 + level: config.logLevel,
21 + redact: { paths: REDACT, censor: "[redacted]" },
22 + transport: config.env === "development" ? { target: "pino-pretty", options: { translateTime: "HH:MM:ss", ignore: "pid,hostname" } } : undefined,
23 + },
24 + trustProxy: config.trustProxy,
25 + bodyLimit: 64 * 1024,
26 + genReqId: () => Math.random().toString(36).slice(2, 10), // request ids only — never used for game outcomes
27 + });
28 +
29 + await app.register(cookie, { secret: config.sessionSecret });
30 + await app.register(authPlugin);
31 +
32 + app.addHook("onRequest", async (_req, reply) => {
33 + reply.header("Cache-Control", "no-store");
34 + reply.header("X-Content-Type-Options", "nosniff");
35 + });
36 +
37 + app.addHook("onReady", async () => {
38 + await refreshSettings(true);
39 + setInterval(() => refreshSettings().catch(() => {}), 10_000).unref();
40 + });
41 +
42 + app.setErrorHandler((err: Error & { validation?: unknown; statusCode?: number }, req, reply) => {
43 + if (err instanceof ApiError) {
44 + reply.code(err.status).send({ error: err.code, message: err.message, details: err.details });
45 + return;
46 + }
47 + if (err instanceof ZodError) {
48 + reply.code(400).send({ error: "VALIDATION", message: "Invalid request.", details: err.flatten() });
49 + return;
50 + }
51 + if ((err as { validation?: unknown }).validation) {
52 + reply.code(400).send({ error: "VALIDATION", message: err.message });
53 + return;
54 + }
55 + if ((err as { statusCode?: number }).statusCode === 413) {
56 + reply.code(413).send({ error: "PAYLOAD_TOO_LARGE", message: "Request too large." });
57 + return;
58 + }
59 + req.log.error({ err }, "unhandled error");
60 + reply.code(500).send({ error: "INTERNAL", message: "Something went wrong on our side." });
61 + });
62 +
63 + app.setNotFoundHandler((_req, reply) => {
64 + reply.code(404).send({ error: "NOT_FOUND", message: "Route not found." });
65 + });
66 +
67 + await app.register(healthRoutes);
68 + await app.register(authRoutes);
69 + await app.register(userRoutes);
70 + await app.register(gameRoutes);
71 + await app.register(rewardRoutes);
72 + await app.register(adminRoutes);
73 +
74 + return app;
75 +}
added apps/api/src/config.ts +22 −0
@@ -0,0 +1,22 @@
1 +export const config = {
2 + env: process.env.NODE_ENV ?? "development",
3 + port: Number(process.env.API_PORT ?? 8231),
4 + host: process.env.API_HOST ?? "127.0.0.1",
5 + databaseUrl: process.env.DATABASE_URL ?? "postgres://localhost:5432/spinza",
6 + redisUrl: process.env.REDIS_URL ?? "redis://127.0.0.1:6379",
7 + sessionSecret: process.env.SESSION_SECRET ?? "dev-secret-change-me-dev-secret-change-me",
8 + siteUrl: process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:8230",
9 + cookieSecure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === "1" : (process.env.NODE_ENV ?? "development") === "production",
10 + adminIpAllowlist: (process.env.ADMIN_IP_ALLOWLIST ?? "")
11 + .split(",")
12 + .map((s) => s.trim())
13 + .filter(Boolean),
14 + logLevel: process.env.LOG_LEVEL ?? "info",
15 + version: process.env.SPINZA_VERSION ?? "0.1.0",
16 + /** Trust X-Forwarded-For from the local reverse proxy / Next rewrite. */
17 + trustProxy: true,
18 +};
19 +
20 +export const allowedOrigins = new Set(
21 + [config.siteUrl, "http://localhost:8230", "http://127.0.0.1:8230", "http://localhost:3000"].map((u) => u.replace(/\/$/, "")),
22 +);
added apps/api/src/lib/crypto.ts +154 −0
@@ -0,0 +1,154 @@
1 +import argon2 from "argon2";
2 +import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
3 +import { RECOVERY_CODE_PREFIX, ROUND_ID_PREFIX } from "@spinza/shared";
4 +import { config } from "../config";
5 +
6 +/* ------------------------------------------------------------- passwords */
7 +
8 +const ARGON_OPTS: argon2.Options = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 };
9 +
10 +export async function hashPassword(password: string): Promise<string> {
11 + return argon2.hash(password, ARGON_OPTS);
12 +}
13 +
14 +export async function verifyPassword(hash: string, password: string): Promise<boolean> {
15 + try {
16 + return await argon2.verify(hash, password);
17 + } catch {
18 + return false;
19 + }
20 +}
21 +
22 +/* ---------------------------------------------------------------- tokens */
23 +
24 +export function randomToken(bytes = 32): string {
25 + return randomBytes(bytes).toString("base64url");
26 +}
27 +
28 +export function sha256(input: string): string {
29 + return createHash("sha256").update(input).digest("hex");
30 +}
31 +
32 +export function hmac(input: string): string {
33 + return createHmac("sha256", config.sessionSecret).update(input).digest("hex");
34 +}
35 +
36 +/** Round ids: spz_rnd_ + 12 chars from an unambiguous alphabet. */
37 +const ROUND_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
38 +export function newRoundId(): string {
39 + let s = "";
40 + for (let i = 0; i < 12; i++) s += ROUND_ALPHABET[randomInt(ROUND_ALPHABET.length)];
41 + return `${ROUND_ID_PREFIX}${s}`;
42 +}
43 +
44 +/* --------------------------------------------------------- recovery code */
45 +
46 +export function generateRecoveryCode(): string {
47 + const groups: string[] = [];
48 + for (let g = 0; g < 3; g++) {
49 + let s = "";
50 + for (let i = 0; i < 4; i++) s += ROUND_ALPHABET[randomInt(ROUND_ALPHABET.length)];
51 + groups.push(s);
52 + }
53 + return `${RECOVERY_CODE_PREFIX}-${groups.join("-")}`;
54 +}
55 +
56 +export function normalizeRecoveryCode(code: string): string {
57 + return code.trim().toUpperCase().replace(/[^A-Z0-9]/g, "");
58 +}
59 +
60 +export async function hashRecoveryCode(code: string): Promise<string> {
61 + return argon2.hash(normalizeRecoveryCode(code), ARGON_OPTS);
62 +}
63 +
64 +export async function verifyRecoveryCode(hash: string, code: string): Promise<boolean> {
65 + try {
66 + return await argon2.verify(hash, normalizeRecoveryCode(code));
67 + } catch {
68 + return false;
69 + }
70 +}
71 +
72 +/* -------------------------------------------------- symmetric encryption */
73 +
74 +function key(): Buffer {
75 + return createHash("sha256").update(config.sessionSecret).digest();
76 +}
77 +
78 +export function encrypt(plain: string): string {
79 + const iv = randomBytes(12);
80 + const c = createCipheriv("aes-256-gcm", key(), iv);
81 + const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
82 + return `${iv.toString("base64url")}.${enc.toString("base64url")}.${c.getAuthTag().toString("base64url")}`;
83 +}
84 +
85 +export function decrypt(payload: string): string {
86 + const [iv, enc, tag] = payload.split(".").map((p) => Buffer.from(p, "base64url"));
87 + const d = createDecipheriv("aes-256-gcm", key(), iv);
88 + d.setAuthTag(tag);
89 + return Buffer.concat([d.update(enc), d.final()]).toString("utf8");
90 +}
91 +
92 +/* ------------------------------------------------------------------ TOTP */
93 +
94 +const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
95 +
96 +export function base32Encode(buf: Buffer): string {
97 + let bits = 0;
98 + let value = 0;
99 + let out = "";
100 + for (const byte of buf) {
101 + value = (value << 8) | byte;
102 + bits += 8;
103 + while (bits >= 5) {
104 + out += B32[(value >>> (bits - 5)) & 31];
105 + bits -= 5;
106 + }
107 + }
108 + if (bits > 0) out += B32[(value << (5 - bits)) & 31];
109 + return out;
110 +}
111 +
112 +export function base32Decode(s: string): Buffer {
113 + const clean = s.toUpperCase().replace(/=+$/, "").replace(/[^A-Z2-7]/g, "");
114 + let bits = 0;
115 + let value = 0;
116 + const out: number[] = [];
117 + for (const ch of clean) {
118 + value = (value << 5) | B32.indexOf(ch);
119 + bits += 5;
120 + if (bits >= 8) {
121 + out.push((value >>> (bits - 8)) & 255);
122 + bits -= 8;
123 + }
124 + }
125 + return Buffer.from(out);
126 +}
127 +
128 +export function generateTotpSecret(): string {
129 + return base32Encode(randomBytes(20));
130 +}
131 +
132 +export function totpCode(secret: string, time = Date.now(), step = 30, digits = 6): string {
133 + const counter = Math.floor(time / 1000 / step);
134 + const msg = Buffer.alloc(8);
135 + msg.writeBigUInt64BE(BigInt(counter));
136 + const h = createHmac("sha1", base32Decode(secret)).update(msg).digest();
137 + const offset = h[h.length - 1] & 0xf;
138 + const bin = ((h[offset] & 0x7f) << 24) | (h[offset + 1] << 16) | (h[offset + 2] << 8) | h[offset + 3];
139 + return String(bin % 10 ** digits).padStart(digits, "0");
140 +}
141 +
142 +export function verifyTotp(secret: string, code: string, window = 1): boolean {
143 + const c = code.replace(/\s+/g, "");
144 + if (!/^\d{6}$/.test(c)) return false;
145 + for (let w = -window; w <= window; w++) {
146 + const expected = totpCode(secret, Date.now() + w * 30_000);
147 + if (timingSafeEqual(Buffer.from(expected), Buffer.from(c))) return true;
148 + }
149 + return false;
150 +}
151 +
152 +export function otpauthUrl(username: string, secret: string): string {
153 + return `otpauth://totp/Spinza%20Admin:${encodeURIComponent(username)}?secret=${secret}&issuer=Spinza%20Admin&algorithm=SHA1&digits=6&period=30`;
154 +}
added apps/api/src/lib/errors.ts +22 −0
@@ -0,0 +1,22 @@
1 +export class ApiError extends Error {
2 + constructor(
3 + public status: number,
4 + public code: string,
5 + message: string,
6 + public details?: unknown,
7 + ) {
8 + super(message);
9 + }
10 +}
11 +
12 +export const errors = {
13 + badRequest: (msg = "Bad request", details?: unknown) => new ApiError(400, "BAD_REQUEST", msg, details),
14 + unauthorized: (msg = "Please sign in.") => new ApiError(401, "UNAUTHORIZED", msg),
15 + forbidden: (msg = "Forbidden") => new ApiError(403, "FORBIDDEN", msg),
16 + notFound: (msg = "Not found") => new ApiError(404, "NOT_FOUND", msg),
17 + conflict: (code: string, msg: string) => new ApiError(409, code, msg),
18 + rateLimited: (retryAfter: number) => new ApiError(429, "RATE_LIMITED", "Too many requests. Please slow down.", { retryAfter }),
19 + insufficient: (balance: number, bet: number) => new ApiError(402, "INSUFFICIENT_CREDITS", "Not enough Spinza Credits for this bet.", { balance, bet }),
20 + maintenance: (message: string) => new ApiError(503, "MAINTENANCE", message),
21 + unavailable: (msg = "Game unavailable") => new ApiError(503, "GAME_UNAVAILABLE", msg),
22 +};
added apps/api/src/lib/pg.ts +7 −0
@@ -0,0 +1,7 @@
1 +/** Extract the PostgreSQL error code from a raw pg error or a drizzle-wrapped one. */
2 +export function pgCode(e: unknown): string | undefined {
3 + const err = e as { code?: string; cause?: { code?: string } } | undefined;
4 + return err?.code ?? err?.cause?.code;
5 +}
6 +
7 +export const PG_UNIQUE_VIOLATION = "23505";
added apps/api/src/lib/redis.ts +52 −0
@@ -0,0 +1,52 @@
1 +import Redis from "ioredis";
2 +import { config } from "../config";
3 +
4 +let _redis: Redis | null = null;
5 +
6 +export function redis(): Redis {
7 + if (!_redis) {
8 + _redis = new Redis(config.redisUrl, { maxRetriesPerRequest: 2, enableOfflineQueue: true, lazyConnect: false });
9 + _redis.on("error", (e) => console.error("[redis]", e.message));
10 + }
11 + return _redis;
12 +}
13 +
14 +export async function pingRedis(): Promise<number> {
15 + const t = Date.now();
16 + await redis().ping();
17 + return Date.now() - t;
18 +}
19 +
20 +export async function closeRedis(): Promise<void> {
21 + if (_redis) {
22 + await _redis.quit();
23 + _redis = null;
24 + }
25 +}
26 +
27 +/**
28 + * Sliding-window rate limit (fixed window with two buckets). Returns retry-after
29 + * seconds when exceeded, else 0.
30 + */
31 +export async function rateLimit(key: string, limit: number, windowSec: number): Promise<number> {
32 + const r = redis();
33 + const now = Math.floor(Date.now() / 1000);
34 + const bucket = Math.floor(now / windowSec);
35 + const k = `rl:${key}:${bucket}`;
36 + const prevK = `rl:${key}:${bucket - 1}`;
37 + const [[, cur], [, prev]] = (await r.multi().incr(k).get(prevK).exec()) as [[null, number], [null, string | null]];
38 + if (cur === 1) await r.expire(k, windowSec * 2);
39 + const elapsed = (now % windowSec) / windowSec;
40 + const weighted = cur + Number(prev ?? 0) * (1 - elapsed);
41 + if (weighted > limit) return Math.max(1, windowSec - (now % windowSec));
42 + return 0;
43 +}
44 +
45 +export async function setJson(key: string, value: unknown, ttlSec: number): Promise<void> {
46 + await redis().set(key, JSON.stringify(value), "EX", ttlSec);
47 +}
48 +
49 +export async function getJson<T>(key: string): Promise<T | null> {
50 + const v = await redis().get(key);
51 + return v ? (JSON.parse(v) as T) : null;
52 +}
added apps/api/src/lib/security.ts +49 −0
@@ -0,0 +1,49 @@
1 +import { db, securityEvents } from "@spinza/database";
2 +import type { FastifyRequest } from "fastify";
3 +
4 +export type SecurityEventType =
5 + | "login.success"
6 + | "login.failed"
7 + | "register"
8 + | "logout"
9 + | "password.changed"
10 + | "recovery.used"
11 + | "recovery.failed"
12 + | "rate_limit"
13 + | "spin.suspicious"
14 + | "admin.login.success"
15 + | "admin.login.failed"
16 + | "admin.wallet.adjust"
17 + | "admin.user.status"
18 + | "admin.game.lifecycle"
19 + | "admin.game.flags"
20 + | "admin.flag"
21 + | "admin.setting"
22 + | "admin.maintenance"
23 + | "csrf.rejected";
24 +
25 +export function clientIp(req: FastifyRequest): string {
26 + const xff = req.headers["x-forwarded-for"];
27 + if (typeof xff === "string" && xff.length) return xff.split(",").pop()!.trim();
28 + return req.ip;
29 +}
30 +
31 +export async function logSecurity(
32 + req: FastifyRequest | null,
33 + type: SecurityEventType,
34 + opts: { userId?: string | null; adminId?: string | null; severity?: "info" | "warn" | "high"; meta?: Record<string, unknown> } = {},
35 +): Promise<void> {
36 + try {
37 + await db.insert(securityEvents).values({
38 + userId: opts.userId ?? null,
39 + adminId: opts.adminId ?? null,
40 + type,
41 + severity: opts.severity ?? (type.includes("failed") || type === "rate_limit" || type === "csrf.rejected" ? "warn" : "info"),
42 + ip: req ? clientIp(req) : null,
43 + userAgent: req ? (req.headers["user-agent"] ?? "").slice(0, 300) : null,
44 + meta: opts.meta ?? null,
45 + });
46 + } catch (e) {
47 + req?.log.error({ err: e }, "security event write failed");
48 + }
49 +}
added apps/api/src/lib/settings.ts +51 −0
@@ -0,0 +1,51 @@
1 +import { db, eq, featureFlags, platformSettings } from "@spinza/database";
2 +import { DAILY_REWARDS, RESCUE_CREDITS_AMOUNT, RESCUE_CREDITS_COOLDOWN_HOURS } from "@spinza/shared";
3 +
4 +/** In-process cache of flags & settings, refreshed every 10 s. */
5 +let flags = new Map<string, boolean>();
6 +let settings = new Map<string, unknown>();
7 +let loadedAt = 0;
8 +const TTL = 10_000;
9 +
10 +export async function refreshSettings(force = false): Promise<void> {
11 + if (!force && Date.now() - loadedAt < TTL) return;
12 + const [f, s] = await Promise.all([db.select().from(featureFlags), db.select().from(platformSettings)]);
13 + flags = new Map(f.map((x) => [x.key, x.enabled]));
14 + settings = new Map(s.map((x) => [x.key, x.value]));
15 + loadedAt = Date.now();
16 +}
17 +
18 +export function flag(key: string, def = true): boolean {
19 + return flags.has(key) ? (flags.get(key) as boolean) : def;
20 +}
21 +
22 +export function setting<T>(key: string, def: T): T {
23 + return settings.has(key) ? (settings.get(key) as T) : def;
24 +}
25 +
26 +export interface MaintenanceSetting {
27 + enabled: boolean;
28 + message: string;
29 +}
30 +export const maintenance = () => setting<MaintenanceSetting>("maintenance", { enabled: false, message: "Spinza is getting an upgrade. Your credits and progress are safe." });
31 +export const rescueConfig = () => setting("rescue", { amount: RESCUE_CREDITS_AMOUNT, cooldownHours: RESCUE_CREDITS_COOLDOWN_HOURS, threshold: 0 });
32 +export const dailySchedule = () => setting("dailyRewards", { schedule: DAILY_REWARDS as readonly number[] }).schedule;
33 +export const profanityWords = () => setting("profanity", { words: [] as string[] }).words;
34 +
35 +export async function setFlag(key: string, enabled: boolean): Promise<void> {
36 + await db.insert(featureFlags).values({ key, enabled }).onConflictDoUpdate({ target: featureFlags.key, set: { enabled, updatedAt: new Date() } });
37 + await refreshSettings(true);
38 +}
39 +
40 +export async function setSetting(key: string, value: unknown): Promise<void> {
41 + await db.insert(platformSettings).values({ key, value }).onConflictDoUpdate({ target: platformSettings.key, set: { value, updatedAt: new Date() } });
42 + await refreshSettings(true);
43 +}
44 +
45 +export function allFlags(): Record<string, boolean> {
46 + return Object.fromEntries(flags);
47 +}
48 +export function allSettings(): Record<string, unknown> {
49 + return Object.fromEntries(settings);
50 +}
51 +export { eq };
added apps/api/src/plugins/auth.ts +150 −0
@@ -0,0 +1,150 @@
1 +import fp from "fastify-plugin";
2 +import type { FastifyReply, FastifyRequest } from "fastify";
3 +import { and, db, eq, gt, sessions, users, adminSessions, adminUsers } from "@spinza/database";
4 +import { ADMIN_COOKIE, ADMIN_SESSION_TTL_HOURS, SESSION_COOKIE, SESSION_TTL_DAYS } from "@spinza/shared";
5 +import { randomToken, sha256 } from "../lib/crypto";
6 +import { getJson, redis, setJson } from "../lib/redis";
7 +import { config, allowedOrigins } from "../config";
8 +import { errors } from "../lib/errors";
9 +import { clientIp, logSecurity } from "../lib/security";
10 +
11 +export interface SessionUser {
12 + id: string;
13 + username: string;
14 + status: string;
15 +}
16 +
17 +export interface AdminUser {
18 + id: string;
19 + username: string;
20 + role: string;
21 +}
22 +
23 +declare module "fastify" {
24 + interface FastifyRequest {
25 + user: SessionUser | null;
26 + sessionId: string | null;
27 + admin: AdminUser | null;
28 + }
29 +}
30 +
31 +const SESSION_CACHE_TTL = 60;
32 +
33 +export async function createSession(reply: FastifyReply, req: FastifyRequest, userId: string): Promise<void> {
34 + const token = randomToken(32);
35 + const tokenHash = sha256(token);
36 + const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 86400_000);
37 + await db.insert(sessions).values({ userId, tokenHash, expiresAt, userAgent: (req.headers["user-agent"] ?? "").slice(0, 300), ip: clientIp(req) });
38 + reply.setCookie(SESSION_COOKIE, token, {
39 + httpOnly: true,
40 + secure: config.cookieSecure,
41 + sameSite: "lax",
42 + path: "/",
43 + maxAge: SESSION_TTL_DAYS * 86400,
44 + });
45 +}
46 +
47 +export async function destroySession(reply: FastifyReply, req: FastifyRequest): Promise<void> {
48 + const token = req.cookies[SESSION_COOKIE];
49 + if (token) {
50 + const h = sha256(token);
51 + await db.delete(sessions).where(eq(sessions.tokenHash, h));
52 + await redis().del(`sess:${h}`);
53 + }
54 + reply.clearCookie(SESSION_COOKIE, { path: "/" });
55 +}
56 +
57 +export async function destroyAllSessions(userId: string): Promise<void> {
58 + const rows = await db.select({ tokenHash: sessions.tokenHash }).from(sessions).where(eq(sessions.userId, userId));
59 + await db.delete(sessions).where(eq(sessions.userId, userId));
60 + if (rows.length) await redis().del(...rows.map((r) => `sess:${r.tokenHash}`));
61 +}
62 +
63 +async function resolveUser(token: string): Promise<{ user: SessionUser; sessionId: string } | null> {
64 + const h = sha256(token);
65 + const cached = await getJson<{ user: SessionUser; sessionId: string }>(`sess:${h}`);
66 + if (cached) return cached;
67 + const row = await db
68 + .select({ id: users.id, username: users.username, status: users.status, sessionId: sessions.id })
69 + .from(sessions)
70 + .innerJoin(users, eq(users.id, sessions.userId))
71 + .where(and(eq(sessions.tokenHash, h), gt(sessions.expiresAt, new Date())))
72 + .limit(1);
73 + if (!row.length) return null;
74 + const value = { user: { id: row[0].id, username: row[0].username, status: row[0].status }, sessionId: row[0].sessionId };
75 + await setJson(`sess:${h}`, value, SESSION_CACHE_TTL);
76 + // Touch last_seen occasionally (cheap update).
77 + db.update(sessions).set({ lastSeenAt: new Date() }).where(eq(sessions.id, value.sessionId)).catch(() => {});
78 + return value;
79 +}
80 +
81 +export async function createAdminSession(reply: FastifyReply, req: FastifyRequest, adminId: string): Promise<void> {
82 + const token = randomToken(32);
83 + const expiresAt = new Date(Date.now() + ADMIN_SESSION_TTL_HOURS * 3600_000);
84 + await db.insert(adminSessions).values({ adminId, tokenHash: sha256(token), expiresAt, ip: clientIp(req) });
85 + reply.setCookie(ADMIN_COOKIE, token, { httpOnly: true, secure: config.cookieSecure, sameSite: "strict", path: "/", maxAge: ADMIN_SESSION_TTL_HOURS * 3600 });
86 +}
87 +
88 +export async function destroyAdminSession(reply: FastifyReply, req: FastifyRequest): Promise<void> {
89 + const token = req.cookies[ADMIN_COOKIE];
90 + if (token) await db.delete(adminSessions).where(eq(adminSessions.tokenHash, sha256(token)));
91 + reply.clearCookie(ADMIN_COOKIE, { path: "/" });
92 +}
93 +
94 +async function resolveAdmin(token: string): Promise<AdminUser | null> {
95 + const row = await db
96 + .select({ id: adminUsers.id, username: adminUsers.username, role: adminUsers.role, disabled: adminUsers.disabled })
97 + .from(adminSessions)
98 + .innerJoin(adminUsers, eq(adminUsers.id, adminSessions.adminId))
99 + .where(and(eq(adminSessions.tokenHash, sha256(token)), gt(adminSessions.expiresAt, new Date())))
100 + .limit(1);
101 + if (!row.length || row[0].disabled) return null;
102 + return { id: row[0].id, username: row[0].username, role: row[0].role };
103 +}
104 +
105 +export const authPlugin = fp(async (app) => {
106 + app.decorateRequest("user", null);
107 + app.decorateRequest("sessionId", null);
108 + app.decorateRequest("admin", null);
109 +
110 + app.addHook("onRequest", async (req) => {
111 + const token = req.cookies?.[SESSION_COOKIE];
112 + if (token) {
113 + const r = await resolveUser(token);
114 + if (r) {
115 + req.user = r.user;
116 + req.sessionId = r.sessionId;
117 + }
118 + }
119 + if (req.url.startsWith("/api/admin")) {
120 + const at = req.cookies?.[ADMIN_COOKIE];
121 + if (at) req.admin = await resolveAdmin(at);
122 + }
123 + });
124 +
125 + // CSRF: state-changing requests must come from an allowed origin.
126 + app.addHook("preHandler", async (req) => {
127 + if (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS") return;
128 + const origin = req.headers.origin ?? (req.headers.referer ? new URL(req.headers.referer).origin : null);
129 + const fetchSite = req.headers["sec-fetch-site"];
130 + if (fetchSite === "same-origin" || fetchSite === "none") return;
131 + if (origin && allowedOrigins.has(origin.replace(/\/$/, ""))) return;
132 + // Requests forwarded by the Next.js server keep the browser's Origin header. Missing origin from a
133 + // non-browser client is accepted only when there is no session cookie to protect.
134 + if (!origin && !req.cookies?.[SESSION_COOKIE] && !req.cookies?.[ADMIN_COOKIE]) return;
135 + await logSecurity(req, "csrf.rejected", { meta: { origin, path: req.url } });
136 + throw errors.forbidden("Cross-site request rejected.");
137 + });
138 +});
139 +
140 +export function requireUser(req: FastifyRequest): SessionUser {
141 + if (!req.user) throw errors.unauthorized();
142 + if (req.user.status !== "active") throw errors.forbidden("This account is suspended.");
143 + return req.user;
144 +}
145 +
146 +export function requireAdmin(req: FastifyRequest): AdminUser {
147 + if (config.adminIpAllowlist.length && !config.adminIpAllowlist.includes(clientIp(req))) throw errors.forbidden("Admin access is restricted.");
148 + if (!req.admin) throw errors.unauthorized("Admin sign-in required.");
149 + return req.admin;
150 +}
added apps/api/src/routes/admin.ts +387 −0
@@ -0,0 +1,387 @@
1 +import type { FastifyInstance } from "fastify";
2 +import os from "node:os";
3 +import { achievements, adminUsers, and, count, creditTransactions, db, desc, eq, gameRounds, gameStatistics, games, gte, ilike, missions, securityEvents, sessions, simulationRuns, sql, users, wallets, gameVersions, or } from "@spinza/database";
4 +import { GAMES, getGame } from "@spinza/games";
5 +import { certify, DEFAULT_CERTIFICATION_RULES, validateDefinition } from "@spinza/game-core";
6 +import { simulateParallel } from "@spinza/simulator";
7 +import { GAME_LIFECYCLE } from "@spinza/shared";
8 +import { z } from "zod";
9 +import { errors } from "../lib/errors";
10 +import { decrypt, verifyPassword, verifyTotp } from "../lib/crypto";
11 +import { rateLimit, redis } from "../lib/redis";
12 +import { createAdminSession, destroyAdminSession, requireAdmin } from "../plugins/auth";
13 +import { clientIp, logSecurity } from "../lib/security";
14 +import { allFlags, allSettings, refreshSettings, setFlag, setSetting } from "../lib/settings";
15 +import { grant } from "../services/wallet";
16 +import { config } from "../config";
17 +import { pingDb } from "@spinza/database";
18 +import { pingRedis } from "../lib/redis";
19 +
20 +const startedAt = Date.now();
21 +
22 +export async function adminRoutes(app: FastifyInstance) {
23 + /* ------------------------------------------------------------- auth */
24 + app.post("/api/admin/auth/login", async (req, reply) => {
25 + if (config.adminIpAllowlist.length && !config.adminIpAllowlist.includes(clientIp(req))) throw errors.forbidden("Admin access is restricted.");
26 + const retry = await rateLimit(`admin-login:${clientIp(req)}`, 10, 600);
27 + if (retry) throw errors.rateLimited(retry);
28 + const body = z.object({ username: z.string().min(1), password: z.string().min(1), totp: z.string().min(6).max(8) }).parse(req.body);
29 + const a = await db.query.adminUsers.findFirst({ where: eq(adminUsers.username, body.username.toLowerCase()) });
30 + const ok = !!a && !a.disabled && (await verifyPassword(a.passwordHash, body.password)) && verifyTotp(decrypt(a.totpSecret), body.totp);
31 + if (!ok) {
32 + await logSecurity(req, "admin.login.failed", { adminId: a?.id ?? null, severity: "high", meta: { username: body.username } });
33 + throw errors.unauthorized("Invalid credentials.");
34 + }
35 + await db.update(adminUsers).set({ lastLoginAt: new Date() }).where(eq(adminUsers.id, a.id));
36 + await createAdminSession(reply, req, a.id);
37 + await logSecurity(req, "admin.login.success", { adminId: a.id, severity: "warn" });
38 + return { admin: { id: a.id, username: a.username, role: a.role } };
39 + });
40 +
41 + app.post("/api/admin/auth/logout", async (req, reply) => {
42 + await destroyAdminSession(reply, req);
43 + return { ok: true };
44 + });
45 +
46 + app.get("/api/admin/me", async (req) => {
47 + const admin = requireAdmin(req);
48 + return { admin };
49 + });
50 +
51 + /* -------------------------------------------------------- dashboard */
52 + app.get("/api/admin/dashboard", async (req) => {
53 + requireAdmin(req);
54 + const now = new Date();
55 + const dayAgo = new Date(now.getTime() - 86400_000);
56 + const weekAgo = new Date(now.getTime() - 7 * 86400_000);
57 + const monthAgo = new Date(now.getTime() - 30 * 86400_000);
58 + const todayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
59 + const [[regs], [dau], [wau], [mau], [activeSessions], todayAgg, topGames, highestWins, latency, errCount] = await Promise.all([
60 + db.select({ n: count() }).from(users),
61 + db.select({ n: sql<number>`count(distinct user_id)::int` }).from(gameRounds).where(gte(gameRounds.createdAt, dayAgo)),
62 + db.select({ n: sql<number>`count(distinct user_id)::int` }).from(gameRounds).where(gte(gameRounds.createdAt, weekAgo)),
63 + db.select({ n: sql<number>`count(distinct user_id)::int` }).from(gameRounds).where(gte(gameRounds.createdAt, monthAgo)),
64 + db.select({ n: count() }).from(sessions).where(gte(sessions.lastSeenAt, new Date(now.getTime() - 30 * 60_000))),
65 + db.execute(sql`select count(*)::int as spins, coalesce(sum(bet),0)::bigint as wagered, coalesce(sum(win),0)::bigint as won, coalesce(avg(duration_ms),0)::float as avg_ms,
66 + coalesce(percentile_cont(0.95) within group (order by duration_ms),0)::float as p95_ms from game_rounds where created_at >= ${todayStart}`),
67 + db.execute(sql`select game_slug, count(*)::int as spins, coalesce(sum(bet),0)::bigint as wagered, coalesce(sum(win),0)::bigint as won from game_rounds where created_at >= ${weekAgo} group by game_slug order by spins desc limit 8`),
68 + db.execute(sql`select r.round_id, r.game_slug, r.bet, r.win, r.multiplier, r.created_at, u.username from game_rounds r join users u on u.id = r.user_id order by r.win desc limit 10`),
69 + pingDb().catch(() => -1),
70 + db.select({ n: count() }).from(securityEvents).where(and(gte(securityEvents.createdAt, dayAgo), eq(securityEvents.severity, "high"))),
71 + ]);
72 + const t = todayAgg.rows[0] as { spins: number; wagered: number; won: number; avg_ms: number; p95_ms: number };
73 + const sessionsAgg = await db.execute(sql`select coalesce(avg(extract(epoch from (last_seen_at - created_at))),0)::float as avg_session from sessions where created_at >= ${weekAgo}`);
74 + const live = await redis().zcount("live:players", Date.now() - 5 * 60_000, "+inf").catch(() => 0);
75 + return {
76 + users: { registered: regs.n, dau: dau.n, wau: wau.n, mau: mau.n, activeSessions: activeSessions.n, livePlayers: live },
77 + today: { spins: t.spins, wagered: Number(t.wagered), won: Number(t.won), effectiveRtp: Number(t.wagered) ? Number(t.won) / Number(t.wagered) : null, avgSpinMs: t.avg_ms, p95SpinMs: t.p95_ms },
78 + averageSessionSec: Number((sessionsAgg.rows[0] as { avg_session: number }).avg_session),
79 + topGames: (topGames.rows as Record<string, unknown>[]).map((r) => ({ slug: r.game_slug, spins: r.spins, wagered: Number(r.wagered), won: Number(r.won), rtp: Number(r.wagered) ? Number(r.won) / Number(r.wagered) : null })),
80 + highestWins: (highestWins.rows as Record<string, unknown>[]).map((r) => ({ roundId: r.round_id, game: r.game_slug, bet: Number(r.bet), win: Number(r.win), multiplier: Number(r.multiplier), at: r.created_at, username: r.username })),
81 + health: { dbLatencyMs: latency, highSeverityEvents24h: errCount[0].n, uptimeSec: Math.round((Date.now() - startedAt) / 1000), version: config.version, node: os.hostname() },
82 + };
83 + });
84 +
85 + /* ------------------------------------------------------------ users */
86 + app.get("/api/admin/users", async (req) => {
87 + requireAdmin(req);
88 + const q = z.object({ q: z.string().optional(), limit: z.coerce.number().int().min(1).max(200).default(50), offset: z.coerce.number().int().min(0).default(0), sort: z.enum(["created", "spins", "balance", "level"]).default("created") }).parse(req.query);
89 + const where = q.q ? ilike(users.username, `%${q.q}%`) : undefined;
90 + const order = { created: desc(users.createdAt), spins: desc(users.totalSpins), balance: desc(wallets.balance), level: desc(users.level) }[q.sort];
91 + const rows = await db
92 + .select({ id: users.id, username: users.username, level: users.level, xp: users.xp, status: users.status, createdAt: users.createdAt, lastLoginAt: users.lastLoginAt, totalSpins: users.totalSpins, biggestWin: users.biggestWin, balance: wallets.balance, wagered: wallets.lifetimeWagered, won: wallets.lifetimeWon })
93 + .from(users)
94 + .innerJoin(wallets, eq(wallets.userId, users.id))
95 + .where(where)
96 + .orderBy(order)
97 + .limit(q.limit)
98 + .offset(q.offset);
99 + const [{ n }] = await db.select({ n: count() }).from(users).where(where);
100 + return { users: rows, total: n };
101 + });
102 +
103 + app.get("/api/admin/users/:id", async (req) => {
104 + requireAdmin(req);
105 + const { id } = req.params as { id: string };
106 + const u = await db.query.users.findFirst({ where: eq(users.id, id) });
107 + if (!u) throw errors.notFound();
108 + const [w, ledger, rounds, sess, events] = await Promise.all([
109 + db.query.wallets.findFirst({ where: eq(wallets.userId, id) }),
110 + db.select().from(creditTransactions).where(eq(creditTransactions.userId, id)).orderBy(desc(creditTransactions.createdAt)).limit(50),
111 + db.select().from(gameRounds).where(eq(gameRounds.userId, id)).orderBy(desc(gameRounds.createdAt)).limit(30),
112 + db.select().from(sessions).where(eq(sessions.userId, id)).orderBy(desc(sessions.lastSeenAt)),
113 + db.select().from(securityEvents).where(eq(securityEvents.userId, id)).orderBy(desc(securityEvents.createdAt)).limit(30),
114 + ]);
115 + const { passwordHash: _p, ...safe } = u;
116 + // Ledger invariant: sum(amount) must equal balance.
117 + const [{ total }] = await db.select({ total: sql<number>`coalesce(sum(amount),0)::bigint` }).from(creditTransactions).where(eq(creditTransactions.userId, id));
118 + return { user: safe, wallet: w, ledger, rounds: rounds.map((r) => ({ ...r, result: undefined })), sessions: sess, events, invariant: { ledgerTotal: Number(total), balance: w?.balance ?? 0, ok: Number(total) === (w?.balance ?? 0) } };
119 + });
120 +
121 + app.post("/api/admin/users/:id/adjust", async (req) => {
122 + const admin = requireAdmin(req);
123 + const { id } = req.params as { id: string };
124 + const body = z.object({ amount: z.number().int().min(-1_000_000_000).max(1_000_000_000).refine((n) => n !== 0), note: z.string().min(3).max(200) }).parse(req.body);
125 + const balance = await db.transaction((tx) => grant(tx, id, "ADMIN_ADJUSTMENT", body.amount, `admin:${admin.username}`, { note: body.note, adminId: admin.id }));
126 + await logSecurity(req, "admin.wallet.adjust", { adminId: admin.id, userId: id, severity: "high", meta: { amount: body.amount, note: body.note } });
127 + return { balance };
128 + });
129 +
130 + app.post("/api/admin/users/:id/status", async (req) => {
131 + const admin = requireAdmin(req);
132 + const { id } = req.params as { id: string };
133 + const body = z.object({ status: z.enum(["active", "suspended"]) }).parse(req.body);
134 + await db.update(users).set({ status: body.status }).where(eq(users.id, id));
135 + if (body.status === "suspended") {
136 + const rows = await db.select({ tokenHash: sessions.tokenHash }).from(sessions).where(eq(sessions.userId, id));
137 + await db.delete(sessions).where(eq(sessions.userId, id));
138 + if (rows.length) await redis().del(...rows.map((r) => `sess:${r.tokenHash}`));
139 + }
140 + await logSecurity(req, "admin.user.status", { adminId: admin.id, userId: id, severity: "high", meta: { status: body.status } });
141 + return { ok: true };
142 + });
143 +
144 + /* ------------------------------------------------------------ games */
145 + app.get("/api/admin/games", async (req) => {
146 + requireAdmin(req);
147 + const rows = await db
148 + .select({ game: games, stats: gameStatistics })
149 + .from(games)
150 + .leftJoin(gameStatistics, eq(gameStatistics.gameId, games.id))
151 + .orderBy(games.sortOrder);
152 + const flags = allFlags();
153 + return {
154 + games: rows.map(({ game, stats }) => {
155 + const def = getGame(game.slug);
156 + return {
157 + ...game,
158 + enabled: flags[`game.${game.slug}.enabled`] ?? true,
159 + rtp: def?.rtp ?? null,
160 + payScale: def?.payScale ?? null,
161 + stats: stats ? { ...stats, effectiveRtp: Number(stats.wagered) ? Number(stats.won) / Number(stats.wagered) : null } : null,
162 + validation: def ? validateDefinition(def) : [{ level: "error", message: "definition missing from library" }],
163 + };
164 + }),
165 + };
166 + });
167 +
168 + app.get("/api/admin/games/:slug", async (req) => {
169 + requireAdmin(req);
170 + const { slug } = req.params as { slug: string };
171 + const g = await db.query.games.findFirst({ where: eq(games.slug, slug) });
172 + if (!g) throw errors.notFound();
173 + const versions = await db.select({ version: gameVersions.version, status: gameVersions.status, createdAt: gameVersions.createdAt, certification: gameVersions.certification, definitionHash: gameVersions.definitionHash }).from(gameVersions).where(eq(gameVersions.gameId, g.id)).orderBy(desc(gameVersions.createdAt));
174 + const stats = await db.query.gameStatistics.findFirst({ where: eq(gameStatistics.gameId, g.id) });
175 + const daily = await db.execute(sql`select date_trunc('day', created_at) as day, count(*)::int as spins, sum(bet)::bigint as wagered, sum(win)::bigint as won, count(distinct user_id)::int as players
176 + from game_rounds where game_id = ${g.id} and created_at >= now() - interval '30 days' group by 1 order by 1`);
177 + const runs = await db.select().from(simulationRuns).where(eq(simulationRuns.gameSlug, slug)).orderBy(desc(simulationRuns.createdAt)).limit(10);
178 + return { game: g, definition: getGame(slug) ?? null, versions, stats, daily: daily.rows, simulationRuns: runs };
179 + });
180 +
181 + app.post("/api/admin/games/:slug/lifecycle", async (req) => {
182 + const admin = requireAdmin(req);
183 + const { slug } = req.params as { slug: string };
184 + const body = z.object({ lifecycle: z.enum(GAME_LIFECYCLE) }).parse(req.body);
185 + const g = await db.query.games.findFirst({ where: eq(games.slug, slug) });
186 + if (!g) throw errors.notFound();
187 + if (body.lifecycle === "published") {
188 + const v = await db.query.gameVersions.findFirst({ where: and(eq(gameVersions.gameId, g.id), eq(gameVersions.version, g.version)) });
189 + const cert = v?.certification as { status?: string; version?: string } | null;
190 + if (!cert || cert.status !== "PASS" || cert.version !== g.version) throw errors.conflict("NOT_CERTIFIED", "A game cannot be published without a PASS certification for its current version.");
191 + }
192 + await db.update(games).set({ lifecycle: body.lifecycle, publishedAt: body.lifecycle === "published" ? sql`coalesce(${games.publishedAt}, now())` : games.publishedAt, updatedAt: new Date() }).where(eq(games.id, g.id));
193 + await logSecurity(req, "admin.game.lifecycle", { adminId: admin.id, severity: "warn", meta: { slug, lifecycle: body.lifecycle } });
194 + return { ok: true };
195 + });
196 +
197 + app.post("/api/admin/games/:slug/flags", async (req) => {
198 + const admin = requireAdmin(req);
199 + const { slug } = req.params as { slug: string };
200 + const body = z.object({ isFeatured: z.boolean().optional(), isNew: z.boolean().optional(), sortOrder: z.number().int().optional() }).parse(req.body);
201 + await db.update(games).set({ ...body, updatedAt: new Date() }).where(eq(games.slug, slug));
202 + await logSecurity(req, "admin.game.flags", { adminId: admin.id, meta: { slug, ...body } });
203 + return { ok: true };
204 + });
205 +
206 + /* -------------------------------------------------------- simulator */
207 + app.post("/api/admin/simulator/run", async (req) => {
208 + const admin = requireAdmin(req);
209 + const body = z.object({ slug: z.string(), spins: z.number().int().min(1000).max(10_000_000), bet: z.number().int().min(10).max(1000).default(100), certify: z.boolean().default(false) }).parse(req.body);
210 + const def = getGame(body.slug);
211 + if (!def) throw errors.notFound("Unknown game");
212 + const running = await db.select({ n: count() }).from(simulationRuns).where(eq(simulationRuns.status, "running"));
213 + if (running[0].n >= 2) throw errors.conflict("BUSY", "Two simulations are already running. Please wait.");
214 + const [run] = await db.insert(simulationRuns).values({ gameSlug: def.slug, gameVersion: def.version, spins: body.spins, requestedBy: admin.id }).returning();
215 + // Fire and forget: worker threads keep the event loop free.
216 + simulateParallel(def.slug, {
217 + spins: body.spins,
218 + bet: body.bet,
219 + payScale: def.payScale,
220 + threads: Math.max(1, Math.min(os.cpus().length - 2, 12)),
221 + onProgress: (done) => {
222 + db.update(simulationRuns).set({ progress: done }).where(eq(simulationRuns.id, run.id)).catch(() => {});
223 + },
224 + })
225 + .then(async (result) => {
226 + const report = body.certify ? certify(def, result, { ...DEFAULT_CERTIFICATION_RULES, minSpins: Math.min(DEFAULT_CERTIFICATION_RULES.minSpins, body.spins) }) : null;
227 + await db.update(simulationRuns).set({ status: "done", progress: body.spins, result: { simulation: result, certification: report } as unknown as Record<string, unknown>, finishedAt: new Date() }).where(eq(simulationRuns.id, run.id));
228 + // A full-size PASS certification from the admin simulator becomes the official certification of this version,
229 + // which is what unlocks the `published` lifecycle.
230 + if (report && report.status === "PASS" && body.spins >= DEFAULT_CERTIFICATION_RULES.minSpins) {
231 + const g = await db.query.games.findFirst({ where: eq(games.slug, def.slug), columns: { id: true } });
232 + if (g) await db.update(gameVersions).set({ certification: report as unknown as Record<string, unknown> }).where(and(eq(gameVersions.gameId, g.id), eq(gameVersions.version, def.version)));
233 + }
234 + })
235 + .catch(async (e) => {
236 + await db.update(simulationRuns).set({ status: "failed", error: String(e?.message ?? e), finishedAt: new Date() }).where(eq(simulationRuns.id, run.id));
237 + });
238 + return { runId: run.id };
239 + });
240 +
241 + app.get("/api/admin/simulator/runs", async (req) => {
242 + requireAdmin(req);
243 + const q = z.object({ slug: z.string().optional(), limit: z.coerce.number().int().min(1).max(50).default(20) }).parse(req.query);
244 + const rows = await db.select({ id: simulationRuns.id, gameSlug: simulationRuns.gameSlug, gameVersion: simulationRuns.gameVersion, spins: simulationRuns.spins, status: simulationRuns.status, progress: simulationRuns.progress, createdAt: simulationRuns.createdAt, finishedAt: simulationRuns.finishedAt, error: simulationRuns.error }).from(simulationRuns).where(q.slug ? eq(simulationRuns.gameSlug, q.slug) : undefined).orderBy(desc(simulationRuns.createdAt)).limit(q.limit);
245 + return { runs: rows };
246 + });
247 +
248 + app.get("/api/admin/simulator/runs/:id", async (req) => {
249 + requireAdmin(req);
250 + const { id } = req.params as { id: string };
251 + const run = await db.query.simulationRuns.findFirst({ where: eq(simulationRuns.id, id) });
252 + if (!run) throw errors.notFound();
253 + return { run };
254 + });
255 +
256 + /** Library certifications shipped with the build. */
257 + app.get("/api/admin/certifications", async (req) => {
258 + requireAdmin(req);
259 + const rows = await db.select({ slug: games.slug, version: games.version, lifecycle: games.lifecycle, certification: gameVersions.certification }).from(games).innerJoin(gameVersions, and(eq(gameVersions.gameId, games.id), eq(gameVersions.version, games.version))).orderBy(games.sortOrder);
260 + return { certifications: rows };
261 + });
262 +
263 + /* ---------------------------------------------------------- economy */
264 + app.get("/api/admin/economy", async (req) => {
265 + requireAdmin(req);
266 + const days = z.object({ days: z.coerce.number().int().min(1).max(90).default(30) }).parse(req.query).days;
267 + const byType = await db.execute(sql`select type, count(*)::int as n, sum(amount)::bigint as total from credit_transactions where created_at >= now() - (${days} || ' days')::interval group by type order by type`);
268 + const daily = await db.execute(sql`select date_trunc('day', created_at) as day, type, sum(amount)::bigint as total from credit_transactions where created_at >= now() - (${days} || ' days')::interval group by 1,2 order by 1`);
269 + const supply = await db.execute(sql`select coalesce(sum(balance),0)::bigint as circulating, coalesce(sum(lifetime_granted),0)::bigint as granted, coalesce(sum(lifetime_wagered),0)::bigint as wagered, coalesce(sum(lifetime_won),0)::bigint as won from wallets`);
270 + const invariant = await db.execute(sql`select count(*)::int as mismatches from (select w.user_id, w.balance, coalesce(sum(t.amount),0) as ledger from wallets w left join credit_transactions t on t.user_id = w.user_id group by w.user_id, w.balance having w.balance <> coalesce(sum(t.amount),0)) x`);
271 + const dist = await db.execute(sql`select width_bucket(balance, 0, 100000, 10) as bucket, count(*)::int as n from wallets group by 1 order by 1`);
272 + return { byType: byType.rows, daily: daily.rows, supply: supply.rows[0], invariant: invariant.rows[0], balanceDistribution: dist.rows };
273 + });
274 +
275 + /* -------------------------------------------------------- analytics */
276 + app.get("/api/admin/analytics/games", async (req) => {
277 + requireAdmin(req);
278 + const days = z.object({ days: z.coerce.number().int().min(1).max(90).default(30) }).parse(req.query).days;
279 + const rows = await db.execute(sql`select game_slug, count(*)::int as spins, count(distinct user_id)::int as players, sum(bet)::bigint as wagered, sum(win)::bigint as won,
280 + avg(bet)::float as avg_bet, count(*) filter (where bonus or free_spins)::int as bonuses, max(win)::bigint as max_win, max(multiplier)::float as max_multiplier,
281 + count(*) filter (where multiplier >= 20)::int as big_wins, avg(duration_ms)::float as avg_ms
282 + from game_rounds where created_at >= now() - (${days} || ' days')::interval group by game_slug order by spins desc`);
283 + const launches = await db.select({ slug: games.slug, launches: gameStatistics.launches, favorites: gameStatistics.favorites }).from(gameStatistics).innerJoin(games, eq(games.id, gameStatistics.gameId));
284 + const l = new Map(launches.map((x) => [x.slug, x]));
285 + return {
286 + games: (rows.rows as Record<string, unknown>[]).map((r) => ({ ...r, wagered: Number(r.wagered), won: Number(r.won), max_win: Number(r.max_win), rtp: Number(r.wagered) ? Number(r.won) / Number(r.wagered) : null, launches: Number(l.get(r.game_slug as string)?.launches ?? 0), favorites: l.get(r.game_slug as string)?.favorites ?? 0 })),
287 + };
288 + });
289 +
290 + app.get("/api/admin/analytics/players", async (req) => {
291 + requireAdmin(req);
292 + const days = z.object({ days: z.coerce.number().int().min(1).max(90).default(30) }).parse(req.query).days;
293 + const signups = await db.execute(sql`select date_trunc('day', created_at) as day, count(*)::int as n from users where created_at >= now() - (${days} || ' days')::interval group by 1 order by 1`);
294 + const activity = await db.execute(sql`select date_trunc('day', created_at) as day, count(distinct user_id)::int as players, count(*)::int as spins from game_rounds where created_at >= now() - (${days} || ' days')::interval group by 1 order by 1`);
295 + const levels = await db.execute(sql`select width_bucket(level, 1, 101, 10) as bucket, count(*)::int as n from users group by 1 order by 1`);
296 + const retention = await db.execute(sql`select count(*) filter (where last_login_at >= now() - interval '1 day')::int as d1, count(*) filter (where last_login_at >= now() - interval '7 days')::int as d7, count(*)::int as total from users`);
297 + return { signups: signups.rows, activity: activity.rows, levels: levels.rows, retention: retention.rows[0] };
298 + });
299 +
300 + /* --------------------------------------------- missions / achievements */
301 + app.get("/api/admin/missions", async (req) => {
302 + requireAdmin(req);
303 + const rows = await db.select().from(missions).orderBy(missions.sortOrder);
304 + const completions = await db.execute(sql`select mission_key, count(*) filter (where completed_at is not null)::int as completed, count(*)::int as started from user_missions where expires_at >= now() - interval '7 days' group by mission_key`);
305 + return { missions: rows, completions: completions.rows };
306 + });
307 + app.patch("/api/admin/missions/:key", async (req) => {
308 + requireAdmin(req);
309 + const { key } = req.params as { key: string };
310 + const body = z.object({ enabled: z.boolean().optional(), target: z.number().int().min(1).optional(), rewardCredits: z.number().int().min(0).optional(), rewardXp: z.number().int().min(0).optional(), name: z.string().min(2).optional(), description: z.string().min(2).optional() }).parse(req.body);
311 + await db.update(missions).set(body).where(eq(missions.key, key));
312 + return { ok: true };
313 + });
314 + app.get("/api/admin/achievements", async (req) => {
315 + requireAdmin(req);
316 + const rows = await db.select().from(achievements).orderBy(achievements.sortOrder);
317 + const unlocks = await db.execute(sql`select achievement_key, count(*)::int as n from user_achievements group by achievement_key`);
318 + return { achievements: rows, unlocks: unlocks.rows };
319 + });
320 + app.patch("/api/admin/achievements/:key", async (req) => {
321 + requireAdmin(req);
322 + const { key } = req.params as { key: string };
323 + const body = z.object({ enabled: z.boolean().optional(), target: z.number().int().min(1).optional(), rewardCredits: z.number().int().min(0).optional(), rewardXp: z.number().int().min(0).optional(), name: z.string().min(2).optional(), description: z.string().min(2).optional() }).parse(req.body);
324 + await db.update(achievements).set(body).where(eq(achievements.key, key));
325 + return { ok: true };
326 + });
327 +
328 + /* ------------------------------------------- flags / settings / maint */
329 + app.get("/api/admin/settings", async (req) => {
330 + requireAdmin(req);
331 + await refreshSettings(true);
332 + return { flags: allFlags(), settings: allSettings() };
333 + });
334 + app.post("/api/admin/flags/:key", async (req) => {
335 + const admin = requireAdmin(req);
336 + const { key } = req.params as { key: string };
337 + const body = z.object({ enabled: z.boolean() }).parse(req.body);
338 + await setFlag(key, body.enabled);
339 + await logSecurity(req, "admin.flag", { adminId: admin.id, meta: { key, enabled: body.enabled } });
340 + return { ok: true };
341 + });
342 + app.post("/api/admin/settings/:key", async (req) => {
343 + const admin = requireAdmin(req);
344 + const { key } = req.params as { key: string };
345 + const body = z.object({ value: z.unknown() }).parse(req.body);
346 + if (key === "rescue") z.object({ amount: z.number().int().min(0), cooldownHours: z.number().min(0), threshold: z.number().int().min(0) }).parse(body.value);
347 + if (key === "dailyRewards") z.object({ schedule: z.array(z.number().int().min(0)).min(1).max(30) }).parse(body.value);
348 + if (key === "maintenance") z.object({ enabled: z.boolean(), message: z.string().min(3) }).parse(body.value);
349 + if (key === "profanity") z.object({ words: z.array(z.string()) }).parse(body.value);
350 + await setSetting(key, body.value);
351 + await logSecurity(req, key === "maintenance" ? "admin.maintenance" : "admin.setting", { adminId: admin.id, severity: key === "maintenance" ? "high" : "info", meta: { key, value: body.value } });
352 + return { ok: true };
353 + });
354 +
355 + /* ---------------------------------------------------------- security */
356 + app.get("/api/admin/security/events", async (req) => {
357 + requireAdmin(req);
358 + const q = z.object({ type: z.string().optional(), severity: z.string().optional(), limit: z.coerce.number().int().min(1).max(200).default(100) }).parse(req.query);
359 + const conds = [];
360 + if (q.type) conds.push(eq(securityEvents.type, q.type));
361 + if (q.severity) conds.push(eq(securityEvents.severity, q.severity));
362 + const rows = await db.select({ ev: securityEvents, username: users.username }).from(securityEvents).leftJoin(users, eq(users.id, securityEvents.userId)).where(conds.length ? and(...conds) : undefined).orderBy(desc(securityEvents.createdAt)).limit(q.limit);
363 + const summary = await db.execute(sql`select type, count(*)::int as n from security_events where created_at >= now() - interval '24 hours' group by type order by n desc`);
364 + return { events: rows.map((r) => ({ ...r.ev, username: r.username })), summary24h: summary.rows };
365 + });
366 +
367 + /* ------------------------------------------------------------ system */
368 + app.get("/api/admin/system", async (req) => {
369 + requireAdmin(req);
370 + const [dbMs, redisMs] = await Promise.all([pingDb().catch(() => -1), pingRedis().catch(() => -1)]);
371 + const spinLatency = await db.execute(sql`select coalesce(avg(duration_ms),0)::float as avg, coalesce(percentile_cont(0.95) within group (order by duration_ms),0)::float as p95, count(*)::int as n from game_rounds where created_at >= now() - interval '1 hour'`);
372 + const mem = process.memoryUsage();
373 + const sizes = await db.execute(sql`select relname as table, pg_total_relation_size(relid)::bigint as bytes, n_live_tup::bigint as rows from pg_stat_user_tables order by bytes desc limit 15`);
374 + return {
375 + services: [
376 + { name: "spinza-api", node: os.hostname(), version: config.version, uptimeSec: Math.round((Date.now() - startedAt) / 1000), status: "ok", latencyMs: 0 },
377 + { name: "postgresql", node: "local", version: "17", uptimeSec: null, status: dbMs >= 0 ? "ok" : "down", latencyMs: dbMs },
378 + { name: "redis", node: "local", version: "7", uptimeSec: null, status: redisMs >= 0 ? "ok" : "down", latencyMs: redisMs },
379 + ],
380 + process: { rssMb: Math.round(mem.rss / 1048576), heapMb: Math.round(mem.heapUsed / 1048576), cpus: os.cpus().length, load: os.loadavg(), totalMemMb: Math.round(os.totalmem() / 1048576), freeMemMb: Math.round(os.freemem() / 1048576), platform: `${os.platform()} ${os.release()}`, nodeVersion: process.version },
381 + spins: spinLatency.rows[0],
382 + tables: sizes.rows,
383 + games: GAMES.length,
384 + };
385 + });
386 + void or;
387 +}
added apps/api/src/routes/auth.ts +151 −0
@@ -0,0 +1,151 @@
1 +import type { FastifyInstance } from "fastify";
2 +import { db, eq, recoveryCodes, userSettings, users, wallets, dailyRewards, sql } from "@spinza/database";
3 +import { STARTING_BALANCE, checkUsername, loginSchema, recoverSchema, registerSchema, passwordSchema } from "@spinza/shared";
4 +import { errors } from "../lib/errors";
5 +import { generateRecoveryCode, hashPassword, hashRecoveryCode, verifyPassword, verifyRecoveryCode } from "../lib/crypto";
6 +import { rateLimit } from "../lib/redis";
7 +import { createSession, destroyAllSessions, destroySession, requireUser } from "../plugins/auth";
8 +import { clientIp, logSecurity } from "../lib/security";
9 +import { flag, profanityWords, maintenance } from "../lib/settings";
10 +import { applyCredit, lockWallet, saveWallet } from "../services/wallet";
11 +import { z } from "zod";
12 +import { PG_UNIQUE_VIOLATION, pgCode } from "../lib/pg";
13 +
14 +async function limit(req: Parameters<typeof clientIp>[0], scope: string, max: number, windowSec: number) {
15 + const retry = await rateLimit(`${scope}:${clientIp(req)}`, max, windowSec);
16 + if (retry) {
17 + await logSecurity(req, "rate_limit", { meta: { scope } });
18 + throw errors.rateLimited(retry);
19 + }
20 +}
21 +
22 +export async function authRoutes(app: FastifyInstance) {
23 + app.post("/api/auth/check-username", async (req) => {
24 + await limit(req, "check-username", 60, 60);
25 + const body = z.object({ username: z.string().max(64) }).parse(req.body);
26 + const check = checkUsername(body.username, profanityWords());
27 + if (!check.ok) return { available: false, reason: check.reason };
28 + const normalized = body.username.trim().toLowerCase();
29 + const exists = await db.query.users.findFirst({ where: eq(users.usernameNormalized, normalized), columns: { id: true } });
30 + return { available: !exists, reason: exists ? "taken" : undefined };
31 + });
32 +
33 + app.post("/api/auth/register", async (req, reply) => {
34 + await limit(req, "register", 10, 600);
35 + if (!flag("registration.enabled")) throw errors.forbidden("Registration is temporarily closed.");
36 + const m = maintenance();
37 + if (m.enabled) throw errors.maintenance(m.message);
38 + const parsed = registerSchema.safeParse(req.body);
39 + if (!parsed.success) throw errors.badRequest("Please check the form.", parsed.error.flatten());
40 + const { username, password } = parsed.data;
41 + const check = checkUsername(username, profanityWords());
42 + if (!check.ok) {
43 + const msg = { length: "Username must be 3–24 characters.", charset: "Use lowercase letters, numbers, _ or -.", reserved: "This username is reserved.", profanity: "This username is not allowed." }[check.reason!];
44 + throw errors.badRequest(msg, { field: "username" });
45 + }
46 + const passwordHash = await hashPassword(password);
47 + const recoveryCode = generateRecoveryCode();
48 + const codeHash = await hashRecoveryCode(recoveryCode);
49 +
50 + let userId: string;
51 + try {
52 + userId = await db.transaction(async (tx) => {
53 + const [u] = await tx
54 + .insert(users)
55 + .values({ username, usernameNormalized: username, passwordHash, ageConfirmedAt: new Date(), lastLoginAt: new Date() })
56 + .returning({ id: users.id });
57 + await tx.insert(wallets).values({ userId: u.id, balance: 0 });
58 + await tx.insert(userSettings).values({ userId: u.id });
59 + await tx.insert(recoveryCodes).values({ userId: u.id, codeHash });
60 + await tx.insert(dailyRewards).values({ userId: u.id });
61 + const w = await lockWallet(tx, u.id);
62 + await applyCredit(tx, w, "INITIAL_GRANT", STARTING_BALANCE, "welcome", { reason: "Welcome to Spinza" });
63 + await saveWallet(tx, w);
64 + return u.id;
65 + });
66 + } catch (e) {
67 + if (pgCode(e) === PG_UNIQUE_VIOLATION) throw errors.conflict("USERNAME_TAKEN", "That username is already taken.");
68 + throw e;
69 + }
70 + await createSession(reply, req, userId);
71 + await logSecurity(req, "register", { userId });
72 + return {
73 + user: { id: userId, username, isNew: true },
74 + balance: STARTING_BALANCE,
75 + recoveryCode,
76 + notice: "Save this recovery code. Spinza does not collect your email address. If you lose your password and recovery code, your account cannot be recovered.",
77 + };
78 + });
79 +
80 + app.post("/api/auth/login", async (req, reply) => {
81 + await limit(req, "login", 20, 300);
82 + const parsed = loginSchema.safeParse(req.body);
83 + if (!parsed.success) throw errors.unauthorized("Invalid username or password.");
84 + const { username, password } = parsed.data;
85 + const perUser = await rateLimit(`login-user:${username}`, 10, 300);
86 + if (perUser) throw errors.rateLimited(perUser);
87 + const u = await db.query.users.findFirst({ where: eq(users.usernameNormalized, username) });
88 + const ok = u ? await verifyPassword(u.passwordHash, password) : await verifyPassword("$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", password);
89 + if (!u || !ok) {
90 + await logSecurity(req, "login.failed", { userId: u?.id ?? null, meta: { username } });
91 + throw errors.unauthorized("Invalid username or password.");
92 + }
93 + if (u.status !== "active") throw errors.forbidden("This account is suspended.");
94 + await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, u.id));
95 + await createSession(reply, req, u.id);
96 + await logSecurity(req, "login.success", { userId: u.id });
97 + return { user: { id: u.id, username: u.username } };
98 + });
99 +
100 + app.post("/api/auth/logout", async (req, reply) => {
101 + const uid = req.user?.id;
102 + await destroySession(reply, req);
103 + if (uid) await logSecurity(req, "logout", { userId: uid });
104 + return { ok: true };
105 + });
106 +
107 + app.post("/api/auth/recover", async (req, reply) => {
108 + await limit(req, "recover", 8, 900);
109 + const parsed = recoverSchema.safeParse(req.body);
110 + if (!parsed.success) throw errors.badRequest("Please check the form.", parsed.error.flatten());
111 + const { username, recoveryCode, newPassword } = parsed.data;
112 + const u = await db.query.users.findFirst({ where: eq(users.usernameNormalized, username) });
113 + const rc = u ? await db.query.recoveryCodes.findFirst({ where: eq(recoveryCodes.userId, u.id) }) : null;
114 + const ok = rc ? await verifyRecoveryCode(rc.codeHash, recoveryCode) : false;
115 + if (!u || !rc || !ok) {
116 + await logSecurity(req, "recovery.failed", { userId: u?.id ?? null, severity: "warn", meta: { username } });
117 + throw errors.unauthorized("Invalid username or recovery code.");
118 + }
119 + const nextCode = generateRecoveryCode();
120 + const passwordHash = await hashPassword(newPassword);
121 + await db.transaction(async (tx) => {
122 + await tx.update(users).set({ passwordHash }).where(eq(users.id, u.id));
123 + await tx.update(recoveryCodes).set({ codeHash: await hashRecoveryCode(nextCode), rotatedAt: new Date(), usedAt: new Date(), useCount: sql`${recoveryCodes.useCount} + 1` }).where(eq(recoveryCodes.userId, u.id));
124 + });
125 + await destroyAllSessions(u.id);
126 + await createSession(reply, req, u.id);
127 + await logSecurity(req, "recovery.used", { userId: u.id, severity: "warn" });
128 + return { user: { id: u.id, username: u.username }, recoveryCode: nextCode, notice: "Your recovery code has been rotated. Save the new one." };
129 + });
130 +
131 + app.post("/api/auth/password", async (req) => {
132 + const user = requireUser(req);
133 + const body = z.object({ currentPassword: z.string().min(1), newPassword: passwordSchema }).parse(req.body);
134 + const u = await db.query.users.findFirst({ where: eq(users.id, user.id) });
135 + if (!u || !(await verifyPassword(u.passwordHash, body.currentPassword))) throw errors.unauthorized("Current password is incorrect.");
136 + await db.update(users).set({ passwordHash: await hashPassword(body.newPassword) }).where(eq(users.id, u.id));
137 + await logSecurity(req, "password.changed", { userId: u.id });
138 + return { ok: true };
139 + });
140 +
141 + /** Rotate the recovery code (requires password). */
142 + app.post("/api/auth/recovery-code/rotate", async (req) => {
143 + const user = requireUser(req);
144 + const body = z.object({ password: z.string().min(1) }).parse(req.body);
145 + const u = await db.query.users.findFirst({ where: eq(users.id, user.id) });
146 + if (!u || !(await verifyPassword(u.passwordHash, body.password))) throw errors.unauthorized("Password is incorrect.");
147 + const code = generateRecoveryCode();
148 + await db.insert(recoveryCodes).values({ userId: u.id, codeHash: await hashRecoveryCode(code) }).onConflictDoUpdate({ target: recoveryCodes.userId, set: { codeHash: await hashRecoveryCode(code), rotatedAt: new Date() } });
149 + return { recoveryCode: code };
150 + });
151 +}
added apps/api/src/routes/games.ts +198 −0
@@ -0,0 +1,198 @@
1 +import type { FastifyInstance } from "fastify";
2 +import { and, db, desc, eq, favorites, gameStatistics, games, sql, userGameStats } from "@spinza/database";
3 +import { getGame, FIRST_GAME_RECOMMENDATIONS } from "@spinza/games";
4 +import { spinSchema, type GameCard, type GameInfo } from "@spinza/shared";
5 +import { requireUser } from "../plugins/auth";
6 +import { errors } from "../lib/errors";
7 +import { flag } from "../lib/settings";
8 +import { spin } from "../services/spin";
9 +import { rateLimit, redis } from "../lib/redis";
10 +import { clientIp, logSecurity } from "../lib/security";
11 +import { z } from "zod";
12 +
13 +type GameRow = typeof games.$inferSelect;
14 +
15 +function toCard(g: GameRow, extra: { popularity?: number; favorite?: boolean; lastPlayedAt?: Date | null } = {}): GameCard {
16 + const s = g.summary as Record<string, unknown>;
17 + return {
18 + slug: g.slug,
19 + name: g.name,
20 + tagline: s.tagline as string,
21 + theme: s.theme as string,
22 + volatility: s.volatility as GameCard["volatility"],
23 + version: g.version,
24 + grid: s.grid as GameCard["grid"],
25 + minBet: s.minBet as number,
26 + maxBet: s.maxBet as number,
27 + maxMultiplier: s.maxMultiplier as number,
28 + features: (s.features as string[]) ?? [],
29 + tags: (s.tags as string[]) ?? [],
30 + palette: s.palette as GameCard["palette"],
31 + lifecycle: g.lifecycle as GameCard["lifecycle"],
32 + isNew: g.isNew,
33 + isFeatured: g.isFeatured,
34 + isJackpot: !!s.isJackpot,
35 + popularity: extra.popularity ?? 0,
36 + favorite: extra.favorite,
37 + lastPlayedAt: extra.lastPlayedAt ? extra.lastPlayedAt.toISOString() : null,
38 + };
39 +}
40 +
41 +export async function publishedGames(): Promise<GameRow[]> {
42 + const rows = await db.select().from(games).where(eq(games.lifecycle, "published")).orderBy(games.sortOrder);
43 + return rows.filter((g) => flag(`game.${g.slug}.enabled`));
44 +}
45 +
46 +export async function gameRoutes(app: FastifyInstance) {
47 + /** Public lobby data. When signed in, includes favorites + continue playing. */
48 + app.get("/api/games", async (req) => {
49 + const rows = await publishedGames();
50 + const stats = await db.select({ gameId: gameStatistics.gameId, spins: gameStatistics.spins, launches: gameStatistics.launches }).from(gameStatistics);
51 + const statMap = new Map(stats.map((s) => [s.gameId, s]));
52 + let favs = new Set<string>();
53 + let recent = new Map<string, Date>();
54 + if (req.user) {
55 + const f = await db.select({ gameId: favorites.gameId }).from(favorites).where(eq(favorites.userId, req.user.id));
56 + favs = new Set(f.map((x) => x.gameId));
57 + const r = await db.select({ gameId: userGameStats.gameId, at: userGameStats.lastPlayedAt }).from(userGameStats).where(eq(userGameStats.userId, req.user.id));
58 + recent = new Map(r.map((x) => [x.gameId, x.at]));
59 + }
60 + const live = await redis().zcount("live:players", Date.now() - 5 * 60_000, "+inf").catch(() => 0);
61 + const cards = rows.map((g) => {
62 + const st = statMap.get(g.id);
63 + return toCard(g, { popularity: Number(st?.spins ?? 0) + Number(st?.launches ?? 0) * 20, favorite: favs.has(g.id), lastPlayedAt: recent.get(g.id) ?? null });
64 + });
65 + return { games: cards, livePlayers: live, recommendations: FIRST_GAME_RECOMMENDATIONS };
66 + });
67 +
68 + app.get("/api/games/:slug", async (req) => {
69 + const { slug } = req.params as { slug: string };
70 + const g = await db.query.games.findFirst({ where: eq(games.slug, slug) });
71 + const def = getGame(slug);
72 + if (!g || !def || g.lifecycle !== "published" || !flag(`game.${slug}.enabled`)) throw errors.notFound("Game not found");
73 + const s = g.summary as Record<string, unknown>;
74 + const cert = (s.certification as GameInfo["certification"] | null) ?? null;
75 + let favorite = false;
76 + if (req.user) favorite = !!(await db.query.favorites.findFirst({ where: and(eq(favorites.userId, req.user.id), eq(favorites.gameId, g.id)) }));
77 + const info: GameInfo = {
78 + ...toCard(g, { favorite }),
79 + rtp: def.rtp,
80 + hitFrequency: cert?.hitRate ?? null,
81 + description: def.description,
82 + rules: def.rules,
83 + paytable: def.symbols
84 + .filter((sym) => sym.pays || sym.scatterPays)
85 + .map((sym) => ({
86 + symbol: sym.id,
87 + label: sym.name,
88 + // Displayed as multiples of total bet (payScale/betDivisor applied), rounded to 2 decimals.
89 + pays: Object.fromEntries(
90 + Object.entries(sym.pays ?? sym.scatterPays ?? {}).map(([k, v]) => [k, Number(((v * def.payScale) / (sym.pays ? def.betDivisor : 1)).toFixed(3))]),
91 + ),
92 + })),
93 + certification: cert,
94 + };
95 + return { game: info, definition: clientDefinition(def) };
96 + });
97 +
98 + app.post("/api/games/:slug/launch", async (req) => {
99 + const { slug } = req.params as { slug: string };
100 + const g = await db.query.games.findFirst({ where: eq(games.slug, slug), columns: { id: true } });
101 + if (!g) throw errors.notFound();
102 + await db.update(gameStatistics).set({ launches: sql`${gameStatistics.launches} + 1` }).where(eq(gameStatistics.gameId, g.id));
103 + if (req.user) {
104 + // Persisted game state for meters/heat so the client can render them on load.
105 + const st = await db.execute(sql`select state from game_states where user_id = ${req.user.id} and game_id = ${g.id}`);
106 + return { ok: true, state: (st.rows[0] as { state?: unknown } | undefined)?.state ?? null };
107 + }
108 + return { ok: true, state: null };
109 + });
110 +
111 + app.post("/api/games/:slug/spin", async (req) => {
112 + const user = requireUser(req);
113 + const { slug } = req.params as { slug: string };
114 + const retry = await rateLimit(`spin:${user.id}`, 240, 60);
115 + if (retry) {
116 + await logSecurity(req, "spin.suspicious", { userId: user.id, severity: "warn", meta: { slug, ip: clientIp(req) } });
117 + throw errors.rateLimited(retry);
118 + }
119 + const parsed = spinSchema.safeParse(req.body);
120 + if (!parsed.success) throw errors.badRequest("Invalid spin request.", parsed.error.flatten());
121 + return spin({ userId: user.id, slug, bet: parsed.data.bet, clientRoundId: parsed.data.clientRoundId });
122 + });
123 +
124 + app.get("/api/games/:slug/history", async (req) => {
125 + const user = requireUser(req);
126 + const { slug } = req.params as { slug: string };
127 + const q = z.object({ limit: z.coerce.number().int().min(1).max(50).default(20) }).parse(req.query);
128 + const rows = await db.execute(sql`select round_id, bet, win, multiplier, balance_after, created_at, features from game_rounds where user_id = ${user.id} and game_slug = ${slug} order by created_at desc limit ${q.limit}`);
129 + return {
130 + entries: (rows.rows as Record<string, unknown>[]).map((r) => ({
131 + roundId: r.round_id,
132 + bet: Number(r.bet),
133 + win: Number(r.win),
134 + multiplier: Number(r.multiplier),
135 + balanceAfter: Number(r.balance_after),
136 + createdAt: (r.created_at as Date).toISOString(),
137 + features: r.features,
138 + })),
139 + };
140 + });
141 +
142 + app.post("/api/games/:slug/favorite", async (req) => {
143 + const user = requireUser(req);
144 + const { slug } = req.params as { slug: string };
145 + const g = await db.query.games.findFirst({ where: eq(games.slug, slug), columns: { id: true } });
146 + if (!g) throw errors.notFound();
147 + const existing = await db.query.favorites.findFirst({ where: and(eq(favorites.userId, user.id), eq(favorites.gameId, g.id)) });
148 + if (existing) {
149 + await db.delete(favorites).where(and(eq(favorites.userId, user.id), eq(favorites.gameId, g.id)));
150 + await db.update(gameStatistics).set({ favorites: sql`greatest(${gameStatistics.favorites} - 1, 0)` }).where(eq(gameStatistics.gameId, g.id));
151 + return { favorite: false };
152 + }
153 + await db.insert(favorites).values({ userId: user.id, gameId: g.id });
154 + await db.update(gameStatistics).set({ favorites: sql`${gameStatistics.favorites} + 1` }).where(eq(gameStatistics.gameId, g.id));
155 + return { favorite: true };
156 + });
157 +
158 + app.get("/api/user/games", async (req) => {
159 + const user = requireUser(req);
160 + const rows = await db
161 + .select({ slug: games.slug, name: games.name, spins: userGameStats.spins, won: userGameStats.won, wagered: userGameStats.wagered, biggestWin: userGameStats.biggestWin, biggestMultiplier: userGameStats.biggestMultiplier, lastPlayedAt: userGameStats.lastPlayedAt })
162 + .from(userGameStats)
163 + .innerJoin(games, eq(games.id, userGameStats.gameId))
164 + .where(eq(userGameStats.userId, user.id))
165 + .orderBy(desc(userGameStats.lastPlayedAt));
166 + return { games: rows.map((r) => ({ ...r, biggestMultiplier: Number(r.biggestMultiplier), lastPlayedAt: r.lastPlayedAt.toISOString() })) };
167 + });
168 +}
169 +
170 +/** Definition data the renderer needs (symbols, grid, presentation). Weights are public "Game Info" data too. */
171 +export function clientDefinition(def: ReturnType<typeof getGame> & object) {
172 + return {
173 + slug: def.slug,
174 + name: def.name,
175 + version: def.version,
176 + grid: def.grid,
177 + payModel: def.payModel,
178 + symbols: def.symbols.map((s) => ({ id: s.id, name: s.name, kind: s.kind, tier: s.tier, style: s.style })),
179 + wild: def.wild ? { id: def.wild.id, expanding: !!def.wild.expanding, sticky: !!def.wild.sticky, multiplier: !!def.wild.multiplier, exploding: !!def.wild.exploding, moving: !!def.wild.moving } : null,
180 + scatter: def.scatter ? { id: def.scatter.id, triggers: def.scatter.triggers } : null,
181 + cascades: !!def.cascades,
182 + spinCollect: def.spinCollect ? { symbolId: def.spinCollect.symbolId, ladder: def.spinCollect.ladder } : null,
183 + holdRespin: def.holdRespin ? { symbolId: def.holdRespin.symbolId, respins: def.holdRespin.respins, jackpots: def.holdRespin.jackpots } : null,
184 + meter: def.meter ? { id: def.meter.id, name: def.meter.name, symbolId: def.meter.symbolId, max: def.meter.max, label: def.meter.onFull.label } : null,
185 + heat: def.heat ? { max: def.heat.max, ladder: def.heat.ladder, bandSize: def.heat.bandSize, label: def.heat.meltdown.label } : null,
186 + mystery: def.mystery ? { symbolId: def.mystery.symbolId } : null,
187 + quantum: !!def.quantum,
188 + dynamicGrid: def.dynamicGrid ?? null,
189 + jackpot: def.jackpot ? { tiers: def.jackpot.tiers.map((t) => ({ id: t.id, multiplier: t.multiplier })) } : null,
190 + pickBonuses: def.pickBonuses?.map((b) => ({ id: b.id, name: b.name, cells: b.cells, picks: b.picks })) ?? [],
191 + minBet: def.minBet,
192 + maxBet: def.maxBet,
193 + maxMultiplier: def.maxMultiplier,
194 + volatility: def.volatility,
195 + presentation: def.presentation,
196 + featureNames: def.featureNames,
197 + };
198 +}
added apps/api/src/routes/health.ts +63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
added apps/api/src/routes/rewards.ts +215 −0
Large diff (215 lines) is not loaded by default to keep the page fast.Load diff
added apps/api/src/routes/user.ts +160 −0
Large diff (160 lines) is not loaded by default to keep the page fast.Load diff
added apps/api/src/server.ts +33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added apps/api/src/services/progression.ts +242 −0
Large diff (242 lines) is not loaded by default to keep the page fast.Load diff
added apps/api/src/services/spin.ts +254 −0
Large diff (254 lines) is not loaded by default to keep the page fast.Load diff
added apps/api/src/services/wallet.ts +64 −0
Large diff (64 lines) is not loaded by default to keep the page fast.Load diff
added apps/api/test/wallet.test.ts +150 −0
Large diff (150 lines) is not loaded by default to keep the page fast.Load diff
added apps/api/tsconfig.json +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added apps/simulator/package.json +27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added apps/simulator/src/cli.ts +128 −0
Large diff (128 lines) is not loaded by default to keep the page fast.Load diff
added apps/simulator/src/index.ts +76 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added apps/simulator/src/worker.ts +24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added apps/simulator/tsconfig.json +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/eslint.config.mjs +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/next.config.ts +32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/package.json +41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/postcss.config.mjs +2 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/public/apple-icon.png +0 −0

Binary file not shown.

added apps/web/public/icon.svg +19 −0
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/public/manifest.webmanifest +17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(auth)/layout.tsx +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(auth)/login/page.tsx +104 −0
Large diff (104 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(auth)/recover/page.tsx +108 −0
Large diff (108 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/(auth)/register/page.tsx +182 −0
Large diff (182 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/achievements/page.tsx +64 −0
Large diff (64 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/analytics/games/page.tsx +71 −0
Large diff (71 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/analytics/players/page.tsx +103 −0
Large diff (103 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/economy/page.tsx +158 −0
Large diff (158 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/games/[slug]/page.tsx +284 −0
Large diff (284 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/games/page.tsx +199 −0
Large diff (199 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/layout.tsx +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/missions/page.tsx +67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/page.tsx +103 −0
Large diff (103 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/rewards/page.tsx +202 −0
Large diff (202 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/security/page.tsx +99 −0
Large diff (99 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/settings/page.tsx +213 −0
Large diff (213 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/simulator/page.tsx +333 −0
Large diff (333 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/system/page.tsx +96 −0
Large diff (96 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/users/[id]/page.tsx +349 −0
Large diff (349 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/users/page.tsx +106 −0
Large diff (106 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/error.tsx +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/games/[slug]/page.tsx +22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/games/page.tsx +42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/globals.css +229 −0
Large diff (229 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/how-it-works/page.tsx +127 −0
Large diff (127 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/layout.tsx +42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/leaderboard/page.tsx +179 −0
Large diff (179 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/legal/privacy/page.tsx +132 −0
Large diff (132 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/legal/terms/page.tsx +96 −0
Large diff (96 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/maintenance/page.tsx +28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/maintenance/retry.tsx +54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/not-found.tsx +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/page.tsx +50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/profile/history/page.tsx +303 −0
Large diff (303 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/profile/ledger/page.tsx +194 −0
Large diff (194 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/profile/page.tsx +177 −0
Large diff (177 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/responsible-play/page.tsx +93 −0
Large diff (93 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/rewards/page.tsx +388 −0
Large diff (388 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/settings/page.tsx +354 −0
Large diff (354 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/charts.tsx +212 −0
Large diff (212 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/format.ts +110 −0
Large diff (110 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/gate.tsx +140 −0
Large diff (140 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/primitives.tsx +443 −0
Large diff (443 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/progression-table.tsx +176 −0
Large diff (176 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/shell.tsx +189 −0
Large diff (189 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/simulation-results.tsx +123 −0
Large diff (123 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/store.ts +75 −0
Large diff (75 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/types.ts +374 −0
Large diff (374 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/use-query.ts +87 −0
Large diff (87 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/brand/logo.tsx +42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/game/game-client.tsx +849 −0
Large diff (849 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/game/renderer.ts +889 −0
Large diff (889 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/game/sound.ts +244 −0
Large diff (244 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/game/symbol-art.ts +269 −0
Large diff (269 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/game/types.ts +46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/lobby/game-art.tsx +760 −0
Large diff (760 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/lobby/game-card.tsx +194 −0
Large diff (194 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/lobby/games-browser.tsx +129 −0
Large diff (129 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/lobby/landing.tsx +163 −0
Large diff (163 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/lobby/lobby.tsx +137 −0
Large diff (137 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/lobby/prose.tsx +64 −0
Large diff (64 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/lobby/rewards-strip.tsx +115 −0
Large diff (115 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/lobby/site-footer.tsx +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/lobby/welcome-overlay.tsx +103 −0
Large diff (103 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/shell/api-error.tsx +34 −0
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/shell/app-shell.tsx +152 −0
Large diff (152 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/shell/providers.tsx +57 −0
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/shell/recovery-code.tsx +66 −0
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/shell/require-auth.tsx +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/shell/session-reminder.tsx +51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/ui/index.tsx +247 −0
Large diff (247 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/api-server.ts +26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/api.ts +42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/store.ts +86 −0
Large diff (86 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/use-api.ts +78 −0
Large diff (78 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/utils.ts +29 −0
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/tsconfig.json +21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added deploy/backup.sh +27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added deploy/install-backup.sh +22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added docs/SPEC-original.md +60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added games/certifications/arctic-fortune.json +317 −0
Large diff (317 lines) is not loaded by default to keep the page fast.Load diff
added games/certifications/cosmic-collapse.json +318 −0
Large diff (318 lines) is not loaded by default to keep the page fast.Load diff
added games/certifications/deep-treasure.json +318 −0
Large diff (318 lines) is not loaded by default to keep the page fast.Load diff
added games/certifications/diamond-heist.json +320 −0
Large diff (320 lines) is not loaded by default to keep the page fast.Load diff
added games/certifications/dragon-core.json +317 −0
Large diff (317 lines) is not loaded by default to keep the page fast.Load diff
added games/certifications/golden-emperor.json +316 −0
Large diff (316 lines) is not loaded by default to keep the page fast.Load diff
added games/certifications/inferno-reels.json +318 −0
Large diff (318 lines) is not loaded by default to keep the page fast.Load diff
added games/certifications/lucky-circuit.json +316 −0
Large diff (316 lines) is not loaded by default to keep the page fast.Load diff
added games/certifications/midnight-tokyo.json +318 −0
Large diff (318 lines) is not loaded by default to keep the page fast.Load diff
added games/certifications/moonbase-77.json +316 −0
Large diff (316 lines) is not loaded by default to keep the page fast.Load diff
added games/certifications/neon-vault.json +318 −0
Large diff (318 lines) is not loaded by default to keep the page fast.Load diff
added games/certifications/obsidian.json +316 −0
Large diff (316 lines) is not loaded by default to keep the page fast.Load diff
added games/certifications/pharaoh-protocol.json +317 −0
Large diff (317 lines) is not loaded by default to keep the page fast.Load diff
added games/certifications/quantum-jackpot.json +319 −0
Large diff (319 lines) is not loaded by default to keep the page fast.Load diff

Diff truncated — file too large.

Menu

Theme