Skip to content

[#40] Content moderation admin API#150

Merged
realproject7 merged 3 commits intomainfrom
task/40-content-moderation
Mar 15, 2026
Merged

[#40] Content moderation admin API#150
realproject7 merged 3 commits intomainfrom
task/40-content-moderation

Conversation

@realproject7
Copy link
Copy Markdown
Owner

Summary

  • Add POST /api/admin/hide and POST /api/admin/unhide routes protected by ADMIN_API_KEY bearer token
  • Both accept { type: "storyline" | "plot", id: number } and use the service-role Supabase client to toggle the hidden column
  • Audited all frontend Supabase queries — all already filter with .eq("hidden", false) (home, discover, story page, OG image, writer dashboard, chain page, reader portfolio, ranking lib)
  • Added ADMIN_API_KEY to .env.example

Fixes #40

Test plan

  • curl -X POST /api/admin/hide with valid bearer token and { type: "storyline", id: 1 } returns { success: true }
  • Same request without/wrong token returns 401
  • Hidden storyline no longer appears on discover, home, or story page
  • curl -X POST /api/admin/unhide reverses the hide
  • Plot-level hide/unhide works with { type: "plot", id: N }

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@project7-interns project7-interns left a comment

Choose a reason for hiding this comment

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

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.

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>
Copy link
Copy Markdown
Collaborator

@project7-interns project7-interns left a comment

Choose a reason for hiding this comment

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

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

  1. No timing-safe comparison for API keyauthHeader !== \Bearer ${adminKey}`uses JS!==, which is susceptible to timing attacks. Use crypto.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 });
}
  1. id validation allows floats/negatives, rejects 0!id || typeof id !== "number" is buggy. Use:
if (typeof id !== "number" || !Number.isInteger(id) || id < 1)

Non-blocking

  1. DB error messages leaked to caller (dbError.message) — log server-side, return generic message.
  2. No 404 when target row doesn't exist — returns { success: true } even if nothing was updated.
  3. Hide/unhide routes are 95% identical — consider a single shared handler.
  4. No audit logging for moderation actions — important for follow-up.

Copy link
Copy Markdown
Collaborator

@project7-interns project7-interns left a comment

Choose a reason for hiding this comment

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

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.timingSafeEqual after normalizing lengths.
    • File: src/app/api/admin/unhide/route.ts:15
    • Suggestion: apply the same timing-safe auth check here.
  • [high] id validation is still too loose: !id || typeof id !== "number" rejects 0 for 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.

Decision

Requesting changes because the admin auth and input validation are not yet robust enough for a privileged moderation API.

Copy link
Copy Markdown
Collaborator

@project7-interns project7-interns left a comment

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator

@project7-interns project7-interns left a comment

Choose a reason for hiding this comment

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

T2b Re-review: APPROVE

Both blocking issues fixed:

  1. Auth now uses timingSafeEqual with length pre-check — timing attack mitigated
  2. ID validation enforces positive integer (Number.isInteger(id) && id > 0)

Service-role client properly isolated. Both routes consistent.

@realproject7 realproject7 merged commit 94cfc62 into main Mar 15, 2026
1 check passed
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.

[P8-1] Content Moderation (MVP)

2 participants