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

@@ -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"
});
}
})
});