Skip to content

feat: PostgreSQL migration, leaderboard page, security hardening#5

Open
wcatz wants to merge 7 commits intomasterfrom
feat/postgres-leaderboard-security
Open

feat: PostgreSQL migration, leaderboard page, security hardening#5
wcatz wants to merge 7 commits intomasterfrom
feat/postgres-leaderboard-security

Conversation

@wcatz
Copy link
Copy Markdown
Owner

@wcatz wcatz commented Mar 19, 2026

Summary

  • Database: Migrate from better-sqlite3 to PostgreSQL (pg Pool) with lazy schema init, advisory locks for address mutex, and DB-backed nonces/rate limits for multi-replica support
  • Leaderboard: New /leaderboard route with ranked table, tier badges, hall of fame, fleet stats, and pagination (SSR)
  • Security: Externalize SESSION_SECRET, add HSTS header, worker-src 'self' CSP directive, API input validation (query param whitelist, startsWith for Prometheus, JSON parse try/catch)
  • Infra: Remove better-sqlite3 native dep from Dockerfile, drop ssr.external from vite config

Test plan

  • Verify npm run build passes (confirmed locally)
  • Deploy to cluster with CNPG PostgreSQL (starforge db)
  • Confirm /health returns 200 when PG is reachable, 503 when not
  • Test game flow: auth-nonce → check-wallet → start-session → deliver-reward
  • Verify /leaderboard renders rankings, hall of fame, fleet stats
  • Confirm nonces and rate limits persist across pod restarts
  • Check HSTS and CSP headers in browser dev tools

Summary by CodeRabbit

  • New Features

    • Public Rankings page with fleet stats, Hall of Fame, pagination; night-supply API and in-game HUD/behavior for low/depleted supply; session refresh endpoint.
  • Security

    • HSTS enabled site-wide; stricter CSP including worker-src.
  • Bug Fixes

    • Safer JSON parsing/validation across APIs; health endpoint verifies DB; session signing secret stabilized.
  • Infrastructure

    • Migrated to PostgreSQL with DB-backed nonces, rate limits, and cluster-safe locks; DB access made async.
  • Dependencies

    • Replaced SQLite native driver with PostgreSQL client; production image no longer installs the native runtime package.

Replace better-sqlite3 with pg Pool for multi-replica support.
Lazy schema init avoids build-time ECONNREFUSED. Advisory locks
replace in-memory address mutex. Nonces and rate limits moved to
PostgreSQL. Add /leaderboard route with rankings, hall of fame,
fleet stats. Externalize SESSION_SECRET, add HSTS, tighten CSP
and API input validation.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 19, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Migrate server from SQLite/better-sqlite3 to PostgreSQL/pg with async DB helpers; replace in-memory nonces, locks and rate-limits with DB-backed implementations; add paginated leaderboard UI and server loader; harden headers/CSP and defensive JSON parsing; remove better-sqlite3 from build/runtime and Docker libstdc++ install.

Changes

Cohort / File(s) Summary
Build / Runtime
Dockerfile, package.json, vite.config.js
Removed Alpine libstdc++ install, removed better-sqlite3 and added pg dependency, and dropped SSR external: ['better-sqlite3'].
DB Core & API surface
src/lib/server/db.js, src/lib/server/leaderboard.js, src/lib/server/night-supply.js
Rewrote DB layer to use pg Pool with async query() and lazy schema init; added leaderboard helpers and cached vending-machine getNightSupply(); schema and table semantics changed (nonces, rate_limits, reward_log, etc.).
Nonce / Rate-limit / Locks
src/lib/server/nonce-store.js, src/routes/api/game/* (deliver-reward, leaderboard, start-session, check-wallet, auth-nonce)
Replaced in-memory nonce Map and per-address locks/rate maps with DB-backed UPSERT/DELETE and Postgres advisory locks; routes now await async nonce/lock/rate-limit operations and handle contention (429).
Session, Health & Headers
src/lib/server/session.js, src/routes/health/+server.js, src/hooks.server.js
Session SECRET now from env.SESSION_SECRET (fallback regenerated); health endpoint calls healthCheck() and returns 503 on DB failure; added Strict-Transport-Security and worker-src 'self' to CSP.
API robustness & parsing
src/routes/api/block_count/+server.js, src/routes/api/game/* (auth-nonce, check-wallet, deliver-reward, start-session, refresh-session, night-supply), src/routes/api/pool_history/+server.js, src/routes/api/prometheus/+server.js, src/routes/api/koios/[...path]/+server.js
Wrapped request.json() in try/catch with 400 responses for invalid JSON, added payload shape validation; tightened pool_history query param whitelist and Prometheus anchored pattern matching; minor proxy whitespace tweak.
Leaderboard UI & Routes
src/routes/leaderboard/+page.server.js, src/routes/leaderboard/+page.svelte, src/routes/+layout.svelte
Added server loader and paginated rankings page, hall-of-fame and fleet stats endpoints; added nav item and icon rendering.
Client game UI / HUD
src/lib/component/game-wrapper.svelte, src/lib/game/hud.js
Expose night-supply state to game loop; fetch initial supply; update HUD/title/game-over displays and immediate in-memory delegation updates with refresh-session call.

Sequence Diagram(s)

sequenceDiagram
  actor Client
  participant Server
  participant Postgres
  participant VendingMachine

  Client->>Server: POST /api/game/deliver-reward (payload)
  Server->>Postgres: TRY acquire advisory lock for stake_address
  alt lock acquired
    Server->>Postgres: SELECT session/ip/recent counts, game session
    Postgres-->>Server: counts / session
    Server->>Postgres: UPDATE game_sessions ... WHERE ... (atomic)
    Postgres-->>Server: rowCount
    alt deliver to vending
      Server->>VendingMachine: GET treasury (cached)
      VendingMachine-->>Server: supply data
    end
    Server->>Postgres: INSERT reward_log / upsert leaderboard / update counters
    Postgres-->>Server: OK
    Server-->>Client: 200 { delivered: true/false, reason? }
  else lock not acquired
    Server-->>Client: 429 "Request in progress"
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐰 I left my SQLite burrow for Postgres fields so wide,
Pools hum and nonces nest where tiny rows reside.
Leaderboards march in pages, supply lights blink and glow,
Secure headers tuck us in — hop on, the rankings grow! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: PostgreSQL migration, leaderboard page, security hardening' accurately summarizes the three main objectives: database migration from SQLite to PostgreSQL, addition of a new leaderboard feature, and security improvements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/postgres-leaderboard-security

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

🧹 Nitpick comments (2)
src/lib/server/session.js (1)

4-5: Silent fallback to random secret may cause subtle production failures.

If SESSION_SECRET is unset in a multi-replica deployment, each pod generates a different random secret, causing tokens issued by one replica to be rejected by others. Consider logging a warning (or failing startup in production) when the env var is missing, to make misconfiguration immediately visible.

🛡️ Suggested improvement
+import { dev } from '$app/environment';
+
 // Stable secret from env (multi-replica safe), random fallback for dev
-const SECRET = env.SESSION_SECRET || randomBytes(32).toString('hex');
+const SECRET = env.SESSION_SECRET || (() => {
+	if (!dev) {
+		console.warn('[session] SESSION_SECRET not set — tokens will not survive restarts or work across replicas');
+	}
+	return randomBytes(32).toString('hex');
+})();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/session.js` around lines 4 - 5, The current SECRET assignment
silently falls back to randomBytes(32) when env.SESSION_SECRET is unset, which
breaks multi-replica token validation; update the startup logic around SECRET
(the env.SESSION_SECRET check and use of randomBytes) to detect a missing
SESSION_SECRET and either (a) log a clear warning with context and include
process.env.NODE_ENV so operators can see misconfiguration, or (b) in
non-development environments exit the process with an error; ensure the message
identifies SESSION_SECRET and the app's environment so the issue is visible in
logs and does not silently produce per-replica secrets.
src/lib/server/nonce-store.js (1)

14-15: Consider probabilistic cleanup to reduce write amplification under load.

Every createNonce call deletes all expired rows, which may cause write amplification under heavy traffic. A probabilistic approach (e.g., 1-5% chance) or a separate background job would be gentler on the database.

♻️ Optional: probabilistic cleanup
 export async function createNonce(stakeAddress) {
 	const now = Date.now();

-	// Opportunistic cleanup of expired nonces
-	await query('DELETE FROM nonces WHERE expires_at < $1', [now]);
+	// Probabilistic cleanup (~5% of requests)
+	if (Math.random() < 0.05) {
+		await query('DELETE FROM nonces WHERE expires_at < $1', [now]);
+	}

 	const nonce = randomBytes(32).toString('hex');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/nonce-store.js` around lines 14 - 15, The current createNonce
implementation runs an opportunistic cleanup on every call by executing
query('DELETE FROM nonces WHERE expires_at < $1', [now]) which can cause heavy
write amplification; change this to a probabilistic cleanup (e.g., only run the
DELETE with a small random chance like 1–5%) or move the deletion into a
separate background task and remove it from createNonce. Locate the DELETE call
in nonce-store.js (inside createNonce) and either wrap it in a random chance
guard before invoking query, or extract the deletion into a new
cleanupExpiredNonces function and schedule it with a periodic timer or external
job, ensuring createNonce only inserts the new nonce and does not always perform
the DELETE.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/server/db.js`:
- Around line 168-174: activeSessionCount is counting all sessions started in
the time window including those already ended; update the SQL in
activeSessionCount to only count sessions where ended is false/0 (i.e., add an
AND ended = 0 or AND ended = FALSE condition) so it only returns currently
active sessions — modify the query call that selects from game_sessions and keep
using the same stakeAddress and cutoff parameters.
- Around line 146-154: The UPDATE in recordForgeAtomic currently omits the
session's ended flag so late/replayed forge requests can increment closed
sessions; modify the WHERE clause of the UPDATE in recordForgeAtomic to include
"AND ended = 0" (or the equivalent check against the ended column) alongside the
existing id, blocks_forged, and last_forge_at conditions so the increment only
occurs for active (not-ended) sessions.
- Around line 95-101: The aggregate query results (SUM/COUNT/BIGINT) are
returned as strings which leads to silent type-coercion bugs; update the
functions getSessionRewards, getIpRewards, getDeliveredNight, and getServerStats
to normalize those aggregate fields to numeric types before returning (e.g.,
wrap the returned aggregate values with Number(...) or parseInt(..., 10)),
ensuring fields like total (from getSessionRewards/getIpRewards),
delivered/night counts (getDeliveredNight), and total_blocks/max_session_blocks
(getServerStats) are converted to JS Numbers so downstream
arithmetic/comparisons work correctly.

In `@src/lib/server/leaderboard.js`:
- Around line 38-48: The current queries in src/lib/server/leaderboard.js use
INNER JOIN and GROUP BY l.display_name which collapses different wallets sharing
a display_name and drops game_sessions with no leaderboard row; update the two
query(...) calls to use LEFT JOIN between game_sessions (alias g) and
leaderboard (alias l), group by g.stake_address (not l.display_name) for the
games_played aggregation, and select the display_name via
COALESCE(l.display_name, g.stake_address) or similar for presentation so
sessions without a leaderboard entry are preserved and aggregation is by wallet.
- Around line 7-16: getRankedLeaderboard currently passes page/perPage directly
to LIMIT/OFFSET and orders only by score; validate and clamp page and perPage
(coerce to integers, default to 1 and 25 on NaN/<=0, and cap perPage at a
reasonable max like 100) before computing offset, and use those safe values in
the query; also make the ORDER BY stable by adding a deterministic tiebreaker
(e.g., ORDER BY score DESC, id ASC) so tied scores return a consistent row order
across requests; update usages of page, perPage, offset, and the SQL passed to
query(...) accordingly.

In `@src/routes/api/block_count/`+server.js:
- Around line 10-14: The code dereferences body._epoch_no without ensuring body
is a non-null object, which can throw for valid JSON like null; update the
request handling in the +server.js handler to validate the parsed body before
accessing _epoch_no: after await request.json() check that body !== null and
typeof body === "object" (and that "_epoch_no" in body or body._epoch_no !==
undefined), and if the shape is invalid return json({ error: 'Invalid payload'
}, { status: 400 }); only then assign const epochNo = body._epoch_no (and
optionally coerce/validate its type).

In `@src/routes/api/game/auth-nonce/`+server.js:
- Line 17: Wrap the await createNonce(stakeAddress) call in a try/catch inside
the route handler (the function that reads stakeAddress and calls createNonce)
so DB errors are handled explicitly; on catch, log the error and return a
structured HTTP response (e.g., JSON error body) with an appropriate status like
503 Service Unavailable (instead of letting an unstructured 500 bubble up), and
only proceed with normal response when createNonce resolves successfully. Ensure
you reference the createNonce call and the stakeAddress variable in the handler
when implementing the try/catch and response paths.
- Around line 7-11: The code destructures { stakeAddress } from body without
ensuring body is a non-null object, so valid JSON like null will cause a runtime
error; after parsing request.json() (where body is assigned) add a guard that
verifies typeof body === 'object' && body !== null and if not return json({
error: 'Invalid JSON' }, { status: 400 }) before performing const { stakeAddress
} = body so destructuring is only done on a safe object.

In `@src/routes/api/pool_history/`+server.js:
- Line 7: ALLOWED_PARAMS currently lists 'epoch_no' without the underscore,
causing inconsistency with Koios param naming and other callers that use
'_pool_bech32'; update the whitelist to use '_epoch_no' instead of 'epoch_no' in
the ALLOWED_PARAMS Set (refer to the ALLOWED_PARAMS constant and the existing
'_pool_bech32' entry) so incoming requests and Koios calls use the same
underscore-prefixed param name.

In `@src/routes/api/prometheus/`+server.js:
- Line 35: The current allowlist check uses query.startsWith(pattern) which
allows bypasses like "upstream_metric" for pattern "up"; change the predicate to
perform a token-boundary match instead by testing against an escaped-regex
anchored at start and requiring a boundary or end after the pattern (e.g. new
RegExp(`^${escapeRegex(pattern)}(?=$|\\W|\\.)`). Implement or reuse an
escapeRegex helper to escape pattern values, replace the arrow callback inside
ALLOWED_METRIC_PATTERNS.some(...) with this regex test, and ensure the logic
accepts exact matches or pattern followed by a valid token separator rather than
any prefix match.

In `@src/routes/leaderboard/`+page.server.js:
- Around line 5-6: The current load function uses
parseInt(url.searchParams.get('page') || '1') which yields NaN for non-numeric
input and lets Math.max(1, NaN) produce NaN; update load to sanitize the page
query by parsing with a radix (parseInt(..., 10)), validating the result with
Number.isFinite or Number.isInteger and falling back to 1 when the parsed value
is NaN or <= 0 so downstream code (e.g., getRankedLeaderboard offset
calculation) always receives a valid positive integer page value.

---

Nitpick comments:
In `@src/lib/server/nonce-store.js`:
- Around line 14-15: The current createNonce implementation runs an
opportunistic cleanup on every call by executing query('DELETE FROM nonces WHERE
expires_at < $1', [now]) which can cause heavy write amplification; change this
to a probabilistic cleanup (e.g., only run the DELETE with a small random chance
like 1–5%) or move the deletion into a separate background task and remove it
from createNonce. Locate the DELETE call in nonce-store.js (inside createNonce)
and either wrap it in a random chance guard before invoking query, or extract
the deletion into a new cleanupExpiredNonces function and schedule it with a
periodic timer or external job, ensuring createNonce only inserts the new nonce
and does not always perform the DELETE.

In `@src/lib/server/session.js`:
- Around line 4-5: The current SECRET assignment silently falls back to
randomBytes(32) when env.SESSION_SECRET is unset, which breaks multi-replica
token validation; update the startup logic around SECRET (the env.SESSION_SECRET
check and use of randomBytes) to detect a missing SESSION_SECRET and either (a)
log a clear warning with context and include process.env.NODE_ENV so operators
can see misconfiguration, or (b) in non-development environments exit the
process with an error; ensure the message identifies SESSION_SECRET and the
app's environment so the issue is visible in logs and does not silently produce
per-replica secrets.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bbfea6e9-8cb4-4554-9717-c489feb468d5

📥 Commits

Reviewing files that changed from the base of the PR and between 9c50707 and 7008e4f.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (21)
  • Dockerfile
  • package.json
  • src/hooks.server.js
  • src/lib/server/db.js
  • src/lib/server/leaderboard.js
  • src/lib/server/nonce-store.js
  • src/lib/server/session.js
  • src/routes/+layout.svelte
  • src/routes/api/block_count/+server.js
  • src/routes/api/game/auth-nonce/+server.js
  • src/routes/api/game/check-wallet/+server.js
  • src/routes/api/game/deliver-reward/+server.js
  • src/routes/api/game/leaderboard/+server.js
  • src/routes/api/game/start-session/+server.js
  • src/routes/api/koios/[...path]/+server.js
  • src/routes/api/pool_history/+server.js
  • src/routes/api/prometheus/+server.js
  • src/routes/health/+server.js
  • src/routes/leaderboard/+page.server.js
  • src/routes/leaderboard/+page.svelte
  • vite.config.js
💤 Files with no reviewable changes (2)
  • src/routes/api/koios/[...path]/+server.js
  • vite.config.js

- Wrap pg aggregate returns (SUM/BIGINT) with Number() to prevent
  string coercion bugs in arithmetic
- Add ended = 0 guard to recordForgeAtomic and activeSessionCount
- Use LEFT JOIN + GROUP BY stake_address in hall of fame queries
- Clamp pagination inputs, add stable ORDER BY tiebreaker
- Fix prometheus allowlist bypass (startsWith → regex boundary)
- Fix NaN propagation in leaderboard page param parsing
- Add null/non-object JSON body guards on auth-nonce, block_count
- Fix pool_history param name epoch_no → _epoch_no
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
src/routes/api/game/auth-nonce/+server.js (1)

20-20: ⚠️ Potential issue | 🟠 Major

Handle DB failures from createNonce() explicitly.

The createNonce call is now async and backed by PostgreSQL. If the database is unavailable, this will throw and result in an unstructured 500 response. Wrap in try/catch to return a structured 503 error.

🛡️ Proposed fix
-	const nonce = await createNonce(stakeAddress);
+	let nonce;
+	try {
+		nonce = await createNonce(stakeAddress);
+	} catch {
+		return json({ error: 'Service temporarily unavailable' }, { status: 503 });
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/api/game/auth-nonce/`+server.js at line 20, The call to
createNonce in the POST handler for the auth-nonce route must be wrapped in a
try/catch so database failures produce a structured 503 response instead of an
unstructured 500; update the code around createNonce(stakeAddress) in the
+server.js handler to catch errors from the async createNonce call, log the
error (including the error object), and return a Response/JSON with a 503 status
and a clear error payload (e.g., { error: "Service unavailable" }) so callers
receive a structured service-unavailable response.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/routes/api/game/auth-nonce/`+server.js:
- Line 20: The call to createNonce in the POST handler for the auth-nonce route
must be wrapped in a try/catch so database failures produce a structured 503
response instead of an unstructured 500; update the code around
createNonce(stakeAddress) in the +server.js handler to catch errors from the
async createNonce call, log the error (including the error object), and return a
Response/JSON with a 503 status and a clear error payload (e.g., { error:
"Service unavailable" }) so callers receive a structured service-unavailable
response.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b1f83461-0a46-4f27-b408-7ae7779ce346

📥 Commits

Reviewing files that changed from the base of the PR and between 7008e4f and 0360776.

📒 Files selected for processing (7)
  • src/lib/server/db.js
  • src/lib/server/leaderboard.js
  • src/routes/api/block_count/+server.js
  • src/routes/api/game/auth-nonce/+server.js
  • src/routes/api/pool_history/+server.js
  • src/routes/api/prometheus/+server.js
  • src/routes/leaderboard/+page.server.js
✅ Files skipped from review due to trivial changes (1)
  • src/lib/server/leaderboard.js
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/routes/api/block_count/+server.js
  • src/routes/api/prometheus/+server.js
  • src/routes/leaderboard/+page.server.js
  • src/routes/api/pool_history/+server.js

wcatz added 3 commits March 19, 2026 16:02
…atus

Query vending machine treasury_read API (cached 60s) to check NIGHT
balance. Early-exit deliver-reward when supply depleted. Show status
banners on title screen, in-game HUD, and game-over screen. Real-time
update if supply_exhausted returned mid-game.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/lib/component/game-wrapper.svelte (1)

53-61: ⚠️ Potential issue | 🟠 Major

Supply status not included in walletState, breaking title screen display.

The supplyDepleted and supplyLow flags are set directly on the game object (lines 60-61), but game.walletState (lines 53-58) does not include these properties. In src/lib/game/hud.js at lines 453-462, drawTitleScreen checks walletState?.supplyDepleted and walletState?.supplyLow—these will always be undefined, so the incentive banner will never show the "NIGHT SUPPLY DEPLETED" or "LOW NIGHT SUPPLY" messages on the title screen.

Proposed fix — include supply status in walletState
 	// Sync wallet state into game for canvas rendering
 	game.walletState = {
 		phase: walletPhase,
 		wallets,
 		error: walletError,
 		leaderboard,
+		supplyDepleted,
+		supplyLow,
 	};
 	game.guestMode = guestMode;
 	game.supplyDepleted = supplyDepleted;
 	game.supplyLow = supplyLow;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/component/game-wrapper.svelte` around lines 53 - 61, The walletState
object is missing the supply flags the HUD expects; update the assignment to
include supplyDepleted and supplyLow in game.walletState (in addition to or
instead of setting game.supplyDepleted/game.supplyLow) so drawTitleScreen can
read walletState?.supplyDepleted and walletState?.supplyLow; locate the
walletState assignment in game-wrapper.svelte (game.walletState = { ... }) and
add supplyDepleted and supplyLow properties sourced from the existing
supplyDepleted and supplyLow variables.
🧹 Nitpick comments (2)
src/lib/game/hud.js (1)

453-462: Inconsistent indentation in conditional block.

The if/else if/else block has inconsistent indentation—the if and first else if use tabs differently than the rest of the function. This appears to be a formatting issue.

Additionally, as noted in game-wrapper.svelte, walletState?.supplyDepleted and walletState?.supplyLow will always be undefined because these properties are not included in game.walletState. The fix should be applied in game-wrapper.svelte.

Suggested indentation fix (assuming walletState is fixed upstream)
-			if (walletState?.supplyDepleted) {
-			drawGlowText(ctx, 'NIGHT SUPPLY DEPLETED — FORGE FOR SCORE', cx, bannerY,
-				'bold 11px monospace', COLOR.red, COLOR.red, 4);
-		} else if (walletState?.supplyLow) {
-			drawGlowText(ctx, 'LOW NIGHT SUPPLY — FORGE WHILE YOU CAN', cx, bannerY,
-				'bold 11px monospace', COLOR.amber, COLOR.amber, 4);
-		} else {
+			if (walletState?.supplyDepleted) {
+				drawGlowText(ctx, 'NIGHT SUPPLY DEPLETED — FORGE FOR SCORE', cx, bannerY,
+					'bold 11px monospace', COLOR.red, COLOR.red, 4);
+			} else if (walletState?.supplyLow) {
+				drawGlowText(ctx, 'LOW NIGHT SUPPLY — FORGE WHILE YOU CAN', cx, bannerY,
+					'bold 11px monospace', COLOR.amber, COLOR.amber, 4);
+			} else {
 			drawGlowText(ctx, 'DELEGATORS EARN 10x NIGHT PER BLOCK', cx, bannerY,
 				'bold 11px monospace', COLOR.amber, COLOR.amber, 4);
-		}
+			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/game/hud.js` around lines 453 - 462, Fix the inconsistent indentation
in the conditional block around drawGlowText and correct the missing properties
on walletState: normalize the indentation for the if / else if / else that
reference walletState?.supplyDepleted and walletState?.supplyLow so the block
matches surrounding code style, and then add the missing supplyDepleted and
supplyLow properties to the game.walletState initialization in
game-wrapper.svelte (or ensure they are derived where walletState is
constructed) so walletState?.supplyDepleted and walletState?.supplyLow are not
always undefined; keep the conditional logic in hud.js (around drawGlowText)
intact but properly indented and rely on the fixed walletState source to provide
those flags.
src/routes/api/game/deliver-reward/+server.js (1)

131-136: Supply check doesn't handle fetch failures gracefully.

When getNightSupply() returns null (due to network errors or missing config), supply?.depleted evaluates to undefined (falsy), allowing the request to proceed to the VM call. This is arguably correct behavior (fail-open), but consider whether you want rewards to continue when supply status is unknown.

If fail-closed behavior is preferred for safety:

Optional: fail-closed on unknown supply status
 	// Check NIGHT supply before wasting a VM call
 	const supply = await getNightSupply();
+	if (!supply) {
+		await logReward(stakeAddress, amount, 'supply_unknown', null, now);
+		return json({ delivered: false, reason: 'supply_check_failed' });
+	}
 	if (supply?.depleted) {
 		await logReward(stakeAddress, amount, 'supply_exhausted', null, now);
 		return json({ delivered: false, reason: 'supply_exhausted' });
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/api/game/deliver-reward/`+server.js around lines 131 - 136, The
supply check currently treats a null/failed getNightSupply() as unknown and
proceeds; change it to fail-closed by explicitly handling a null/undefined
supply: after calling getNightSupply(), if supply is null/undefined or
supply.depleted is true, call logReward(stakeAddress, amount,
'supply_check_failed' /* or 'supply_unknown' */ , null, now) and return json({
delivered: false, reason: 'supply_check_failed' }); update the branch that
references supply?.depleted to instead test (supply == null || supply.depleted)
so unknown fetch failures are treated as exhausted.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/server/night-supply.js`:
- Around line 13-19: The fetch response is parsed without checking HTTP status,
so replace the direct res.json() usage in the block that calls fetch with a
guard on res.ok (using vmUrl, vmKey and NIGHT_TOKEN_ID to locate the code): if
!res.ok, read the response body (text or json) and surface/throw an error
(including res.status and the body) instead of continuing to access
data[NIGHT_TOKEN_ID]; only call res.json() and proceed to inspect
data[NIGHT_TOKEN_ID] when res.ok is true so real API errors are not treated as a
depleted supply.
- Around line 21-26: Validate night.balance before computing thresholds: parse
Number(night.balance) into a variable (e.g., rawBalance) and check
Number.isFinite(rawBalance); only divide by 1_000_000 and compare to
LOW_THRESHOLD / DEPLETED_THRESHOLD when finite. If not finite, set available to
null (or a sentinel) and set lowSupply and depleted to true (or another explicit
"unknown" handling) so malformed balances don't silently report false; update
the return in the function that reads night.balance to use these checks and the
constants LOW_THRESHOLD and DEPLETED_THRESHOLD.

In `@src/routes/api/game/night-supply/`+server.js:
- Around line 6-8: The handler currently treats a null/failed getNightSupply()
as "not depleted" which is misleading; update the code path where supply is
falsy (the block returning json({ available: 0, lowSupply: false, depleted:
false })) to instead return an explicit unknown flag (e.g., json({ available: 0,
lowSupply: false, depleted: false, unknown: true })) or replace depleted with
unknown: true so clients can detect uncertainty; reference the getNightSupply
call and the endpoint response object so you adjust the fallback response
returned by the API handler accordingly.

---

Outside diff comments:
In `@src/lib/component/game-wrapper.svelte`:
- Around line 53-61: The walletState object is missing the supply flags the HUD
expects; update the assignment to include supplyDepleted and supplyLow in
game.walletState (in addition to or instead of setting
game.supplyDepleted/game.supplyLow) so drawTitleScreen can read
walletState?.supplyDepleted and walletState?.supplyLow; locate the walletState
assignment in game-wrapper.svelte (game.walletState = { ... }) and add
supplyDepleted and supplyLow properties sourced from the existing supplyDepleted
and supplyLow variables.

---

Nitpick comments:
In `@src/lib/game/hud.js`:
- Around line 453-462: Fix the inconsistent indentation in the conditional block
around drawGlowText and correct the missing properties on walletState: normalize
the indentation for the if / else if / else that reference
walletState?.supplyDepleted and walletState?.supplyLow so the block matches
surrounding code style, and then add the missing supplyDepleted and supplyLow
properties to the game.walletState initialization in game-wrapper.svelte (or
ensure they are derived where walletState is constructed) so
walletState?.supplyDepleted and walletState?.supplyLow are not always undefined;
keep the conditional logic in hud.js (around drawGlowText) intact but properly
indented and rely on the fixed walletState source to provide those flags.

In `@src/routes/api/game/deliver-reward/`+server.js:
- Around line 131-136: The supply check currently treats a null/failed
getNightSupply() as unknown and proceeds; change it to fail-closed by explicitly
handling a null/undefined supply: after calling getNightSupply(), if supply is
null/undefined or supply.depleted is true, call logReward(stakeAddress, amount,
'supply_check_failed' /* or 'supply_unknown' */ , null, now) and return json({
delivered: false, reason: 'supply_check_failed' }); update the branch that
references supply?.depleted to instead test (supply == null || supply.depleted)
so unknown fetch failures are treated as exhausted.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 12469583-a2be-4e67-ba38-d384136a8fa2

📥 Commits

Reviewing files that changed from the base of the PR and between 25d73eb and fd12906.

📒 Files selected for processing (6)
  • src/lib/component/game-wrapper.svelte
  • src/lib/game/hud.js
  • src/lib/server/night-supply.js
  • src/routes/api/game/deliver-reward/+server.js
  • src/routes/api/game/night-supply/+server.js
  • src/routes/leaderboard/+page.svelte
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/routes/leaderboard/+page.svelte

Comment on lines +13 to +19
const res = await fetch(`${vmUrl}?action=treasury_read`, {
headers: { 'X-API-Token': vmKey },
signal: AbortSignal.timeout(10000),
});
const data = await res.json();
const night = data[NIGHT_TOKEN_ID];
if (!night) return { available: 0, lowSupply: true, depleted: true };
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing HTTP status check before parsing JSON.

The code calls res.json() without first checking res.ok. If the vending machine returns a 4xx/5xx error with a JSON body (e.g., {"error": "unauthorized"}), the code will proceed and likely hit the !night branch on line 18-19, returning depleted: true for what is actually an API error.

Proposed fix
 	const res = await fetch(`${vmUrl}?action=treasury_read`, {
 		headers: { 'X-API-Token': vmKey },
 		signal: AbortSignal.timeout(10000),
 	});
+	if (!res.ok) {
+		throw new Error(`Treasury API error: ${res.status}`);
+	}
 	const data = await res.json();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const res = await fetch(`${vmUrl}?action=treasury_read`, {
headers: { 'X-API-Token': vmKey },
signal: AbortSignal.timeout(10000),
});
const data = await res.json();
const night = data[NIGHT_TOKEN_ID];
if (!night) return { available: 0, lowSupply: true, depleted: true };
const res = await fetch(`${vmUrl}?action=treasury_read`, {
headers: { 'X-API-Token': vmKey },
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
throw new Error(`Treasury API error: ${res.status}`);
}
const data = await res.json();
const night = data[NIGHT_TOKEN_ID];
if (!night) return { available: 0, lowSupply: true, depleted: true };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/night-supply.js` around lines 13 - 19, The fetch response is
parsed without checking HTTP status, so replace the direct res.json() usage in
the block that calls fetch with a guard on res.ok (using vmUrl, vmKey and
NIGHT_TOKEN_ID to locate the code): if !res.ok, read the response body (text or
json) and surface/throw an error (including res.status and the body) instead of
continuing to access data[NIGHT_TOKEN_ID]; only call res.json() and proceed to
inspect data[NIGHT_TOKEN_ID] when res.ok is true so real API errors are not
treated as a depleted supply.

Comment on lines +21 to +26
const balance = Number(night.balance) / 1_000_000;
return {
available: balance,
lowSupply: balance < LOW_THRESHOLD,
depleted: balance < DEPLETED_THRESHOLD,
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

NaN propagation from malformed balance produces incorrect supply status.

If night.balance is undefined, null, or a non-numeric string, Number(night.balance) returns NaN. Then NaN < LOW_THRESHOLD and NaN < DEPLETED_THRESHOLD both evaluate to false, causing the function to report lowSupply: false, depleted: false when the actual state is unknown.

Proposed fix
 	const balance = Number(night.balance) / 1_000_000;
+	if (Number.isNaN(balance)) {
+		return { available: 0, lowSupply: true, depleted: true };
+	}
 	return {
 		available: balance,
 		lowSupply: balance < LOW_THRESHOLD,
 		depleted: balance < DEPLETED_THRESHOLD,
 	};
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const balance = Number(night.balance) / 1_000_000;
return {
available: balance,
lowSupply: balance < LOW_THRESHOLD,
depleted: balance < DEPLETED_THRESHOLD,
};
const balance = Number(night.balance) / 1_000_000;
if (Number.isNaN(balance)) {
return { available: 0, lowSupply: true, depleted: true };
}
return {
available: balance,
lowSupply: balance < LOW_THRESHOLD,
depleted: balance < DEPLETED_THRESHOLD,
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/server/night-supply.js` around lines 21 - 26, Validate night.balance
before computing thresholds: parse Number(night.balance) into a variable (e.g.,
rawBalance) and check Number.isFinite(rawBalance); only divide by 1_000_000 and
compare to LOW_THRESHOLD / DEPLETED_THRESHOLD when finite. If not finite, set
available to null (or a sentinel) and set lowSupply and depleted to true (or
another explicit "unknown" handling) so malformed balances don't silently report
false; update the return in the function that reads night.balance to use these
checks and the constants LOW_THRESHOLD and DEPLETED_THRESHOLD.

Comment on lines +6 to +8
if (!supply) {
return json({ available: 0, lowSupply: false, depleted: false });
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Inconsistent default when supply fetch fails.

When getNightSupply() returns null (due to fetch errors or missing config), the endpoint returns depleted: false. This is misleading—a failure to determine supply status should not be interpreted as "supply is available." The client may continue attempting rewards when the supply state is actually unknown.

Consider returning an explicit unknown: true flag or propagating the error so the client can handle uncertainty appropriately.

Proposed fix
 export async function GET() {
 	const supply = await getNightSupply();
 	if (!supply) {
-		return json({ available: 0, lowSupply: false, depleted: false });
+		return json({ available: 0, lowSupply: false, depleted: false, unknown: true });
 	}
 	return json(supply, {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/api/game/night-supply/`+server.js around lines 6 - 8, The handler
currently treats a null/failed getNightSupply() as "not depleted" which is
misleading; update the code path where supply is falsy (the block returning
json({ available: 0, lowSupply: false, depleted: false })) to instead return an
explicit unknown flag (e.g., json({ available: 0, lowSupply: false, depleted:
false, unknown: true })) or replace depleted with unknown: true so clients can
detect uncertainty; reference the getNightSupply call and the endpoint response
object so you adjust the fallback response returned by the API handler
accordingly.

After delegation TX is submitted, immediately grant 10x multiplier
and re-issue session token via /api/game/refresh-session. Removes
the awaitTx delay and the redundant connectWallet call that forced
a second CIP-8 signature popup.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/routes/api/game/refresh-session/+server.js (1)

49-49: Avoid silent Koios failures; add structured logging.

Line 49 swallows provider/network errors, which makes incident diagnosis and alerting difficult.

🛠️ Proposed fix
-		} catch {}
+		} catch (error) {
+			console.warn('refresh-session: koios account_info failed', {
+				stakeAddress,
+				error: String(error)
+			});
+		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/api/game/refresh-session/`+server.js at line 49, The empty catch
block after the Koios provider/network call should not silently swallow errors;
replace the empty catch {} with structured logging and appropriate error
handling: capture the caught error (e) and call your logger (e.g.,
processLogger.error or console.error) with a descriptive message including
context (e.g., "Koios provider call failed", provider/network name, sessionId or
userId if available) and the error object, then either rethrow the error or
return a controlled error response from the route handler so failures are
observable and alertable; update the catch located directly after the Koios call
in the route handler (the empty catch {} in +server.js) accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/routes/api/game/refresh-session/`+server.js:
- Around line 29-31: The code currently trusts the client-supplied delegated
boolean (variable delegated and derived isDelegated) and uses it when minting a
delegated token; replace this by deriving delegation server-side only: stop
using !!delegated, instead validate delegation by checking server-side state or
verifying the delegation transaction (e.g., call your session lookup / DB flag
or a verifyDelegationTx(wallet, tx) helper) and set isDelegated from that
trusted check, and ensure the code path that mints or issues delegated tokens
(the mint/create session token code that currently reads isDelegated) uses this
server-validated result only.

---

Nitpick comments:
In `@src/routes/api/game/refresh-session/`+server.js:
- Line 49: The empty catch block after the Koios provider/network call should
not silently swallow errors; replace the empty catch {} with structured logging
and appropriate error handling: capture the caught error (e) and call your
logger (e.g., processLogger.error or console.error) with a descriptive message
including context (e.g., "Koios provider call failed", provider/network name,
sessionId or userId if available) and the error object, then either rethrow the
error or return a controlled error response from the route handler so failures
are observable and alertable; update the catch located directly after the Koios
call in the route handler (the empty catch {} in +server.js) accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e4b17fbb-16b4-4a65-8ac9-42cb06fa24e4

📥 Commits

Reviewing files that changed from the base of the PR and between fd12906 and 70413c5.

📒 Files selected for processing (2)
  • src/lib/component/game-wrapper.svelte
  • src/routes/api/game/refresh-session/+server.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/lib/component/game-wrapper.svelte

Comment on lines +29 to +31
// If client reports delegation TX was just submitted, trust it
// (they already have a valid session — worst case they get 10x for one session)
let isDelegated = !!delegated;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Client-controlled delegation flag enables privilege escalation.

Line 31 trusts a client-supplied delegated value and Line 52 mints a delegated token from it. Any valid non-delegated session can self-upgrade by posting {"delegated": true}.

🔐 Proposed fix (derive delegation server-side only)
-	// If client reports delegation TX was just submitted, trust it
-	// (they already have a valid session — worst case they get 10x for one session)
-	let isDelegated = !!delegated;
+	// Never trust client-supplied privilege flags
+	let isDelegated = session.isDelegated;

-	// Otherwise verify via Koios
-	if (!isDelegated) {
+	// Verify via Koios only when not already delegated
+	if (!isDelegated) {
 		const koios = getKoiosUrl();
 		try {
 			const res = await fetch(`${koios}/account_info`, {

Also applies to: 34-35, 52-53

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/api/game/refresh-session/`+server.js around lines 29 - 31, The
code currently trusts the client-supplied delegated boolean (variable delegated
and derived isDelegated) and uses it when minting a delegated token; replace
this by deriving delegation server-side only: stop using !!delegated, instead
validate delegation by checking server-side state or verifying the delegation
transaction (e.g., call your session lookup / DB flag or a
verifyDelegationTx(wallet, tx) helper) and set isDelegated from that trusted
check, and ensure the code path that mints or issues delegated tokens (the
mint/create session token code that currently reads isDelegated) uses this
server-validated result only.

CIP-8 signature already proves wallet ownership. If Turnstile
widget can't load (ad blocker, browser privacy), allow auth
to proceed rather than hard-blocking with 403.
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.

1 participant