mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
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:
@@ -0,0 +1 @@
|
||||
- **feat(gamification):** enforce the documented 1000 XP/min per-API-key anti-cheat rate limit on the XP award path; over-limit awards are logged and skipped instead of persisted, and the sliding window now matches the timestamp format stored in `xp_audit_log` ([#2403](https://github.com/diegosouzapw/OmniRoute/issues/2403))
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { validateScoreChange, getAnomalies } from "../../../src/lib/gamification/antiCheat";
|
||||
import { getDbInstance } from "../../../src/lib/db/core";
|
||||
|
||||
describe("Anti-Cheat", () => {
|
||||
describe("validateScoreChange", () => {
|
||||
@@ -14,6 +15,38 @@ describe("Anti-Cheat", () => {
|
||||
assert.equal(result.allowed, false);
|
||||
assert.ok(result.reason);
|
||||
});
|
||||
|
||||
// #2403: rows written through the table default (datetime('now'), "YYYY-MM-DD HH:MM:SS")
|
||||
// must count toward the sliding window. Compares are lexical on TEXT, so the window
|
||||
// boundary has to use the same format as the stored timestamps.
|
||||
it("counts XP persisted inside the window toward the per-minute limit", async () => {
|
||||
const db = getDbInstance();
|
||||
const key = `window-hit-${Date.now()}`;
|
||||
db.prepare("INSERT INTO xp_audit_log (api_key_id, action, xp_earned) VALUES (?, ?, ?)").run(
|
||||
key,
|
||||
"request",
|
||||
1000
|
||||
);
|
||||
|
||||
const result = await validateScoreChange(key, "request", 1);
|
||||
assert.equal(result.allowed, false);
|
||||
assert.match(result.reason ?? "", /Rate limit exceeded: 1001 > 1000 XP\/min/);
|
||||
|
||||
db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(key);
|
||||
});
|
||||
|
||||
it("ignores XP persisted before the window", async () => {
|
||||
const db = getDbInstance();
|
||||
const key = `window-miss-${Date.now()}`;
|
||||
db.prepare(
|
||||
"INSERT INTO xp_audit_log (api_key_id, action, xp_earned, created_at) VALUES (?, ?, ?, datetime('now', '-2 minutes'))"
|
||||
).run(key, "request", 1000);
|
||||
|
||||
const result = await validateScoreChange(key, "request", 1);
|
||||
assert.equal(result.allowed, true);
|
||||
|
||||
db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(key);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAnomalies", () => {
|
||||
|
||||
@@ -42,4 +42,83 @@ describe("Gamification Events", () => {
|
||||
// Cleanup
|
||||
db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(testKey);
|
||||
});
|
||||
|
||||
// #2403: the per-key rate limit (1000 XP/min) documented for the anti-cheat layer must
|
||||
// actually gate the award path. Each case seeds xp_audit_log directly so the window state
|
||||
// is deterministic, then emits a 1 XP "request" event.
|
||||
describe("anti-cheat gate on the award path", () => {
|
||||
function seedXp(apiKeyId: string, xp: number, createdAtModifier?: string): void {
|
||||
const db = getDbInstance();
|
||||
if (createdAtModifier) {
|
||||
db.prepare(
|
||||
"INSERT INTO xp_audit_log (api_key_id, action, xp_earned, created_at) VALUES (?, ?, ?, datetime('now', ?))"
|
||||
).run(apiKeyId, "seed", xp, createdAtModifier);
|
||||
} else {
|
||||
db.prepare("INSERT INTO xp_audit_log (api_key_id, action, xp_earned) VALUES (?, ?, ?)").run(
|
||||
apiKeyId,
|
||||
"seed",
|
||||
xp
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function countRequestRows(apiKeyId: string): number {
|
||||
const row = getDbInstance()
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = 'request'"
|
||||
)
|
||||
.get(apiKeyId) as { count: number };
|
||||
return row.count;
|
||||
}
|
||||
|
||||
function leaderboardScore(apiKeyId: string): number | undefined {
|
||||
const row = getDbInstance()
|
||||
.prepare("SELECT score FROM leaderboard WHERE api_key_id = ? AND scope = 'global'")
|
||||
.get(apiKeyId) as { score: number } | undefined;
|
||||
return row?.score;
|
||||
}
|
||||
|
||||
function cleanup(apiKeyId: string): void {
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(apiKeyId);
|
||||
db.prepare("DELETE FROM leaderboard WHERE api_key_id = ?").run(apiKeyId);
|
||||
db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(apiKeyId);
|
||||
}
|
||||
|
||||
it("skips the award once the key has exhausted 1000 XP inside the last minute", async () => {
|
||||
const key = `rate-limited-${Date.now()}`;
|
||||
seedXp(key, 1000);
|
||||
|
||||
await assert.doesNotReject(emitGamificationEvent({ apiKeyId: key, action: "request" }));
|
||||
|
||||
assert.equal(countRequestRows(key), 0, "over-limit award must not be persisted");
|
||||
assert.equal(
|
||||
leaderboardScore(key),
|
||||
undefined,
|
||||
"over-limit award must not reach the leaderboard"
|
||||
);
|
||||
cleanup(key);
|
||||
});
|
||||
|
||||
it("applies the award when the window total stays at or below the limit", async () => {
|
||||
const key = `under-limit-${Date.now()}`;
|
||||
seedXp(key, 999); // 999 + 1 == 1000, which is allowed (limit is exclusive of the cap)
|
||||
|
||||
await emitGamificationEvent({ apiKeyId: key, action: "request" });
|
||||
|
||||
assert.equal(countRequestRows(key), 1);
|
||||
assert.equal(leaderboardScore(key), 1);
|
||||
cleanup(key);
|
||||
});
|
||||
|
||||
it("ignores XP that was earned before the one-minute window", async () => {
|
||||
const key = `stale-window-${Date.now()}`;
|
||||
seedXp(key, 1000, "-2 minutes");
|
||||
|
||||
await emitGamificationEvent({ apiKeyId: key, action: "request" });
|
||||
|
||||
assert.equal(countRequestRows(key), 1, "stale XP must not block a fresh award");
|
||||
cleanup(key);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user