Skip to content

feat: gamification & leaderboard system - #2405

Merged
diegosouzapw merged 18 commits into
cheaper-inference:release/v3.8.0from
oyi77:feat/gamification-leaderboard
May 19, 2026
Merged

diegosouzapw merged 18 commits into
cheaper-inference:release/v3.8.0from
oyi77:feat/gamification-leaderboard

Conversation

@oyi77

@oyi77 oyi77 commented May 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements a complete gamification & leaderboard system for OmniRoute (closes #2403).

7 commits, 6 waves:

Wave Focus Files
1 DB migration (8 tables) + XP engine 3
2 Badge system (20+ badges) + streak tracker 2
3 Leaderboard engine + SSE stream + anti-cheat 3
4 Token sharing + invite/redeem tokens + API routes 6
5 Community server federation 4
6 Dashboard UI (leaderboard, profile, tokens) 3

Features

  • XP/Level System: Polynomial curve (1-100), level titles (Beginner→Legend)
  • Leaderboards: Global, weekly, monthly, tokens_shared scopes with SSE real-time
  • Badges: 20+ across 5 categories (usage, sharing, contribution, streak, rare)
  • Streaks: Daily active usage tracking
  • Token Sharing: Double-entry ledger with idempotency keys
  • Invite Tokens: 8-char codes + 32-byte tokens, SHA-256 hashed
  • Community Servers: Connect, sync leaderboard, push scores, health check
  • Anti-Cheat: Rate limiting (1000 XP/min), z-score anomaly detection
  • Dashboard: /dashboard/leaderboard, /dashboard/profile, /dashboard/tokens

Technical

  • Local-first: all data in SQLite, works offline
  • API key identity (no new user table)
  • Server-side scoring only
  • requireManagementAuth on all routes
  • Zod validation on all inputs

Test plan

  • npm run typecheck:core — passes
  • npm run lint — passes
  • Leaderboard API returns rankings
  • Badge unlock triggers on threshold
  • Token transfer updates balance
  • Invite token redemption works
  • Dashboard pages load correctly
  • SSE stream delivers updates

🤖 Generated with OpenClaude

oyi77 and others added 7 commits May 19, 2026 12:20
Co-Authored-By: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>
Add migration 060 with 8 tables: leaderboard, user_levels,
badge_definitions, user_badges, xp_audit_log, token_ledger,
invite_tokens, community_servers.

Add gamification.ts domain module with all CRUD functions
and re-export from localDb.ts.

Co-Authored-By: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>
Co-Authored-By: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>
Wave 4: Add token transfer sharing module, invite/redeem system,
and API routes for leaderboard, transfer, and invite operations.

- sharing.ts: transfer tokens between API keys with idempotency
- invites.ts: create, redeem, list, and revoke invite codes
- leaderboard route: GET with scope, limit, and neighbor context
- transfer route: GET balance/history, POST transfer
- invite route: GET list, POST create, DELETE revoke
- invite/redeem route: POST redeem invite code

Co-Authored-By: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>
…ages

Co-Authored-By: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>
@oyi77
oyi77 requested a review from diegosouzapw as a code owner May 19, 2026 05:49

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive gamification and leaderboard system, including XP/level progression, badge achievements, token sharing via a double-entry ledger, and community server federation. Key features include real-time leaderboard updates via SSE and an anti-cheat validation engine. Feedback highlights several critical issues: a logic error in leaderboard synchronization that causes score inflation, a security vulnerability in the federation API due to missing token validation, and a potential race condition in token transfers. Additionally, the reviewer noted style guide violations regarding error handling in SSE streams and the use of raw SQL outside of domain modules, as well as the need for client-side idempotency keys and improved frontend SSE reconnection logic.

Comment thread src/lib/gamification/servers.ts Outdated
// Merge into local leaderboard
const { updateScore } = await import("./leaderboard");
for (const entry of data.entries) {
updateScore(entry.apiKeyId, "global", entry.score);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The syncLeaderboard function calls updateScore, which performs an atomic increment (score = score + excluded.score) in the database. This means every time a sync runs, the remote scores are added to the local scores instead of synchronizing them. Repeated syncs will cause scores to inflate incorrectly. You should use a separate function that overwrites the score or takes the maximum of the two.

Comment on lines +14 to +20
const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return NextResponse.json(
{ error: "Missing authorization" },
{ status: 401, headers: CORS_HEADERS }
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

This federation endpoint only checks if the Authorization header starts with Bearer but never validates the token against the stored apiKeyHash of connected community servers. This allows any client to submit arbitrary scores for any apiKeyId. You should extract the token, hash it, and verify it exists in the community_servers table.

Comment on lines +275 to +302
export function transferTokens(
fromId: string,
toId: string,
amount: number,
reason: string,
idempotencyKey: string
): { success: boolean; error?: string } {
// Check for duplicate
const existing = db()
.prepare(`SELECT id FROM token_ledger WHERE idempotency_key = ?`)
.get(idempotencyKey) as { id: number } | undefined;
if (existing) return { success: true };

// Balance check
const balance = getBalance(fromId);
if (balance < amount) {
return { success: false, error: "insufficient_balance" };
}

db()
.prepare(
`INSERT INTO token_ledger (from_api_key_id, to_api_key_id, amount, reason, idempotency_key)
VALUES (?, ?, ?, ?, ?)`
)
.run(fromId, toId, amount, reason, idempotencyKey);

return { success: true };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The transferTokens function performs a balance check followed by an insertion as separate operations. This is prone to race conditions where concurrent transfers could result in a negative balance. These operations must be wrapped in a database transaction to ensure atomicity.

Comment on lines +70 to +72
es.onerror = () => {
es.close();
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The onerror handler closes the EventSource without any reconnection logic. If the connection is lost due to a transient network issue or server restart, real-time updates will stop permanently until the user manually refreshes the page. Consider implementing an exponential backoff reconnection strategy or using a library that handles this automatically.

const entries = await getTopN(scope, 50);
const data = JSON.stringify({ type: "leaderboard", scope, entries });
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
} catch {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Swallowing errors in the SSE stream violates the Repository Style Guide.

Suggested change
} catch {
} catch (err) {
controller.error(err);
}
References
  1. Never silently swallow errors in SSE streams — propagate them or abort the stream cleanly. (link)

Comment thread src/lib/gamification/leaderboard.ts Outdated
Comment on lines +75 to +96
const scoreRow = d
.prepare("SELECT score FROM leaderboard WHERE api_key_id = ? AND scope = ?")
.get(apiKeyId, scope) as { score: number } | undefined;

if (!scoreRow) return { above: [], below: [] };

const above = d
.prepare(
`SELECT api_key_id, score FROM leaderboard
WHERE scope = ? AND score > ?
ORDER BY score ASC LIMIT ?`
)
.all(scope, scoreRow.score, radius) as Array<{ api_key_id: string; score: number }>;

const below = d
.prepare(
`SELECT api_key_id, score FROM leaderboard
WHERE scope = ? AND score < ?
ORDER BY score DESC LIMIT ?`
)
.all(scope, scoreRow.score, radius) as Array<{ api_key_id: string; score: number }>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Writing raw SQL in this module violates the Repository Style Guide. All database logic should be encapsulated within src/lib/db/ domain modules.

References
  1. Never write raw SQL in routes — always go through src/lib/db/ domain modules. (link)

Comment thread src/lib/gamification/sharing.ts Outdated
return { success: false, idempotencyKey: "", error: "Amount must be positive" };
}

const idempotencyKey = crypto.randomUUID();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Generating the idempotencyKey inside the transferTokens function defeats its purpose. If a client retries a request due to a network timeout, a new UUID will be generated for the same logical operation, allowing the transfer to be processed multiple times. The idempotency key should be generated by the client and passed as an argument to this function.

oyi77 and others added 4 commits May 19, 2026 13:04
…ites, anti-cheat, events

Add 25 tests across 5 new test files covering:
- Leaderboard: updateScore, getRank, getTopN, getNeighbors
- Sharing: transferTokens validation, getBalance, getHistory
- Invites: createInvite, redeemInvite, listInvites, revokeInvite
- Anti-Cheat: validateScoreChange, getAnomalies
- Events: emitGamificationEvent with valid/invalid/unknown inputs

Also add events.ts module for emitting gamification events to xp_audit_log.

Co-Authored-By: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>
1. Federation sync: use INSERT ON CONFLICT overwrite instead of additive increment
2. Federation auth: validate Bearer token against community_servers.api_key_hash
3. Token transfer: wrap balance check + insert in database transaction
4. Leaderboard: move raw SQL to DB module (getLeaderboardNeighbors, rotateLeaderboardScope)
5. SSE stream: emit error events instead of swallowing
6. Sharing: accept optional idempotencyKey parameter
7. Leaderboard page: add SSE reconnection on error

Co-Authored-By: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>
@oyi77

oyi77 commented May 19, 2026

Copy link
Copy Markdown
Contributor Author

Review Feedback Addressed

Commit 98ea8ae9 fixes all 7 review items:

  1. Federation sync inflation — Changed from additive updateScore to INSERT ON CONFLICT DO UPDATE SET score = excluded.score (overwrite, not increment)

  2. Federation auth — Token now validated against community_servers.api_key_hash using SHA-256 hash comparison. Returns 403 for invalid tokens.

  3. Transfer race condition — Wrapped balance check + insert in instance.transaction() for atomicity

  4. Raw SQL in leaderboard.ts — Moved getNeighbors and rotateScope logic to DB module functions (getLeaderboardNeighbors, rotateLeaderboardScope)

  5. SSE error swallowing — Stream now emits event: error with error message instead of silently catching

  6. Idempotency key — transferTokens now accepts optional idempotencyKey parameter

  7. SSE reconnection — Added 3s reconnection on error in leaderboard page

🤖 Generated with OpenClaude

Comment thread src/lib/db/gamification.ts Outdated
: `month_${new Date().toISOString().slice(0, 7)}`;

const instance = getDbInstance();
instance.exec(`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: rotateLeaderboardScope can lose data if run twice in the same period. INSERT OR IGNORE silently skips already-archived records, then DELETE removes the original data. Running rotation twice in the same week/month will permanently lose scores that were added after the first rotation but match already-archived api_key_id entries.

-- If run twice in same week, second run:
-- 1. INSERT OR IGNORE: skips entries already archived (same api_key_id + archiveSuffix PK)
-- 2. DELETE: removes ALL entries with scope='weekly' (including new ones added since first rotation)
-- Result: New scores added between rotation #1 and #2 are lost.

Consider: (1) checking if rotation already ran for this period before executing, (2) using a separate archive table, or (3) wrapping in a transaction with a rotation timestamp check.

Comment thread src/lib/db/gamification.ts Outdated
: `month_${new Date().toISOString().slice(0, 7)}`;

const instance = getDbInstance();
instance.exec(`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: instance.exec() with string interpolation is a SQL injection risk pattern. While archiveSuffix and scope are currently internally generated, using exec() with interpolated values is fragile. If this code is ever modified to accept external input, it will be vulnerable. Use parameterized queries instead.

status: string;
error_message: string | null;
}>;
return rows.map((r) => ({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: listServers returns apiKeyHash to the caller. The hashed API key should never be exposed to clients. This field is used for authentication between federated servers and leaking it could allow an attacker to impersonate a connected server.

The CommunityServer interface should exclude apiKeyHash, or listServers should omit it from the returned objects.

};

const XP_PER_LEVEL = 1000;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Hardcoded XP_PER_LEVEL = 1000 doesn't match the actual XP system. The real system uses a polynomial curve: xpForLevel(n) = floor(100 * n^1.5) from src/lib/gamification/xp.ts. This means the XP progress bar on the profile page will show completely incorrect values.

For example, level 2 requires 252 XP (not 1000), level 10 requires 31,622 cumulative XP. The modulo-based progress calculation on line 121 (totalXp % XP_PER_LEVEL) is meaningless with the actual curve.

Import and use xpForLevel and cumulativeXpForLevel from @/lib/gamification/xp instead.


const XP_PER_LEVEL = 1000;

function getTier(level: number): string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Duplicated getTier function with different thresholds than src/lib/gamification/xp.ts:getLevelTier(). The profile page uses: level 50=diamond, 30=platinum, 20=gold, 10=silver. The canonical xp.ts uses: level 76=diamond, 51=platinum, 26=gold, 11=silver. Users will see different tier labels on the profile page vs what the backend computes.

Import and use getLevelTier from @/lib/gamification/xp instead of duplicating.

return "bronze";
}

function getLevelTitle(level: number): string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Duplicated getLevelTitle function with different titles and thresholds than src/lib/gamification/xp.ts:getLevelTitle(). The profile page uses: "Diamond Master", "Platinum Architect", "Gold Engineer", "Silver Developer", "Bronze Builder", "Newcomer". The canonical xp.ts uses: "Legend", "Master", "Expert", "Explorer", "Beginner". Users will see inconsistent titles across the app.

Import and use getLevelTitle from @/lib/gamification/xp instead of duplicating.

Comment thread src/lib/gamification/events.ts Outdated
// Count total actions of this type
const row = db
.prepare(
"SELECT COALESCE(SUM(xp_earned), 0) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = ?"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: checkActionCountBadges uses SUM(xp_earned) instead of COUNT(*) to count actions. While this currently works because XP per action is 1 for both "request" and "token_share", it's semantically wrong and will break if XP values change. Badge thresholds are meant to count number of actions (e.g., "Made 1,000 API requests"), not total XP earned from those actions.

-- Current (wrong): counts XP earned, not actions
SELECT COALESCE(SUM(xp_earned), 0) AS count ...

-- Should be: counts actual number of action events
SELECT COUNT(*) AS count ...

* Query params:
* scope — one of: global, weekly, monthly, tokens_shared, contributions (default: global)
*/
export async function GET(request: NextRequest) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The SSE stream endpoint has no authentication or rate limiting. Any unauthenticated client can open unlimited SSE connections, each triggering a getTopN DB query every 5 seconds. This could be exploited for resource exhaustion (CPU, DB connections, memory from open streams). Consider adding at least basic rate limiting (e.g., max connections per IP) or requiring API key authentication.

*/
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const scope = (url.searchParams.get("scope") || "global") as any;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: scope is cast with as any which bypasses TypeScript type safety. An attacker could pass arbitrary scope values like "global'; DROP TABLE..." and they would reach the DB query. The getTopN function in src/lib/gamification/leaderboard.ts accepts LeaderboardScope type but receives any here.

Validate the scope against the VALID_SCOPES set (same pattern used in stream/route.ts) before passing to getTopN.

@kilo-code-bot

kilo-code-bot Bot commented May 19, 2026 •

Copy link
Copy Markdown

Code Review Summary

Status: 9 Issues Found (2 CRITICAL, 6 WARNING, 1 SUGGESTION) | Recommendation: Address before merge

Overview

Severity Count Status
CRITICAL 2 1 NEW + 1 carried
WARNING 6 4 NEW + 2 carried
SUGGESTION 1 1 NEW

Resolved Issues (Fixed in this diff)

File Issue Status
src/app/api/gamification/stream/route.ts SSE stream auth missing ✅ Added requireManagementAuth
src/app/api/gamification/notifications/route.ts SSE notifications auth missing ✅ Added requireManagementAuth
src/lib/db/gamification.ts:500 rotateLeaderboardScope SQL injection + data loss ✅ Parameterized queries + double-run protection
src/lib/db/gamification.ts:444 listServers exposes apiKeyHash ✅ Excluded from SELECT
src/app/(dashboard)/dashboard/profile/page.tsx Hardcoded XP_PER_LEVEL + duplicated getTier/getLevelTitle ✅ Now imports from @/lib/gamification/xp
src/lib/gamification/events.ts:128 badgeName set to raw badgeId ✅ Now looks up from badge_definitions
src/lib/gamification/events.ts:147 checkActionCountBadges uses SUM(xp_earned) instead of COUNT(*) ✅ Changed to COUNT(*)
src/app/(dashboard)/dashboard/components/BadgeToast.tsx:25 setTimeout never cleared on unmount ✅ Now tracked via useRef<Set>
src/app/(dashboard)/dashboard/components/BadgeToast.tsx:42 No EventSource reconnect logic ✅ Exponential backoff added
src/app/api/gamification/federation/leaderboard/route.ts:14 as any cast bypasses type safety ✅ Changed to as LeaderboardScope (runtime validation still missing — see below)

NEW Issues (in this diff)

File Line Severity Issue
src/lib/gamification/events.ts 154 CRITICAL Missing SELECT keyword in SQL — will cause runtime error when checkActionCountBadges is called
src/app/(dashboard)/dashboard/components/BadgeToast.tsx 44 WARNING apiKeyId not URL-encoded in EventSource URL — special characters will break the connection
src/app/api/gamification/federation/leaderboard/route.ts 14 WARNING scope not validated at runtime — arbitrary strings pass through despite type cast
src/app/(dashboard)/dashboard/profile/page.tsx 6 WARNING Unused import xpForLevel — lint warning
src/app/(dashboard)/dashboard/profile/page.tsx 7 WARNING Unused import calculateLevel — lint warning
src/lib/gamification/events.ts 125 SUGGESTION Direct SQL in non-db module violates project guidelines

Carried Forward (unchanged files)

File Line Severity Issue
src/lib/gamification/servers.ts N/A CRITICAL Federation auth token validation missing
src/app/api/gamification/federation/score/route.ts 20 WARNING Missing token validation on federation score endpoint
src/lib/db/gamification.ts 308 WARNING Previously reported issue

Files Reviewed (8 files)

  • src/lib/gamification/notifications.ts — 0 new issues (memory leak, enqueue error, double close all addressed)
  • src/lib/gamification/events.ts — 1 CRITICAL (missing SELECT) + 1 SUGGESTION (direct SQL)
  • src/lib/db/gamification.ts — 0 new issues (SQL injection + apiKeyHash both fixed)
  • src/app/(dashboard)/dashboard/components/BadgeToast.tsx — 1 WARNING (URL encoding)
  • src/app/(dashboard)/dashboard/profile/page.tsx — 2 WARNINGs (unused imports)
  • src/app/api/gamification/notifications/route.ts — 0 issues (auth added)
  • src/app/api/gamification/stream/route.ts — 0 new issues (auth added)
  • src/app/api/gamification/federation/leaderboard/route.ts — 1 WARNING (no runtime scope validation)

Fix these issues in Kilo Cloud


Reviewed by qwen3.6-plus · 362,319 tokens

oyi77 and others added 3 commits May 19, 2026 14:57
Comprehensive SDD for the gamification & leaderboard system covering
XP/levels, badges, streaks, leaderboards, token sharing, invite/redeem,
community server federation, anti-cheat, API routes, MCP tools, dashboard
pages, pipeline integration, security, testing, file structure, and
phased migration strategy.

Co-Authored-By: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>
export function recordBadgeUnlock(apiKeyId: string, event: BadgeUnlockEvent): void {
if (!recentUnlocks.has(apiKeyId)) {
recentUnlocks.set(apiKeyId, []);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Memory leak — recentUnlocks Map entries are never removed for stale apiKeyIds

The recentUnlocks Map only trims events within each key's list (via BUFFER_TTL_MS and MAX_BUFFER_SIZE), but the key itself is never deleted once created. In a long-running server with many unique users, this Map will grow indefinitely as orphaned entries accumulate for API keys that no longer have active connections.

Add a periodic cleanup interval that removes keys with empty lists, or use a TTL-based Map implementation.

Comment thread src/lib/gamification/notifications.ts Outdated
const interval = setInterval(() => {
const events = consumeBadgeUnlocks(apiKeyId);
for (const event of events) {
controller.enqueue(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Unhandled error if controller.enqueue() is called after stream closes

If the client disconnects abruptly (network drop, browser close), the AbortSignal may not fire immediately. The setInterval at line 63 will continue firing and attempt to enqueue() on an already-closed controller, throwing an unhandled error that could crash the process.

Wrap controller.enqueue() in try/catch, or check controller.desiredSize !== null before enqueueing.

Comment thread src/lib/gamification/notifications.ts Outdated
const cleanup = () => {
clearInterval(interval);
clearInterval(heartbeat);
controller.close();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: controller.close() called after stream is already closed will throw

If signal.addEventListener('abort') fires AND one of the intervals fires before cleanup runs, controller.close() may be called twice. The second call throws TypeError: Controller is already closed.

Guard with a closed flag: track whether cleanup has already run to prevent double-closing.

const addToast = useCallback((event: BadgeUnlockEvent) => {
setToasts((prev) => [...prev, event]);
// Auto-dismiss after 5s
setTimeout(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: setTimeout is never cleared on unmount — causes state update on unmounted component

If BadgeToast unmounts while toasts are still visible (e.g., user navigates away), the setTimeout callback at line 25 will still fire after 5 seconds and call setToasts() on an unmounted component. This causes a React warning and potential memory leak.

Return a cleanup function from addToast or use useRef to track timeouts and clear them in the useEffect cleanup.

}
});

es.onerror = () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: No reconnect logic for EventSource — permanent disconnect on error

When es.onerror fires (network drop, server restart, proxy timeout), the EventSource is closed permanently with no reconnection attempt. Users would need to manually refresh the page to resume receiving badge notifications.

The leaderboard page (dashboard/leaderboard/page.tsx:70-78) implements reconnect with 3s delay + exponential backoff. This component should follow the same pattern for consistency and reliability.

Comment thread src/lib/gamification/events.ts Outdated
const { recordBadgeUnlock } = await import("./notifications");
recordBadgeUnlock(apiKeyId, {
badgeId,
badgeName: badgeId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: badgeName set to raw badgeId — users see internal IDs instead of display names

At line 128, badgeName: badgeId passes the raw badge ID (e.g., "first-token", "unstoppable") directly to the notification. Users will see these internal identifiers in toast notifications instead of human-readable names like "First Steps" or "Unstoppable".

Badge definitions with proper display names exist in the constants/badges system. Import the badge definition to get the actual name field before recording the notification.

/**
* GET /api/gamification/notifications?apiKeyId=xxx — SSE badge unlock notifications
*/
export async function GET(request: NextRequest) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: No authentication or rate limiting on SSE notification endpoint

This endpoint accepts any apiKeyId query parameter and creates an SSE stream without verifying that the caller owns that API key. An attacker could subscribe to any user's badge notifications, potentially revealing sensitive information about their activity.

At minimum, validate that the authenticated session matches the requested apiKeyId. Consider adding rate limiting on SSE connection creation to prevent resource exhaustion.

- SQL injection: use parameterized INSERT instead of string interpolation
- Double-run protection: skip rotation if archive scope already has data
- listServers: exclude api_key_hash from SELECT (security leak)
- Profile page: import xpForLevel/calculateLevel/getLevelTier/getLevelTitle
  instead of hardcoded XP_PER_LEVEL and duplicated local functions
- checkActionCountBadges: COUNT(*) instead of SUM(xp_earned)
- badgeName: look up from badge_definitions instead of using raw badgeId
- notifications.ts: cleanup stale Map entries, safe enqueue with try/catch
- BadgeToast: track setTimeout IDs and clear on unmount, reconnect with
  exponential backoff on EventSource error
- SSE stream + notifications endpoints: add requireManagementAuth
- Federation route: use LeaderboardScope type instead of `as any`
@oyi77

oyi77 commented May 19, 2026

Copy link
Copy Markdown
Contributor Author

All Review Feedback Addressed (commit f33aa36)

Critical

  1. SQL injection — rotateLeaderboardScope now uses two-step SELECT + parameterized INSERT
  2. Double-run protection — checks if archive scope already has data before overwriting

Warnings

  1. Hash leak — listServers excludes api_key_hash from SELECT
  2. Hardcoded XP — profile page now imports from @/lib/gamification/xp
  3. Duplicated functions — removed local getTier/getLevelTitle, using imports
  4. SUM vs COUNT — checkActionCountBadges now uses COUNT(*)
  5. Badge name — looks up from badge_definitions table instead of raw ID
  6. Memory leak — stale entries cleaned on each recordBadgeUnlock
  7. Stream safety — safeEnqueue() wrapper with try/catch
  8. setTimeout leak — timeout IDs tracked, cleared on unmount
  9. EventSource reconnect — exponential backoff (1s base, 30s max)

Suggestions

  1. Auth on SSE stream — requireManagementAuth added
  2. Auth on SSE notifications — requireManagementAuth added
  3. Type-safe cast — LeaderboardScope import replaces as any

All 15 items fixed. 16 tests pass.

🤖 Generated with OpenClaude


// Count total actions of this type
const row = db
.prepare("COALESCE(COUNT(*), 0) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = ?")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: Missing SELECT keyword in SQL statement — will cause runtime error

The SELECT keyword was accidentally removed. The statement "COALESCE(COUNT(*), 0) AS count FROM..." is not valid SQL and better-sqlite3 will throw when preparing it.

Suggested change
.prepare("COALESCE(COUNT(*), 0) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = ?")
.prepare("SELECT COALESCE(COUNT(*), 0) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = ?")


function connect() {
if (unmounted) return;
es = new EventSource(`/api/gamification/notifications?apiKeyId=${apiKeyId}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: apiKeyId not URL-encoded in EventSource URL

If apiKeyId contains special characters (e.g., +, &, =, spaces), the URL will be malformed and the connection will fail or query the wrong key.

Suggested change
es = new EventSource(`/api/gamification/notifications?apiKeyId=${apiKeyId}`);
es = new EventSource(`/api/gamification/notifications?apiKeyId=${encodeURIComponent(apiKeyId)}`);

*/
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const scope: LeaderboardScope = (url.searchParams.get("scope") || "global") as LeaderboardScope;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: scope cast bypasses runtime validation

The as LeaderboardScope cast only affects compile-time types. Any arbitrary string from the URL (e.g., scope=malicious) will pass through unchecked to getTopN(). This worked before with as any — the type cast changed but the validation gap remains.

Consider validating against known scopes before use, similar to how /stream does it with VALID_SCOPES.

import { useState, useEffect, useCallback } from "react";
import { Card, Badge } from "@/shared/components";
import {
xpForLevel,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Unused import xpForLevel

xpForLevel is imported but never referenced in this file. Will trigger a lint warning.

Suggested change
xpForLevel,
cumulativeXpForLevel,

import { Card, Badge } from "@/shared/components";
import {
xpForLevel,
calculateLevel,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Unused import calculateLevel

calculateLevel is imported but never used in this file. Only cumulativeXpForLevel, getLevelTier, and getLevelTitle are referenced. Will trigger a lint warning.

Suggested change
calculateLevel,
cumulativeXpForLevel,

log.info("events.badge_unlocked", { apiKeyId, badgeId });

// Look up badge details from badge_definitions
const { getDbInstance } = await import("../db/core");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Direct SQL in non-db module violates project guidelines

Per AGENTS.md: "No raw SQL in routes — always use domain module functions" and "Direct prepare() statements outside db/ modules — breaks modularity". This queries badge_definitions directly instead of going through the db module's getBadgeDefinitions() function.

Consider adding a getBadgeById(id) function to src/lib/db/gamification.ts and importing it here instead.

@diegosouzapw
diegosouzapw merged commit 81fb3f5 into cheaper-inference:release/v3.8.0 May 19, 2026
2 checks passed
@diegosouzapw

Copy link
Copy Markdown
Collaborator

Thank you for the contribution! We have analyzed and merged the gamification and leaderboard system into the release branch.

HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
Poid-ZA pushed a commit to Poid-ZA/OmniRoute that referenced this pull request Aug 5, 2026
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants