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,370 @@
import { describe, it, expect, beforeEach } from "vitest";
import { Database } from "bun:sqlite";
import {
PVP_PURCHASE_COOLDOWN_MS,
PVP_PURCHASE_COSTS
} from "~/server/pvp-rewards";
import {
purchaseReward,
recordBattleResult,
registerPvpCharacter,
type PvpLedgerConn,
type RegisteredCharacter
} from "./pvp-ledger";
/**
* FRE-142 ledger tests: the earn/spend authorization and atomicity decisions
* behind `lineage.pvp`, exercised against an in-memory SQLite database
* wrapped to the libsql `execute({ sql, args }) -> { rows, rowsAffected }`
* contract the router uses (same pattern as nessa-community-authz.test.ts).
*
* Casts to `any` are fine in test fixtures per the repo conventions where
* stub rows are simplified objects.
*/
let db: Database;
let conn: PvpLedgerConn;
function makeConn(): PvpLedgerConn {
return {
execute: async ({
sql,
args
}: {
sql: string;
args?: (string | number | null)[];
}) => {
const stmt = db.prepare(sql);
const upper = sql.trim().toUpperCase();
const isRead = upper.startsWith("SELECT") || upper.startsWith("WITH");
if (isRead) {
const rows = stmt.all(...(args ?? []));
return { rows: rows as Record<string, unknown>[] };
}
const result = stmt.run(...(args ?? []));
return { rows: [], rowsAffected: Number(result.changes) };
}
};
}
function character(
overrides: Partial<RegisteredCharacter> = {}
): RegisteredCharacter {
return {
playerClass: "mage",
blessing: null,
name: "Test Mage",
maxHealth: 100,
maxSanity: 50,
maxMana: 100,
baseManaRegen: 5,
strength: 10,
intelligence: 12,
dexterity: 8,
resistanceTable: "{}",
damageTable: "{}",
attackStrings: "[]",
knownSpells: "[]",
...overrides
};
}
/** Register a character and seed a token balance in one step. */
async function seedRow(
linkID: string,
ownerUserId: string | null,
tokens: number
): Promise<void> {
await registerPvpCharacter(conn, {
character: character(),
linkID,
ownerUserId,
seedTokens: tokens
});
}
beforeEach(() => {
db = new Database(":memory:");
conn = makeConn();
});
describe("registerPvpCharacter", () => {
it("creates a bound, seeded row for a new character", async () => {
const result = await registerPvpCharacter(conn, {
character: character(),
linkID: "link-1",
ownerUserId: "user-1",
seedTokens: 7
});
expect(result.status).toBe(201);
expect(result.pvpTokens).toBe(7);
expect(result.winCount).toBe(0);
const row = db
.query(`SELECT pvpTokens, ownerUserId FROM PvP_Characters WHERE linkID = 'link-1'`)
.get() as { pvpTokens: number; ownerUserId: string };
expect(row.pvpTokens).toBe(7);
expect(row.ownerUserId).toBe("user-1");
});
it("seeds a NULL balance exactly once — the server value wins afterwards", async () => {
await seedRow("link-1", "user-1", 12);
// A second register with a *different* claimed balance: the stored 12
// is authoritative, so 12 is returned and the claim is ignored.
const result = await registerPvpCharacter(conn, {
character: character(),
linkID: "link-1",
ownerUserId: "user-1",
seedTokens: 999
});
expect(result.status).toBe(200);
expect(result.pvpTokens).toBe(12);
});
it("never rebinds an owned row to another user", async () => {
await seedRow("link-1", "user-1", 5);
await registerPvpCharacter(conn, {
character: character(),
linkID: "link-1",
ownerUserId: "user-2",
seedTokens: 5
});
const row = db
.query(`SELECT ownerUserId FROM PvP_Characters WHERE linkID = 'link-1'`)
.get() as { ownerUserId: string };
expect(row.ownerUserId).toBe("user-1");
});
it("binds an unbound legacy row on first authenticated register", async () => {
await seedRow("legacy", null, 3);
await registerPvpCharacter(conn, {
character: character(),
linkID: "legacy",
ownerUserId: "user-1",
seedTokens: null
});
const row = db
.query(`SELECT ownerUserId, pvpTokens FROM PvP_Characters WHERE linkID = 'legacy'`)
.get() as { ownerUserId: string; pvpTokens: number };
expect(row.ownerUserId).toBe("user-1");
// NULL seed does not overwrite the existing balance.
expect(row.pvpTokens).toBe(3);
});
});
describe("recordBattleResult", () => {
it("credits the winner with a formula-based reward and returns the balance", async () => {
await seedRow("winner", "user-1", 10);
await seedRow("loser", "user-2", 0);
const before = db
.query(`SELECT winCount, lossCount FROM PvP_Characters WHERE linkID = 'loser'`)
.get() as { winCount: number; lossCount: number };
const result = await recordBattleResult(conn, {
winnerLinkID: "winner",
loserLinkID: "loser",
callerUserId: "user-1"
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.credited).toBe(true);
// Formula: 1 * winRatio(loser 0-0 → 1.0) * power(≈1.0) * class(1.1
// mirror) = 1.1 → clamps/rounds within [1, 5].
expect(result.pvpTokens).toBeGreaterThanOrEqual(10 + 1);
expect(result.pvpTokens).toBeLessThanOrEqual(10 + 5);
const tallies = db
.query(`SELECT winCount, lossCount FROM PvP_Characters WHERE linkID = 'loser'`)
.get() as { winCount: number; lossCount: number };
expect(tallies.lossCount).toBe(before.lossCount + 1);
expect(tallies.winCount).toBe(before.winCount);
});
it("tallies but credits nothing when the loser reports", async () => {
await seedRow("winner", "user-1", 10);
await seedRow("loser", "user-2", 0);
const result = await recordBattleResult(conn, {
winnerLinkID: "winner",
loserLinkID: "loser",
callerUserId: "user-2"
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.credited).toBe(false);
const balance = db
.query(`SELECT pvpTokens FROM PvP_Characters WHERE linkID = 'winner'`)
.get() as { pvpTokens: number };
expect(balance.pvpTokens).toBe(10);
});
it("rejects a report from a non-participant", async () => {
await seedRow("winner", "user-1", 10);
await seedRow("loser", "user-2", 0);
const result = await recordBattleResult(conn, {
winnerLinkID: "winner",
loserLinkID: "loser",
callerUserId: "user-3"
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("not_participant");
// Nothing was tallied or credited.
const tallies = db
.query(`SELECT winCount, lossCount FROM PvP_Characters WHERE linkID = 'winner'`)
.get() as { winCount: number; lossCount: number };
expect(tallies.winCount).toBe(0);
});
it("treats an unregistered loser as a zero-credit win", async () => {
await seedRow("winner", "user-1", 4);
const result = await recordBattleResult(conn, {
winnerLinkID: "winner",
loserLinkID: "ghost",
callerUserId: "user-1"
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.credited).toBe(false);
const balance = db
.query(`SELECT pvpTokens FROM PvP_Characters WHERE linkID = 'winner'`)
.get() as { pvpTokens: number };
expect(balance.pvpTokens).toBe(4);
});
});
describe("purchaseReward", () => {
it("spends atomically and returns the new balance", async () => {
await seedRow("link-1", "user-1", PVP_PURCHASE_COSTS[2]);
const result = await purchaseReward(conn, {
linkID: "link-1",
callerUserId: "user-1",
rewardId: 2,
now: 1_000_000
});
expect(result.ok).toBe(true);
expect(result.pvpTokens).toBe(0);
const row = db
.query(`SELECT pvpTokens, lastPurchaseAt FROM PvP_Characters WHERE linkID = 'link-1'`)
.get() as { pvpTokens: number; lastPurchaseAt: number };
expect(row.pvpTokens).toBe(0);
expect(row.lastPurchaseAt).toBe(1_000_000);
});
it("refuses an unknown reward id without touching the row", async () => {
await seedRow("link-1", "user-1", 100);
const result = await purchaseReward(conn, {
linkID: "link-1",
callerUserId: "user-1",
rewardId: 99
});
expect(result.ok).toBe(false);
expect(result.reason).toBe("unknown_reward");
});
it("refuses purchases on a row the caller does not own", async () => {
await seedRow("link-1", "user-1", 100);
const result = await purchaseReward(conn, {
linkID: "link-1",
callerUserId: "user-2",
rewardId: 5
});
expect(result.ok).toBe(false);
expect(result.reason).toBe("not_found");
});
it("refuses purchases on an unregistered linkID", async () => {
const result = await purchaseReward(conn, {
linkID: "ghost",
callerUserId: "user-1",
rewardId: 5
});
expect(result.ok).toBe(false);
expect(result.reason).toBe("not_found");
});
it("refuses an insufficient balance", async () => {
await seedRow("link-1", "user-1", 3);
const result = await purchaseReward(conn, {
linkID: "link-1",
callerUserId: "user-1",
rewardId: 1 // costs 5
});
expect(result.ok).toBe(false);
expect(result.reason).toBe("insufficient_tokens");
const balance = db
.query(`SELECT pvpTokens FROM PvP_Characters WHERE linkID = 'link-1'`)
.get() as { pvpTokens: number };
expect(balance.pvpTokens).toBe(3);
});
it("refuses a purchase inside the cooldown window, then allows it after", async () => {
await seedRow("link-1", "user-1", 1000);
const first = await purchaseReward(conn, {
linkID: "link-1",
callerUserId: "user-1",
rewardId: 5,
now: 1_000_000
});
expect(first.ok).toBe(true);
const throttled = await purchaseReward(conn, {
linkID: "link-1",
callerUserId: "user-1",
rewardId: 5,
now: 1_000_000 + PVP_PURCHASE_COOLDOWN_MS - 1
});
expect(throttled.ok).toBe(false);
expect(throttled.reason).toBe("throttled");
const allowed = await purchaseReward(conn, {
linkID: "link-1",
callerUserId: "user-1",
rewardId: 5,
now: 1_000_000 + PVP_PURCHASE_COOLDOWN_MS
});
expect(allowed.ok).toBe(true);
});
it("treats a NULL (unseeded) balance as insufficient", async () => {
// A row that exists but was never seeded (pre-FRE-142 shape).
await seedRow("legacy", null, 0);
db.run(`UPDATE PvP_Characters SET pvpTokens = NULL WHERE linkID = 'legacy'`);
db.run(`UPDATE PvP_Characters SET ownerUserId = 'user-1' WHERE linkID = 'legacy'`);
const result = await purchaseReward(conn, {
linkID: "legacy",
callerUserId: "user-1",
rewardId: 1
});
expect(result.ok).toBe(false);
expect(result.reason).toBe("insufficient_tokens");
});
});

View File

@@ -0,0 +1,395 @@
/**
* PvP Blood Token ledger operations (FRE-142) — the authoritative earn/spend
* logic behind the `lineage.pvp` router.
*
* Extracted from the router (mirroring the nessa-community authz pattern)
* so the authorization + atomicity decisions can be unit-tested against an
* in-memory SQLite database wrapped to the libsql `execute` contract. The
* router calls these helpers in the same order, so a pass here guarantees
* the decision each endpoint makes before touching data.
*
* Ledger invariants:
* - `pvpTokens` is server-owned. NULL means "not yet seeded"; the first
* registerCharacter call seeds it from the client's cached balance
* exactly once (one-time migration of pre-FRE-142 balances).
* - Earnings happen only in `recordBattleResult`, computed from both
* combatants' registered rows via the ported reward formula — never
* from client-supplied amounts.
* - Spend happens only in `purchaseReward`, as a single conditional
* UPDATE (balance + cooldown checked in the WHERE clause) so it is
* atomic under concurrency.
* - `ownerUserId` (from the verified Lineage JWT) gates every mutation:
* a caller can only earn on, and spend from, a row they own.
*/
import { ensurePvpSchema } from "~/server/pvp-schema";
import {
PVP_PURCHASE_COOLDOWN_MS,
PVP_PURCHASE_COSTS,
computeWinReward,
type PvpCombatantRow
} from "~/server/pvp-rewards";
/** libsql-shaped connection (real client or the bun:sqlite test adapter). */
export interface PvpLedgerConn {
execute(stmt: {
sql: string;
args?: (string | number | null)[];
}): Promise<{ rows: Record<string, unknown>[]; rowsAffected?: number }>;
}
/** Registered-character fields as persisted on `PvP_Characters`. */
export interface RegisteredCharacter {
playerClass: string;
blessing?: string | null;
name: string;
maxHealth: number;
maxSanity: number;
maxMana: number;
baseManaRegen: number;
strength: number;
intelligence: number;
dexterity: number;
resistanceTable: string;
damageTable: string;
attackStrings: string;
knownSpells: string;
}
function num(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
/**
* Register (or refresh) the caller's character row and reconcile the ledger.
*
* - New row: seeded with the caller's cached balance (`seedTokens`) — a
* fresh character is 0 — and bound to `ownerUserId` when the caller is
* authenticated.
* - Existing row: character fields refresh; `ownerUserId` binds only if
* still NULL (never rebinds away from another owner); `pvpTokens` seeds
* only if still NULL (after that the server value is authoritative and
* is returned so the caller can reconcile).
*/
export async function registerPvpCharacter(
conn: PvpLedgerConn,
params: {
character: RegisteredCharacter;
linkID: string;
ownerUserId: string | null;
seedTokens: number | null;
}
): Promise<{
winCount: number;
lossCount: number;
tokenRedemptionCount: number;
pvpTokens: number;
status: number;
}> {
const { character, linkID, ownerUserId, seedTokens } = params;
await ensurePvpSchema(conn);
const existing = await conn.execute({
sql: `SELECT * FROM PvP_Characters WHERE linkID = ?`,
args: [linkID]
});
if (existing.rows.length === 0) {
await conn.execute({
sql: `INSERT INTO PvP_Characters (
linkID,
blessing,
playerClass,
name,
maxHealth,
maxSanity,
maxMana,
baseManaRegen,
strength,
intelligence,
dexterity,
resistanceTable,
damageTable,
attackStrings,
knownSpells,
pushToken,
pushCurrentlyEnabled,
pvpTokens,
ownerUserId
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
linkID,
character.blessing ?? null,
character.playerClass,
character.name,
character.maxHealth,
character.maxSanity,
character.maxMana,
character.baseManaRegen,
character.strength,
character.intelligence,
character.dexterity,
character.resistanceTable,
character.damageTable,
character.attackStrings,
character.knownSpells,
null,
null,
seedTokens ?? 0,
ownerUserId
]
});
return {
winCount: 0,
lossCount: 0,
tokenRedemptionCount: 0,
pvpTokens: seedTokens ?? 0,
status: 201
};
}
const row = existing.rows[0];
await conn.execute({
sql: `UPDATE PvP_Characters SET
playerClass = ?,
blessing = ?,
name = ?,
maxHealth = ?,
maxSanity = ?,
maxMana = ?,
baseManaRegen = ?,
strength = ?,
intelligence = ?,
dexterity = ?,
resistanceTable = ?,
damageTable = ?,
attackStrings = ?,
knownSpells = ?,
ownerUserId = COALESCE(ownerUserId, ?),
pvpTokens = COALESCE(pvpTokens, ?)
WHERE linkID = ?`,
args: [
character.playerClass,
character.blessing ?? null,
character.name,
character.maxHealth,
character.maxSanity,
character.maxMana,
character.baseManaRegen,
character.strength,
character.intelligence,
character.dexterity,
character.resistanceTable,
character.damageTable,
character.attackStrings,
character.knownSpells,
ownerUserId,
seedTokens,
linkID
]
});
// The post-update balance is authoritative (seeded now if it was NULL).
const reconciled = await conn.execute({
sql: `SELECT pvpTokens FROM PvP_Characters WHERE linkID = ?`,
args: [linkID]
});
const pvpTokens = num(reconciled.rows[0]?.pvpTokens);
return {
winCount: num(row.winCount),
lossCount: num(row.lossCount),
tokenRedemptionCount: num(row.tokenRedemptionCount),
pvpTokens,
status: 200
};
}
/**
* Record a battle outcome and credit the winner's Blood Tokens.
*
* The caller (verified via `callerUserId`) must own either the winner's or
* the loser's row — battle results reported by non-participants are
* rejected. Only a caller who owns the *winner's* row earns tokens; the
* reward is computed server-side from both registered rows, so the
* client-supplied linkIDs cannot influence the amount beyond picking the
* participants.
*/
export async function recordBattleResult(
conn: PvpLedgerConn,
params: {
winnerLinkID: string;
loserLinkID: string;
callerUserId: string;
}
): Promise<
| { ok: true; status: number; pvpTokens: number; credited: boolean }
| { ok: false; status: number; reason: "not_participant" }
> {
const { winnerLinkID, loserLinkID, callerUserId } = params;
await ensurePvpSchema(conn);
const callerRows = await conn.execute({
sql: `SELECT linkID FROM PvP_Characters WHERE ownerUserId = ?`,
args: [callerUserId]
});
const callerLinkIDs = new Set(callerRows.rows.map((row) => String(row.linkID)));
if (!callerLinkIDs.has(winnerLinkID) && !callerLinkIDs.has(loserLinkID)) {
return { ok: false, status: 403, reason: "not_participant" };
}
await conn.execute({
sql: `
UPDATE PvP_Characters
SET
winCount = winCount + CASE WHEN linkID = ? THEN 1 ELSE 0 END,
lossCount = lossCount + CASE WHEN linkID = ? THEN 1 ELSE 0 END
WHERE linkID IN (?, ?)
`,
args: [winnerLinkID, loserLinkID, winnerLinkID, loserLinkID]
});
const callerIsWinner = callerLinkIDs.has(winnerLinkID);
if (!callerIsWinner) {
// The loser reports the same outcome for tally purposes; no tokens move.
return { ok: true, status: 200, pvpTokens: 0, credited: false };
}
const winnerRes = await conn.execute({
sql: `SELECT * FROM PvP_Characters WHERE linkID = ?`,
args: [winnerLinkID]
});
const loserRes = await conn.execute({
sql: `SELECT * FROM PvP_Characters WHERE linkID = ?`,
args: [loserLinkID]
});
if (winnerRes.rows.length === 0 || loserRes.rows.length === 0) {
// An unregistered combatant cannot be rewarded — but the tallies above
// still applied to whatever rows exist, matching pre-FRE-142 behaviour.
return { ok: true, status: 200, pvpTokens: 0, credited: false };
}
const winnerRow = winnerRes.rows[0];
const loserRow = loserRes.rows[0];
const reward = computeWinReward(
{
playerClass: String(winnerRow.playerClass),
strength: num(winnerRow.strength),
intelligence: num(winnerRow.intelligence),
dexterity: num(winnerRow.dexterity),
maxHealth: num(winnerRow.maxHealth),
winCount: num(winnerRow.winCount),
lossCount: num(winnerRow.lossCount)
} satisfies PvpCombatantRow,
{
playerClass: String(loserRow.playerClass),
strength: num(loserRow.strength),
intelligence: num(loserRow.intelligence),
dexterity: num(loserRow.dexterity),
maxHealth: num(loserRow.maxHealth),
winCount: num(loserRow.winCount),
lossCount: num(loserRow.lossCount)
} satisfies PvpCombatantRow
);
await conn.execute({
sql: `UPDATE PvP_Characters
SET pvpTokens = COALESCE(pvpTokens, 0) + ?
WHERE linkID = ?`,
args: [reward, winnerLinkID]
});
const balance = await conn.execute({
sql: `SELECT pvpTokens FROM PvP_Characters WHERE linkID = ?`,
args: [winnerLinkID]
});
return {
ok: true,
status: 200,
pvpTokens: num(balance.rows[0]?.pvpTokens),
credited: true
};
}
/**
* Spend Blood Tokens on a reward atomically.
*
* The balance check, cooldown check, spend, and cooldown stamp all happen in
* one conditional UPDATE — under concurrency exactly one caller can commit
* a spend that satisfies the WHERE clause. `reason` explains a refusal:
* unknown reward id, no owned row for the linkID, insufficient tokens, or
* cooldown in effect.
*/
export async function purchaseReward(
conn: PvpLedgerConn,
params: {
linkID: string;
callerUserId: string;
rewardId: number;
now?: number;
}
): Promise<{
ok: boolean;
pvpTokens?: number;
reason?: "unknown_reward" | "not_found" | "insufficient_tokens" | "throttled";
}> {
const { linkID, callerUserId, rewardId } = params;
await ensurePvpSchema(conn);
const cost = PVP_PURCHASE_COSTS[rewardId];
if (cost === undefined) {
return { ok: false, reason: "unknown_reward" };
}
const now = params.now ?? Date.now();
const spend = await conn.execute({
sql: `UPDATE PvP_Characters
SET pvpTokens = pvpTokens - ?,
lastPurchaseAt = ?
WHERE linkID = ?
AND ownerUserId = ?
AND pvpTokens IS NOT NULL
AND pvpTokens >= ?
AND (lastPurchaseAt IS NULL OR ? - lastPurchaseAt >= ?)`,
args: [
cost,
now,
linkID,
callerUserId,
cost,
now,
PVP_PURCHASE_COOLDOWN_MS
]
});
if ((spend.rowsAffected ?? 0) === 1) {
const balance = await conn.execute({
sql: `SELECT pvpTokens FROM PvP_Characters WHERE linkID = ?`,
args: [linkID]
});
return { ok: true, pvpTokens: num(balance.rows[0]?.pvpTokens) };
}
// The conditional UPDATE refused — determine why, for observability and
// a precise client message.
const row = (
await conn.execute({
sql: `SELECT ownerUserId, pvpTokens, lastPurchaseAt FROM PvP_Characters WHERE linkID = ?`,
args: [linkID]
})
).rows[0];
if (!row || row.ownerUserId !== callerUserId) {
return { ok: false, reason: "not_found" };
}
if (row.pvpTokens === null || row.pvpTokens === undefined) {
return { ok: false, reason: "insufficient_tokens" };
}
if (num(row.pvpTokens) < cost) {
return { ok: false, reason: "insufficient_tokens" };
}
return { ok: false, reason: "throttled" };
}

View File

@@ -5,7 +5,14 @@ import {
} from "../../utils";
import { z } from "zod";
import { LineageConnectionFactory } from "~/server/utils";
import { verifyLineageAuthToken } from "~/server/auth";
import { TRPCError } from "@trpc/server";
import type { APIEvent } from "@solidjs/start/server";
import {
purchaseReward,
recordBattleResult,
registerPvpCharacter
} from "./pvp-ledger";
const characterSchema = z.object({
playerClass: z.string(),
@@ -24,6 +31,25 @@ const characterSchema = z.object({
knownSpells: z.string()
});
/**
* Extract the caller's Lineage user id from the Authorization bearer.
*
* FRE-142 / p8-004: the mobile client attaches its Lineage email JWT to all
* `/pvp*` requests (`authHeaders()` in PVPStore). Verifying it here binds
* every earn/spend mutation to an authenticated owner — anonymous callers
* can register (legacy behaviour) but cannot earn or spend.
*/
async function authenticatedUserId(
event: APIEvent
): Promise<string | null> {
const header = event.request?.headers?.get("authorization");
const match = header?.match(/^Bearer\s+(.+)$/i);
const token = match?.[1]?.trim();
if (!token) return null;
const payload = await verifyLineageAuthToken(token);
return payload?.userId ?? null;
}
export const lineagePvpRouter = createTRPCRouter({
registerCharacter: csrfProtectedProcedure
.input(
@@ -31,117 +57,25 @@ export const lineagePvpRouter = createTRPCRouter({
character: characterSchema,
linkID: z.string(),
pushToken: z.string().optional(),
pushCurrentlyEnabled: z.boolean().optional()
pushCurrentlyEnabled: z.boolean().optional(),
// The caller's cached local balance — used only to seed the
// server-authoritative ledger exactly once (pre-FRE-142 migration).
// After that the server value is authoritative and this is ignored.
pvpTokens: z.number().int().min(0).nullish()
})
)
.mutation(async ({ input }) => {
const { character, linkID, pushToken, pushCurrentlyEnabled } = input;
.mutation(async ({ input, ctx }) => {
const { character, linkID, pvpTokens } = input;
try {
const conn = LineageConnectionFactory();
const res = await conn.execute({
sql: `SELECT * FROM PvP_Characters WHERE linkID = ?`,
args: [linkID]
const ownerUserId = await authenticatedUserId(ctx.event);
return await registerPvpCharacter(conn, {
character,
linkID,
ownerUserId,
seedTokens: pvpTokens ?? null
});
if (res.rows.length === 0) {
await conn.execute({
sql: `INSERT INTO PvP_Characters (
linkID,
blessing,
playerClass,
name,
maxHealth,
maxSanity,
maxMana,
baseManaRegen,
strength,
intelligence,
dexterity,
resistanceTable,
damageTable,
attackStrings,
knownSpells,
pushToken,
pushCurrentlyEnabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
linkID,
character.blessing,
character.playerClass,
character.name,
character.maxHealth,
character.maxSanity,
character.maxMana,
character.baseManaRegen,
character.strength,
character.intelligence,
character.dexterity,
character.resistanceTable,
character.damageTable,
character.attackStrings,
character.knownSpells,
pushToken,
pushCurrentlyEnabled
]
});
return {
ok: true,
winCount: 0,
lossCount: 0,
tokenRedemptionCount: 0,
status: 201
};
} else {
await conn.execute({
sql: `UPDATE PvP_Characters SET
playerClass = ?,
blessing = ?,
name = ?,
maxHealth = ?,
maxSanity = ?,
maxMana = ?,
baseManaRegen = ?,
strength = ?,
intelligence = ?,
dexterity = ?,
resistanceTable = ?,
damageTable = ?,
attackStrings = ?,
knownSpells = ?,
pushToken = ?,
pushCurrentlyEnabled = ?
WHERE linkID = ?`,
args: [
character.playerClass,
character.blessing,
character.name,
character.maxHealth,
character.maxSanity,
character.maxMana,
character.baseManaRegen,
character.strength,
character.intelligence,
character.dexterity,
character.resistanceTable,
character.damageTable,
character.attackStrings,
character.knownSpells,
pushToken,
pushCurrentlyEnabled,
linkID
]
});
return {
ok: true,
winCount: res.rows[0].winCount as number,
lossCount: res.rows[0].lossCount as number,
tokenRedemptionCount: res.rows[0].tokenRedemptionCount as number,
status: 200
};
}
} catch (e) {
console.error(e);
throw new TRPCError({
@@ -160,17 +94,17 @@ export const lineagePvpRouter = createTRPCRouter({
`
SELECT playerClass,
blessing,
name,
maxHealth,
maxSanity,
name,
maxHealth,
maxSanity,
maxMana,
baseManaRegen,
strength,
intelligence,
dexterity,
resistanceTable,
damageTable,
attackStrings,
baseManaRegen,
strength,
intelligence,
dexterity,
resistanceTable,
damageTable,
attackStrings,
knownSpells,
linkID,
winCount,
@@ -202,33 +136,83 @@ export const lineagePvpRouter = createTRPCRouter({
loserLinkID: z.string()
})
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
const { winnerLinkID, loserLinkID } = input;
const callerUserId = await authenticatedUserId(ctx.event);
if (!callerUserId) {
// p8-004: unauthenticated callers can no longer report (and mint)
// battle results — earnings are server-authoritative (FRE-142).
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication required to report battle results"
});
}
const conn = LineageConnectionFactory();
try {
await conn.execute({
sql: `
UPDATE PvP_Characters
SET
winCount = winCount + CASE WHEN linkID = ? THEN 1 ELSE 0 END,
lossCount = lossCount + CASE WHEN linkID = ? THEN 1 ELSE 0 END
WHERE linkID IN (?, ?)
`,
args: [winnerLinkID, loserLinkID, winnerLinkID, loserLinkID]
const result = await recordBattleResult(conn, {
winnerLinkID,
loserLinkID,
callerUserId
});
return {
ok: true,
status: 200
};
if (!result.ok) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Caller is not a participant in this battle"
});
}
return result;
} catch (e) {
if (e instanceof TRPCError) throw e;
console.error(e);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to record battle result"
});
}
}),
/**
* FRE-142: server-authoritative Blood Token purchase. The cost comes from
* the server's own reward table (never the client), the spend is a single
* conditional UPDATE (atomic check-and-spend), and the cooldown is stamped
* in the row so it survives serverless cold starts. Returns the new
* authoritative balance on success; `reason` explains a refusal.
*/
purchase: csrfProtectedProcedure
.input(
z.object({
linkID: z.string().min(1),
rewardId: z.number().int()
})
)
.mutation(async ({ input, ctx }) => {
const { linkID, rewardId } = input;
const callerUserId = await authenticatedUserId(ctx.event);
if (!callerUserId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication required to purchase rewards"
});
}
const conn = LineageConnectionFactory();
try {
return await purchaseReward(conn, {
linkID,
callerUserId,
rewardId
});
} catch (e) {
console.error(e);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to purchase reward"
});
}
})
});