Files
freno-dev/src/server/pvp-schema.ts
Michael Freno 7108f77bf9 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).
2026-10-06 10:56:29 -04:00

98 lines
3.3 KiB
TypeScript

/**
* 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 });
}
}
}