feat(gamification): enforce the per-key XP rate limit on the award path (#12390)

validateScoreChange() — the documented 1000 XP/min per-API-key limit plus the velocity anomaly check — was exported but never called, so the award path applied every XP delta unconditionally. It now runs before addXp; a rejected award is logged at warn level and skipped, and the fire-and-forget path never throws.

The second finding is the one that made the first invisible: getRecentXp's window query was inert. created_at is stored by the table default as YYYY-MM-DD HH:MM:SS and was compared lexically against a JS ISO string, so same-day rows never matched and the limit could not have tripped even if it had been wired. The window start is now computed in SQLite, matching the style computeZScore already used.

Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green.

Thanks @pacocartones.
This commit is contained in:
Paco Cartones
2026-09-02 08:13:37 +02:00
committed by GitHub
parent eb09e894cb
commit bb5c6d148e
5 changed files with 130 additions and 3 deletions

View File

@@ -140,13 +140,17 @@ async function computeZScore(apiKeyId: string): Promise<number | null> {
*/
async function getRecentXp(apiKeyId: string, windowMs: number): Promise<number> {
const d = db();
const since = new Date(Date.now() - windowMs).toISOString();
// xp_audit_log.created_at is written by the table default datetime('now') as
// "YYYY-MM-DD HH:MM:SS", and TEXT compares are lexical. Computing the window start in
// SQLite keeps both sides in the same format (an ISO "T…Z" string from JS never matched
// same-day rows, so the window read as empty).
const windowStart = `-${Math.ceil(windowMs / 1000)} seconds`;
const row = d
.prepare(
"SELECT COALESCE(SUM(xp_earned), 0) AS total FROM xp_audit_log WHERE api_key_id = ? AND created_at > ?"
"SELECT COALESCE(SUM(xp_earned), 0) AS total FROM xp_audit_log WHERE api_key_id = ? AND created_at > datetime('now', ?)"
)
.get(apiKeyId, since) as { total: number };
.get(apiKeyId, windowStart) as { total: number };
return row.total;
}

View File

@@ -44,6 +44,16 @@ export async function emitGamificationEvent(params: {
// 1. Award XP
const xpAmount = getXpForAction(action);
if (xpAmount > 0) {
// Anti-cheat gate (#2403): the per-key 1000 XP/min rate limit and the z-score anomaly
// check run before anything is persisted. A rejected award is dropped and logged — the
// caller is fire-and-forget, so this must never throw.
const { validateScoreChange } = await import("./antiCheat");
const verdict = await validateScoreChange(apiKeyId, action, xpAmount);
if (!verdict.allowed) {
log.warn("events.award_rejected", { apiKeyId, action, xpAmount, reason: verdict.reason });
return;
}
const { addXp } = await import("../db/gamification");
addXp(apiKeyId, action, xpAmount, metadata ? JSON.stringify(metadata) : undefined);