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:
2026-10-06 10:56:29 -04:00
parent 687df016a6
commit 7108f77bf9
8 changed files with 1357 additions and 130 deletions

View File

@@ -0,0 +1,121 @@
import { describe, expect, it, beforeEach } from "bun:test";
import { Database } from "bun:sqlite";
import {
PVP_CHARACTERS_ADDED_COLUMNS,
PVP_CHARACTERS_DDL,
ensurePvpSchema
} from "./pvp-schema";
// Same rationale as analytics-schema.test.ts: PvP_Characters lived only in
// the live Turso database, so nothing proved the DDL, the added columns, and
// the router's statements agreed. These tests apply the real schema to a
// throwaway in-memory database and exercise the idempotent applier against
// both a fresh database and a pre-FRE-142 table shape.
let db: Database;
/** Minimal adapter so `ensurePvpSchema` can drive bun:sqlite. */
function sqliteConn(db: Database) {
return {
execute: async (stmt: { sql: string }) => {
const sql = stmt.sql.trim().toUpperCase();
if (sql.startsWith("SELECT")) {
return { rows: db.query(stmt.sql).all() };
}
db.run(stmt.sql);
return { rows: [] };
}
};
}
function columnNames(table: string): string[] {
return db
.query(`SELECT name FROM pragma_table_info('${table}')`)
.all()
.map((row) => (row as { name: string }).name);
}
beforeEach(() => {
db = new Database(":memory:");
});
describe("PVP_CHARACTERS_DDL", () => {
it("creates the table with every column the router touches", () => {
db.run(PVP_CHARACTERS_DDL);
const columns = columnNames("PvP_Characters");
expect(columns).toContain("linkID");
expect(columns).toContain("winCount");
expect(columns).toContain("lossCount");
expect(columns).toContain("tokenRedemptionCount");
expect(columns).toContain("pvpTokens");
expect(columns).toContain("ownerUserId");
expect(columns).toContain("lastPurchaseAt");
});
it("is idempotent", () => {
db.run(PVP_CHARACTERS_DDL);
db.run(PVP_CHARACTERS_DDL);
expect(columnNames("PvP_Characters").length).toBeGreaterThan(0);
});
});
describe("ensurePvpSchema", () => {
it("creates the full table on a fresh database", async () => {
await ensurePvpSchema(sqliteConn(db));
const columns = columnNames("PvP_Characters");
for (const column of PVP_CHARACTERS_ADDED_COLUMNS) {
expect(columns).toContain(column.name);
}
});
it("adds the FRE-142 columns to a pre-existing legacy table", async () => {
// The legacy shape: everything except the three new columns.
db.run(`
CREATE TABLE 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
)
`);
expect(columnNames("PvP_Characters")).not.toContain("pvpTokens");
await ensurePvpSchema(sqliteConn(db));
const columns = columnNames("PvP_Characters");
expect(columns).toContain("pvpTokens");
expect(columns).toContain("ownerUserId");
expect(columns).toContain("lastPurchaseAt");
// Existing data is untouched by the migration.
db.run(
`INSERT INTO PvP_Characters (linkID) VALUES ('legacy-row')`
);
const row = db
.query(`SELECT pvpTokens, ownerUserId FROM PvP_Characters WHERE linkID = 'legacy-row'`)
.get() as { pvpTokens: number | null; ownerUserId: string | null };
expect(row.pvpTokens).toBeNull();
expect(row.ownerUserId).toBeNull();
});
it("is idempotent on an already-migrated table", async () => {
await ensurePvpSchema(sqliteConn(db));
await ensurePvpSchema(sqliteConn(db));
expect(columnNames("PvP_Characters")).toContain("pvpTokens");
});
});