From e071e5b57432dcf6f354be9c65971e87b59dcf61 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 12 Oct 2026 20:21:58 -0400 Subject: [PATCH] Add persistent cross-game player bank (Refs #146) --- .env.example | 4 + docker-compose.yml | 2 + docs/README.md | 1 + docs/guides/bank.md | 145 ++++++++++ src/lib/server/auth/access.ts | 1 + src/lib/server/auth/bank.ts | 41 +++ src/lib/server/db/bank.ts | 27 ++ src/lib/server/db/migrations.ts | 4 +- src/lib/server/fleet/bank.ts | 232 +++++++++++++++ src/routes/api/bank/[action]/+server.ts | 39 +++ .../players/[guid]/+page.server.ts | 2 + .../leaderboard/players/[guid]/+page.svelte | 41 +++ tests/unit/bank.test.ts | 267 ++++++++++++++++++ tests/unit/ui-player-map-records.test.ts | 61 ++++ 14 files changed, 866 insertions(+), 1 deletion(-) create mode 100644 docs/guides/bank.md create mode 100644 src/lib/server/auth/bank.ts create mode 100644 src/lib/server/db/bank.ts create mode 100644 src/lib/server/fleet/bank.ts create mode 100644 src/routes/api/bank/[action]/+server.ts create mode 100644 tests/unit/bank.test.ts diff --git a/.env.example b/.env.example index 5385762..66d8c4c 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,10 @@ EC_SETUP_TOKEN= # Game reporters send Authorization: Bearer or X-EC-Telemetry-Token. # Telemetry ingestion is disabled until a token of at least 32 chars is set. EC_TELEMETRY_TOKEN= +# Unique per-server bank secrets: JSON object mapping exact server names to +# ec_bank_<32+ random URL-safe characters>. Never reuse the telemetry secret. +EC_BANK_TOKENS={} +EC_BANK_MAX_BALANCE=250000 # Persistent SQLite metadata, game-file storage, and beta archives. # For an existing deployment, retain its original data directory. EC_DATA_DIR=/root/zombies/.electric-cherry diff --git a/docker-compose.yml b/docker-compose.yml index aea8a4b..88f5ad2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: ORIGIN: ${EC_PUBLIC_URL:-http://localhost:3000} EC_SETUP_TOKEN: ${EC_SETUP_TOKEN:-} EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-} + EC_BANK_TOKENS: ${EC_BANK_TOKENS:-} + EC_BANK_MAX_BALANCE: ${EC_BANK_MAX_BALANCE:-250000} EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false} # Legacy host imports stream whole game files; buffering routes enforce their own caps. BODY_SIZE_LIMIT: 'Infinity' diff --git a/docs/README.md b/docs/README.md index 6b7b6f6..0f36759 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,7 @@ and launch your first server. - [Beta test plans](guides/beta-test-plans.md) — game reporters, test requirements, coverage, and outcomes. - [Plugin releases](guides/plugin-releases.md) — GitHub release discovery and imports. - [Metrics](guides/metrics.md) — utilization, capacity pressure, and hosting experiments. +- [Player bank](guides/bank.md) — shared balances, per-server credentials, transfers and recovery. - [Leaderboard and matches](guides/leaderboard.md) — player statistics, match history, and the read API. ## Development diff --git a/docs/guides/bank.md b/docs/guides/bank.md new file mode 100644 index 0000000..5ff8aee --- /dev/null +++ b/docs/guides/bank.md @@ -0,0 +1,145 @@ +# Player bank + +The bank stores one points balance per exact GUID across T5 and T6. Players use +`.b`, `.d ` and `.w ` with the TWS bank scripts. Deposits +move spendable match points into the bank; withdrawals return bank points to +the current match. Neither changes earned `score_total`. Bank-assisted games +still count toward the existing leaderboards; the ledger records their match +IDs. This release does not introduce separate bank-assisted rankings. + +## Setup and rollout + +1. Back up the controller database. Deploy Electric Cherry first and restart + the controller. Migration 20 creates accounts, transfer requests and a ledger. +2. Generate a **different** secret for each registered game server, for example + `ec_bank_` followed by `openssl rand -hex 32`. Configure `EC_BANK_TOKENS` as a + JSON object mapping exact server names to these secrets. Docker Compose + forwards this environment variable. An example shape with placeholders is + `{"t5_zombie_theater_1":"ec_bank_"}`. +3. Set `EC_BANK_MAX_BALANCE` (default 250000, range 1–1000000). Invalid configuration + refuses new deposits. A limit reduction does not confiscate existing balances + or invalidate already prepared deposits; withdrawals remain available. +4. Build/install TWS plugins `t5-plugin`/`t6-plugin` 1.1.0 or later and restart + the **whole game-server process**. Install `tws_t5_bank.gsc` or + `tws_t6_bank.gsc` 1.0.0, the game's common module (1.0.0+) and telemetry + reporter (1.0.0+). Restart the map after installing scripts/configuration. +5. In each server's private configuration, set `tws_server_id` to its registered + name and `tws_bank_token` to that server's secret. The optional `tws_bank_url` + defaults to `https://ec.thewrightserver.net/api/bank`; overrides must use + HTTPS. Keep these settings out of shared scripts and public config downloads. + +Bank tokens must match `ec_bank_` followed by 32–128 URL-safe letters, digits, +underscores or hyphens. Missing, malformed, duplicate, or telemetry-reused tokens +disable authentication. `EC_TELEMETRY_TOKEN`, host tokens and browser sessions +cannot authorize bank requests. The credential determines the server, and a +different `server` in the body is refused. Rotate a token by updating both the +controller mapping and that server's private configuration, then restart them. +Keep the same server name and bank URL so existing decisions can finish. + +## Machine API + +All routes below are `POST /api/bank/`, with `Content-Type: +application/json` and `Authorization: Bearer `. Responses +are never cached. Bodies are capped at 4096 bytes. GUIDs and request IDs stay +strings; never convert them to numbers. + +| Action | Body | Meaning | +| ---------- | ------------------------- | -------------------------------------------------------------------- | +| `balance` | `server`, `guid` | Read the shared balance and currently available points | +| `deposit` | Transfer fields below | Reserve deposit capacity without crediting the account yet | +| `withdraw` | Transfer fields below | Reserve bank funds without debiting the account yet | +| `commit` | Identical transfer fields | Apply a prepared transfer once and append its ledger entry | +| `cancel` | Identical transfer fields | Release a reservation; also prevents a delayed prepare from applying | + +Transfer example: + +```json +{ + "server": "t5_zombie_theater_1", + "guid": "18446744073709551615", + "match": "reporter-match-key", + "request_id": "unique-process-token-17", + "operation": "deposit", + "amount": 2000, + "capacity": 995000 +} +``` + +`match` is the reporter's telemetry match key, **not** Electric Cherry's numeric +match ID. `request_id` is unique for the server and at most 64 characters. +`amount` is a positive integer up to 1000000; withdrawal also accepts `"all"`. +The script resolves deposit `all` to its captured spendable points. `capacity` +is the current room below the game's bank withdrawal cap of 1000000 points. +An `all` withdrawal that exceeds this room is refused, rather than truncated. + +Preparation requires the GUID to be present in the server's latest active match, +with the exact telemetry match key. Late joiners may need to wait for the next +30-second heartbeat. Unknown/destroyed servers, absent players and old matches +cannot prepare transfers. Settlement of an existing decision remains possible +after match completion, retention or server deletion. + +Successful application responses include `ok`, `state`, `code`, `request_id`, +`amount` and `balance`. States are `balance`, `prepared`, `committed`, `cancelled` +or `rejected`. For balance reads, `amount` is the available balance. A business +rejection uses HTTP 200 with `ok: false`, `state: "rejected"` and a code such as +`insufficient_balance`, `empty`, `bank_limit`, `points_limit`, `not_in_match` or +`pending`. It never changes points. Authentication failures use 401/403, +malformed input 400/413/415, identity conflicts 409, and unavailable service 503. + +Repeated requests return the saved result/state without applying a second +change. Settled IDs never reopen; a committed result retains its original +resulting balance even if later transfers change the account. Reusing an ID +with different transfer fields returns 409. A GUID has at most one prepared +transfer across all servers. SQLite transactions serialize checks, account +updates and ledger insertion, so concurrent requests cannot overspend funds or +deposit past the configured maximum. + +## Lost responses and recovery + +The game saves a cancellation decision **before** sending a prepare request. +Only a validated prepared reply lets it recheck the player's life, current +spendable points and withdrawal room. It then records a commit decision and +updates spendable points without yielding. The sender retries settlement with +the original identity, at delays of 1, 2, 4, 8, 16, then 30 seconds. Invalid, +truncated or uncorrelated replies never authorize points changes. + +A failed/uncertain prepare leaves game points unchanged and queues cancellation. +Cancellation can arrive first: its tombstone blocks the delayed prepare. Death, +respawn, disconnect or match end before application also cancels. After a commit +decision, retries only finish the ledger; they never change game points again. +These points now belong to the current match, so an applied withdrawal is not +automatically refunded if that match ends or its server crashes. + +Keep `fs_homepath/tws-bank/t5/` or `t6/` on persistent writable storage with a +single process per server/endpoint. Journals contain transfer data, not secrets. +On a map/process restart, saved decisions settle before new bank commands. +Unreadable storage disables the bank; failed writes prevent points changes. +Pending decisions remain until acknowledged, even on credential errors. Bank +holds do not expire automatically, because expiring a saved commit can lose a +deposit or create a free withdrawal. Restore connectivity/configuration and the +original journal to resolve holds; never delete a pending journal to unblock +the bank. If storage is permanently lost, reconcile against the retained +request and ledger records before any manual repair. + +The journal provides recovery from map/process restart. It cannot make live +game memory and SQLite one atomic transaction or preserve match points after a +machine/storage failure. Back up persistent storage and verify crash behavior +in the actual Windows/Plutonium deployment before rollout. + +## History and validation + +Player profiles show the shared balance, pending status and 20 newest ledger +entries, including signed amounts, resulting balances, server, time and match. +Accounts, request IDs and ledger entries have no cascading match/server foreign +keys and are not deleted by match retention or server deletion. Retained IDs +also keep old retries idempotent. Expired match links become plain historical +IDs. Include these tables in normal database backups. + +Run `npx vitest run tests/unit/bank.test.ts`, then the usual lint, type checks, +tests and build. TWS supplies `node tools/check_bank.mjs` and +`python3 tools/test_plugin_bank.py`. Live validation must cover both games: +deposit/withdraw amounts and `all`, insufficient funds and caps, joining before +telemetry, `.help`, score HUD updates, death/respawn/disconnect during requests, +lost prepare/commit replies, controller outage, disk failures and map/process +restart with pending work. Confirm earned score and leaderboard behavior, and +verify that another server's credential and the telemetry credential fail. diff --git a/src/lib/server/auth/access.ts b/src/lib/server/auth/access.ts index fb0e4b1..a3803ab 100644 --- a/src/lib/server/auth/access.ts +++ b/src/lib/server/auth/access.ts @@ -37,6 +37,7 @@ export function routePermission(routeId: string | null, method: string): RoutePe return 'admin'; if (within(routeId, '/api/agent')) return 'machine'; + if (routeId === '/api/bank/[action]' && method === 'POST') return 'machine'; if (routeId === '/api/telemetry' && method === 'POST') return 'machine'; if (routeId === '/api/leaderboard' && reading) return 'public'; if ((routeId === '/login' || routeId === '/setup') && reading) return 'public'; diff --git a/src/lib/server/auth/bank.ts b/src/lib/server/auth/bank.ts new file mode 100644 index 0000000..936298d --- /dev/null +++ b/src/lib/server/auth/bank.ts @@ -0,0 +1,41 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; + +const digest = (value: string) => createHash('sha256').update(value).digest(); +const valid = (value: unknown): value is string => + typeof value === 'string' && /^ec_bank_[A-Za-z0-9_-]{32,128}$/.test(value); + +/** Dedicated, unique credentials; telemetry and browser credentials never authorize spending. */ +export function authenticateBank( + request: Request +): { ok: true; server: string } | { ok: false; status: 401 | 503; code: string } { + let entries: [string, string][]; + try { + const config: unknown = JSON.parse(process.env.EC_BANK_TOKENS ?? '{}'); + if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error(); + const values = Object.entries(config); + if ( + !values.length || + values.some( + ([server, token]) => + !server || !valid(token) || token === process.env.EC_TELEMETRY_TOKEN + ) + ) + throw new Error(); + entries = values as [string, string][]; + if (new Set(entries.map(([, token]) => token)).size !== entries.length) throw new Error(); + } catch { + return { ok: false, status: 503, code: 'unconfigured' }; + } + const token = request.headers + .get('authorization') + ?.match(/^Bearer (ec_bank_[A-Za-z0-9_-]{32,128})$/i)?.[1]; + if (!token) return { ok: false, status: 401, code: 'unauthorized' }; + const supplied = digest(token); + let matched: string | undefined; + for (const [server, expected] of entries) { + if (timingSafeEqual(supplied, digest(expected))) matched = server; + } + return matched + ? { ok: true, server: matched } + : { ok: false, status: 401, code: 'unauthorized' }; +} diff --git a/src/lib/server/db/bank.ts b/src/lib/server/db/bank.ts new file mode 100644 index 0000000..51a1574 --- /dev/null +++ b/src/lib/server/db/bank.ts @@ -0,0 +1,27 @@ +import type Database from 'better-sqlite3'; + +/** No match/server foreign keys: money and idempotency outlive fleet/history cleanup. */ +export function migrateBank(handle: Database.Database): void { + handle.exec(` + CREATE TABLE IF NOT EXISTS bank_accounts ( + guid TEXT PRIMARY KEY, + balance INTEGER NOT NULL DEFAULT 0 CHECK (typeof(balance) = 'integer' AND balance BETWEEN 0 AND 1000000) + ); + CREATE TABLE IF NOT EXISTS bank_requests ( + server TEXT NOT NULL, request_id TEXT NOT NULL, guid TEXT NOT NULL, + fingerprint TEXT NOT NULL, operation TEXT NOT NULL CHECK (operation IN ('deposit','withdraw')), + match_id INTEGER, amount INTEGER NOT NULL DEFAULT 0, + state TEXT NOT NULL CHECK (state IN ('prepared','committed','cancelled','rejected')), + result TEXT NOT NULL, created_at TEXT NOT NULL, settled_at TEXT, + PRIMARY KEY(server, request_id) + ); + CREATE UNIQUE INDEX IF NOT EXISTS bank_pending_guid ON bank_requests(guid) WHERE state = 'prepared'; + CREATE TABLE IF NOT EXISTS bank_ledger ( + id INTEGER PRIMARY KEY AUTOINCREMENT, guid TEXT NOT NULL, + server TEXT NOT NULL, request_id TEXT NOT NULL, match_id INTEGER, + amount INTEGER NOT NULL, balance INTEGER NOT NULL, created_at TEXT NOT NULL, + UNIQUE(server, request_id) + ); + CREATE INDEX IF NOT EXISTS bank_ledger_guid ON bank_ledger(guid, id DESC); + `); +} diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 9bb4921..8fda343 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -18,6 +18,7 @@ import { migrateAuthSchema, migrateSuperViewerRole } from '../auth/schema.js'; import { migrateGameFileSchema } from '../game-files/schema.js'; import { migrateLeaderboardReports } from './leaderboard-reports.js'; import { migratePlayerScoreTotal } from './player-score-total.js'; +import { migrateBank } from './bank.js'; import type { Migration } from './migration-runner.js'; // Append only: never renumber entries or change an applied migration to add an @@ -109,7 +110,8 @@ export const migrations: readonly Migration[] = [ version: 19, name: 'cumulative player score per match', run: migratePlayerScoreTotal - } + }, + { version: 20, name: 'persistent player bank and transfer ledger', run: migrateBank } ]; export const databaseVersion = migrations.length; diff --git a/src/lib/server/fleet/bank.ts b/src/lib/server/fleet/bank.ts new file mode 100644 index 0000000..7ad5eef --- /dev/null +++ b/src/lib/server/fleet/bank.ts @@ -0,0 +1,232 @@ +import { db } from '../db/index.js'; + +export const BANK_HARD_LIMIT = 1_000_000; +export type BankAction = 'balance' | 'deposit' | 'withdraw' | 'commit' | 'cancel'; +interface Transfer { + server: string; + guid: string; + match: string; + request_id: string; + operation: 'deposit' | 'withdraw'; + amount: number | 'all'; + capacity: number; +} +interface Result { + ok: boolean; + state: string; + code: string; + request_id: string; + amount: number; + balance: number; +} +interface Saved { + fingerprint: string; + state: string; + amount: number; + match_id: number | null; + result: string; +} +const token = (value: unknown, limit: number): value is string => + typeof value === 'string' && + value.length > 0 && + value.length <= limit && + /^[A-Za-z0-9_.:-]+$/.test(value); +const integer = (value: unknown, min = 0): value is number => + Number.isSafeInteger(value) && Number(value) >= min && Number(value) <= BANK_HARD_LIMIT; + +export function bankMaximum(): number { + const value = Number(process.env.EC_BANK_MAX_BALANCE ?? 250_000); + if (!integer(value, 1)) + throw new Error('EC_BANK_MAX_BALANCE must be an integer from 1 to 1000000'); + return value; +} + +export function bankProfile(guid: string) { + const handle = db(); + const account = handle.prepare('SELECT balance FROM bank_accounts WHERE guid = ?').get(guid) as + { balance: number } | undefined; + const pending = handle + .prepare( + "SELECT operation, amount FROM bank_requests WHERE guid = ? AND state = 'prepared'" + ) + .get(guid) as { operation: string; amount: number } | undefined; + const entries = handle + .prepare( + `SELECT l.id, l.server, l.amount, l.balance, l.created_at AS createdAt, + l.match_id AS matchId, CASE WHEN m.id IS NOT NULL THEN 1 ELSE 0 END AS matchAvailable + FROM bank_ledger l LEFT JOIN leaderboard_matches m ON m.id = l.match_id + WHERE l.guid = ? ORDER BY l.id DESC LIMIT 20` + ) + .all(guid) as { + id: number; + server: string; + amount: number; + balance: number; + createdAt: string; + matchId: number | null; + matchAvailable: number; + }[]; + const balance = account?.balance ?? 0; + return { + balance, + available: balance - (pending?.operation === 'withdraw' ? pending.amount : 0), + pending: Boolean(pending), + entries + }; +} + +/** One immediate SQLite transaction serializes limits, reservations and ledger updates. */ +export function bankRequest( + action: BankAction, + value: unknown, + authenticatedServer: string +): { status: number; body: Result } { + const result = (status: number, code: string, request_id = '') => ({ + status, + body: { ok: false, state: 'rejected', code, request_id, amount: 0, balance: 0 } + }); + if (!value || typeof value !== 'object' || Array.isArray(value)) + return result(400, 'invalid_request'); + const input = value as Transfer; + if (input.server !== authenticatedServer) return result(403, 'wrong_server'); + if (!token(input.guid, 64)) return result(400, 'invalid_request'); + if (action === 'balance') { + const profile = bankProfile(input.guid); + return { + status: 200, + body: { + ok: true, + state: 'balance', + code: profile.pending ? 'pending' : 'ok', + request_id: '', + amount: profile.available, + balance: profile.balance + } + }; + } + if ( + !token(input.request_id, 64) || + !token(input.match, 64) || + !['deposit', 'withdraw'].includes(input.operation) || + !(integer(input.amount, 1) || (input.operation === 'withdraw' && input.amount === 'all')) || + !integer(input.capacity) || + ((action === 'deposit' || action === 'withdraw') && action !== input.operation) + ) + return result(400, 'invalid_request'); + const { server, guid, request_id, operation, match, amount, capacity } = input; + const fingerprint = JSON.stringify({ guid, operation, match, amount, capacity }); + const handle = db(); + return handle + .transaction(() => { + const saved = handle + .prepare('SELECT * FROM bank_requests WHERE server = ? AND request_id = ?') + .get(server, request_id) as Saved | undefined; + if (saved && saved.fingerprint !== fingerprint) + return result(409, 'request_conflict', request_id); + const balance = + ( + handle.prepare('SELECT balance FROM bank_accounts WHERE guid = ?').get(guid) as + { balance: number } | undefined + )?.balance ?? 0; + const now = new Date().toISOString(); + const reply = ( + state: string, + code: string, + transferred = 0, + resulting = balance + ): Result => ({ + ok: state !== 'rejected', + state, + code, + request_id, + amount: transferred, + balance: resulting + }); + const insert = (body: Result, matchId: number | null = null) => { + handle + .prepare( + `INSERT INTO bank_requests(server,request_id,guid,fingerprint,operation,match_id,amount,state,result,created_at) + VALUES (?,?,?,?,?,?,?,?,?,?)` + ) + .run( + server, + request_id, + guid, + fingerprint, + operation, + matchId, + body.amount, + body.state, + JSON.stringify(body), + now + ); + return { status: 200, body }; + }; + if (action === 'cancel') { + // Tombstones win even when an earlier prepare is still in transit. + if (!saved) return insert(reply('cancelled', 'cancelled')); + if (saved.state !== 'prepared') + return { status: 200, body: JSON.parse(saved.result) as Result }; + const body = reply('cancelled', 'cancelled'); + handle + .prepare( + 'UPDATE bank_requests SET state = ?, result = ?, settled_at = ? WHERE server = ? AND request_id = ?' + ) + .run(body.state, JSON.stringify(body), now, server, request_id); + return { status: 200, body }; + } + if (action === 'commit') { + if (!saved) return result(409, 'not_prepared', request_id); + if (saved.state !== 'prepared') + return { status: 200, body: JSON.parse(saved.result) as Result }; + const delta = operation === 'deposit' ? saved.amount : -saved.amount; + const resulting = balance + delta; + handle + .prepare( + `INSERT INTO bank_accounts(guid,balance) VALUES (?,?) ON CONFLICT(guid) DO UPDATE SET balance = excluded.balance` + ) + .run(guid, resulting); + handle + .prepare( + `INSERT INTO bank_ledger(guid,server,request_id,match_id,amount,balance,created_at) VALUES (?,?,?,?,?,?,?)` + ) + .run(guid, server, request_id, saved.match_id, delta, resulting, now); + const body = reply('committed', 'ok', saved.amount, resulting); + handle + .prepare( + 'UPDATE bank_requests SET state = ?, result = ?, settled_at = ? WHERE server = ? AND request_id = ?' + ) + .run(body.state, JSON.stringify(body), now, server, request_id); + return { status: 200, body }; + } + if (saved) return { status: 200, body: JSON.parse(saved.result) as Result }; + const member = handle + .prepare( + `SELECT m.id FROM leaderboard_matches m + JOIN servers s ON s.name = m.server AND s.destroyed_at IS NULL + JOIN leaderboard_match_players p ON p.match_id = m.id AND p.guid = ? AND p.present = 1 + WHERE m.server = ? AND m.reporter_match_key = ? AND m.status = 'active' + AND m.id = (SELECT MAX(id) FROM leaderboard_matches WHERE server = ?)` + ) + .get(guid, server, match, server) as { id: number } | undefined; + if (!member) return insert(reply('rejected', 'not_in_match')); + if ( + handle + .prepare("SELECT 1 FROM bank_requests WHERE guid = ? AND state = 'prepared'") + .get(guid) + ) + return insert(reply('rejected', 'pending'), member.id); + const requested = amount === 'all' ? balance : amount; + let code = 'ok'; + if (requested === 0) code = 'empty'; + else if (operation === 'withdraw' && requested > balance) code = 'insufficient_balance'; + else if (operation === 'withdraw' && requested > capacity) code = 'points_limit'; + else if (operation === 'deposit' && balance + requested > bankMaximum()) + code = 'bank_limit'; + return insert( + reply(code === 'ok' ? 'prepared' : 'rejected', code, code === 'ok' ? requested : 0), + member.id + ); + }) + .immediate(); +} diff --git a/src/routes/api/bank/[action]/+server.ts b/src/routes/api/bank/[action]/+server.ts new file mode 100644 index 0000000..4cb1d95 --- /dev/null +++ b/src/routes/api/bank/[action]/+server.ts @@ -0,0 +1,39 @@ +import { json, isHttpError } from '@sveltejs/kit'; +import { authenticateBank } from '$lib/server/auth/bank.js'; +import { bankRequest, type BankAction } from '$lib/server/fleet/bank.js'; +import { jsonBody } from '$lib/server/request-body.js'; + +export async function POST({ request, params }: { request: Request; params: { action: string } }) { + const headers = { 'Cache-Control': 'no-store' }; + const authentication = authenticateBank(request); + if (!authentication.ok) + return json( + { ok: false, code: authentication.code }, + { status: authentication.status, headers } + ); + if (!['balance', 'deposit', 'withdraw', 'commit', 'cancel'].includes(params.action)) + return json({ ok: false, code: 'invalid_action' }, { status: 404, headers }); + if ( + request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== + 'application/json' + ) + return json({ ok: false, code: 'invalid_content_type' }, { status: 415, headers }); + let value: unknown; + try { + value = await jsonBody(request, 4096); + } catch (cause) { + if (isHttpError(cause, 413)) throw cause; + return json({ ok: false, code: 'invalid_request' }, { status: 400, headers }); + } + try { + const { body, status } = bankRequest( + params.action as BankAction, + value, + authentication.server + ); + return json(body, { status, headers }); + } catch { + console.error('Bank transaction failed; request may be retried with the same identity.'); + return json({ ok: false, code: 'unavailable' }, { status: 503, headers }); + } +} diff --git a/src/routes/leaderboard/players/[guid]/+page.server.ts b/src/routes/leaderboard/players/[guid]/+page.server.ts index df67413..f989c04 100644 --- a/src/routes/leaderboard/players/[guid]/+page.server.ts +++ b/src/routes/leaderboard/players/[guid]/+page.server.ts @@ -1,3 +1,4 @@ +import { bankProfile } from '$lib/server/fleet/bank.js'; import { error } from '@sveltejs/kit'; import { playerReport } from '$lib/server/reports/index.js'; import { isLeaderboardMetric } from '$lib/server/fleet/leaderboard.js'; @@ -38,6 +39,7 @@ export async function load({ params, url }: { params: { guid: string }; url: URL if (!profile) error(404, 'Player not found'); return { profile, + bank: bankProfile(params.guid), returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')), scope: { game: game ?? '', map: map ?? '', players: players ?? '', metric, entry } }; diff --git a/src/routes/leaderboard/players/[guid]/+page.svelte b/src/routes/leaderboard/players/[guid]/+page.svelte index cafd30a..533462a 100644 --- a/src/routes/leaderboard/players/[guid]/+page.svelte +++ b/src/routes/leaderboard/players/[guid]/+page.svelte @@ -1,4 +1,5 @@