pvp: make the blood token ledger server-authoritative
The PvP_Characters table tallied wins and losses but held no token balance and no purchase endpoint: earnings were client-reported (winners credited themselves locally), so verifying only a spend would have been security theater — a cheater could mint tokens by self-reporting battle wins. The server is now the ledger authority (FRE-142): registerCharacter seeds the balance from the client cache exactly once and reconciles it on every sync; battleResult requires the verified Lineage JWT bearer, rejects non-participants, and credits the winner via the reward formula ported from the client (computed from both registered rows); a new purchase mutation checks cost, balance, and cooldown in one conditional UPDATE scoped to the caller-owned row, so the spend is atomic under concurrency and rate limiting survives cold starts. Schema changes ship as an idempotent pvp-schema bootstrap (pvpTokens, ownerUserId, lastPurchaseAt), mirroring the analytics-schema pattern. Client-side counterpart shipped in the lineage repo; design record in its docs/security/pvp-server-side-purchases.md. Tests: pvp-schema (DDL + legacy-column migration + idempotency), pvp-rewards (formula parity with the client port, cost table), pvp-ledger (seed-once reconciliation, owner binding, participant verification, atomic spend, cooldown, refusal reasons) — 31 new tests against in-memory SQLite; suite 572 pass (1 pre-existing audit date-filter failure, unrelated).
This commit is contained in:
97
src/server/pvp-schema.ts
Normal file
97
src/server/pvp-schema.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* PvP_Characters schema — the single source of truth for the table and the
|
||||
* server-authoritative Blood Token ledger columns (FRE-142).
|
||||
*
|
||||
* Like VisitorAnalytics (see analytics-schema.ts), `PvP_Characters` has no
|
||||
* `CREATE TABLE` in this repo — it exists only in the live Turso database.
|
||||
* The definition below mirrors the columns the router reads and writes.
|
||||
* The FRE-142 columns are appended via idempotent `ALTER TABLE` statements:
|
||||
*
|
||||
* - `pvpTokens` — the authoritative Blood Token balance. NULL means
|
||||
* "not yet seeded"; the first registerCharacter call
|
||||
* after this ships seeds it from the client's cached
|
||||
* balance exactly once, then it is server-owned.
|
||||
* - `ownerUserId` — the User.id of the authenticated owner, extracted
|
||||
* from the verified Lineage JWT bearer. Purchase and
|
||||
* battle-result mutations only act on rows the caller
|
||||
* owns (p8-004: bind characters to authenticated
|
||||
* linkIDs).
|
||||
* - `lastPurchaseAt` — epoch-ms timestamp of the last committed purchase,
|
||||
* used for server-side rate limiting.
|
||||
*
|
||||
* Pure string constants, no imports — so tests can apply them to an
|
||||
* in-memory SQLite database without a network or environment.
|
||||
*/
|
||||
|
||||
export const PVP_CHARACTERS_DDL = `
|
||||
CREATE TABLE IF NOT EXISTS PvP_Characters (
|
||||
linkID TEXT PRIMARY KEY,
|
||||
blessing TEXT,
|
||||
playerClass TEXT,
|
||||
name TEXT,
|
||||
maxHealth INTEGER,
|
||||
maxSanity INTEGER,
|
||||
maxMana INTEGER,
|
||||
baseManaRegen INTEGER,
|
||||
strength INTEGER,
|
||||
intelligence INTEGER,
|
||||
dexterity INTEGER,
|
||||
resistanceTable TEXT,
|
||||
damageTable TEXT,
|
||||
attackStrings TEXT,
|
||||
knownSpells TEXT,
|
||||
pushToken TEXT,
|
||||
pushCurrentlyEnabled INTEGER,
|
||||
winCount INTEGER NOT NULL DEFAULT 0,
|
||||
lossCount INTEGER NOT NULL DEFAULT 0,
|
||||
tokenRedemptionCount INTEGER NOT NULL DEFAULT 0,
|
||||
pvpTokens INTEGER,
|
||||
ownerUserId TEXT,
|
||||
lastPurchaseAt INTEGER
|
||||
)
|
||||
`;
|
||||
|
||||
/** Columns that may be missing from a table created before FRE-142. */
|
||||
export const PVP_CHARACTERS_ADDED_COLUMNS = [
|
||||
{
|
||||
name: "pvpTokens",
|
||||
ddl: `ALTER TABLE PvP_Characters ADD COLUMN pvpTokens INTEGER`
|
||||
},
|
||||
{
|
||||
name: "ownerUserId",
|
||||
ddl: `ALTER TABLE PvP_Characters ADD COLUMN ownerUserId TEXT`
|
||||
},
|
||||
{
|
||||
name: "lastPurchaseAt",
|
||||
ddl: `ALTER TABLE PvP_Characters ADD COLUMN lastPurchaseAt INTEGER`
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Apply the schema idempotently: create the table if absent and add any
|
||||
* FRE-142 columns missing from a pre-existing table. Cheap enough to run
|
||||
* before each write mutation; safe to run concurrently with itself.
|
||||
*/
|
||||
export async function ensurePvpSchema(conn: {
|
||||
execute: (stmt: { sql: string }) => Promise<unknown>;
|
||||
}): Promise<void> {
|
||||
await conn.execute({ sql: PVP_CHARACTERS_DDL });
|
||||
const existing = await conn.execute({
|
||||
sql: `SELECT name FROM pragma_table_info('PvP_Characters')`
|
||||
});
|
||||
const present = new Set(
|
||||
(
|
||||
existing as {
|
||||
rows: Array<{ name: string } | Record<string, unknown>>;
|
||||
}
|
||||
).rows.map((row) => {
|
||||
const r = row as Record<string, unknown>;
|
||||
return String(r.name);
|
||||
})
|
||||
);
|
||||
for (const column of PVP_CHARACTERS_ADDED_COLUMNS) {
|
||||
if (!present.has(column.name)) {
|
||||
await conn.execute({ sql: column.ddl });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user