forked from Mike/freno-dev
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:
@@ -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"
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user