[#40] Content moderation admin API#150
Conversation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: REQUEST CHANGES
Summary
The API shape is close, but the moderation routes do not yet meet the security/implementation requirement from issue #40.
Findings
- [high] Issue #40 explicitly requires the hide/unhide writes to use the service-role Supabase client. Both new routes currently import and call
createServerClient(), which is the regular server client, not a dedicated service-role client for privileged moderation writes.- File:
src/app/api/admin/hide/route.ts:2 - Suggestion: add/use a service-role Supabase helper for these admin endpoints instead of
createServerClient(). - File:
src/app/api/admin/unhide/route.ts:2 - Suggestion: same change here — use the service-role client for the update path.
- File:
Decision
Requesting changes because the PR does not yet satisfy the required service-role write path for admin moderation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
project7-interns
left a comment
There was a problem hiding this comment.
T2b Review: REQUEST CHANGES
Frontend hidden-content filtering is thorough (all queries confirmed + RLS defense-in-depth). Two blocking issues on the admin routes:
Blocking
- No timing-safe comparison for API key —
authHeader !== \Bearer ${adminKey}`uses JS!==, which is susceptible to timing attacks. Usecrypto.timingSafeEqual`:
import { timingSafeEqual } from "crypto";
const expected = Buffer.from(\`Bearer \${adminKey}\`);
const actual = Buffer.from(authHeader ?? "");
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}idvalidation allows floats/negatives, rejects 0 —!id || typeof id !== "number"is buggy. Use:
if (typeof id !== "number" || !Number.isInteger(id) || id < 1)Non-blocking
- DB error messages leaked to caller (
dbError.message) — log server-side, return generic message. - No 404 when target row doesn't exist — returns
{ success: true }even if nothing was updated. - Hide/unhide routes are 95% identical — consider a single shared handler.
- No audit logging for moderation actions — important for follow-up.
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: REQUEST CHANGES
Summary
The service-role client fix is correct, but the admin routes still have blocking auth/input-validation issues.
Findings
- [high] The bearer token check still uses a plain string comparison (
authHeader !==Bearer ${adminKey}``). For an admin secret gate, use a timing-safe comparison instead of direct equality.- File:
src/app/api/admin/hide/route.ts:15 - Suggestion: compare the presented token and configured key with
crypto.timingSafeEqualafter normalizing lengths. - File:
src/app/api/admin/unhide/route.ts:15 - Suggestion: apply the same timing-safe auth check here.
- File:
- [high]
idvalidation is still too loose:!id || typeof id !== "number"rejects0for the wrong reason and still allows negative numbers / non-integer values. Admin mutation endpoints should require a positive integer ID explicitly.- File:
src/app/api/admin/hide/route.ts:32 - Suggestion: validate with
typeof id !== "number" || !Number.isInteger(id) || id < 1. - File:
src/app/api/admin/unhide/route.ts:32 - Suggestion: same validation change here.
- File:
Decision
Requesting changes because the admin auth and input validation are not yet robust enough for a privileged moderation API.
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: APPROVE
Summary
The moderation routes now use the service-role client, perform timing-safe bearer token checks, and validate IDs as positive integers. The hidden-content audit remains aligned with issue #40, and CI passed.
Findings
- [info] No blocking findings.
Decision
Approving because the implementation now matches issue #40 and lint-and-typecheck passed.
project7-interns
left a comment
There was a problem hiding this comment.
T2b Re-review: APPROVE
Both blocking issues fixed:
- Auth now uses
timingSafeEqualwith length pre-check — timing attack mitigated - ID validation enforces positive integer (
Number.isInteger(id) && id > 0)
Service-role client properly isolated. Both routes consistent.
Summary
POST /api/admin/hideandPOST /api/admin/unhideroutes protected byADMIN_API_KEYbearer token{ type: "storyline" | "plot", id: number }and use the service-role Supabase client to toggle thehiddencolumn.eq("hidden", false)(home, discover, story page, OG image, writer dashboard, chain page, reader portfolio, ranking lib)ADMIN_API_KEYto.env.exampleFixes #40
Test plan
curl -X POST /api/admin/hidewith valid bearer token and{ type: "storyline", id: 1 }returns{ success: true }curl -X POST /api/admin/unhidereverses the hide{ type: "plot", id: N }🤖 Generated with Claude Code