Add persistent cross-game player bank (Refs #146)
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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);
|
||||
`);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { bankProfile } from '$lib/server/fleet/bank.js';
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
@@ -43,6 +44,7 @@
|
||||
}: {
|
||||
data: {
|
||||
profile: PlayerProfile;
|
||||
bank: ReturnType<typeof bankProfile>;
|
||||
returnTo: string;
|
||||
scope: {
|
||||
game: string;
|
||||
@@ -162,6 +164,45 @@
|
||||
description="Stats appear after a game's end report arrives, provided this player was observed at round 2 or later. Games without an end report do not contribute."
|
||||
/>
|
||||
{/if}
|
||||
<section aria-labelledby="bank-title">
|
||||
<h2 id="bank-title">Bank</h2>
|
||||
<p>
|
||||
<strong>{number.format(data.bank.balance)} points</strong> shared across Black Ops I and II.
|
||||
</p>
|
||||
{#if data.bank.pending}<p>
|
||||
A transfer is pending. Available: {number.format(data.bank.available)} points.
|
||||
</p>{/if}
|
||||
{#if data.bank.entries.length}
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<caption>Recent bank transfers</caption>
|
||||
<thead
|
||||
><tr
|
||||
><th scope="col">Time</th><th scope="col">Server</th><th scope="col"
|
||||
>Transfer</th
|
||||
><th scope="col">Balance</th><th scope="col">Match</th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each data.bank.entries as entry (entry.id)}
|
||||
<tr
|
||||
><td>{$timeFormat.dateTime(entry.createdAt)}</td><td
|
||||
>{serverTitle(entry.server)}</td
|
||||
><td>{entry.amount > 0 ? '+' : ''}{number.format(entry.amount)}</td
|
||||
><td>{number.format(entry.balance)}</td><td
|
||||
>{#if entry.matchAvailable}<a href={`/matches/${entry.matchId}`}
|
||||
>#{entry.matchId}</a
|
||||
>{:else}{entry.matchId
|
||||
? `#${entry.matchId} (history expired)`
|
||||
: '—'}{/if}</td
|
||||
></tr
|
||||
>
|
||||
{/each}</tbody
|
||||
>
|
||||
</table>
|
||||
</div>
|
||||
{:else}<p>No bank transfers yet.</p>{/if}
|
||||
</section>
|
||||
<nav class="jump-links" aria-label="Player sections">
|
||||
<a href="#overview">Overview</a><a href="#maps-title">Maps</a><a href="#history-title"
|
||||
>Matches</a
|
||||
|
||||
Reference in New Issue
Block a user