Confidential · Internal · For Zion
Read-only static review of the source dump. Technical findings, evidence, and the five-phase remediation plan behind the client approval page.
Social Roster is a genuinely capable three-sided marketplace built solo. The data model is real and thought through: a full creator-campaign lifecycle, automatic deliverable creation, an application-quota billing system, and a 21+/ID compliance flow. The problems below are the ones almost every fast-built platform carries at this stage, not signs of carelessness.
Headline risk · Authorization
The app runs almost all database traffic through the Supabase service-role client, which bypasses Row-Level Security entirely.
src/lib/supabase/server.ts:31-36, referenced in 68 files. It is a valid pattern, but it makes each route's own check the only thing standing between a user and everyone's data, and RLS the last line of defense for anything reaching the database with the public anon key. Both layers have holes today, and two are pre-authentication account-takeover paths.
The one fact that frames everything
createAdminClient() is the dominant data path (68 files, including creator-facing routes). So RLS is not the enforcement layer for normal traffic, per-route checks are. His own CLAUDE.md documents the intent. The fix is not to fight the pattern but to make it fail closed: a mandatory shared role guard and locked-down RLS as defense in depth. Both, not one.
.eq('creator_id', user.id)). Verified across invites, notifications, deliverables, submit-media, explore, celebrate..rpc() calls, no raw SQL interpolation; all DB access through the PostgREST query builder. One low-severity filter-string exception (F24).creator-ids from public to private in migration 013. The crown-jewel bucket is locked.force-dynamic in 26 files. The caching bug he feels is a narrower client-state pattern (see 6.0), not a data-caching failure.Impact. raw_user_meta_data is fully attacker-controlled. Anyone can call signUp({ data: { role: 'admin' } }) against the public anon endpoint and the trigger provisions an admin profile: full read of every creator's PII, government-ID photos, payments, and every admin API. Total compromise before login even matters.
Hard-code role = 'creator' in the trigger; provision brand/admin only server-side after out-of-band verification; reject any auth-driven insert with role != creator.
Impact. A logged-in creator, using the public anon key directly, runs update profiles set role='admin' where id = auth.uid() and becomes admin, bypassing the UI. An absent WITH CHECK defaults to the USING expression, which only pins id, so role is freely writable. Independent second path to full compromise.
Make role immutable from the client: column-level REVOKE UPDATE(role) from anon/authenticated, plus a WITH CHECK/trigger forbidding role changes unless caller is service_role.
Impact. A creator sets subscription_status='active' or paid_application_credits=9999 (or flips own status to approved) straight through the anon client: unlimited free applications, full revenue bypass, self-approval.
Restrict client-editable creators columns to genuine profile fields. Billing / subscription / status / stripe_* writable only by the service role.
Impact. Any authenticated creator can PATCH /api/admin/assignments/<id> to approve their own application or set status='paid' on any assignment. Admin authz rests on each route remembering an inline check, with two live misses already.
Add the guard to both routes (recur = cron-only behind a shared secret). Extend middleware to gate /api/admin and extract one shared requireAdmin() so a forgotten check fails closed. Note: reachable by any authenticated non-admin, so High leaning Critical.
Impact. Mass PII disclosure and tampering via the public API: all waitlist rows (name/email/city/IG), all business_applications (name/email/phone), all notifications; rows insertable/deletable.
Enable RLS + explicit deny-by-default on every public table; commit code-only tables into migrations; add a CI/startup assertion that fails if any public table has RLS off (this mistake has recurred 4+ times).
001:220 enables RLS on brands with no policy, so deny-all to anon/authenticated. Not a hole (app reads via service role); confirm brand self-service reads are intended to route only through server routes.
Impact. For alcohol/nightlife campaigns, the "21+ signed" and "ID verified" evidence is trivially forgeable: any string, blank image, any typed name. This is the exact liquor-liability / age-gating control from his context; it provides weak legal assurance.
Generate the storage path server-side, persist keyed to (user, campaign); in apply, verify the object exists and belongs to this user/campaign. For alcohol campaigns, evaluate a real age/ID vendor (his choice).
Validate magic bytes, require role==='creator', randomize object names.
Impact. Deliverable media and view-count screenshots (which can carry PII/analytics) are world-readable by URL; effectively an open file host.
Private bucket + short-lived signed URLs; validate type/size; randomize keys.
IDs are kept indefinitely once uploaded; no purge after verification. State ID/biometric laws and data-minimization (lifecycle stage 5) call for deletion after the verification decision.
Auto-purge ID photos on approval/decision; log the purge.
011_campaign_compliance.sql:29-31 created creator-ids public ("PUBLIC for simplicity now"); 013 flipped it private. IDs uploaded in that window may have been fetched/cached; id-upload still returned getPublicUrl and campaign_creators.id_*_url still hold public-style URLs.
Audit/rotate objects uploaded before 013; migrate stored values to storage paths.
src/app/api/admin/id-photo/route.ts:20 — extractPath() returns input verbatim when it lacks the storage marker, then signs it. Admin-only (the route enforces verifyAdmin) and scoped to creator-ids, so an admin-only IDOR-within-bucket, not an external hole. Downgraded from the seed.
Validate the user/campaign/... path shape before signing.
Include every PII-bearing table plus a stored-file inventory or signed links.
Impact. Residual PII survives a "delete me"; partial failures report success.
Purge waitlist/business_applications by email and the content bucket; make deletion transactional or per-step verified; write a deletion audit record.
api/waitlist/route.ts and business-applications/route.ts are unauthenticated service-role inserts, no rateLimit(), no captcha; the waitlist unique-email 409 also leaks membership.
Rate-limit + captcha; generic responses.
Data-integrity, not just UX
Not a data-caching problem and not the Next.js router cache (he already handles that). The mechanism is a client-state pattern: components seed local state once from server props (useState(prop) / uncontrolled defaultValue), and the App Router does not remount client components when navigating between two URLs that render the same tree with a different param, or after router.refresh(). The initial value only applies at first mount, so the previous record's selections survive onto the next.
src/app/(admin)/admin/campaigns/[id]/edit/page.tsx
All-client form (19 useState, ~22 seeded fields). The effect keyed on params.id never resets loading to true, so A -> B renders A's populated form while B loads. No .catch/.finally: a save writes A's field values onto campaign B. A cross-record data-corruption path, not cosmetic.
src/app/(admin)/admin/creators/CreatorFilters.tsx
Keyword input is keyed; the city input below is not (defaultValue={location}, no key). "Clear all" empties the URL but stale text stays. A half-applied fix confirms they have been chasing this exact class.
Widespread useState(prop) widgets
CreatorNotificationBell, PromoCodesManager, DueDateCell, DeliverableChecklist, etc. silently show stale values after any router.refresh().
(1) Key stateful containers by record id so React remounts on record change; (2) setLoading(true) at the top of the param-change effect + add .catch; (3) prefer controlled-from-server for small widgets; (4) codify the rule in his CLAUDE.md. Impact high — removes a data-integrity risk and a daily annoyance.
The precise diagnosis: his pain is mostly workflow, not hosted Postgres. Hosted Postgres is the part that works.
Real pains: manual copy-paste migrations (no CLI, unrepeatable), RLS treated as a tax without the benefit, free-tier egress anxiety on images, single-vendor coupling.
Local hard drives, honest split. Good for nightly encrypted pg_dump + bucket sync to his Mac mini + offsite drive (data custody, cheap exit hatch). Bad for serving the live public marketplace off residential internet on one machine (ISP/update outages mid-campaign, home IP as front door, single-drive failure).
Recommended, staged: now — adopt the Supabase CLI (migrations in git) + automated nightly local+offsite backups (the "local drives" ask pointed at the job it is right for). Near-term — image serving behind next/image caching, shift public media to Cloudflare R2 if egress bites. Later, only on a real trigger — self-host Postgres on a VPS/homelab. Keep Supabase as the live engine, fix the workflow, give him data custody today.
PAY · PCI-DSS · SOC 2. payments (001:143-153), promo_codes.sales_count, commission fields, compensation. No Stripe yet, so this is financial integrity, not card data — payout/commission/paid-status reachable through F3/F4. 1099/tax-reporting gap.
Restrict all payment/status writes to admin/service role; audit trail on payment-status changes; server-side promo reconciliation; Stripe Connect when live (never store PANs).
Keep PCI scope minimal; use hosted Stripe (SAQ-A) at checkout.
FTC. deliverables stores submission_link/notes but no disclosure attestation; content_requirements is free text. Paid nightlife promotion is squarely the FTC endorsement-guide surface.
Required disclosure attestation (checkbox + stored record) on submission; #ad template copy; admin-review checklist item.
creators.age_range is free text; combined with F7 there is no verified age anywhere. Tie to F7 for alcohol campaigns.
src/lib/rate-limit.ts is per-instance, resets on deploy; absent from assignments, business-applications, waitlist, recur. Move to a shared store (Upstash/Redis); apply to state-changing/PII routes.
api/admin/assignments/[id]/route.ts:5,9,26,40 logs assignment/creator IDs and status transitions to console. Strip or redact.
Require auth, per-session history, rate-limit, keep it Tailscale-only.
See 1.2. Commit to both a shared requireRole() guard and locked-down RLS as defense in depth.
api/creator/apply/route.ts:83-132 reads the quota then inserts with no transaction. Two concurrent applies to different campaigns can each pass the "2 free/month" check. Same-campaign dupes are blocked by UNIQUE + 409. Small revenue leak; moot until Stripe is live.
Enforce the quota with an atomic DB check. Fold into the payments/Stripe workstream.
admin/creators/page.tsx:158 interpolates admin-supplied location into a .or() filter string. Admin-only on a service-role query the admin can already run, so near-zero real impact — flagged so the pattern is not copied to any user-facing filter.
Escape/reject , . ) in the filter input, or use structured .or() with sanitized parts.
auth/callback/route.ts:29 does redirect(`${origin}${next}`) with a client-supplied next. Because origin is always prefixed, next resolves to a same-host relative path — the classic open-redirect is neutralized. At most an Info-level hardening item. Reported transparently rather than inflated.
Current state (from access notes, not from any dumped secret): the Supabase service-role key plus Resend and Anthropic keys live in a single .env.local on Gabe's personal Mac; a shared clawdbot admin bot exists; the box is on Tailscale with ngrok configured. Risks: single ungoverned high-privilege secret store, shared logins defeating attribution, no rotation, no MFA evidence.
Ordering logic: protect before build. Security P0/P1 is placed ahead of feature-building because government-ID/DSAR protection outranks features. Every phase is independently approvable; nothing runs until Gabe approves it and read-only is lifted. Phases 6 and 7 (scale, then app stores) cover his thousands-of-users and App Store goals; two scale prerequisites (hot-path indexes into Phase 1, shared rate limiter with F19 in Phase 1/4) are pulled earlier rather than deferred. Full scale detail in sections/scale.md, App Store paths in sections/appstore.md.
Account-takeover + mass-data-exposure · we-do
F1 · F2 · F3 · F4 · F5 · S2 hot-path indexes + nightly encrypted backups to his own drive/offsite + Supabase CLI migration hygiene
Exit: no signup/anon/API path yields admin or another tenant's rows; every public table has deliberate RLS; his data is backed up nightly on his own hardware.
Effort MHighest-sensitivity PII + privacy law · we-do, one vendor choice
F7 · F8 · F9 · F10 · F11 · F12 · F13 · F-RET
Exit: ID/age gating is verifiable; IDs and UGC are private and access-controlled; export and delete cover 100% of a user's PII including stored files; IDs auto-purge after decision.
Effort M-LHis felt pain, cheapest-loudest first
Caching-bug fix (c) → apply notifications (e) + verified email domain → Deliverables Hub + reminders (b) → pipeline board (e) → verification assist Tier 1 (d) → action-inbox dashboard (f)
Exit: he knows the moment someone applies, sees who owes what across all campaigns, and the platform chases people for him.
~2-3 weeksHardening + revenue + competitive
F15 · F17 · F14 · F19 · F20 · F21 · Need (a) · the 12.0 monitoring/MFA/IR/audit items · Stripe wiring · two-way messaging · brand-portal depth · CSV exports · mobile-first admin
Exit: money and payout state are admin-only and audited; sponsored posts carry stored #ad disclosure; the admin team uses a governed secret store with MFA; audit logging and a breach runbook exist; revenue is no longer manual.
Effort LDifferentiation
Calendar view · content library · promo-code redemption tracking · OAuth metric pulls (Tier 3) · creator media-kit pages · test-suite + CI floor
Exit: differentiators live; test/CI floor established. External clocks: start Meta/TikTok app-review and Stripe setup early, even though builds land later.
Effort LScale & performance readiness · after feature/hardening
S1 server-side pagination on every list · S8 next/image + R2/CDN media (egress) · S5/S6 selective caching (ISR + cached settings row) · S3 fix Deliverables N+1 · S7 trim login bundle (Three.js) · S9 async/out-of-band email · S10 batch recurring-campaign inserts
Exit: no unbounded list query; dashboard / creators / deliverables / pipeline hold sub-second at ~5k creators / ~50k campaign_creators (measured); list images cached; no request blocks on an email send. Prereqs pulled earlier: S2 indexes (Phase 1), S4 shared rate limiter (F19, Phase 1/4).
Effort M-L · ~1.5-2.5 wkMobile / App Store · Capacitor wrapper (Path B) · external clock
Start clocks now: Apple Developer + Google Play accounts, decide payments story (keep subs/credits web-only for v1 to dodge Apple IAP) · prereqs (mostly in the security plan): F13 account-deletion complete, UGC moderation on content bucket, privacy nutrition labels from the PII inventory, S7 login trim · build Capacitor iOS/Android shell: native push, deep/universal links, native camera for ID capture · submit + budget review round-trips · PWA (Path A) as interim · hold Path C (native rebuild) until mobile is the product
Exit: live installable listings on App Store + Google Play against the existing Supabase backend; account-deletion / privacy / moderation pass review, no IAP conflict; push + deep links + native ID capture work on device; Gabe owns the developer accounts.
Effort M build · L wall-clock (review clock)