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