Project
CARNALIFE
Private membership platforms live or die on trust: rigorous vetting has to coexist with real member discretion, and most admin tooling treats those as a tradeoff rather than solving for both. I built the full admin and vetting backend for a private membership platform, application review through ongoing safety enforcement, with every sensitive read and write enforced at the database level so member privacy holds even if the application code ever gets it wrong.

I built this as the sole engineer on a Next.js App Router and Supabase system covering the entire admin surface of a private membership platform: a multi-reviewer application queue with claim, recommend, approve, and deny actions and a full audit trail, Stripe Identity verification on intake, a reference and vouching system that gates approval, and a revocation workflow distinct from denial that blocks a revoked identity from quietly reapplying under different contact details. I architected the whole thing around Postgres Row Level Security rather than app-level role checks alone: every table's access rules live in the database itself, with anti-spoofing constraints (an actor can only ever attribute an action to their own account, enforced by the database, not just the UI) and column-level immutability triggers on audit tables so a completed action can't be silently edited later. The design decision I'm most proud of is the broadcast tool: rather than the obvious approach of putting every recipient in one email's To field, I send an individual API call per recipient, because on a discretion-first platform, one member seeing another member's address in an email header is a real privacy failure, not an edge case. I also built a KPI dashboard (application approval rate, referral share, safety-incident escalation rate) using Recharts, the first chart-based UI in the app, on top of an otherwise table-and-form-driven admin system.
Project README
CARNALIFE
A private membership platform for the kink, fetish, BDSM, and sex-positive lifestyle community. Built with Next.js (App Router) on the frontend and Supabase (Postgres, Auth, Storage, Row Level Security) on the backend.
Product/business context lives in docs/ (Brand Bible, Business Plan, Website Plan, Marketing Campaign, Legal Research). Engineering specs and implementation plans for each build phase live in docs/superpowers/specs/ and docs/superpowers/plans/. A running status summary of what's built vs. planned is in .claude/memory.md.
Repo layout
frontend/ Next.js app (this directory)
backend/ Supabase config, SQL migrations, RLS integration tests
docs/ Product/business docs and engineering specs & plans
Note on the Next.js version: this repo pins a Next.js release with breaking changes relative to older docs/training data. Before writing code that touches routing, middleware, or server/client boundaries, check
frontend/node_modules/next/dist/docs/for the actual current behavior.
Prerequisites
- Node.js
- Docker (for local Supabase)
- Supabase CLI
Getting started
1. Start the local Supabase stack (from backend/):
cd backend
supabase start
This applies every migration in backend/supabase/migrations/ to a fresh local Postgres instance and prints your local API URL and keys. Studio, Mailpit (catches outgoing email locally), and the Postgres connection are all available from the URLs it prints.
2. Configure environment variables (frontend/.env.local, gitignored):
SUPABASE_URL=http://127.0.0.1:54321
SUPABASE_ANON_KEY=<from `supabase status`>
SUPABASE_SERVICE_ROLE_KEY=<from `supabase status`>
STRIPE_SECRET_KEY=<Stripe test key>
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=<Stripe test publishable key — client-side Elements checkout>
STRIPE_IDENTITY_WEBHOOK_SECRET=<Stripe CLI `stripe listen` webhook secret, applicant verification>
STRIPE_PAYMENTS_WEBHOOK_SECRET=<Stripe CLI `stripe listen` webhook secret, ticket payments — separate endpoint/secret from Identity>
RESEND_API_KEY=<optional — omitted, transactional emails log to the console instead of sending>
There is no root .env or .env.example checked in (secrets never belong in git); ask whoever set up the project for real values, or use your own Stripe test-mode keys.
3. Run the dev server (from frontend/):
npm install
npm run dev
Open http://localhost:3000.
Testing
Frontend (Vitest + Testing Library, Supabase client mocked):
cd frontend
npm test # run once
npm run test:watch
Backend (Vitest, run against a real local Postgres via RLS — not mocked; requires supabase start first):
cd backend
npm test
Both suites are expected to be green on master at all times.
Linting & type-checking
cd frontend
npm run lint
npx tsc --noEmit
What's here
Public site (frontend/app/(public)/): homepage, About, sub-brand pages (Revelry, Knowledge live; others coming-soon), consent and vetting-criteria policy pages, the membership application funnel (/apply, with Stripe Identity verification and a self-service reference-confirmation flow), and an application-status page.
Member portal (frontend/app/member/(protected)/, gated by lib/auth/require-active-member.ts): a profile editor with directory-visibility controls, a member directory (browse + detail pages), a friends/follows system (one-way follow with optional approval, plus mutual friend requests), a connections page (Friends/Following/Followers/Blocked), a private-draft-to-public-review member blog (reuses the admin Markdown editor), a support-ticket submission flow, a member-facing block/report tool — block severs and prevents relationships and mutually hides both parties from each other's directory; report feeds the admin safety-incident log with a review status visible only to the reporter — private messaging (1:1 and group threads, real-time via Supabase Realtime, text and file/image attachments, no staff read access to message content), and event listings/RSVP/ticketing (per-category pricing and capacity, a real waitlist for both free and paid categories with automatic promotion on cancellation, a couple RSVP either links a confirming existing member or opens an admin-reviewed guest request, Stripe Elements checkout with referral-credit redemption for priced categories — cancelling a confirmed paid RSVP is a final sale, no refund).
Admin (frontend/app/admin/(protected)/, gated by frontend/proxy.ts + lib/auth/require-staff.ts / require-admin.ts, navigable via a categorized sidebar in the shared layout): a founder-authored blog CMS, the vetting reviewer queue (claim/recommend/approve/deny with a full audit log), members management (revoke/lift with reinstatement lockout enforced at application intake), a referral tracker with an admin-configurable per-referral credit system and a settings page for that amount, a safety/incident log (staff can log an incident directly, or triage member-filed reports — any staff can mark one reviewed, only an admin can link it to a resulting revocation), a KPI/analytics dashboard (Recharts), a broadcast/mass-communication tool (one individual email per recipient, never a shared to:), a member-blog moderation queue, a support-ticket queue, and event management (create/publish/cancel events with per-category pricing and an RSVP cutoff, an attendee roster, guest-request approval, and a payments page with a one-step manual refund action for exceptional cases).
Auth (frontend/app/forgot-password/, frontend/app/reset-password/, frontend/app/api/forgot-password/, frontend/app/api/reset-password/): a shared password-recovery flow serving both /admin/login and /apply/login, built on Supabase Auth's generateLink/exchangeCodeForSession rather than a custom token system, plus phone-number login as an identifier alternative to email (auto-enabled on application approval; one form server-side-detects which identifier type was entered). Password recovery requires <origin>/api/reset-password/callback to be on the Supabase project's Redirect URLs allow-list before it works (see .claude/memory.md).
Backend (backend/supabase/migrations/): one SQL file per schema change, applied in order. RLS is the enforcement layer throughout — the app's Supabase clients rely on policies, not just application-code checks, to gate access by role (member / reviewer / admin). A recurring pattern worth knowing before touching RLS here: a policy that needs to check a fact about an other row's owner (not the caller's own) can't use a plain inline subquery, because the subqueried table's own RLS silently filters the result — the fix each time has been a SECURITY DEFINER helper function that bypasses RLS for that one lookup (is_staff_member(), member_is_active(), members_blocked()).
See .claude/memory.md for the fuller built-vs-planned status by phase.
Key conventions
- Every admin-facing route handler calls a
verifyOrigincheck before anything else, then gates on the session's role via Supabase RLS (requireStaff/requireAdmin), never trusting client-supplied identity. - State-changing actions that matter for audit/compliance write to an append-only log table (
application_reviews,member_revocations,member_revocation_attempts) rather than only updating a status column in place. - Transactional email (
frontend/lib/email/) falls back to a console log whenRESEND_API_KEYisn't set, so the full application/vetting flow works end-to-end in local dev with no email provider configured. - No browser-side Supabase client exists anywhere in this codebase. Every Supabase call happens server-side, in Route Handlers or Server Components (
lib/supabase/server.tsfor service-role,lib/supabase/session-server.tsfor cookie-bound sessions); client components only everfetch()this app's own API routes. - Member-originated writes (profile, posts, follows/friend-requests, blocks, reports) always go through a service-role-backed API route with ownership/target pinned server-side from the verified session, never a direct client-side RLS-gated insert/update.
- Any user- or path-supplied id that gets interpolated into a raw PostgREST
.or()filter string must be validated as UUID format first (,.()are structurally significant to that filter syntax) — every id in this schema is a real UUID, so a value that fails the check can only be malicious input, safe to treat as not-found/400.
Project Gallery
This album serves as the gallery for CARNALIFE.


