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.
Events are planned as arcs, not nights. docs/superpowers/plans/2026-08-30-event-ladder-to-launch.md defines the house format — an opener a week out that moves vetting off the main night, a second opener the day of, one to three main nights, and a landing the next day, each at its own venue — and schedules the three real events that exercise the payment, email and door rails before 31 October. .claude/skills/carnalife-event/ is the companion skill: it emits every field for /admin/events/new in the form's own order and refuses the traps (the ratio gate's floor constraint, dollars-vs-cents, the day-of address release, the DST boundary on launch night).
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 — from CARNALIFE's OWN Stripe account, not the personal one>
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_IDENTITY_RESULTS_KEY=<a RESTRICTED key (rk_) in the SAME Stripe account as STRIPE_SECRET_KEY, granted Identity's sensitive verification results; without it no date of birth is ever stored>
EMAIL_FROM=<optional — sender for every email; defaults to CARNALIFE <hello@carnalife.org>>
LOGIN_HASH_PEPPER=<optional — salts the hashed IP on failed-login records; unset, no IP is stored at all>
STRIPE_PAYMENTS_WEBHOOK_SECRET=<Stripe CLI `stripe listen` webhook secret, ticket payments — separate endpoint/secret from Identity>
STRIPE_CONNECT_WEBHOOK_SECRET=<signing secret of the CONNECTED-ACCOUNTS endpoint, /api/webhooks/stripe-connect: seller orders, Dens stays and educator class tickets charged on a seller's account>
STRIPE_ACCOUNTS_WEBHOOK_SECRET=<signing secret of the v2 THIN event destination, /api/webhooks/stripe-accounts: seller account requirements and capability changes>
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.
A Stripe key alone is not a working Identity setup. verificationSessions.create throws until Identity is activated on the account, while list keeps returning an empty 200 — so every read-only check looks healthy and the funnel still produces nothing. Confirm with an actual create in test mode:
stripe post /v1/identity/verification_sessions -d "type=document"
A session with a url back means it is on. See §2 of the Launch Runbook for the rest, including why the Identity webhook's Events from must be Your account and never Connected accounts.
The ordinary key never sees a date of birth. Stripe returns verified_outputs.dob only to a restricted key granted access to sensitive results, and only when the request expands verified_outputs.dob by name. Until 1 October (d51b1488) the webhook used the ordinary key, so no verified date of birth had ever been stored, in production included, and the 18+ check, the 21+ guest rule and the identity revocation match had nothing to read. The webhook now reads through STRIPE_IDENTITY_RESULTS_KEY. If that key belongs to a different Stripe account, every session is resource_missing; the webhook then completes the verification without a date of birth and logs why, rather than failing for Stripe to retry forever. An admin can fill in an older application from its page with Re-read identity from Stripe (audited; it reports what the ID shows and never decides). getStripeClient refuses sk_live_ and rk_live_ outside production.
The Stripe SDK is v23, pinned to API version 2026-09-30.endive (lib/stripe/client.ts). The webhook endpoints' own API versions are set in the Stripe dashboard and are a separate manual step from upgrading the SDK.
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.
For a scripted walkthrough, use next build then next start, not next dev. In a git worktree, next dev answered Next's own 404 for many nested
routes (/api/apply/login, /api/notifications/*, /api/member/reports) that
the production build serves correctly, and clearing .next did not help. It
also rewrites the tracked frontend/AGENTS.md; restore it with git. What worked
(28 September 2026, both notification and references walkthroughs): export the
local stack's values in the shell and refuse to start on a non-local URL, leaveRESEND_API_KEY unset so every email is a console line, sign in with@supabase/ssr's createServerClient over an in-memory cookie store and replay
its cookies, and for /api/admin/* enrol and verify a TOTP factor throughauth.mfa so the session is genuinely aal2. next start's stdout reaches a
redirected log file late, so count emails per unique recipient across the
whole log and poll, never "new lines since the last step".
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.
From a git worktree, copy the image pins before the first supabase db reset. The main checkout's gitignored backend/supabase/.temp/ pins the service images (storage-version, gotrue-version, rest-version, postgres-version, storage-migration). A fresh worktree has none, so a reset from it restarts the shared *_backend containers on the CLI's older defaults. The old storage server cannot write to the newer storage.objects schema, and every upload, service role included, fails with 42P10. Copy only those files, never project-ref or linked-project.json, which link to production. Then run supabase stop and supabase start, and confirm with docker inspect supabase_storage_backend --format '{{.Config.Image}}'.
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.
Never run the frontend and backend suites at the same time. Measured on
26 September 2026: overlapped with the backend RLS run and its Docker stack, the
frontend suite failed in a different, unrelated file each run (EventEditorForm,
then palette-discipline at 20s) and took 657s. The backend run beside it had 25
auth timeouts. Run alone, the frontend was 607/607 files in 260s and the backend
100/100. The guard tests' source walk takes about 150ms, so a timeout there is CPU
starvation, not slow code. Run the suites one after the other, and do not "fix" a
timeout that only appears under overlap.
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 builds the event and broadcast payloads andpush-payload.test.ts asserts it against a list of revealing terms. Anotify() push uses its type's fixed pushBody from the registry, never the
content, and lib/notify/registry.test.ts walks every registered type against
the same kind of list.
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.
Sessions and sign-out
Sign-out ends this browser only. supabase-js signOut() defaults toscope: 'global', which deletes every session on the account, so signing out on a
laptop used to end the session on the phone, and the next visit there failed withrefresh_token_not_found. Every logout route passes scope: 'local'; keep it that
way in anything new.
One getUser() per request. Each auth gate used to make its own network call,
about 100 /user requests per admin page load once the nav's prefetches were
counted. lib/supabase/memoize-get-user.ts shares one call per client and
refetches after sign-in, sign-out, a user update or an MFA step-up, andgetSessionServerClient is wrapped in React cache() so a layout and its page
share one client. When the proxy answers with its own redirect or 401 after a
refresh, carrySession() copies the rotated session cookies onto that response,
which otherwise discarded them.
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.
Capability layer (RBAC)
Rank (member/reviewer/admin) is linear. Capabilities are new and
orthogonal to it: an admin-defined, admin-assigned grant any member can
hold at any rank, without it touching members.role. /admin/roles
(admin-only, permanently) composes named roles from a fixed catalogue
(lib/auth/capabilities.ts, mirroring capabilities/roles/role_capabilities/member_roles), and a panel on each member's detail page
assigns them, audit-logged with a required written reason.
public.has_capability(p_key) is shaped like is_staff_member() on purpose:
no viewer parameter (it reads auth.uid() itself, so it cannot be asked
about somebody else), status = 'active' (a revoked member loses every
capability the instant the next query runs), and MFA-gated, exactly like
rank-based staff access. Every capability policy is added beside an
existing one, never instead of it — Postgres OR's RLS policies, so the layer
can only widen access, and nothing that worked before it shipped stopped
working.
All fifteen catalogue keys are enforced. docs/STAFF ROLES for Carnalife.md
is the operator's guide (what each grants, the recommended roles, how to
assign and review them); in brief:
| Key | Grants |
|---|---|
vetting_review |
Claim, unclaim and recommend on applications, and health-verification review. Approve and deny stay admin. Sees what a reviewer sees. |
trust_safety |
The safety-incident queue, narrower than a reviewer: only content a visible incident reports. |
assign_staff_tags |
/admin/tag-assignment, not the member-360 page. |
event_ops |
Events, venues, vendors, staffing and the event plan. Nobody but an admin rosters themselves. |
door_check_in |
Check-in, fast-track and the guest-list download. Sits beside the older is_door_staff flag, which still works. |
event_floor |
/admin/floor and the printable roster for events they are rostered on. |
aftercare |
Processing circles and therapist referrals. |
community_moderation |
Posts, forums, support tickets, talk questions, and filing safety incidents. Display names, never emails. |
manage_page_content, manage_brand_pages |
The page, pillar and help editors. Capability-only: an admin holds nothing here until a role carrying the key is assigned. |
marketing |
The Marketing area; campaigns and the interest list on screen only. |
send_broadcasts |
The only path to emailing people: broadcasts and email campaigns. Recipients are resolved server-side. |
finance_view |
/admin/payments, event payments, founding places, reconciliation reports. Read only. |
issue_refunds |
The refund buttons on those pages. No table policy; two-step sign-in on every refund, admins included. |
kpi_view |
/admin/kpi, aggregates only. No table policy. |
Several capabilities open data only through the service role behind the
page or route guard, with no table policy at all (issue_refunds,kpi_view, and the recipient and RSVP reads behind send_broadcasts,finance_view and marketing). For those the guard is the only control, and
the RLS test for each asserts that the holder's own session reads nothing.
Three things sit around the catalogue. A role assignment can take an end
date: has_capability() ignores an expired row at once, and the nightly
cron closes it out and tells the member. A capability holder holds nothing
until they accept the current staff confidentiality agreement
(/admin/agreement); rank-based access is untouched by it. And door and
floor shifts (event_staff_assignments) grant access to their own event
while the shift is on, through requireDoorAccess and the floor guards,
without any role. /admin/roles offers every recommended role as a one-click
template, /admin/roles/holders is the access review, and admins get a
monthly reminder to look at it.
app/admin/gate-coverage.test.ts fails if an admin page or API route calls
no guard, or if a capability-gated nav link leads to a page whose guard does
not admit that capability.
A policy wired without its page gate is a capability nobody can reach, and
that failure mode is why the frontend half exists at all.requireCapability/requireAnyCapability (lib/auth/require-capability.ts)
are the MFA-checked, data-access gates, mirroring requireStaff/requireAdmin. The three outer admission gates — proxy.ts, the protected
layout, /admin/security, /admin/mfa-challenge — instead userequireRoleHolder, a separate, MFA-unaware sibling that readsmember_roles directly: an MFA-gated admission check would refuse the exact
enrolled-but-unchallenged session that needs to reach the enrolment page,
which is a closed loop. requireAdminOrCapability/requireStaffOrCapability
check rank and capability independently rather than short-circuiting either,
so a door-staff-plus-capability session is never silently dropped by
whichever check happens to run first.
There is no manage_roles capability, and there never will be. Creating
and assigning roles stays admin-rank-only, permanently — a capability that
could mint capabilities would let its holder write themselves a role
containing everything.
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, so a second processor's ids cannot
collide with Stripe's. On event_ticket_payments the payment intent and the
charge are unique per (provider, id, event_rsvp_id) and limited to ONE member
by exclusion constraints (20261103091500): one charge may pay for several of one
buyer's RSVPs, THE PURGE's Friday and Saturday, each with its own row and its
share in amount_cents. (Until that migration the intent's uniqueness was the
single-column constraint left over from before the rename, not a composite.)
Every reader of a payment by intent handles several rows: the webhook handlers,
lapsed-hold expiry, the fee writer (lib/payments/write-charge-detail.ts, which
splits Stripe's fee across the rows) and reconciliation.
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.
Cash App Pay is not available. The Stripe Dashboard reports it ineligible
for this account's category (MCC 8699, membership organizations), although
Stripe's Cash App Pay page lists 8699 as Restricted, not Prohibited. An appeal
is drafted in docs/drafts/2026-10-07-cash-app-pay-appeal.md. Every flow
already uses dynamic payment methods, so if the appeal succeeds it is a
Dashboard toggle and no code. Never change the account's category to unlock
a payment method; the category must describe the business.
Planned, nothing built: a token as the primary rail. The founder intends a
DAO with its own token to become the primary payment method for everything,
Revelry included, with Stripe, CCBill and Cash App Pay becoming backup rails
and crypto kept off Stripe. The decision and the questions that must be
answered before any design (privacy on a public chain above all) are indocs/superpowers/specs/2026-10-07-payment-rails-direction.md. A token rail
would sit beside the ProviderChargeHandle union, not replace it.
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.
Staff alerts are one email per recipient now. They used to be a single send
with every admin on a shared to:, which meant one person's preference could
only ever be everybody's. They go through notify() (see "Opt-in
notifications"), and no sender passes more than one address any more (several
still pass a one-element array, which is the same thing).
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 submit a recent test result, a reviewer confirms it, and a
verification badge can then appear beside their name. Participation is opt-in
twice over: a member chooses to submit, and chooses whether the badge shows.
Reviewers work to docs/HEALTH VERIFICATION SOP for Carnalife.md.
What "verified" means (founder, 27 September 2026): a complete panel (HIV-1
and HIV-2, chlamydia, gonorrhea, syphilis, hepatitis B, hepatitis C,
trichomoniasis), every result negative or cleared by a clinician's letter,
tested within 90 days. A positive result is rejected. The badge computes that
window at render (lib/health-verification.ts) rather than storing a flag, in
calendar days in Eastern, so a stale badge cannot outlive its test through a
missed job.
A submission holds several documents (20261014090000). The submission is
the unit and health_verification_documents are its children, up to 8 (a
trigger enforces it). The old single-file route deleted every earlier document
on each upload, so a result that came as two files could never reach a
reviewer whole. The lifecycle is draft -> pending -> verified | rejected | returned, and returned -> pending, with one open submission per member (a
partial unique index). Nothing reaches the queue until the member presses
Submit, and files cannot change while a reviewer has them.
- Files go straight to the private bucket by a signed upload URL the
server mints for a path it chooses (api/member/health-verification/documents).
A Vercel function body is capped at 4.5MB and the bucket allows 10MB, so a
file never passes through our server. The browser sends it with a plainfetchin the same shapeuploadToSignedUrluses, because the one browser
Supabase client is reserved for Realtime. - Submitting checks the bytes (
.../submit): a row whose upload never
finished is dropped, each file's first bytes must match its declared type,
and photos are re-encoded without metadata bylib/media/strip-image-metadata.ts
(fails closed). PDFs are left alone, and the SOP says so. - No filename is stored, because a filename can carry a result.
The reviewer side (/admin/health-verifications, staff or vetting_review):
- Each card shows the member's verified legal name and date of birth from
their application, and each document opens separately through an audited
route. The audit row is written before the signed URL, and a failed write
refuses the view: the one place an audit failure blocks the action. - Verify is refused until this reviewer has opened every document, checked
againstadmin_actions. The date recorded is the earliest across all of them. - Reasons come from a fixed list (
lib/health/review-reasons.ts), and the
route refuses anything else, so a stored reason can never name a test or a
result. A positive result and a suspected forgery share "Not accepted". - Return sends an incomplete set back with its files kept. Reject
deletes the documents at once. Rejecting as "Not accepted" also retracts any
earlier verification, so a positive result cannot leave an old badge (or
gated-event entry) running. - Every decision is conditional on the row still being
pending(409
otherwise), and a reviewer cannot decide their own or their partner's
submission (403). - The member gets a content-free email ("there is an update on your profile"),
and the staff alert now reachesvetting_reviewholders as well as roles.
Retention and reminders run in the daily cron (lib/health/sweep.ts,lib/health/reminders.ts). The sweep deletes drafts untouched for 7 days,
verified documents past their window, returned submissions abandoned for 30
days, documents of anyone no longer active, and any bucket object no row points
at (after a 24-hour grace). Only files go: verification rows survive for the
audit trail. The reminders tell a member 10 days before a badge they display
lapses, and re-alert reviewers when anything has waited over 48 hours.
Health documents are not backed up. .github/workflows/backup.yml copies
every bucket except health-verification, because snapshots live in git
history forever and would make every deletion and withdrawal false. The
records still are; after a restore, members re-upload.
Health-gated events
An event can require a health verification current on the event's date,
with an optional stricter window (20261015090000). The founder's use case is a
party where condoms are optional.
- The rule is a trigger on
event_rsvps, because eight code paths write
that table. It checks a new booking, a cancelled one brought back, or a change
of who is on it, and refuses a couple unless both partners are members and
both are eligible. It raisesCH001, not23514: every booking path reads
23514 as "full, waitlist them", and a refused member must be told why. - Routes ask first (
lib/health/eligibility.ts,healthGateRefusal), so a
member reads a reason before anything is written or charged. That includes
checkout for somebody promoted from the waitlist: promotion is an update the
trigger deliberately does not re-run. - The door re-checks on the night with no override. The list marks the
person "NOT ELIGIBLE TONIGHT" (never "health"), check-in refuses, and door
staff can turn the ticket into an event credit (/api/admin/door/health-credit),
adoor_refusalcredit that is exactly-once per payment by the existing
unique index. Issuing it cancels the booking. - A gated event refuses guest access and seat purchase at the database, and
cloning copies the gate. - Members see the requirement and their own eligibility on the event page, and
are emailed 10 days out if they will not qualify. The editor shows how many
people holding places would be refused, as a count. - An anonymous attendee advisory (
/api/admin/events/[id]/health-advisory)
emails every checked-in attendee separately, the reporter included, once per
event. There is no column anywhere for who reported.
Both switches ship off (health_gating_settings, on admin Settings). A
trigger refuses to gate any event while gating is off, service role included.
Counsel has not answered whether gating on HIV status is lawful here; see the
SOP's Section 15.
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.
Founding places (lib/membership/founding-offer.ts, /admin/founding, a
panel on /member/billing) sell the founding membership to approved members
before launch night: Individual, Couple and Patron tiers fromfounding_offer_tiers, which ship switched off until the founder turns them
on from /admin/settings. A place is a one-off charge on the dues path, not a
subscription, so it inherits the doubled, launch-deferred first term thattermWindowFor already gives door dues. Eligibility calls the sameisFoundingFirstTerm the term writer uses, so the price and the term cannot
disagree. It is membership bought early and nothing more: the schema has no
column for a return or a share, on purpose. Public /founding is unreleased and
noindex.
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.
Cancelling is reversible while the term is still running. cancel setscancel_at_period_end, and resume (/api/member/membership/resume) sets it
back — both payer-only, mirroring each other, both writing our own row
immediately as well as via the webhook that follows. Resume exists because/billing-terms promised it and nothing delivered it: cancelling was one-way,
and checkout refuses anyone who already has a subscription, so a member who
cancelled by mistake could neither undo it nor re-subscribe until the term ran
out. Resume does not create a subscription — once Stripe has actually ended
one there is nothing to un-cancel, and starting a fresh term is checkout's job
at checkout's price.
A renewal that needs 3-D Secure starts the grace window, same as a failure.
Stripe sends invoice.payment_action_required and the invoice simply sits
unpaid; no invoice.payment_failed arrives at that moment. Both route toapplyInvoiceFailed, which refuses to restart a clock already running — Stripe
re-raises these on every retry — and completing the challenge producesinvoice.paid, which clears the grace. Access is never touched by either; only
the nightly sweep ends a term.
A payer in grace settles it on the billing page. complete-payment
(/api/member/membership/complete-payment, payer-only) sends them to the open
invoice's Stripe-hosted page, which takes a new card or completes the bank's
challenge — one button for both causes, since our row cannot tell them apart.
Before redirecting it sets the subscription'spayment_settings.save_default_payment_method to on_subscription: Checkout
pinned the original card as subscription.default_payment_method, which
renewals are charged to first, so without it a new card would pay this invoice
and the next renewal would go to the dead card again. The customer portal was
not used: it needs Dashboard configuration and brings its own cancel and plan
controls, which would sidestep the payer-only rules above.
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.
Opt-in notifications
Staff and members choose, per notification type, how it reaches them: email
immediately, a daily summary, or no email, and whether to get a push. Everything
also lands in a stored inbox. Plan and record:docs/superpowers/plans/2026-09-27-opt-in-notifications.md.
A type is defined in code, and the database holds only what a person
changed. lib/notify/registry.ts declares every type: its audience (staff
or member), its class, who is eligible (roles and/or capability keys), each
role's default, whether it may be digested, and its push body.notification_preferences is sparse, so a missing row means "the default for
my role" and a new type needs no backfill. The classes:
- critical (safety incidents, payment failover, cron failures): may be
turned off, but never by the last person still receiving it immediately, and
never digested; - routine: freely opt-in;
- transactional: always sent. Listed read-only in settings so nobody wonders
why it cannot be turned off (application decisions, billing, event changes,
waitlist and seats, account security, volunteer shifts).
notify() (lib/notify/notify.ts) is how a staff alert or a stored notice is
sent. It resolves who is eligible and active, applies preferences over
defaults, writes an inbox row per person, sends one email per recipient (never a
shared to:, since one person's choice must not mute everyone), pushes to those
who opted in, and leaves digest items pending. The inbox row doubles as the
dedupe: only a new row goes on to email and push, so a retried alert lands once.
It never throws, because every caller is a side effect of something that
already happened.
The floor is enforced twice. Saving a preference goes throughset_notification_preference(), which refuses to let the last immediate
listener mute a critical type, under a per-type advisory lock so two admins
muting at once cannot both win. It is service-role only: the eligible list comes
from the registry, and a caller who chose that list could empty it. At send
time, a critical type that would reach nobody goes to every active admin.
Member types mostly keep their own senders. Event reminders and aftercare,
new conversations, follow and friend requests, and the announcement and Affinity
pushes read their preference through lib/notify/member-preferences.ts, which
fails to the default, as the old member_profiles reads did. Affinity's default
is off, so a failed read can never push it. The six old member_profiles
preference columns were copied across by 20261016090200 (every non-default
choice) and are marked SUPERSEDED; drop them once production is verified.
Event push is built from the attendee list, not the email list
(eventPushRecipients). It used to reuse the email list, so turning event email
off silently turned event push off too.
Settings and inboxes. /admin/notifications and/admin/notifications/settings for staff (gated like /admin/security, with a
bell in the admin shell so a plain capability holder with no account links can
reach it); /member/notifications/settings for members, which replaced the
three checkboxes on the profile form and holds the device push toggle
(components/notify/PushNotificationToggle.tsx, moved out ofcomponents/member/ because the member-shell register guard forbids light
classes there). /member/notifications lists unread stored notices beside
messages, requests and announcements, and the badge counts them. The shared
routes POST /api/notifications/read and PATCH /api/notifications/preferences
take the member from the session, never the body.
The daily digest (lib/notify/digest.ts) sends one email per person from the
inbox text, which each sender already wrote to name nobody it need not. Items
are stamped only after the email goes, so a failed send retries tomorrow and a
re-run sends nothing twice. Every optional email ends with a link to the
settings page (lib/notify/footer.ts), except to a guest, who has no settings
page.
Throttles count the inbox, not the email log. The health stale-queue alert
and the metric nag used to count email_deliveries; once someone can choose a
digest or no email, "nobody was emailed" no longer means "nobody was told".
Eligibility fixes that came with it: safety incidents reach Trust & Safety
holders as well as admins, tag suggestions reach assign_staff_tags holders,
no alert (or staff broadcast) reaches an inactive member, and reviewers are
told when an application reaches their queue (application_ready_for_review,
from the identity webhook; admins default to the digest).
References and referrals
The references/vouching flow (specdocs/superpowers/specs/2026-07-12-references-vouching-design.md) gained four
things in its second iteration
(docs/superpowers/plans/2026-09-27-references-referrals-iter2.md).
- Late referral credit. A referrer linked after approval used to be
credited never, since approval is the only place the credit was granted.link_referrer()(20261016090400) links and, once approved, credits in one
transaction with the application row locked. After approval only an admin
may link (by role, not capability). The referrer locks once credited, and a
revoked or guest member earns nothing, on both the late path and the
approval path. The referrer is told throughnotify(), naming no applicant,
since their Referral panel shows only counts.ReferrerLinkPanelreads the
credit with the service role (avetting_reviewholder's session cannot readreferral_credits, and a silent "no credit" would unlock a paid referrer) and
fails closed when it cannot. - Replacing a reference. A reviewer can swap in a new reference when the
original cannot be reached (replace_application_reference(),20261016090500, one transaction). The old one stays on record withsuperseded_at, a partial unique index allows one current reference per
application, and a replaced reference's link reads exactly like an invalid
one. A decline stays binding: the approve gate checks every reference ever
given, and only the admin override with a note clears it. - Reminders. Day 3 and day 7 after the request, never a third, only while
the application waits in the queue (lib/applications/remind-references.ts,
thereference_reminderscron pass). - Hardening. The public confirmation POST checks the request origin and is
rate-limited to 10 attempts per 15 minutes per IP, in its ownreference-confirmnamespace.
Never read "the" reference with .single(). Once an application can have
two rows, .single() returns no data, and the approve gate once read that as
"not declined". Every reader filters superseded_at is null except the approve
gate, which is a list query over every row. The KPI reference-response rate is
deliberately unfiltered: a replaced reference that was emailed and never
answered is a real non-response.
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 is one slot shared by every scheduled job, run as named passes in a
deliberate order: event reminders and the post-event aftercare follow-up, staff
shift reminders, waitlist advance, the page-view rollup, lapsed checkout holds,
the Stripe fee backfill, the membership lapse sweep and its notices, scheduled
blog posts, the stale-metric nag, affinity expiry,close_stalled_applications then reference_reminders (so an application
closed tonight gets no reminder tonight), seat_release, thencancellation_refunds (refunds owed by cancelled events, deadline-bounded and
before any mail), seat_identity_nudge, the retention sweeps
(health_retention, support_retention, guest_review_retention,talk_question_retention, help_media, help_metrics,staff_applications_retention), role_expiry then access_review (so a role
that ended tonight is not counted), health_reminders, the onboarding mail
(membership_welcome, getting_started_nudge), help_guides_stale, thenotification_digest, and last the marketing campaign send.
The ordering is not cosmetic. maxDuration is 300 seconds and a
thousand-recipient campaign at roughly 200ms per send is 200 of them, so the
campaign pass is given a deadline measured from when the handler started,
stops when it reaches it, and resumes the next night from per-recipient rows
that carry their own status. Without that bound it eventually times out the
handler and takes event notifications down with it: a member not being told
their event was cancelled because a build update was going out is a far worse
outcome than a build update going out a day late. lib/email/send-campaigns.ts
carries the reasoning; two tests pin the ordering and the deadline.
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.
Guest-open events
Munches and meetups do not require membership. A stranger can find a guest-open
event, register with an email, accept the guest terms, hold a seat, and be
admitted at the door. Everything else stayed members-only.
A guest is a members row at a status that grants nothing.event_rsvps.member_id is not null references members(id), and the capacity
trigger, check-in tokens, waitlist promotion, the door roster, the address gate
and the email plumbing all key on it — a parallel guest table would mean
rebuilding all of that. It is also not a new idea: pending_payment andlapsed are already rows in that table that member_is_active() returns false
for. guest is a third.
That is the whole safety property. member_is_active() is status = 'active',
so messaging, the directory, albums, Dens, Affinity and every non-guest-open
event refuse a guest with no new code. We did not add doors that must each
be remembered; we added a person every existing door already refuses, and
opened exactly one. backend/tests/guest-identity-rls.test.ts is a wall of
assertions proving that rather than assuming it — every one paired with a
positive control, because a broken fixture satisfies all of them at once.
A guest is structurally absent from the directory, not filtered out of it.
They get no member_profiles row at all. A predicate can be got wrong once and
be wrong everywhere; a missing row cannot be selected by a policy that forgets
to exclude it. The cost is that the door roster's || 'Member' name fallback
caught every guest, so names come from guest_details and the check-in list
says GUEST · not a member, check ID beside them.
Five guards live in the database, because a check in one route is a check
the next route will not have — event_rsvps has no authenticated INSERT policy,
so every write is service-role and satisfies every policy by definition:
events_guest_access_open_formatsrefuses guest access on aplay_partyor
an event with no format. This is half the legal argument, expressed as a
CHECK: the private space is not something an admin can open by mistake.events_guest_access_is_generalrequires general admission, because guests
book a seat and are never asked a category.enforce_guest_rsvp_eligibilityrefuses a guest at a members-only event and
a revoked member anywhere.create_member_profile_for_new_memberskips guests.event_rsvps_interest_list_gate(20261104090100): before launch, a
non-member holds a place at a guest-open event only if their address is on
the early interest list. Active members pass. It raisesCH002, not 23514,
because every booking path reads 23514 as "full, waitlist them".
Before launch, guest places are for the early interest list (founder,
3 October 2026). lib/events/pre-launch-interest-gate.ts decides it from the
date, not a per-event switch: every event starting before LAUNCH_PROMO_CUTOFF
(the end of THE PURGE 2027, 2027-10-31T07:00:00Z) is covered, and the rule
switches itself off at launch. holdGuestPlace checks first so the person can
be told to join at /join; the anonymous route sends the join-first email
behind the same generic response and creates no account. The trigger's copy of
the cutoff is private.pre_launch_cutoff(), andpre-launch-interest-gate.test.ts fails when the two disagree, so moving the
launch again needs a migration redefining it.
The interest list stores addresses lowercased and trimmed
(pre_launch_signups_email_normalized, 20261104090000), and the signup
schema normalizes before insert. Every reader matches on that form: this gate,
the reviewer's interest-list panel, the seat booking-link request and the
conversion stamp on approval.
The registration route must never be a membership oracle. A stranger, an
applicant, a member, a lapsed member and a revoked person all submit the same
form and five different things happen; the caller can tell none of them apart.
The response is byte-identical from every branch including the failures and the
rate limit, tested by comparing serialised JSON with the status code. Any
difference answers "does this address belong to a CARNALIFE member", which is
the question couple_invites was built around never answering.
The reasoning, the guards, and what is still open for the founder and counsel
are written out in docs/GUEST-OPEN EVENTS - the argument and the guards.md.
Linked series
An arc — an opener, the night or nights, a landing — used to be several
unrelated rows, each firing its own week reminder, final reminder and
follow-up. Five slots times three kinds is fifteen emails to one attendee for
one weekend, against a Resend free tier of a hundred a day.
Events in a series send three between them. Exactly one event speaks for
each kind: the reminders anchor on the first night, because an arc is announced
once before any of it happens, and the follow-up on the last. Anchoring readsstarts_at and never series_position — position is what somebody typed into
a form, starts_at is what the calendar says, and a mistyped position must not
be able to send a reminder against the wrong night.
Each person is told only the slots they hold. Telling somebody who booked
Saturday to turn up on Friday is worse than telling them nothing.
A series groups communication and nothing else. No bundled price, no shared
capacity, no shared waitlist, and the address still releases per event on that
event's own date. series_id is on delete set null: dissolving an arc
ungroups its events and never deletes a weekend that happened.
Event waiver
Code of Conduct §11 has always said every member affirms the Code "separately
from and in addition to any event-specific waiver signed at check-in". No such
instrument existed anywhere in the schema. It does now, modelled onden_host_waivers down to the sha256, signable from the member event page, and
refused at the door rather than warned about — a soft warning on a door
worked at speed is a warning nobody reads.
The override is a real answer and not a loophole: somebody will arrive unsigned
and a paper form is how that night goes. It lands on the check-in row with the
name of whoever decided it, because an override nobody can trace is the same as
no requirement at all.
Arc pricing and guest payment
The event-ladder plan (docs/superpowers/plans/2026-08-30-event-ladder-to-launch.md) needed two prices where the schema had one, and a way for a guest to actually pay for a priced guest-open event.
Advance discount and unsponsored-guest premium are two different columns for a reason. advance_discount_percent / advance_pricing_ends_at (events) give any event a lower price before local midnight on its own start date -- the same instant its exact address releases, which is one rule to explain rather than two. unsponsored_guest_premium_percent is door-only: a guest with no advance place and no approved vouch or guest request pays more at the door, and applying for membership is what removes it. priceForCategory -- what every public listing, member page, and online checkout quotes -- never applies the premium; doorPriceFor is the one function that applies both, called from exactly one route (/api/admin/door/ticket). A guest booking online is an advance RSVP, the exact behaviour the discount rewards, so charging the premium there would punish the thing it exists to encourage.
A guest can pay for a priced guest-open event now, online and at the door. Neither path existed before: holdGuestPlace inserted a confirmed RSVP unconditionally, so a priced guest-open event admitted every guest for free. It now branches on price -- zero confirms or waitlists directly as before, anything above zero delegates to reserveAndChargeRsvp, the same reservation-and-charge helper member checkout uses. /api/guest/events/[id]/checkout is the online path (requireGuestOrMember, admitting exactly the four statuses a guest-open event admits and never revoked); /api/admin/door/ticket is the in-person one, gated on door staff rather than admin, mirroring /api/admin/door/dues. isSponsoredGuest checks event_guest_requests and event_fast_track_vouches for an approved row matching the buyer's email or member id -- no new table, since both already record exactly this.
Cancelling a paid guest-open place has to promote into a paid hold, not a free one. releaseGuestPlace used to hardcode isPaid: false on the reasoning that guest-open events are free, which stopped being true the moment the above shipped. It now decides per event, matching the pattern already established on the member cancel route: priceForCategory(event, category) > 0.
Cloning an event now carries every column, and joins no arc unless told to. clone-event.ts's COPIED_COLUMNS had never been updated for the format-and-rules migration, so a cloned play party came back with no event_type, no waiver_required, no barriers_required, no safer_sex_supplies -- every safety-bearing field, quietly reset. Cloning is the documented way to build an arc (/api/admin/events/[id]/clone -- author the main night, clone it, adjust), so this was the common path, not an edge case. backend/tests/clone-event-column-coverage.test.ts reads the live events column list and fails when a new one is not classified into copied, handled, or deliberately-not-copied. The same clone was also inheriting the source event's series_id, which would have filed a launch night in whichever arc it was cloned from; the caller now names the arc explicitly, or the copy joins none.
The door's "today" is an overlap check, not a calendar day. today-window.ts used to build its bounds from the server's own clock (UTC on Vercel, not ORG_TIME_ZONE) and a midnight-to-midnight window, which drops any event crossing midnight -- every main night in this arc format does. isRelevantToDoorNow checks starts_at <= now + 6h and ends_at >= now - 2h instead, applied to both /admin/door and its polling API route, so a night that started before midnight and a walk-up converting at the handoff both stay on the roster.
Purchased seats
A person who is not a member, and has never applied, can pay for a place at a
play party. They file a membership application in the same checkout, clear the
same Stripe Identity check every applicant clears, sign the same event waiver,
and hold a real seat against the same 20/20/18 as everybody else. They are still
not admissible: a human has to approve the application first, and if nobody
does, or the answer is no, they are refunded in full and the seat goes back to
the pool.
Why it is a second door and not a wider first one.events_guest_access_open_formats refuses guest_access on a play party, and
its own comment calls that constraint "half the legal argument". Widening it was
never on the table. events.seat_purchase_open is a separate column with its own
constraints, and events_seat_purchase_not_guest_open refuses an event that has
both. That keeps the position sayable in one sentence: at a guest-open event no
application is required but the format cannot be a play party; at a
seat-purchase event the format may be a play party but everybody in the room is
a member or under review. The full argument, and the pillar of the guest-open
one it breaks, is indocs/PURCHASED-SEAT APPLICANTS - the argument and the guards.md.
The warrant, and what makes it a gate. event_seat_applications is one row
per person per event linking them to their own application. RLS gives it noanon or authenticated write policy at all, so every write is service-role,
matching event_rsvps. private.member_holds_a_purchased_seat is what the RSVP
trigger consults, and it lives in private for the reason the visibility helpers
do: a function you can pass a member id to is not a gate. It requires the warrant
unreleased, the event published and seat_purchase_open, the application'sauth_user_id to match the member's own, and the application not to be denied.private.enforce_guest_rsvp_eligibility gained a third branch rather than a
wider second one, and private.member_can_attend_open_event is byte-identical
with a test that says so.
private.member_paid_for_event is narrower than its name, deliberately. It
used to return true for any confirmed RSVP with a settled payment, because until
this feature every confirmed paid holder was a member. It now also requires that
the asker does not hold an unreleased warrant on an unapproved application.
Without that clause the exact address of a play party, released at local midnight
on the event's own date (2026-10-31T04:00:00Z), goes to every unvetted stranger
twelve hours before the cutoff at 16:00Z takes their seat back. It is keyed on
the ASKER rather than on the RSVP, so on a couple booking the approved half is
not punished for the undecided one. The clause reads redundant and has no other
caller to make its purpose obvious. Do not simplify it.
The order in the purchase transaction is load-bearing twice.purchase-seat-and-apply.ts runs createApplication() before registerGuest(),
because registerGuest creates the auth user with admin.createUser andcreateApplication's signUp on that same address would then fail as already
registered. Reversed it works with no new code, because registerGuest already
documents this exact case. The warrant is written before the seat, because the
capacity trigger reads it. recordEventWaiver() is last, because the acceptance
row is immutable by trigger and a signature written before a failed reservation
can never be repaired.
Only the early interest list can buy, on the launch night.events.seat_purchase_requires_interest_list is per event and off by default.
Somebody not on the list is directed to join it rather than refused, and the
route never answers who is on it: seat_booking_invites carries a token the
email holds, so the page cannot be turned into a membership oracle by typing
addresses at it.
Couples buy as two applications against one payment. The buyer names an
address; the partner completes their own half from an invited link, with their
own Identity and their own waiver, and nothing about consent is inferred from the
fact that one of them paid. coupleRsvpStatusForRsvp had to be extended because
the original keyed on a standing couple membership link, which two strangers do
not have, and without it a seat-purchase couple sat atpending_partner_confirmation forever. A couple seat falls as one: either half
denied releases both, and the refund goes to the payer.
Giving the seat and the money back. releaseUnvettedSeats refunds FIRST and
does bookkeeping second, because a crash after the refund is recoverable and a
crash after the release leaves somebody out of pocket with no seat. It cancels
the RSVP BEFORE stamping released_at, because releasing the warrant makesmember_paid_for_event more permissive rather than less, and the cancelled RSVP
is what actually closes the address. processRefund's 409 on an already-refunded
payment is treated as success, so the pass is safe to run twice. There is a cron
pass and an admin button, because the cron fires at 14:00 UTC and THE PURGE
closes bookings at 16:00 UTC on 31 October.
The door cannot read isGuest alone any more. A seat buyer ismembers.status = 'guest', exactly like somebody who came to a munch, and the
two are not the same person at a play party. loadDoorAttendees carriesseatApplicationStatus and waiverSigned; the check-in list showsPAID / VETTING PENDING / DO NOT ADMIT or PAID / APPROVED / check ID INSTEAD
of the guest line, because both would contradict each other (one ends "check ID",
the other ends "DO NOT ADMIT"); and the scanner's refusal names the real reason.
"No longer active" is false about somebody who never was, and a wrong sentence
read off a phone at speed becomes a wrong decision. An approved buyer falls
through the dues branch, because approval writesmembers.status = 'pending_payment' and approval does not retroactively unsell a
ticket they already paid for. Revocation still outranks everything.
waiverSigned is boolean | null, and the null is the point: at an event that
asks for no waiver a plain false marks every row unsigned, and a warning that
fires on everybody is one the door stops reading by the fourth person.
Identity abandonment is the failure mode to watch. A stranger pays and is
then asked for a government ID and a selfie. buildLiveSnapshot countspaidIdentityNotStarted separately from paidIdentityPending, because those are
different problems with different fixes and one number hides which you have.nudgeSeatIdentity writes once, ever, after 48 hours, and the "once" is enforced
by email_deliveries alone rather than by a nudged_at column, because a second
record of it is a second thing to get out of step. The letter points at/apply/status, which now offers a real button for a not_started or canceled
applicant. It used to tell them "verifying your identity, this usually takes a
few minutes", which was never happening, and there was no way back to a working
Stripe link once the first had expired.
The paper roster is the only part of the door that survives no signal./admin/events/[id]/roster/print prints staff, then members and guests, then
approved seat holders, then paid-but-unvetted, then the waitlist. Paid-but-
unvetted are pulled OUT of the attendee list rather than marked inside it, andDO NOT ADMIT prints on the row, never in a legend. A couple prints as one row
with two names. Admin only, and audit-logged on render, because once it is on
paper it is outside every control this codebase has.
Two exports, and the split is deliberate. The attendee CSV is
display-names-only by design; the seat-applicant CSV
(/api/admin/events/[id]/seat-applicants/export) carries email addresses,
because its whole purpose is a conversation after the night. Folding them
together would put every attendee's address into a file people download to a
laptop and carry to a venue. Both build on loadDoorAttendees, which closed a
defect that predated this work: the attendee export resolved names frommember_profiles alone and fell back to ?? 'Member', and a guest has nomember_profiles row, so every guest exported as the literal string
"Member". It read member_id only as well, so the confirmed partner on a
couple booking was missing from the file entirely.
Released to nobody. /events/[id]/seat is registered released: false and
no event has seat_purchase_open = true. Flipping either is when money starts
moving. Refund policy v2 is published and promises a full refund on denial or an
undecided cutoff, which is why releaseUnvettedSeats shipped ahead of its place
in the plan: a promise on a live page with nothing behind it is worse than an
unbuilt feature.
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.
Marketing system
/admin/marketing and its sub-pages turn docs/MARKETING CAMPAIGN for Carnalife.md from a document into something that can be worked, scheduled and
measured. Five things are worth knowing before touching it.
Platform rules are columns, not prose. campaign_platforms holdsallows_event_promotion, allows_explicit_imagery, allows_exact_venue, the
cadence targets and a critical_rule per channel, each with asource_reference — because the Brand Bible and the campaign doc cover
different, non-overlapping platform sets and neither supersedes the other.
Rows exist for platforms CARNALIFE does not use (TikTok, Instagram) so that
"never promote an event on TikTok" has something to be checked against;is_active separates a channel we post to from one we merely know the rules
for. Three rules block at write time via a trigger with no override; everything
else, cadence and voice-register mismatch included, is a warning that never
blocks — an unescapable constraint in a one-person shop becomes a reason to stop
using the tool.
The calendar does not fan a post across platforms, because the campaign
doesn't. Each named post appears once, so reposts_placement_id points
placement to placement: "repost the FetLife content" means repost that
appearance, and the appearance is what holds a published_url.
A second trigger guards the assets. Checking only at placement time would
let somebody place a clean post and then attach an explicit image; an account is
suspended over an image, never over a paragraph. Uploads go through/api/admin/marketing/assets, which writes the row before storing the file
so the rule decides before anything lands in the private campaign-assets
bucket.
Attribution is first-touch and reads window.location in an effect, neveruseSearchParams() — that hook opts consuming routes out of static
generation, which the nonce-free CSP depends on. Targets and readings are
matched on a composite ${platform_slug}_${metric} key built bymetricKeyFor; matching the bare enum against marketing_targets.metric never
succeeded, which is what let the progress panel read "Never recorded" however
many readings existed.
Nothing here posts, and nothing sends without a bound. Section 18.1 requires
properly labeled adult content on X, which is a judgment no scheduler can make,
and a CHECK forbids an active post_content capability outright. Campaign email
carries an unsubscribe footer and a List-Unsubscribe header pointing at a
route handler rather than the footer's page, because RFC 8058 one-click POSTs
to the header URI and a page cannot answer a POST.
A campaign can be edited, unscheduled or deleted only while it is draft orscheduled. The status test lives in the update's WHERE clause, not in a read
first, because the send pass claims a campaign by moving it scheduled -> sending, and a read-then-write would race the cron. A sent campaign is never
editable: it is the record of what reached real people.
Carnal Dens
A marketplace for kink- and fetish-friendly venues — members post spaces and
rent them, including their own homes for a set period. /member/dens and/admin/dens.
The first increment shipped without the transaction. Listings, review and
enquiry work; rates, availability and payment do not exist yet. That is a gate,
not a scope decision: Legal Research line 115 says SOB ordinances define a
"sexual encounter establishment" as somewhere providing space "for any form
of consideration", and calls it the critical risk point for this
organisation. Renting a space is that, and renting a residence is that with the
volume up. Counsel has to clear it, and Stripe Connect has to be reconciled
with the CCBill failover requirement, before rent ships.
So there is no rate, fee, deposit or currency column in den_listings yet.
Adding one is not a small edit — it is the moment the §115 question becomes
live, and it should be taken deliberately.
The platform stores no address, and the absence IS the enforcement.den_listings has no address column, so nothing a later policy edit could
expose. Contrast venues, which holds real addresses and is admin-only for
exactly that reason — putting member listings there would have recreated the
event-address leak one table over. A host tells whoever they choose, directly.
Three limits worth knowing before you extend it: the address still lands inmessages in plain text; approval is of the host and the description, never
of the premises, because nobody can inspect a room they cannot locate; and a
safety incident at a den gives staff no address and no attendee list.
Area is a controlled vocabulary, unlike events.location_area. A host
typing freely writes their neighbourhood, and a listing plus a neighbourhood
pinpoints a house — Legal Research records a 2011 raid on a house in Seffner,
in this county.
Approval is enforced by trigger, and a material edit to an approved listing
resets it to pending along with the approval stamp. Otherwise an admin
approves one description and the member serves another. Members may submit and
withdraw; nothing else about status is theirs.
The waiver is append-only and hash-pinned, with each of its fifteen points
stored as its own row rather than one "I agree" — so no dispute turns on a
single checkbox. Rows rather than columns because the set changes with every
version: renting alone will retire the "no consideration" point and add
several about payment. The hash is computed server-side from the words this
deployment holds; a client-supplied one would prove only that the client can
hash. A host on a stale version goes invisible until they accept the new one,
enforced in the visibility predicate rather than by a page remembering.
One carve-out to immutability, and it is deliberate. §3 promises members "a
real way to erase their footprint", which an absolutely immutable waiver cannot
honour. Erasure wins and leaves a tombstone: identifiers and IP go, and the
row survives saying only that a waiver of that version was accepted on that
date. The carve-out is service-role only, listed columns only, and member_id
may move toward null but never to another member — there is a test for that
last one, because "erasure" would otherwise be a route to forging an acceptance.
Recording is a structured field on the listing, not only a waiver promise,
defaulting to "nothing records" the way events.photography_policy defaults tobanned. A private home routinely contains cameras nobody thinks of as
cameras, and a promise no guest can read protects nobody — so the host affirms
it and a guest sees it before enquiring.
An enquiry is a first message; there is no enquiry table. A permanent
structured record that member X asked to use member Y's home is exactly the
artefact "no money, no booking" means does not exist. Guards sit at the call
site, not inside ensureDirectThread, which checks nothing by design.
Not built yet, and each waits on the transaction rather than being unwanted:
rates and availability (a calendar before booking records nothing while
implying a reservation), reviews (rating a transaction is defensible in a way
rating somebody's home is not), and duplicate detection (needs an address to
match on).
Photos, and why they were last. A photo of a living room is an address to
anyone who knows the area, and nothing in a database can see that. So the
controls are the ones that can exist: EXIF comes off in the route, before
Storage (a phone photograph of a residence carries its address as
coordinates) and an undecodable file is refused rather than stored unstripped;
the bucket is private with no authenticated policy, so URLs are signed per
request and never stored; and adding or removing a photo sends an approved
listing back to review, by trigger, because otherwise the review step is
bypassed by approving an innocuous listing and then adding the pictures. What
is in the frame stays the host's judgement and the reviewer's — so the upload
copy names the four ways a room becomes an address rather than saying "be
careful", and the admin queue shows every photo beside the words it approves.
Renting is approved to build as of 12 August 2026, a founder decision
lifting the counsel gate. The risk it was gating is unchanged, not resolved:
Legal Research line 115 makes "for any form of consideration" the SOB
trigger, which is what renting is, and it lands hardest on a residence.
Direct charges on the host's own account, since 6 October 2026
(20261103090900_dens_direct_charges). A booking is charged on the host's
seller account, so the host is the merchant: they take the payment, issue
refunds from their own Stripe dashboard and answer disputes, and Stripe covers
a negative balance (losses_collector: 'stripe'). CARNALIFE's 12% is theapplication_fee_amount on that charge, which is platform_fee_cents
unchanged. Hosts and sellers share one seller account per member
(lib/selling/ensure-account.ts); see Sellers below for the account shape.
This replaced separate charges and transfers with hold-and-release, in which
CARNALIFE took the guest's money, held it until two days after checkout, and
was liable to Stripe for every dispute in between. Renting had never opened, so
the transfer columns and the payout-release pass were dropped rather than
migrated. Crew pay still uses that older shape, by transfer from the
platform balance, and there CARNALIFE remains the party Stripe holds liable
(see Get involved and crew pay, below).
Still one processor. CCBill has no Connect equivalent, so Dens is an
accepted exception to the processor-failover rule §1 calls a resilience must:
Dens rides on one processor where dues ride on two. Written down as an
exception rather than found during an outage.
Bookable means the host can take a card payment, read fromseller_accounts.card_payments_status, and the host has given the address
(20261103091000, entered through /api/member/dens/[id]/address). Without
the second condition a guest could pay, be confirmed, and find no address
where the page promised one.
Renting is still off, and a CHECK keeps it off. Every renting waiver so
far, den-host-draft-v1 through den-host-v4, tells the host that CARNALIFE
handles payment for stays, which direct charges made untrue. The database
refuses to open renting against any of them. Do not loosen that check: the
fix is a v5 waiver that describes direct charges, through counsel. After that,
opening renting is one UPDATE to den_settings.renting_waiver_version.
What the schema does:
den_bookings.stayis a half-opendaterangeunder abtree_gist
exclusion constraint, so two guests reading the same free calendar cannot
both book. Half-open because a checkout and a check-in on the same morning
are not a conflict — closed ranges cost a host a night on every booking.
Pending bookings are outside the constraint: an unpaid hold that blocked
dates would let anyone freeze a host's season for free.den_addressesisevent_addressesagain, and a confirmed, uncancelled,
unfinished booking entitles disclosure. Not pending — the same trap avoided
by refusing to reuse a helper matching "anything not cancelled".den_bookings.provideris constrained tostripe. A Dens charge failing
over to CCBill would take a guest's money with no connected account to route
it to, so the single-processor decision is unrepresentable rather than
remembered.- A guest who pays for nights somebody else has already taken is refunded in
full, automatically, on the host's account.
den-host-draft-v2 is written and pinned but not required. It retiresno_consideration rather than rewording it, since a spent point cannot leave
v1 without breaking the hash its acceptances were recorded against, and gives
new ids to the two points whose meaning genuinely changed.
Cancellation follows the published cancellation terms. Staff can cancel a
stay under them, and guest and host are both told, whoever cancelled
(lib/dens/notify-cancellation.ts). Every cancellation returns CARNALIFE's
whole fee, with one exception (founder, 1 October): when staff cancel a stay as
not as described, the guest is refunded only the unused nights and CARNALIFE
keeps its share of the nights used. That choice is recorded on the booking, in
a money column only CARNALIFE writes, because the refund webhook runs
afterwards and would otherwise return the whole fee.
The waiver text goes to hosts without counsel having read it — a founder
decision of 12 August 2026, over a recommendation to hold it. It follows the
Terms, which shipped the same way for the reason recorded in the plan's §8
fallback: provisional text at a canonical URL, version on its face, beats
legal text that is unversioned and buried in a form. A host reads that these
terms are provisional in the first line of the waiver, and both halves of that
are pinned by den-host-waiver-consistency.test.ts — the file must keep saying
it, and so must the words the host actually reads.
It changes nothing about what the waiver can do. It reduces contract risk
between CARNALIFE and a host; it does nothing about the criminal-enforcement
risk (a county is not a party to a private agreement), and it does not unblock
renting, which is gated separately. What makes it recoverable is that counsel
returning is a new version, not an edit to this one.
Sellers
Members and approved non-members sell through CARNALIFE on Stripe's platform
model: the seller is the merchant of record (founder, 1 October 2026).
Deployed 6 October (PRs #76 and #77, migrations 20261103090600 to20261103091400) and shipped closed.
Three things must all be true before anything is for sale, and the
storefront query checks them in the database, not on a page:seller_settings.selling_open (false until Stripe has confirmed in writing
that these categories are acceptable), a non-nullseller_settings.required_agreement_version (null until counsel and the
founder settle the seller agreement), and the seller's acceptance of that
version. lib/selling/can-sell.ts reads the same row for the routes.
The account shape. One seller account per member or identity
(lib/selling/ensure-account.ts, shared with Dens hosts), with card payments
and a customer configuration, a full Stripe dashboard, and Stripe as fees and
losses collector. Sales are direct charges on that account with
CARNALIFE's share as application_fee_amount: seller_settings.platform_fee_bps,
1200 (12%) by default. There is no seller subscription.
- Connected-account events have their own endpoint,
/api/webhooks/stripe-connect(orders, Dens stays and class tickets), and
seller account changes arrive as v2 thin events at/api/webhooks/stripe-accounts. Neither ever enters the ticket, dues or
failover chain. Their secrets are in Getting started. - CARNALIFE's fee is returned whenever the buyer is, in the same
proportion as the refund (feeToReturnCentsinlib/selling/order-events.ts). - Any edit to an approved listing sends it back to review, price and
session length included. - Storefronts are members only, at
/member/sellers/[slug]. A seller's URL
is a slug, never the Stripe account id. Buyers see their orders at/member/orders; staff review at/admin/selling.
Who sells what:
Members work in
/member/selling.Approved non-members (educators and vendors; Dens hosting stays members
only) sign in at/sellwith a one-time emailed link, never a password, and
are never members, so no member page or data reaches them. A seller
identity is the email an admin approved; approvals, agreements and the
Stripe account each belong to exactly one member or one identity. The link
is built fromSITE_URL, never the request's Host header (7056eb76).Educator classes stay events (
20261103091200): RSVP, capacity,
waitlist, check-in and the address gate are unchanged, and the ticket is a
direct charge on the educator's account viaevents.seller_account_id.
Stripe only, since CCBill cannot make a direct charge, and no referral
credit, which is CARNALIFE's money. Revenue reporting counts only
CARNALIFE's share.Private sessions with an educator (S7,
20261103091400): aseller_productsrow of kindsession, booked into slots the educator
publishes. A slot is taken by a paid order and nothing weaker, so an
abandoned checkout freezes nothing; a unique index allows one paid order
per slot, and the second payer is refunded in full. A slot's location is
read with the service role and released only to the buyer who paid.Digital goods (S8,
20261103091600, deployed 13 October, PR #83)
ship closed: whileseller_settings.digital_openis false the database
refuses a digital listing or file from anywhere. Images only (JPEG, PNG,
WebP, at most 4 MB), metadata stripped before storage
(lib/media/strip-image-metadata.ts), in the privateseller-digital-filesbucket with no member policy. Approval records the
file it approved (seller_products.approved_digital_file_id), and buyers
receive that file, never a replacement still waiting for review. A paid
buyer gets a one-minute signed link, five downloads per order, counted
under a lock (lib/selling/digital-files.ts).The REPORT Act hold (18 U.S.C. 2258A;
lib/selling/digital-holds.ts):
an admin holds apparent CSAM from/admin/selling/products, during review
or after approval. The file is never served again, admins included; the
listing is withdrawn and deactivated in Stripe, open checkouts close, and
admins and Trust and Safety get a critical alert. The responsible person
reports on NCMEC's own CyberTipline portal and records the report number
at/admin/selling/holds, where it is permanent. The file is kept a year
from the report; the database refuses to delete a held file or a hold. A
hold released before any report sends the listing back to review.replace_seller_digital_fileis service role only, andseed.sql
re-applies its revoke locally.
Walked end to end in Stripe test mode on 13 October: onboarding, a pickup
item listed, approved, bought, refunded (CARNALIFE's fee returned in full),
and a session booked with its location shown only to the buyer and its time
refused to a second member.
CARNALIFE's revenue from selling is the fee, never the price./admin/kpi shows a "Marketplace fees" card from kpi_marketplace_fees
(20261103091700): the application fees on seller sales and Dens stays,
what was returned on refunds, and what CARNALIFE kept. The sellers' and
hosts' takings appear only as context, labelled as theirs. Educator class fees
were already in ticket revenue. A seller order counts from paid_at; a Dens
booking, which has no paid time, from created_at once it has a payment. If
the read fails the card says so rather than showing zero.
Sellers are asked for a discreet statement descriptor on their selling
page. It is guidance only: a seller's full Stripe dashboard owns the
descriptor, so CARNALIFE cannot set or enforce it.
Still gated: Stripe's written approval before any real seller is
onboarded, since a violation can reach the platform account that dues and
tickets run on; the Florida tax and marketplace-facilitator question
(counsel); digital_open, which also needs Stripe's approval of digital
content and a named person who files CyberTipline reports; and coaches, which
wait for the Mastery merge. Connect is enabled on the live account (checked
13 October).
Get involved and crew pay
Team applications and venue introductions (20261103090000,20261103090300): public forms under /get-involved for monitors and door
staff, educators, event staff and back office, plus venue owners. An account
is optional and every form requires 21+. Admins decide at/admin/team-applications and /admin/venue-introductions, with CSV export.
An accepted venue introduction prefills a new venue. Accepting an admin-kind
application grants nothing; roles are still assigned on the member page.
Members offer help separately, at /member/get-involved.
A shift hands over the play-space address, because /member/shifts readsevent_addresses. So an accepted crew applicant gets a guest-status member row
only so they can be rostered, and a trigger on event_staff_assignments
(20261103090200) refuses to assign any guest without an accepted crew
application whose identity is verified as 21 or older on the event's own
date. Crew are held to at least the bar THE PURGE's one-night guests meet. A
revoked member cannot read the event behind a shift they are still rostered
on (5eab2ead: the crew migration had re-created that policy without
master's revoked check, and running later it won).
At the door, licensed security guards check IDs and search, as a vendor on
invoice and not crew. A CARNALIFE door attendant runs check-in, andrequireDoorStaff requires an active member, so a crew guest can never be the
attendant.
Crew pay (20261103090400, 20261103090500): each shift carries its pay,
and each payout is one row per shift, approved by an admin after the night
(/api/admin/events/[id]/crew-payouts). Contractors are then paid by Stripe
transfer from the platform balance to their recipient account inden_host_payout_accounts; payroll and invoices are marked paid outside. A
transfer cannot happen twice (lib/events/crew-payouts.ts) (compare-and-set on the attempt, an idempotency
key per attempt, and a per-payout transfer_group checked before creating),
and a Connect contractor is never marked paid by hand, so Stripe's 1099 counts
everything they were paid. Every payout also writes a real staff line intoevent_expenses.
Still the founder's: Stripe's 1099 settings, counsel on whether monitors
are contractors, the actual ratio and rate, a crew agreement (the guest
agreement the crew accept page records is worded for public socials), and
Connect enabled on the account.
Carnal Affinity
Matchmaking, at /member/affinity and /admin/affinity. Design:docs/superpowers/specs/2026-08-10-carnal-affinity-design.md.
Staff propose, both sides accept blind, nobody browses anybody. That shape is
not a preference: Website Plan §3 removed demographic filtering from the member
directory as an anti-stalking measure, and a browse of "compatible" members
would rebuild exactly that filter over exactly the population it protected. It
costs founder labour per introduction and does not scale past a few hundred
members. Revisit it deliberately when it stops fitting; do not let it decay into
a browse.
introductions has no member-facing SELECT policy, deliberately. The row's
keys ARE the two identities, so any policy letting a party read their own row
hands over the other person's id and can_view_member_profile() does the rest.
Members read through affinity_my_introductions() and answer throughaffinity_respond_to_introduction() — both SECURITY DEFINER, both returning no
member uuid, both deriving the caller from auth.uid() with no viewer parameter
to spoof. Adding a policy here would end the feature, and a test that checked
columns rather than uuids would not notice, so affinity-introductions-rls.test.ts
searches the serialised payload for the real ids.
A decline does not surface until the window closes (seven days). Recorded
instantly for staff, hidden from the other party until expiry, because an
introduction going dark on day three would otherwise read as a rejection and one
going dark on day seven as silence. The member-facing state is derived from the
two answers and the clock, never from the stored status.
Reasons are redacted where they are built, not where they are rendered. They
are frozen at proposal, so redacting at render would leave the identifying text
in the jsonb forever. A tag is named only if at least five opted-in members hold
it, and below affinity_settings.naming_population_gate (40) nothing names a tag
at all. Co-attendance is a boolean: a count is a fingerprint.
The open-incident flag never reads reported_by. It readsreported_member_id and involved_member_id only. Flagging whoever filed a
report would make reporting cost a member introductions, which teaches people
not to report — through a query that looks entirely reasonable. There is a test.
Eligibility is derived rather than swept: a profile paused until the 3rd is
eligible again on the 3rd with nothing having run. The affinity_expiry cron
pass only materialises staff-facing history, which is why the feature is correct
even on a night the cron does not fire.
Carnal Lifestyle: posts and who can read them
posts.visibility has four values. private and submitted are states a post
passes through (a draft, a draft in review); public and members_only are the
two audiences a live post can have. public is readable by the anonymous
internet. members_only is readable by active members only, throughprivate.member_is_active, so guests and lapsed, pending and revoked accounts see
nothing, and post_tags mirrors the same rule one for one.
Staff choose the audience every time a post goes live, and nothing is
preselected. Admin Publish, reviewer Approve and scheduling each requireaudience (lib/validation/post-audience.ts) and return 400 without it. On a
live post, publishing again is how its audience changes ("Move to members only",
"Make public"). Moving a public post to members only takes it off /lifestyle at
once but cannot recall copies search engines have already taken, and the editor
says so. The member submission form does not offer a choice.
A schedule carries its audience. scheduled_visibility sits besidescheduled_publish_at, and posts_schedule_pairing_check refuses one without the
other. The nightly publisher (lib/content/publish-scheduled.ts) reads onlyvisibility = 'private'. It used to read neq('public'), which with a second
live state would have moved a members-only post to whatever a stale schedule said.
Two sets of reads, and they do not mix. lib/blog/queries.ts:
getPublicPosts,getPublicPostBySlug,getTitleSlugMaprun on the anon
client and filtereq('visibility', 'public')./lifestyle, both sitemaps and
the BlogPosting JSON-LD use these, so a members-only post can never reach them.getMemberPosts,getMemberPostBySlug,getMemberTitleSlugMaptake the
caller's session client and filterin('visibility', ['public', 'members_only']). The explicit filter is not redundant with RLS: on the same
session RLS also returns the caller's own drafts and, for staff, every post./member/lifestyle, its post page and the member feed use these.
Member pages render wikilinks under /member/lifestyle (renderMarkdown(..., { wikilinkBase })), so a link between two members-only posts does not send the
reader to the public 404.
Not gated: the cover image. post-images is a public bucket, so a
members-only post's cover is readable by anyone holding its object URL. Title,
slug, body and tags are gated. Accepted by the founder on 25 September 2026 and
recorded in 20261013090000_posts_members_only_visibility.sql.
Admin page content editor
/admin/content/pages edits the static and landing pages section by section:
drag to reorder, save against optimistic concurrency, roll back to any earlier
version, publish and unpublish. /admin/content/pillars edits the eight pillar
pages through a form shaped to pillar_config rather than a section list. A
campaign placement can point at a published page instead of a retyped URL.
Code is in lib/pages/.
Five writes in it once silently did nothing, all the same bug. A write that
RLS refuses is not an error: it matches zero rows and returns cleanly. Each is
now either a service-role write behind an explicit check, or a read that can tell
"refused" from "absent". JSON-LD goes through one component, andlib/seo/jsonld-safety.test.ts stops a fourth hand-rolled copy of the escaping.
Vetting
Vetting follows docs/VETTING SOP for Carnalife.md: recorded background checks
and interviews gate approval, Stripe Identity checks a selfie and 18+, applicants
are matched against revocations, couple applications are linked, stalled
applications close overnight with a Reopen button, the launch backfill has a hold
queue (/admin/vetting-backfill), and members are re-screened annually and
re-vetted after a staff-filed incident. Both queues require the vetting_review
capability. References can be replaced and are reminded twice, and a referrer
can be credited after approval; see "References and referrals".
Responsive
Four tiers, and only four. Brand Bible §13.3: unprefixed is Mobile, sm: is Tablet at 640px, lg: is Desktop at 1024px, xl: is Wide at 1280px. md: and 2xl: exist in Tailwind and are not tiers this brand designs against; lib/brand/breakpoints.test.ts refuses them in class position.
Two things about that worth knowing before you reach for a breakpoint:
- The Tablet tier is 640–1023px, a 1.6× range served by one set of classes. A
sm:layout has to work on a large phone in landscape and an iPad in portrait. Design against both ends rather than one. If a component genuinely cannot serve both, that is a finding to raise — not a licence to typemd:. - The Wide tier needs almost no classes, and that is correct. §13.3 gives Desktop and Wide the same 12-column grid; Wide's only distinguishing clause is "content maxes at 1280px, centered", which
max-w-7xl mx-autoincontainerClassesalready does for every page.xl:appearing rarely is the tier working, not the tier being neglected.
The shared levers
Most responsive behaviour is bought once, in a helper, rather than page by page — which is why 97 of 118 pages needed no breakpoint of their own:
| Lever | Where | What it carries |
|---|---|---|
containerClasses() |
components/ui/containerClasses.ts |
the width vocabulary, and a gutter that grows for a landscape notch |
PAGE_RHYTHM |
same file | 32/48/64px vertical, instead of a flat 64 at every width |
Section |
components/ui/Container.tsx |
the same for hero and tight rhythms |
cardClasses() |
components/ui/cardClasses.ts |
16px padding on a phone, 24px from Tablet |
TOUCH_TARGET |
components/ui/tokens.ts |
the 44px minimum every control inherits |
Things that only fail on a device
Each of these was a real bug here, none of them visible in a test:
env(safe-area-inset-*)is0pxunless the viewport declaresviewport-fit: cover. The tab bar's home-indicator clearance was written, commented, and had never once run.100vhon a mobile browser is the URL-bar-collapsed height, so amin-h-screenshell is taller than the viewport and every page scrolls by that much. Usedvh.whitespace-pre-wrapdoes not break a long word. A pasted URL widens the container, and because the container is inside the page, the whole page scrolls sideways. Pair it withbreak-words.- A
flexrow whose children areflex-1compresses rather than stacking. Three price inputs sharing a 375px screen get about 95px each.flex-col sm:flex-row, and release any fixed widths inside the row at the same breakpoint. - A finger never hovers. Every
hover:affordance owes anactive:counterpart or it acknowledges nothing on touch.
Tables
overflow-x-auto stops columns being unreachable; it does not make a queue readable. Eight columns in a 375px pane means dragging sideways and back for every row, losing the header on the way.
So a wide admin table takes stack (on the Table primitive) or table-stack (hand-rolled), and each <td> carries data-label="Header". Below sm the row becomes a card and the cell becomes a labelled line. The label sits on the cell it names, so the value and its header are the same line of code and cannot drift.
Two rules if you add a column: add the data-label with the <th>, and remember a row opening with <th scope="row"> occupies column one — a sweep that counts only <td> will shift every label by one. lib/brand/table-stack.test.ts checks the first header is accounted for, which is the assertion that catches exactly that.
Not everything should stack. A month grid's columns are the days of the week; exemptions live in that guard with a reason written beside them.
What the test suite can and cannot tell you
jsdom applies no media queries. Every responsive assertion in this repo checks a contract with the stylesheet — that a class is used, that a rule still exists, that two files still agree — never rendered layout. SiteHeader.test.tsx says so in as many words.
That makes several contracts two-ended, and the guards pin both ends deliberately: .nav-wide is meaningless if globals.css stops styling it, and .tabbar-short-hide without .tabbar-short-pad leaves a 5rem gap at the bottom of every member page. Deleting either half would otherwise break a phone with a completely green suite.
A green suite means nothing has regressed textually. The device matrix in docs/LAUNCH RUNBOOK for Carnalife.md §5.1 is the only thing that means it looks right.
Design system
The Brand Bible's sections 10 through 21 are the design contract, andfrontend/components/ui/ is where they became code: around thirty
primitives — Button (with a real loading state), Card, Alert, Badge,Avatar, Table, ProgressBar, Skeleton, EmptyState, PageHeader,Icon, Photo, ButtonLink, StatusNote, Spinner, Breadcrumb,NavLink, CollapsibleSection, FilterNav, BackLink, CommandPalette,ConfirmDialog and the form set.
The guards in frontend/lib/brand/ are the part that keeps it from decaying,
and they matter more than the components:
| Guard | What it refuses |
|---|---|
palette-discipline |
a colour that is not a token |
opacity-discipline |
opacity used where a surface token belongs |
contrast |
AAA is computed from the palette, not asserted |
tokens |
a utility class naming a token that does not exist |
no-local-primitives |
a hand-rolled button/card where a primitive exists |
table-scroll |
a table that cannot scroll on a phone |
field-labelling |
an input whose only label is its placeholder |
house-style |
em/en dashes, "customer", gendered founder pronouns |
route-conventions |
a page outside the three shells |
photography |
an image without the licence fields §14 requires |
Each carries an explicit allow-list where an exemption exists, with the reason
written next to it — an exemption without a reason is how a rule quietly stops
applying.
Three things worth knowing before writing a guard or a sweep of your own:
- A guard that reads source text will eventually read itself. Three separate
guards had to be narrowed from matching a mention to matching a usage,
because a sweep rewrote prose inside its own comments. - Tailwind emits no rule for an unknown utility, and never warns. So a
misspelt or undefined class fails as "inherits", which always looks
intentional:text-functional-errorproduced zero CSS in seventeen places, and
seventeen error messages were simply not red. That is whattokens.test.ts
exists for. - Tailwind scans source for complete class strings, so a class name assembled
at runtime (cls.replace('border-t-', 'border-l-')) compiles to nothing at all.
Fonts are committed as static TTF in frontend/assets/fonts/, under the SIL
Open Font License, and both halves of that are deliberate. next/font/google
leaves only hashed .woff2 under .next and satori cannot read woff2; and
satori renders a variable font at its default instance while ignoring the
weight axis, so a variable Fraunces silently comes out at the wrong weight.
Share cards (lib/seo/og-card.tsx) are generated per route, reading the
palette out of globals.css at build time so the card cannot drift from the site
it advertises. Note the known intermittent build failure documented in that
file's header: next build dies roughly one run in three with colourspace: parameter space not set, on a different route each time, because @vercel/og
prefers sharp over its own bundled resvg whenever that import resolves — andsharp is a real dependency here (lib/media/strip-image-metadata.ts strips
EXIF from member uploads with it). Only ever seen on Windows. Retry the build.
Every file a share card reads is named by a literal path:join(process.cwd(), 'assets/fonts/Inter-Regular.ttf') at the call site, never
parts spread into a helper. A path Turbopack cannot resolve statically makes it
trace the whole project, public/ included, into the server output of everyopengraph-image route, and it says so only as a build warning. Do not reach forturbopackIgnore instead: that drops the fonts from any card rendered at request
time. next.config.ts also pins turbopack.root to frontend/, because root
detection walks up looking for a lockfile and found a stray one in the home
directory.
Navigation
frontend/lib/nav/routes.ts is the single source of truth for every route in
the app, and it replaced four independent hardcoded lists that nobody kept in
sync — PUBLIC_ROUTES, NAV_LINKS in SiteHeader, three arrays inSiteFooter, and NAV_CATEGORIES in AdminNav. Pages fell out of navigation
silently under that arrangement; /member/billing, a paid feature, was reachable
from no navigation at all.
One RouteEntry per page carries its slots (which navigations it belongs in),visibleTo (which roles), pillar (its signal colour), indexable, andreleased. PUBLIC_ROUTES and TRACKED_PATHS are derived from it, with
their previous values preserved exactly.
The registry is enforced by the filesystem, not by discipline.lib/nav/routes.test.ts walks every app/**/page.tsx, derives its URL, and
fails unless the page is registered or listed in UNLISTED with a written
reason. A page cannot be added without registering it or saying why not.
Other pieces:
lib/nav/is-active.ts— one definition of "current".'section'mode
resolves the longest registered match, which is what stopped five links on/admin/marketing/*reportingaria-currentsimultaneously./sitemap— role-gated and interactive. Anonymous visitors see public
routes only, members see theirs, staff see the admin tree through the samevisibleTofilter the sidebar uses. Published blog posts are included, so
"automatically updates" is true of content and not only of routes.- Cmd-K command palette — filters the same already-role-filtered array the
sidebar renders, deliberately performing no second permission check.
The release switch
nav_overrides (migration 20260925090000) holds deviations from the
registry, never the whole list — so adding a page needs no row and cannot be
forgotten into invisibility, and deleting one leaves at worst an orphan row
rather than a nav entry pointing at a 404. If the query fails, the app falls
back to the compiled-in defaults, so an outage can neither publish an
unreleased pillar nor black out the navigation.
released: false means two different things, and the difference is
load-bearing:
- Member routes are closed.
proxy.tsredirects the page to that pillar's
public page and 404s the API routes underneath it.lib/nav/release-gate.ts
derives/api/member/xfrom/member/x, and its test walksapp/api/member
and fails if any route escapes a prefix — an un-gateable API route would stay
open while its pillar read as shut. - Public and admin routes are only unadvertised. The page still answers to
anybody holding the URL. Closing a public page with a wayfinding control would
delete marketing by accident, and closing an admin one is how an admin locks
themselves out of the panel they are standing in.
Carnal Dens and Carnal Affinity are currently switched off for members under
this, with their public overview pages deliberately left open. Admins flip either
from Operations → Settings → Navigation links in the admin sidebar, with no
deploy.
Neither meaning is a permission. Who may enter is still decided by RLS and by
the role gates in proxy.ts; a disabled row shuts a door and never opens one.
The write policy is admin-only and is worth treating as security-relevant: a
member who could write {'/member/dens', enabled: true} would release a pillar
the organization has not released.
Two traps if you touch this:
/memberis exempt by exact path, not by prefix. It is a registry entry
carrying no pillar, so closing it would redirect/member→/member; and
putting it in the prefix-matchedNEVER_GATEDlist instead would exempt every
member route in the app, leaving the gate reading as installed while doing
nothing.create policyhas noIF NOT EXISTSin Postgres. Guard a policy
migration by dropping each policy before creating it, or a run that stops
halfway can never be completed — every retry dies on 42710 at the same line.
States
Every navigation control implements the same seven states, and they are a class
contract rather than a matter of taste: default (--text-label), hover
(brightness, never a colour swap, per §21.1), focus (the existing ring floor),
pressed (active:scale-[0.98]), current (aria-current plus weight and surface,
never colour alone), expanded/collapsed (aria-expanded + aria-controls), and
contains-current — a collapsed group whose subtree holds the current page is
marked. That last one is what makes collapsing safe: a group that hides where you
are is worse than one that does not collapse.
Brand Bible §6.2 has one exception, and only one. In navigation labels a
sub-brand may show its second word alone ("Revelry"), provided the full name is
still announced through aria-label — which WCAG 2.5.3 permits, because the
accessible name contains the visible label. The registry carries both forms aslabel and fullLabel. Footers, sitemaps, headings, prose, email, print and
speech all keep the full two words.
The admin landing page
/admin was a redirect until 16 August 2026: it sent admins to /admin/posts and everyone else to /admin/applications. Two things were wrong with that. Signing in put a founder on the blog CMS, and a signed-in door staffer was shown the login form — they are not admin, so they went to the applications queue, which runs requireStaff, gets null, and redirects to /admin/login.
It is now a page answering two questions kept deliberately apart:
- What needs you — work waiting on a person. Applications leads with the unclaimed count rather than the total, because that is the number that says whether anybody has picked the work up.
- Site health — things that fail without saying so: failed and never-sent email, notification batches that stopped halfway and burned their claim, broadcasts with failed recipients. This strip exists because the sending domain was once unverified and every email the project had ever sent was silently rejected, with
email_deliveries.statusthe only place the truth appeared and nothing reading it./admin/deliveriesis where those counts open.
null is not 0, and that is the whole safety model. den_listings, email_deliveries, the marketing tables and reconciliation are is_admin_member()-gated, so a reviewer querying them gets count = 0 rather than an error — and a silent zero reads as "nothing to do", which is worse than a failure because nobody investigates a clean queue. lib/admin/dashboard-summary.ts returns null for those fields when the caller is not an admin, and the page renders no tile for a null. Gate a tile on the viewer's role, never on whether its count came back empty.
Door staff get their own landing — tonight's surface and a way into check-in — rather than a page whose every tile would be hidden.
next, and the one gate it goes through
safeAdminRedirect in lib/auth/mfa.ts is the only place a next parameter may become a destination, and lib/auth/admin-redirect.test.ts enforces that nothing else builds one. An unsanitised next on a login form is an open redirect, and a login form is the worst place in an app to have one: the link sends a staff member elsewhere immediately after they type a password, and the domain in the address bar was ours right up until it was not.
It rejects schemes, hosts, protocol-relative //, backslashes and query strings. The last of those means a deep link keeps its path and loses its filters, which is the right trade — loosening the pattern to carry a query is how a second redirect gets smuggled through the first.
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 |
push to master, and every PR (not both, since 23 September 2026) |
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), an application-status page, and the money-terms pages — /billing-terms and /refunds (added 1 September 2026), rendered from lib/content/legal/ drafts with billing-and-refunds-consistency.test.ts pinning each page to its source, the same pattern the Dens waiver and cancellation policies use. They were first published marked as drafts not read by counsel; the current versions (refund policy v6, billing terms v3) carry no such marker, and billing-and-refunds-consistency.test.ts refuses "provisional" or "counsel" on the rendered pages. A later guard also keeps dashes out of every rendered legal page. They mostly publish rules that already existed and had never been stated together (membership cancellation stops renewal without refunding the paid term; tickets sold no-refund; Dens bookings keep their own tiered policy and are deliberately not restated, since a booking is governed by the version it was made under). Two clauses were new and were confirmed by the founder on 1 September 2026 rather than inherited: if CARNALIFE cancels, ticket holders are refunded, and safety is never a fee.
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-review member blog (reuses the admin Markdown editor; staff decide at approval whether a post goes out public or to members only), 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 landing dashboard at /admin (see below), 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, and a written reason is required — the audit records who and what, the reason is the why) for changing rank (member/reviewer/admin) and the is_door_staff/is_trust_safety_officer capability flags; a role builder (/admin/roles, admin-only permanently — there is deliberately no capability conferring the power to hand out capabilities, since one would be a self-escalation primitive) for composing named roles from a fixed catalogue of capabilities the database enforces, with a matching panel on each member's detail page for assigning them (see "Capability layer" above for the fifteen keys and what each grants); a tag-assignment surface (/admin/tag-assignment, reachable by staff or by anyone holding the assign_staff_tags capability alone) for searching a member and setting their staff tags without the full member-360 page's other staff-only reads; 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 (since 27 September a submission holds several documents; see "Health verification"); 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 18 August 2026: admin-gifted membership time (lib/membership/gift-membership-time.ts, a panel on /admin/members/[id], migration 20261003090000_membership_gift_time.sql) — an admin can extend a member's term by hand, for comping a founding member, making good on an outage, or settling something at the door. It moves the membership term only and never the Stripe subscription: a gift that silently altered billing would either double-charge or quietly cancel a paying member, and neither failure announces itself. The two clocks are deliberately separate and nobody has yet decided how they reconcile. Gifted months surface in /admin/kpi so they do not read as revenue.
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.
Added 27 September 2026: several documents per health verification, and health-gated events (see both sections above). Member: the profile panel's draft, add, remove and submit, and a gated event's requirement on the event page. Admin: the rebuilt review queue, a "Health-gated events" panel on Settings, the gate and window on the event editor, "not eligible" and the event credit at the door, and the attendee advisory on the event page. Cron: health_retention and health_reminders. Gating and the advisory are both switched off.
Added 27-28 September 2026, merged and deployed 28 September (migrations 20261016090000 through 20261016090600, applied to production first): opt-in notifications and references and referrals iteration 2 (see both sections above). Staff: /admin/notifications and its settings page, a bell in the admin shell, the "application ready for review" alert, a four-state Referrer panel that can credit after approval, and a Replace reference form with previous references listed. Members: /member/notifications/settings, stored notices in /member/notifications, and a settings link at the foot of optional emails. Cron: reference_reminders and notification_digest.
Added 22-23 September 2026: purchased seats (see "Purchased seats" above). Public: /events/[id]/seat and /events/[id]/seat/partner, both released: false and both noindex. Admin: a seat_purchase_open toggle on the event editor, a Vetting column and a "checking out now" section on the attendee roster, a printable roster at /admin/events/[id]/roster/print, a seat-applicant CSV at /api/admin/events/[id]/seat-applicants/export (its own audit action, because unlike the attendee export it carries email addresses), and a button on the event page that refunds and releases every undecided seat. Cron: a seat_release pass and a seat_identity_nudge pass. Nothing is reachable by anybody yet.
Added 29 September to 5 October 2026 (every migration through 20261102091000 is in production; .claude/memory.md has the detail and the decisions):
- Site issue reports and support replies. A public
/reportpage (footer, and every error and not-found page) files a support ticket from anyone, with an optional screenshot and an email that is only ever answered once its owner confirms it. Staff reply from/admin/support-tickets/[id]; members read their tickets and replies at/member/support. Resolved reports lose their screenshot and email after 90 days unless a staff member places a legal hold. Cron:support_retention. - Launch guests for THE PURGE. Where an event sets
seat_purchase_allows_guests, a seat buyer can come as a one-night guest (applications.kind = 'event_guest'): verified identity, 21+ on the event date, waiver, interest list, a person's approval, and no membership. Before the launch ends (LAUNCH_PROMO_CUTOFF, 3 AM EDT on 31 October 2027, moved from 1 November 2026 when the founder postponed the launch on 12 October 2026), every guest place also needs the early interest list. A seat buyer's ticket lives at/events/[id]/ticket. Cron:guest_review_retention. - Staff roles. Ten more capabilities (see "Capability layer"), role end dates, the staff agreement at
/admin/agreement, door and floor shift access, the door guest-list download,/admin/floor,/admin/payments,/admin/roles/holders, one-click role templates, and converting the door-staff flag to a role. Cron:role_expiry,access_review. - Member help. Public
/how-it-works; the Help Center at/member/help(switched on in production) with 56 guides and a "?" on every member page;/admin/content/helpwith images, callouts and insights; a getting-started checklist, page tips, "New" notes, tours, a welcome email on first activation and a day-7 reminder. Browser tests infrontend/e2e(npm run e2e). A member feature change updates its guide in the same change (frontend/AGENTS.md). - Get involved. Members offer to help at
/member/get-involved; admins decide at/admin/staff-applications. Accepting grants nothing by itself. - Code of Conduct agreements (
code_of_conduct_acceptances): recorded per member per version with a fingerprint of the exact words, andproxy.tssends a member who has not agreed to the current version to/member/code-of-conduct. - Cancelling an event refunds every paid ticket automatically (inline, then the nightly cron), with a Retry refunds button on the event page. The refund policy (v6) and billing terms (v3) say so.
- Smaller: talk questions (
/ask,/take-home,/admin/talk-questions), referral banners with each member's own link on the dashboard, a flyer prompt on the admin event page, venues limited to the eleven Tampa Bay areas with recording and accessibility fields, reportable Lifestyle posts, and unpaid helpers able to work their own shift.
Added 6 October 2026 (PRs #76 and #77; migrations 20261103090000 to 20261103091400, applied to production first):
- Public get-involved forms under
/get-involved(monitors and door, educators, staff, back office, venues), decided at/admin/team-applicationsand/admin/venue-introductions, with the crew ID gate and crew pay (see "Get involved and crew pay"). - Sellers on the platform model, shipped closed:
/member/selling,/sellfor approved non-members, members-only storefronts at/member/sellers/[slug],/member/orders,/admin/selling, educator classes and private sessions (see "Sellers"). - Carnal Dens on direct charges, with the host's address required before a space is bookable, staff cancellation under the terms, and cancellation notices to both sides (see "Carnal Dens").
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 (public and members-only, linked into /member/lifestyle), 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, unless the production build refuses. Since 5 October a prebuild step (scripts/check-migrations-applied.mjs) fails any production build whose migrations production has not recorded, so the previous deployment stays live; apply the migrations, then redeploy (LAUNCH RUNBOOK §0). 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 capabilities, which are independent of rank — any member, including a reviewer, can hold one without it affecting their rank-based access. is_door_staff and is_trust_safety_officer were the first two, hard-coded as columns before there was a catalogue to put them in; the general capability layer (has_capability(), fifteen keys, admin-defined roles — see "Capability layer" above) now enforces every key, including door_check_in, which sits beside the still-working is_door_staff column rather than replacing it. 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.
A function the app calls through the service role is the other case, and there revoking is exactly right. set_notification_preference, backfill_member_notification_preferences, link_referrer and replace_application_reference live in public so the server can call them by RPC, take the actor or target as a parameter, and are referenced by no policy, so each migration does revoke execute ... from public, anon, authenticated and grants only service_role. Re-apply that revoke at the end of backend/supabase/seed.sql: the seed's blanket grant all on all routines runs after migrations on every local reset and silently undoes it, so a test asserting the revoke fails locally while production is fine, which is the direction that invites someone to weaken the test. The same goes for a column-level revoke (the notifications inbox lets a member update read_at and nothing else).
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, or a capability-aware guard such asrequireAdminOrCapability,requireDoorAccessorrequireAal2), never trusting client-supplied identity.app/admin/gate-coverage.test.tsfails if a new admin page or route calls no guard. - A route that reads through the service role on behalf of a capability holder has no RLS behind it: its guard is the only control. Return computed numbers or display names (
lib/members/display-names.ts), never member emails, and pin with an RLS test that the holder's own session reads nothing. - A change to a member-facing feature updates its help guide in the same change; tests fail if a member page has no guide or a guide quotes a button label that no longer exists.
- 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.


