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.
Going live is a sequence, not a command. docs/LAUNCH RUNBOOK for Carnalife.md has the order (dumps, migrations, verification, then push), the configuration that fails silently when wrong, and the device checks nothing in this repo can run.
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`>
NEXT_PUBLIC_SUPABASE_URL=<same as SUPABASE_URL — read by the browser client>
NEXT_PUBLIC_SUPABASE_ANON_KEY=<same as SUPABASE_ANON_KEY — read by the browser client>
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>
CRON_SECRET=<any value locally — the bearer token /api/cron/notifications requires>
VAPID_PUBLIC_KEY=<web push; generate the pair with `npx web-push generate-vapid-keys`>
VAPID_PRIVATE_KEY=<web push; server-side only>
VAPID_SUBJECT=<a mailto: or https: contact URL, required by the push services>
NEXT_PUBLIC_VAPID_PUBLIC_KEY=<the same value as VAPID_PUBLIC_KEY — the browser needs it to subscribe>
NEXT_PUBLIC_APP_URL=<optional — the origin used for canonical URLs and JSON-LD; defaults to https://carnalife.org>
Analytics needs no environment variable at all. It is first-party
(/api/track), and the tracker is gated on NEXT_PUBLIC_VERCEL_ENV beingproduction, which Vercel sets for you.
Push fails closed and silent when the VAPID trio is unset, the same posture asCRON_SECRET: a misconfigured deploy sends nothing rather than throwing on
every cron run. NEXT_PUBLIC_VAPID_PUBLIC_KEY duplicates the public half on
purpose, and like every NEXT_PUBLIC_ value it is inlined at build time.
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.
The two NEXT_PUBLIC_ Supabase variables are duplicates of the server-side pair and are easy to miss, because nothing fails at build time without them: lib/supabase/browser-client.ts throws in the browser, so messaging's realtime subscription and the album video upload break while every server-rendered page looks fine. They are also inlined at build time, so adding them to a deployed environment does nothing until the next rebuild.
3. Run the dev server (from frontend/):
npm install
npm run dev
Open http://localhost:3000.
Testing
Running the app against the local stack takes deliberate care. .env.local
points at the production Supabase project and carries a liveRESEND_API_KEY, so starting next dev with it and clicking through a flow
writes to the real database and emails real people. Override the Supabase
variables and blank the Resend key, and confirm which database you actually
reached before writing anything: create a member in local Supabase and log in
through the app. Success means local; failure means the server is on
production. A stale next dev holding port 3000 will silently push a new one
to 3001, which is exactly how a walkthrough ends up pointed at the wrong
database.
Note also that Next 16 with Turbopack streams client-component output in the
RSC flight payload rather than as HTML, so grepping a response for rendered
text finds nothing and looks like a broken page. Assert on the props insideself.__next_f.push(...), or on API routes.
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.
The backend suite needs SUPABASE_URL, SUPABASE_ANON_KEY, and SUPABASE_SERVICE_ROLE_KEY in its environment (take the new-style PUBLISHABLE_KEY / SECRET_KEY values from supabase status -o env). It is also timeout-flaky under full parallelism against the local stack, so prefer npx vitest run --no-file-parallelism --testTimeout=20000. If dozens of tests fail at once with errors that look nothing like the RLS behaviour under test, check that the supabase_vector container is not thrashing; stopping it clears that up.
Backend tests share one database and it is not reset between tests, even
within a file. An assertion that reads a whole table therefore sees rows an
earlier test created — three assertions once read "expected 1, got 2" for exactly
that reason. Scope every read by the fixture's own id.
The frontend suite had its own version of the flakiness and it is fixed rather
than worked around: testTimeout is 20s in vitest.config.ts, becauseuserEvent.setup() yields to the event loop between keystrokes and under this
many parallel workers those yields get starved for whole seconds while the tests
do milliseconds of work. Nothing was hanging. The real speedup, when a broad diff
is worth it, is userEvent.setup({ delay: null }); forty test files still callsetup() without it.
backend/supabase/seed.sql exists for one reason and is local-only: recent Supabase CLI versions narrowed the postgres role's default privileges in schema public to Dxtm (truncate/references/trigger), and migrations run as postgres, so on a fresh supabase db reset every table lands with no SELECT for anon or authenticated and PostgREST answers every request with "permission denied for table ...". The seed re-grants what a hosted project already has. RLS is still the enforcement layer; table grants are the coarse layer those policies sit behind.
PWA, analytics, and security headers
Security headers come from proxy.ts, not next.config.ts. They have to
reach the redirect and 401 responses the auth gates produce, and those never
pass through the config's headers(). That is why the proxy matcher covers
every route rather than only the gated ones.
That widening made one existing line dangerous. X-Robots-Tag used to be set
unconditionally at the top of the handler, which was harmless while the matcher
only covered /admin, /member, and /apply/status -- under the wider matcher
it would have marked the entire public marketing site noindex. It is now set
only for gated paths, and the proxy tests assert both halves.
The CSP deliberately has no nonce, departing from Next's own guidance. Their
approach needs dynamic rendering -- "Static pages are generated at build time,
when no request or response headers exist, so no nonce can be injected" -- and
this build emits 61 static pages which are the public marketing site. A nonced
policy with strict-dynamic would block every script on all of them once
enforcing. So script-src carries 'unsafe-inline'. Two things bound that: the
XSS vector it would otherwise open is user-authored Markdown, and that pipeline
already runs rehype-sanitize against an explicit allowlist after rehype-raw;
and the directives that stop exfiltration and clickjacking stay strict. If the
marketing pages ever stop being statically generated, put the nonce back.
The CSP is enforcing (CSP_REPORT_ONLY = false). /api/csp-report still
collects violations in both the Reporting API and legacy shapes, and is now the
place to look when something stops loading in production.
It shipped report-only with the exit condition "flip after a clean week of
reports", and that condition could not be met as written: the endpoint only
receives violations from real production traffic, and the site is pre-launch, so
a clean week proved nothing except that nobody was visiting. The comment inlib/security/headers.ts records that rather than leaving the flag looking
merely un-flipped. If a legitimate resource is blocked after launch, the fix is a
directive in that file, not flipping this back.
The service worker's cache list is a privacy decision. An offline cache is a
copy of what someone looked at, on a device that may be shared, borrowed, or
seized. /member, /admin, /api, /apply/status, and every cross-origin
request (which covers Supabase signed media URLs) are never cached. Logout posts
a clear-caches message, which clears not member content -- there is none -- but
the record that this device visited CARNALIFE at all. public/sw.test.ts
executes the real sw.js in a node:vm sandbox rather than re-implementing its
rules, because the never-cache list is the guarantee.
Two operational details: registration is production-only, since a caching
service worker fights Turbopack HMR and the stale-chunk breakage looks like a
build bug; and next.config.ts serves sw.js with must-revalidate, because a
cached service worker pins itself and no future deploy ever reaches users.
Push bodies name nothing. A lock screen is read by whoever picks up the
phone, so no payload contains an event title, sub-brand, sender, or member.lib/notifications/push-payload.ts is the only place they are built andpush-payload.test.ts asserts it against a list of revealing terms.
event_notifications gained a channel column and the unique constraint moved
to (event_id, kind, channel). Email and push claim separately: one claim
governing both would mean a push failure recorded against a claim already marked
complete, with neither channel able to retry -- the burned-claim failure this
codebase has hit before.
Analytics is first-party and mounted in app/(public)/layout.tsx, never the
root layout. A URL is itself data, and /member/directory/<uuid> would say
which profiles were being read.
The Website Plan §1 names Plausible. We do not use it: it was never configured,
and a beacon on our own origin serves the stated rationale ("cookieless, no
personal data collection... to avoid a cookie-consent banner") more completely,
because no request reaches a third party at all. That also makes Stripe the
only third-party origin left in the CSP. page_views stores no member id, IP,
cookie, session, or user agent, which is enforced by a test pinning the column
set rather than by convention. The cost is real and permanent: no unique
visitors, no bounce rate, no sessions.
Icons were generated once by a throwaway script and committed rather than built.sharp is an undeclared transitive dependency of Next, so a build step resting
on it would break on upgrade; and the mark is drawn as vector paths because
librsvg resolves fonts from the system, so a text mark silently falls back off
Fraunces to a generic serif.
Staff MFA
Admin and reviewer accounts require an authenticator app. Enrol at/admin/security; a staff account without a factor is redirected there and can
reach nothing else. Door staff are exempt from the requirement, because they
work at a venue door on a phone with unreliable signal and only ever see an
attendee roster, but a factor is honoured if they enrol one anyway.
The gate is in the database, not in this app. is_staff_member(),is_admin_member() and is_door_staff_member() each refuse a session whose
assurance level is below what that account's own enrolment demands
(20260812090000). That is the whole design: a stolen password produces a
valid aal1 token that queries PostgREST directly with the public anon key, so
a check in proxy.ts alone would be bypassed by simply not visiting a page.
Because every staff policy is written as using (is_staff_member()), putting
the check inside those functions covers every table at once.
The redirects in proxy.ts are not a security control. They decide who must
enrol — something RLS cannot express, since it can only honour a factor that
exists, never require one into being — and they keep a legitimate staff member
out of an /admin shell where every table silently reads as empty.
The rule is "if you have a verified factor, your session must be aal2", not
"admins must be aal2". The strict version cannot be deployed: it locks out
every existing account the moment it lands, including the only admin, who then
cannot reach the page that would let them enrol.
Recovery is a database operation. There are no printed recovery codes, on
purpose: a code written down is a second password, and a stolen password is the
problem being solved. To restore access, delete the account's row fromauth.mfa_factors. Be clear about what that implies — whoever holds Supabase
project access can always get back in, so that access is the real root of trust
here, and should be protected accordingly.
Local dev needs [auth.mfa.totp] enabled in backend/supabase/config.toml
(already set). Basic TOTP is included on every Supabase plan including Free;
only phone MFA is a paid add-on, despite what the stock config comment claimed.
Money path and reconciliation
Payment state transitions live in lib/payments/ (apply-payment-succeeded,apply-payment-terminal, apply-refund-succeeded), not in the webhook route.
The Stripe webhook and the reconciliation repair actions call the same
functions: a repair that reimplemented "mark this payment succeeded" would
drift from the real handler, and the drift would only surface the day someone
used it.
A database failure in the webhook returns 500 on purpose. The route used to
return 200 regardless with none of its writes checked, so a transient failure
stranded a paid RSVP at pending_payment permanently while Stripe saw success
and never retried. Live-mode webhooks retry for about three days, so the common
case now self-heals; what does not appears in stripe_webhook_events withprocessed_at still null, which is the dead-letter list reconciliation reports.
Refund attempts are rows, not a status. event_refunds holds one row per
Stripe attempt with its own idempotency key, so retrying is simply callingprocessRefund again. The key is per attempt rather than per payment because
Stripe expires keys after 24 hours, and because keying on the payment intent
would make a legitimate second partial refund silently return the first.event_ticket_payments.refunded_cents is maintained by trigger, never by
application code.
Reconciliation (/admin/reconciliation) reads charges and refunds back
from Stripe and compares them. Read-only by default; one-click repair exists
only where Stripe is unambiguously right, and every repair is logged toadmin_actions. Stripe is authoritative about money movement, not seat
allocation — restoring a payment never silently restores an RSVP, because
the place may already have gone to the waitlist. The fetch sits behind a
provider-neutral adapter, so adding CCBill or Epoch means one new adapter and
no change to the diff engine.
getStripeClient() refuses an sk_live_ key unlessNEXT_PUBLIC_VERCEL_ENV is production. .env.local once declaredSTRIPE_SECRET_KEY twice, live above test, so local dev ran in test mode only
because the test key came second.
Fees are backfilled, never read during the webhook. A charge'sbalance_transaction is null at webhook time and Stripe attaches it a few
seconds later, so fee and net cannot be recorded there.lib/payments/backfill-missing-fees.ts fills them on the nightly cron, bounded
to 200 charges per pass, and leaves a charge whose transaction has not landed
alone rather than writing zero — a zero fee is a number somebody will later
trust. Never block a payment confirmation on this lookup.
Payment resilience
Adult-adjacent businesses lose payment processing, and the industry alternatives
(CCBill, Epoch) take weeks to provision. The payment tables are therefore
processor-agnostic: provider_* columns with composite uniqueness on(provider, provider_payment_id), so a second processor's ids cannot collide
with Stripe's.
ProviderChargeHandle in lib/payments/provider.ts is a union, and that is the
whole design:
| { kind: 'client_secret'; providerPaymentId: string; clientSecret: string }
| { kind: 'redirect'; providerPaymentId: string; redirectUrl: string }
Stripe Elements confirms client-side against a secret; CCBill redirects. An
interface returning clientSecret alone would be Stripe's concepts wearing an
abstraction's name, and would need rewriting the day the abstraction was actually
needed. An interface shaped around one provider's concepts is not an
abstraction.
Restriction is detected by error class, never by counting failures
(lib/payments/restriction.ts). NEVER_RESTRICTED_TYPES covers CardError,InvalidRequestError, RateLimitError, ConnectionError andIdempotencyError, and a test asserts that 100 consecutive declines change
nothing. Counting declines would abandon a perfectly healthy processor because
a hundred members mistyped their cards, on the busiest night of the year.
Provider selection is a 30-second cached read of a settings row
(lib/payments/provider-state.ts); an unreadable row falls back to 'stripe',
and a failed read is deliberately not cached. No second processor is
provisioned, so the failover path has nowhere to go today — that account is the
long-lead item, not the code.
Transactional email
Every send goes through one Resend transport, lib/email/send.ts, extracted from
18 senders that each carried their own copy of the fetch. Every send is recorded
in email_deliveries via lib/email/record-delivery.ts, which is service-role
and never throws — a logging failure must not cost someone their email.
The sender is hello@carnalife.org, and getting that wrong cost every email
the project ever sent. It read carnalife.com from the first commit until 4
August 2026 — the Brand Bible's working assumption, made before the domain was
registered — and Resend rejects an unverified sending domain. Nothing was ever
delivered: no application decision, no password reset, no event reminder, no
interest-list broadcast. It survived because the delivery log recorded HTTP 400
and discarded Resend's explanation, so three separate faults (wrong domain, then
an unverified domain, then an invalid key in Vercel) were indistinguishable.
The transport now keeps the response body, and a test asserts the sender domain
is .org and names .com specifically, because that is what autocomplete and
every copied snippet in docs/superpowers/plans will suggest again.
email_deliveries.status is the only honest signal: sent means delivered,failed means Resend refused it, fallback means no key at all. A broadcast is
the exception — it passes recordSuccess: false, so a successful broadcast
writes no row, and absence is the success signal.
The transport deliberately does not catch. The void senders leave fetch
unguarded because their callers wrap the whole side effect, andsendScheduledNotification counts a failure by catching a throw. Swallowing
errors here would silently report every send as delivered, which is precisely the
failure an unset RESEND_API_KEY already causes.
Retention, because one broadcast should not be thousands of permanent rows.pruneEmailDeliveries runs on the nightly cron and drops successful sends
after 365 days, 5000 per pass (PostgREST has no limit on a delete, so it
selects ids first; an unbounded delete against a year of rows is the statement
that locks a table on the night nobody is watching). Failures and fallbacks are
never pruned. They are rare, and they are the rows anyone actually goes looking
for: "they say they never got it" is the question this log exists to answer, and
that answer does not expire. Broadcast recipients are recorded once per
broadcast, not once per person.
Health verification
Members may upload a test result, staff review it, and a verification badge then
appears beside their name. Participation is opt-in twice over: a member chooses
to submit, and chooses whether the badge shows.
Three properties hold this together:
- The document's contents are never stored in the database — only the fact of
a review and a storage reference. - Viewing a document is audited, and the audit row is written before the
signed URL. A failed audit write refuses the view. This is the one place
in the codebase where an audit failure blocks the action instead of being
logged and stepped over, because an unaudited look at someone's medical
document is not a thing to recover from gracefully. - The badge expires on its own.
lib/health-verification.tscomputes it at
render over a 90-day window rather than storing a flag, so a stale badge cannot
outlive its test result through a missed job. Expiry compares calendar days
in Eastern, not instants: "tested on the 1st" is a date, not a timestamp.
Membership lifecycle
Membership is a thing that expires. members.status isactive | pending_payment | lapsed | revoked, with membership_plans
(month, quarter, half_year, year, each in an individual and a couple rate) andmemberships alongside it.
Approval grants the right to pay, not access — with one deliberate,
dated exception. The reviewer queue creates members as pending_payment, and
the approved-notice email says so.
The launch promotion. Applications submitted on or before 1 Nov 2026
earn six months of membership free, and those members are created active
with a term nobody was charged for (membership_terms.source = 'comp',amount_cents 0, no provider_subscription_id). Eligibility is keyed on the
application date while the term runs from approval: vetting latency is
not the applicant's fault and must not cost them the perk, but somebody
approved in December should not find their free period already half spent.
The Day 1 badge does not simply share the cutoff, and that is the subtle
part. isDayOneMember keys on the member's join date while eligibility keys
on the application date, so a slow approval would strip the badge from
somebody who plainly qualified. Approval sets day_one_override instead, so
the two agree by construction rather than by two date computations happening
to match. LAUNCH_PARTY_CUTOFF still moved to 2026-11-02T00:00:00Z so the
automatic path is right for the common case.
Comped members receive two of the four notices, and that is correct:
term-ending and lapsed. payment_failed and grace_ending require a payment
to have failed, and nothing charges them. Membership is not tickets —
dues are waived, event tickets are not. Dues are
collectable: through Stripe subscriptions on /member/billing, or in person at
the door.
The door is the deliberate exception and activates directly. It shares the
approval helper, and a fast-tracked guest buys their event ticket through a
route gated on requireActiveMember — inheriting pending_payment would
approve someone at a venue door and then refuse the money they came to spend.
Dues are taken in person in the same visit, as a one-off charge, not a
subscription: provider_subscription_id is nullable precisely so a term can
exist without one, and manufacturing a recurring charge from a card presented
once at a door is the chargeback exposure the cancellation flow exists to limit.
Those charges are marked in metadata and skipped by reconciliation, since they
have no event_ticket_payments row by design.
Memberships expire on the nightly cron. sweepLapsedMemberships lapses
terms whose grace has run out, and sendMembershipNotices sends four notices
around it: renewal week, payment failed, grace ending, and lapsed. Both read
live state rather than advancing a watermark, so a missed night self-heals.
Notices are claimed before sending and keyed (member_id, kind, period_end) --
per TERM, not per member, or nobody would receive a second year's reminder.
Renewal rate and membership revenue both report on /admin/kpi, frommembership_terms -- an append-only row per term paid for, written by both
payment paths. It exists because memberships holds only the current term, so
renewal rate was uncomputable without it. There is no backfill and none is
possible, so periods before it shipped read zero and the page says why.
Membership revenue is reported beside ticket revenue and never added to it:
ticket money is net of processor fees, nothing captures the fee on a
subscription invoice, and a gross figure added to a net one is neither. The
amount is recorded on the payer's row only, or every couple renewal would count
twice.
A lapsed member holding a paid ticket renews at the door. Check-in returns
402 with duesOwed for lapsed/pending_payment, which the door UI turns into
a dues-collection panel; revoked still returns 403 with no way to pay past it.
Admitting them instead was rejected on Legal Research line 56.
The seeded prices are placeholders and are live rows, not fixtures.
What is settled and should not be re-litigated:
Lapsing lives on members.status because status = 'active' is checked by
RLS helpers and policies across at least six migrations plusrequire-active-member.ts. A new status value therefore loses access everywhere
at once, fail-closed, with no policy left behind. Deriving access from a separate
membership table would mean teaching every one of those gates about it, where one
missed policy silently leaves a lapsed member with full access.
It is safe beside revoked because the reinstatement lockout keys offmember_revocations rows, not status. A lapsed member never touches the
conduct-removal machinery and is restored by paying, not by re-vetting.
Staff can never be lapsed, enforced by a trigger (private.prevent_lapsing_staff),
not by remembering to filter the sweep. An admin locked out of their own platform
by an expired card is the worst failure available here.
requireBillingAccess admits pending_payment and lapsed, and refusesrevoked. A member who cannot reach the page that takes their money cannot
pay, and those are exactly the statuses that fail the normal active gate. It is
not a hole: somebody removed for conduct cannot buy their way back in.
Reinstatement is a vetting decision, not a payment.
proxy.ts's BILLING_PATHS covers the couple routes for the same reason.
Linking with a partner is how two people reach the couple rate, so the members
who need it are the unpaid ones; left off that list, the cheaper option would be
reachable only by people who had already bought the more expensive one. Apending_payment or lapsed member is also redirected to /member/billing
rather than /apply/login, which for someone holding a valid session is a loop
rather than an error message.
Reconciliation skips subscription charges, keyed on charge.invoice (every
subscription charge has one, no PaymentIntent charge does — metadata would be
defeated by a missing key). Without this, every renewal surfaces ascharge_without_local_payment on the one page whose job is telling the truth
about money, and a page that cries wolf monthly is a page nobody reads by March.
Couple memberships
Two members can share one membership at the couple rate, and either of them can
be the one who pays. This reverses an earlier decision that membership was per
person; the reasons given then were real costs rather than blockers, and the
questions they rested on now have answers.
A couple is a link, not a container. member_couples records the
relationship; each member keeps their own memberships row, and the two share aprovider_subscription_id with one carrying is_payer. A households table
that memberships hangs off is the obvious design and the wrong one here:memberships is keyed by member_id, every query in the feature is.eq('member_id', ...), and members.status is a per-person access gate.
The cost of two rows is that they can drift, and a drifted pair means somebody
loses access they paid for. Every membership write resolves the covered
members through one helper (coveredMemberIds), and both upserted rows carry
an identical key set — supabase-js normalises keys across a batch upsert, so a
partner row built without provider_customer_id would be sent an explicit null
rather than falling back to a default. A test compares the key sets.
One live couple per member is a trigger, not partial unique indexes. Indexes
on member_a_id and member_b_id both pass when a member is a in one couple
and b in another, because neither compares across columns.
Linking is by code, not by email. Any email-based invite has to tell the
sender something, and every version of that answers "is this address a member",
which is the fact a private club is built on not disclosing. members.email is
neither unique nor required anyway. The inviter generates a code and hands it
over; every redemption failure returns one identical message, because an
expired, used, or nonexistent code are all still facts about whether a code
exists.
Either partner may unlink; only the payer may cancel. Unlinking gets used at
a bad moment in someone's life and must never need the other person's
cooperation, while stopping a recurring charge belongs to whoever's card it is.
Both halves are enforced server-side. Unlink, cancellation and revocation all
resolve identically: renewal stops and both partners keep the term already paid
for, which is one rule and needs no refund arithmetic.
Either partner can pay, including rescuing a failed renewal. When a payment
fails both enter grace, and the partner can take over — implemented as
cancel-and-resubscribe, with the old subscription cancelled only when the new
one's first invoice is paid. A checkout session is a promise to pay; cancelling
on the promise would leave the couple with neither.
Converting an individual term mid-way turns the remainder into account
credit, capped at the plan price and rounded down. It uses the existingreferral_credits ledger, so it is spendable at event checkout with no change
to redemption. The uniqueness guard is (source_couple_id, member_id) and notsource_couple_id alone: both partners can be converting at once, and the
single-column version would silently swallow the second one's credit.
A linked couple books events without confirming twice. A couple RSVP
normally waits at pending_partner_confirmation, which stays the default for
anyone unlinked. The link must be with that specific partner, so a member cannot
skip confirmation while naming somebody else.
The Business Plan's "$150-300 per couple or individual" now needs restating
rather than contradicting: a couple paying the couple rate and two individuals
paying separately are different numbers, and the seeded prices are placeholders
awaiting that decision.
A lapsed member holding a paid ticket renews at the door rather than being
admitted. Legal Research line 56 lists "whether non-members receive the same
privileges as members" among the things courts examine when testing a
members-only claim, and admitting a lapsed member on the same footing as a paid
one is that pattern, against the private-club exemption the business rests on.
Built: check-in returns 402 with duesOwed and the member id for a lapsed orpending_payment member, which the door turns into a dues-collection panel without
having to search for somebody it just scanned. revoked still returns 403 and offers
no way to pay past it, because removal for conduct is not a payment problem.
Event-day presence
event_checkins is the source of truth for who is physically at an event, keyed (event_id, member_id). It used to be two columns on event_rsvps, which could not express per-person presence: a couple RSVP is one row for two people, so a single door scan marked the pair, and "the requester scanned, so their partner is here too" is a claim about someone's physical location that can simply be false. The unique constraint is also what makes two door devices and offline-queue replay safe — the insert either succeeds or raises 23505 — replacing the conditional .is('checked_in_at', null) update the old columns needed.
Deliberately not a second RSVP row per partner: such a row would have to be couple/confirmed to be checkable-in, which double-counts in both capacity counters (lib/events/capacity.ts and the separate inline one in confirm-partner/route.ts), silently waitlisting couples who should be confirmed.
Members read presence through exactly one path: public.event_presence_roster(p_event_id). They get no SELECT policy on event_presence_optins at all, so there is no second path a later change can widen. The function takes an event id and no viewer — the caller comes from auth.uid(), so there is nothing to spoof — and returns nothing unless the caller is themselves checked in and opted in, inside the event's live window (starts_at - 2h through ends_at, which is what makes "never persistent" a property of the data rather than a promise in the UI). Read it with the session client, never service-role, or every guarantee above evaporates silently.
One deliberate asymmetry, pinned by a test: presence does not consult hidden_from_directory. Delegating to can_view_member_profile looked consistent, but that helper is applied to the target and never the viewer, so a directory-hidden member would have read the whole room and appeared to nobody — the exact one-way visibility this feature rules out. Active-and-not-blocked are the right factors here; directory browsability is not.
No geolocation is involved and none should be added. The Website Plan forbids real-time GPS, and the check-in record is already coarser than a city.
Scheduled notifications
frontend/vercel.json registers one Vercel Cron entry that hits/api/cron/notifications daily at 14:00 UTC (mid-morning Eastern year-round). It
sends event reminders and the post-event aftercare follow-up.
The route authenticates the bearer token Vercel Cron sends against CRON_SECRET
and fails closed if that variable is unset, so a misconfigured deploy sends
nothing rather than running unauthenticated. It sits outside proxy.ts's matcher
by design, like the Stripe webhooks.
Two things it is worth knowing before enabling it in a new environment:
CRON_SECRETand the migrations are both manual. Nothing in this repo
deploys either. A cron firing against a database withoutevent_notifications
will 500 once a day.- The first run emails people. Any published event starting within the next
seven days gets a reminder immediately. Aftercare is bounded to events that
ended within the last seven days, which is what stops the first run mailing
every attendee of every past event.
To exercise it locally, run the dev server and call it directly:
curl -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/notifications
With RESEND_API_KEY unset the senders log instead of sending, so this is safe
to run for real against local data.
Scheduling decisions worth not re-litigating: the run uses catch-up due-logic
(what is now due and unsent) rather than window matching, so a missed run delays
a notification but never skips it — which is what makes a single daily fire
correct for both reminder offsets. event_notifications has aunique (event_id, kind) constraint and rows are claimed before sending, so
a crash mid-batch leaves the notification claimed and unretried: duplicate email
is worse than one missed message, and completed_at is null flags the
interrupted batch on /admin/events.
Event management and planning
An event is a night somebody has to run, not just a row somebody buys a ticket
for. /admin/events/[id] is the hub: capacity, waitlist depth, check-ins, and
links to the plan, the live view, payments and the report.
The plan is stored relative to the start, never as timestamps. Task
deadlines are days before starts_at; run-of-show segments and staff shifts are
minutes from it, and signed, because load-in and the staff briefing happen
before doors. Absolute times break twice: a cloned event carries deadlines
already in the past, and a rescheduled event silently strands its whole schedule
on the old date — and the edit route has always sent reschedule notices, so
events do move. That one choice is also what makesPOST /api/admin/events/[id]/clone correct, which is why there is no templates
table and no recurrence engine.
Capacity is enforced by the database, not by the application.event_rsvps_enforce_capacity takes an advisory lock per (event_id, category)
and re-counts inside it. The application genuinely cannot do this itself:
supabase-js speaks to PostgREST, which runs one transaction per request, so no
lock can span the read and the write. Before this, eight simultaneous buyers all
took the same last slot. A row that already holds a slot is never re-checked,
because a checkout hold lapses after fifteen minutes and re-gating the webhook
that confirms the payment would refuse somebody whose money has been taken.
Callers read a capacity refusal as an answer rather than a fault: a racing buyer
is waitlisted, and is not charged.
Ratio management exists and is switched off. max_single_male_ratio is null
on every event, and the effective single-male capacity is then exactly the
configured one — the test that lets it ship disabled asserts that for any number
of confirmed women. When set, places open as single women confirm (not hold —
an abandoned checkout must never have raised the cap), and a man beyond the limit
joins the waitlist and is emailed when a place opens rather than being
refused. He is never told the reason. The floor rule is a CHECK constraint, not a
form rule, because without a floor an event opens with a cap of zero and a
checkout that refuses everybody. It is deliberately not in the capacity
trigger: that trigger prevents overselling, which is a safety property, while a
ratio is policy.
/admin/events/[id]/live reports booking category, not gender.member_profiles.gender allows nonbinary and prefer_not_to_say, so a
men-versus-women headcount would either drop those people or file them somewhere
they did not choose, and it would build a running tally of the gender of everyone
physically inside a venue from a field given for another purpose. The endpoint
returns counts only — asserted structurally, like page_views — because it is
polled every twenty seconds from a tab left open on a bar counter, and it is
rate-limited by member rather than by IP since the whole door shares one address.
A failed poll keeps the last figures on screen with a visible age rather than
blanking: the venue wifi is assumed bad.
Staffing is a plan; is_door_staff is a capability. They are set separately,
so assigning a shift never quietly grants an admin surface./member/shifts lives in the member shell rather than /admin, because an admin
login would mean granting is_staff_member(). It sits in its own route group
because (protected) requires an active membership and a helper may be
lapsed or never have paid dues; requireSignedInMember grants nothing on its own,
since every row returns through RLS keyed on holding a non-declined assignment.
A helper may accept or decline and change nothing else — the own-row update policy
alone would have let them rewrite their hours or which event they were on, so a
trigger rejects every other column.
The rules a member reads before deciding (photography_policy,alcohol_policy, safer_sex_supplies, barriers_required,quiet_space_available, what_to_bring) are structured fields shown above the
RSVP controls, not prose in etiquette. Photography defaults to banned: for
a community built on discretion, permitting cameras has to be a decision somebody
made rather than an omission. A quiet space is mentioned only when there is one.
The per-event report has one number that lies. provider_fee_cents is null
at webhook time and backfilled nightly, so a report read the morning after an
event has no fees in it. kpi_event_report counts unsettled payments separately
and the page says provisional above the figures, because a profit that is too
high looks exactly as confident as a settled one. A fully refunded ticket leaves
the club down its fee, since Stripe does not return it. Door dues are reported
beside ticket revenue and never inside it.
Linting & type-checking
cd frontend
npm run lint # eslint --max-warnings 0
npm run typecheck # tsc --noEmit
Both gate on zero. The baseline was 11 type errors and 49 lint problems
until 2 August 2026; all 11 were one defect, a vi.fn() declared with fewer
parameters than it is called with, which types mock.calls as an empty tuple.
Excluding tests from tsc was rejected deliberately: it turns the gate green by
deleting what it measures, and a mock that has drifted from the module it
replaces still passes while asserting nothing.
tsc passing is not sufficient. A zod .refine() added to eventSchema
typechecked clean and broke every PATCH route, because .refine() returns aZodEffects and .partial() does not exist on one. Seven route tests caught it.
Never pipe these through tail in a && chain — the pipe's exit code istail's, so npm run lint | tail -3 && git commit commits on a failing lint.
Continuous integration
.github/workflows/, added 2 August 2026 — before which nothing ran unless
somebody remembered, while a push to master deployed straight to production.
| Workflow | When | What |
|---|---|---|
ci.yml |
every push and PR | lint, typecheck, tests, build |
rls-nightly.yml |
08:00 UTC, and any PR touching backend/ |
the RLS suite (699 tests) against a real Supabase stack |
backup.yml |
07:00 UTC | encrypted database snapshot |
audit.yml |
Mondays | npm audit, scheduled only |
Two things worth knowing before editing them. The RLS job must start the CLI
from backend/, because members-rls.test.ts shells out to docker exec supabase_db_backend psql and that container name comes from project_id inconfig.toml. And the pinned CLI version must not drift below the version
that wrote config.toml — an older one cannot parse it and fails in 13
seconds with 'db' has invalid keys.
audit.yml is scheduled and never runs on pull requests: one upstream advisory
with no fix available would otherwise block every deploy, possibly during
launch week. The requirement is that somebody finds out, not that shipping
stops.
Backups and restore are documented in docs/OPERATIONS RUNBOOK for Carnalife.md.
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 read-only recognition-badge line, and an interest picker over the curated tag vocabulary (with a suggest-a-tag path for anything missing), a member directory (browse + detail pages, each showing the member's tenure tier and, where it applies, a "Day 1 Member" badge — both computed from member_profiles.created_at in lib/member-badges.ts unless an admin has set an override — plus interest chips that link to ?tag= filtered browsing), 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, an aftercare page (peer-facilitated processing circle schedule and a members-only, admin-curated list of licensed kink-aware therapists, fronted by the Brand Bible's own bound that CARNALIFE refers out rather than treating in-house), 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 — a check-in QR code once confirmed — one per person, so each half of a couple scans as themselves — and a form to vouch for a guest's fast-tracked membership at the door, separate from the guest-request flow), event-day presence (a "who's here" list on the event page, live only from two hours before the start until the event ends, showing members who are both checked in and opted in; reciprocity is total, so a member who has not arrived sees no section at all and a member who has not opted in sees nobody), and photo/video albums (public/private/friends-only visibility, immediate-visibility-then-report moderation — no staff pre-publish review — with video uploaded directly browser-to-Storage via a signed URL; every upload ships with a deliberately unwired content-safety scan seam, pending a vendor decision, mitigated by defaulting new albums to private and reusing the block/report tool's report path).
Admin (frontend/app/admin/(protected)/, gated by frontend/proxy.ts + lib/auth/require-staff.ts / require-admin.ts / require-door-staff.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, event management — an overview hub per event (/admin/events/[id]) with capacity, waitlist and check-in counts, plus create/publish/cancel with per-category pricing, an RSVP cutoff, the format and rules a member reads before deciding, an attendee roster, guest-request approval, a payments page with a one-step manual refund, a planning page (/plan: checklist with owners and days-before deadlines, run of show as signed offsets from the start, and staffing), a live view (/live, requireStaff, counts-only and polled), a report (/report: costs, per-event P&L and the debrief), and a clone action that copies the plan and nobody who attended; a venue book (/admin/venues, admin-only rather than staff-wide, since a venue row carries an exact address and a door code), a door check-in + fast-track page (/admin/door, reachable by any member holding the is_door_staff capability flag, independent of rank) — name-search or QR-scan check-in with an offline-caching fallback, listing both people of a couple booking as separate rows so each is admitted individually, plus an undo for a mis-tapped check-in, and a fast-track queue to confirm-in-person and approve a vouched-for guest's membership on the spot; a role/capability assignment panel on each member's detail page (admin-only, audit-logged) for changing rank (member/reviewer/admin) and the is_door_staff/is_trust_safety_officer capability flags; a badge-override panel on the same page (admin-only, not audit-logged — cosmetic recognition, not access) for pinning a member's tenure tier or Day 1 status, or reverting either to automatic; an aftercare manager (/admin/aftercare, admin-only) for curating therapist referrals and scheduling processing circles, both created unpublished so a named practitioner never becomes visible to the membership by accident; a tag vocabulary manager (/admin/tags, admin-only) for curating tags, approving member suggestions, and archiving retired ones; and a staff-tag panel on each member's detail page (admin-only to set, readable by any staff, and never shown to the tagged member) whose assignments are what tag-gating reads.
Added since: a member relationship timeline on each member's detail page (lib/members/timeline.ts) drawing applications, reviews, revocations, incidents, tickets and payments into one chronology — member-to-member messages are deliberately excluded, because no staff read access to message content exists anywhere in this schema and a "relationship manager" is exactly the feature that would erode that by accident; a health-verification review queue (/admin/health-verifications, under Vetting rather than Admin because reviewers do this work and RLS lets them) where opening a document writes an audit row first and refuses the view if that write fails; a financial reconciliation view (/admin/reconciliation, admin-only); and the pre-launch interest list (/admin/interest-list, admin-only rather than staff-wide — it is a marketing list of non-members who never consented to reviewer scrutiny, so it follows member_role_changes and email_deliveries rather than the staff-wide default; CSV export exists at /api/admin/interest-list/export, admin-only and audit-logged to admin_actions before the file is returned, because once it exists the file is outside every control here; every value is neutralised against spreadsheet formula injection by lib/csv/to-csv.ts, since each field was typed into a public form). The list is also reachable by broadcast as the interest_list segment.
Added 3-4 August 2026: an announcements inbox (/member/announcements) and the activity feed (/member/feed, now where an active member lands after signing in).
Added 5 August 2026: /member/shifts, where somebody helping to run a night reads their own shift, their brief, and that event's run of show — and nothing else. It sits in its own route group rather than under (protected), because that layout requires an active membership and a helper may be lapsed, brand new, or a volunteer who never paid dues; sending them to a login page when they try to read their own shift would lock out exactly the people the feature exists to recruit. RLS carries the scoping: a helper reads their own assignment and gets nothing from attendees, check-ins, payments, tasks, or anybody else's shift on their own night, which would be a roster of who staffs this club.
Announcements exist because a broadcast used to be an email and nothing else — broadcasts is staff-read, so a member had no way to see one that was filtered, bounced or simply missed. member_announcements is a row per member per broadcast, which broadcast_recipients deliberately is not: that one is a delivery log holding an array of ids, this one is an inbox, and it needs read state, per-row realtime delivery, and a member who can read their announcement without reading who else received it. Subscribing to broadcast_recipients instead would have meant exposing an array that, for an active_members send, is the membership list. The broadcasts read policy is scoped through the inbox, so the subquery runs with the caller's own rights and can only match their own rows — no SECURITY DEFINER helper needed, because unlike the visibility helpers it asks only about the caller. Announcement push carries no subject: a subject is admin-written free text and the likeliest place a sub-brand or event name appears, and a lock screen is read by whoever picks up the phone.
The feed aggregates published writing, albums, upcoming events, announcements and new members. Every query runs on the caller's own session client, never service-role, which is the safety design rather than an implementation detail: the feed invents no visibility rules and a blocked member vanishes from it for exactly the same reason they vanish from the directory, with that reason living in one policy instead of two. Who RSVP'd and who befriended whom are deliberately absent — event_rsvps is readable only by the attendee, their partner and staff, and member_relationships only by its participants, so including either means widening a policy rather than adding a page. This schema already answered attendance disclosure once, differently: event_presence_roster is opt-in, reciprocal, and confined to the live event window.
Members can change their own password (/member/account). updateUser does not verify the current one and other sessions survive by default, so the route re-authenticates against a throwaway client (persistSession: false — on the cookie-backed client, signInWithPassword would overwrite the caller's session mid-request) and then calls signOut({ scope: 'others' }), which is the half that makes the feature worth having: a change that leaves an attacker signed in accomplishes nothing. Note that Supabase refuses a password update on an aal1 session once a factor exists, and proxy.ts challenges for MFA on /admin only — so staff signing in at /apply/login need to present a code first, which the route now checks for and says.
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).
Deploying. The frontend deploys itself: GitHub auto-deploy to Vercel is on, so merging to master and pushing puts the code on carnalife.org. Migrations do not follow automatically; they are a manual supabase db push against the linked project, run from backend/ — from the repo root the CLI finds no migrations, declares every remote version orphaned and refuses with an alarming error that means only that you are one directory too high. Letting migrations lag is how production ended up 33 migrations behind its own frontend for three weeks, and how the live site once queried five tables that did not exist.
There are still no down migrations, so a bad one is recovered by restoring rather than reversing. Since 3 August 2026 that restore has something to work from: .github/workflows/backup.yml takes a daily encrypted snapshot, so the manual dump is no longer the only copy. Take one anyway before a schema change — it is one command and the alternative is depending on a job that ran at 07:00 for something you are doing at 15:00. Procedure, and the ordering a restore must follow, are in docs/OPERATIONS RUNBOOK for Carnalife.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, ranked via member_role_rank() — a higher rank automatically satisfies a lower rank's checks, e.g. is_staff_member() is rank >= reviewer) plus two capability flags independent of rank (is_door_staff, is_trust_safety_officer — any member, including a reviewer, can hold either without it affecting their rank-based access). 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(), private.member_is_active(), private.members_blocked(), is_door_staff_member(), private.can_view_member_profile()).
Every parameterized helper lives in the private schema, and that is load-bearing. config.toml exposes public to PostgREST, Postgres grants EXECUTE to PUBLIC on new functions by default, and no migration ever revoked it — so before 20260808090000 each of these was callable as /rest/v1/rpc/<name> by any member holding the anon key and a session, bypassing every policy that relied on it. A helper taking the viewer as a parameter was not a gate at all: the caller simply supplied whichever viewer they wanted to be. Revoking EXECUTE is not an option, because RLS policy expressions evaluate with the querying user's privileges and the policies themselves would start erroring. PostgREST only exposes the schemas it is configured for, so private removes the RPC surface while policies keep working. backend/tests/security-definer-exposure.test.ts pins the exposed RPC list to an explicit allowlist; adding to it is a decision about what any authenticated member may ask the database directly. When writing a new helper: put it in private, and schema-qualify any call to a sibling helper, since these carry set search_path = public.
The same migration fixed a NULL-viewer hole. An applicant holds an auth.users row from /apply and a valid authenticated session with no members row, so the viewer subquery in every policy yielded NULL for them — and can_view_member_profile was p_member_id = p_viewer_id or exists (...), where the first arm is NULL and, inside the exists, members_blocked(target, NULL) is an EXISTS over an empty set, so false, so not false is true. NULL or true is true, and pending applicants could read the whole member directory. can_view_album had the identical hole by a different route. Both now require a non-null, active viewer. Any new helper that takes a viewer id needs the same guard.
Tag-based access control (events and albums) runs through member_holds_any_staff_tag(), and one property matters more than any other in this schema: it reads member_staff_tags and never references member_self_tags at all. That is why self-assigned and staff-assigned tags are separate tables rather than one table with a kind column — a member editing their own profile must never be able to unlock a gated resource, and a table that is never mentioned cannot be un-filtered by a careless edit. passes_event_tag_gate() / passes_album_tag_gate() are ANDed inside the existing visibility rules, so a gate can only ever narrow an audience, and member_has_active_event_rsvp() grandfathers anyone who already holds a non-cancelled RSVP so a later gate cannot revoke access someone paid for. Note that RLS alone does not protect the member-facing event routes: they read through service-role, so each re-reads the event through the session client first (lib/events/event-visible-to-member.ts). The door routes deliberately keep their service-role bypass — is_door_staff is not is_staff_member(), so a session re-read would lock a door staffer out of the event they are working.
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. - Almost no Supabase calls happen client-side. Every read/write goes server-side, in Route Handlers or Server Components (
lib/supabase/server.tsfor service-role,lib/supabase/session-server.tsfor cookie-bound sessions); client components otherwise only everfetch()this app's own API routes. The two narrow exceptions both uselib/supabase/browser-client.ts(an anon-key client, no elevated access): messaging's realtime subscription, and the album video-upload flow's direct-to-StorageuploadToSignedUrl()call against a server-issued signed token. - 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.


