Spinza v0.1.0 — fictional social casino: engine, 20 certified games, API, web, admin
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
133 changed files +20,001 −0
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.