feat(docs): add a Diátaxis documentation section to the public site

A new public /docs area covering the whole platform, organized by Diátaxis:
tutorials (learning), how-to guides (tasks), reference (facts), and explanation
(the why). 29 pages, each grounded in the actual routes, actions, schema, and
UI — verified against the code and corrected for accuracy (e.g. admin-only vs
admin+board gating, where the permissions matrix actually lives, how Stripe
reconciliation matches).

Implementation:
- lib/docs/nav.ts is the single source of truth (categories + pages), driving
  the sidebar, static params, and prev/next.
- Content authored as content/docs/<category>/<slug>.md, read at build time
  (the routes are statically generated), rendered by <DocsMarkdown> — reuses the
  existing react-markdown/GFM stack, no new dependencies.
- (docs) route group with its own theme (docs.css) matching the public site's
  dark / monospace / lime aesthetic; sidebar with active-state, breadcrumb,
  prev/next, and a Diátaxis home. Public — signed-in users can read it too.
- "Docs" link added to the landing nav.

typecheck 0 · lint 0 errors · build ok (all /docs pages prerendered)
This commit is contained in:
Moheeb Zara
2026-08-05 00:42:42 -07:00
parent fc6adaf16e
commit 16229ae523
38 changed files with 2928 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
hackerspace.sh is a Next.js 16 App Router app written in TypeScript, backed by Supabase (Postgres plus Auth). Every space is a tenant, and the whole platform is built around one idea: reads flow through row-level security, and writes flow through server actions that authorize and scope themselves. This page explains that shape and why it is drawn this way.
## The stack
The app is a single Next.js codebase. The UI is React Server Components under `app/(app)/`, the database is Postgres managed by Supabase, and authentication runs on Supabase Auth (GoTrue). There is no separate API service — the browser talks to Next.js, and Next.js talks to Postgres. In production, both the app and a self-hosted Supabase stack run on one DigitalOcean droplet behind Caddy; see [Deployment](/docs/explanation/deployment) for how that is provisioned and how `main` deploys on every push.
## Route groups
The `app/` directory uses App Router route groups — parenthesized folders that organize routes without adding a URL segment:
- `app/(app)/` holds the authenticated product: `/members`, `/customize`, `/proposals`, `/door`, and the rest. Its `layout.tsx` is the gate every one of those screens renders inside.
- `app/(landing)/` holds the public marketing page at `/`.
A screen under `app/(app)/<route>/` follows one convention: a server `page.tsx` fetches data and runs its guard, then hands the data as props to a thin `<route>-client.tsx` orchestrator that composes `panels/` and `components/`. Authorization and data-loading live in the server component; the client component only orchestrates interaction.
## Three Supabase clients
There are three ways to reach Postgres, and choosing the right one is the core security decision in any change.
| Client | Factory | Key | RLS | Use for |
|---|---|---|---|---|
| Server | `lib/supabase/server.ts` → `createClient()` | anon | Enforced | Server components and most server actions |
| Browser | `lib/supabase/client.ts` → `createClient()` | anon | Enforced | Auth (login/signup) and the comms realtime subscription only |
| Admin | `lib/supabase/admin.ts` → `createAdminClient()` | service role | **Bypassed** | Trusted server-only work that must cross a normal RLS boundary |
The server client is cookie-bound: it reads the caller's Supabase session from cookies and runs every query as that user, so RLS decides what rows come back. The browser client uses the same anon key and is deliberately confined — client components never write to the database directly, because a direct browser write skips validation, the authorization gate, the activity log, and (for encrypted data) encryption.
The admin client uses the service-role key and bypasses RLS entirely. That makes code-side scoping non-negotiable: **every read, update, and delete on the admin client must be constrained with `.eq('space_id', member.space_id)`** (or an id already proven to belong to that space). RLS will not catch a mistake there — the scope is the only thing standing between a query and a cross-tenant leak.
## The request path
A request to an authenticated screen passes two gates before it renders:
1. `proxy.ts` at the repo root is the Next 16 middleware (the framework renamed `middleware` to `proxy`). It refreshes the Supabase session cookie on every request and checks the path against a `PUBLIC_ROUTES` allowlist. Anything not on the list requires a session, or the request is redirected to `/login`. Public routes — webhooks, cron endpoints, the `/f/` public forms, `/track`, the resources subsite — are exempt from the session check because each one self-authenticates with its own signature, bearer secret, or token.
2. `app/(app)/layout.tsx` is the second gate, defense in depth. It re-checks the user with `auth.getUser()`, loads the active `space_members` row, and redirects to `/signup` when there is no space or `/onboarding` when onboarding is incomplete. It is marked `force-dynamic` so it never caches — the membership check runs on every request.
## Server actions are the write path
Every mutation is a `'use server'` action under `lib/actions/`, and they all follow one contract:
```ts
const v = parseInput(schema, input) // 1. validate with Zod
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES) // 2. authorize
if (!auth.ok) return { error: auth.error }
const { member } = auth
// 3. mutate, scoped by member.space_id
await logActivity(supabase, member, 'updated', 'thing', id) // 4. audit
revalidatePath('/things') // 5. revalidate
```
The shared helpers in `lib/auth-helpers.ts` make that uniform: `parseInput` runs a Zod schema and returns a discriminated result; `requireMember` loads the caller's active membership; `requireMemberWithRole` additionally checks role and rejects a member whose status is `unverified` (pending approval holds no privileged capability); `logActivity` writes an advisory row to `activity_log`. `getAuthMember` selects the single active membership with `.single()` — the platform is one active space per user today, and that call fails closed rather than guess if a user somehow has two.
## Why it is drawn this way
The design exists to make cross-tenant isolation hard to get wrong. RLS on all tables is the backstop, but RLS alone is fragile: the moment code reaches for the admin client to do something legitimate, the backstop is gone. So the platform pairs RLS with an explicit, repeated pattern — validate, authorize, scope by `space_id`, audit, revalidate — that a reviewer can check at a glance and a test can lock in. Keeping writes in server actions rather than the browser makes that pattern the *only* way data changes, so validation, permission checks, and the activity trail can never be skipped. The uniformity is the point: every feature is built the same way, so the security-critical steps stay boring and visible.
@@ -0,0 +1,41 @@
Most hackerspaces collect dues through tools they already use — PayPal, Venmo, Zeffy, cash, and increasingly Stripe. Reconciliation is the work of turning that scattered money trail into a trustworthy answer to one question: is this member paid up? This page explains the two ideas that make that answer safe — dues that only ever advance, and payments that link to members by hand or automatically.
## Two separate ledgers
The platform keeps money and membership state in two places on purpose.
A row in `payments` is an immutable record of a transaction: an amount, a `platform` (`paypal`, `zeffy`, `venmo`, `cash`, or `stripe`), a `transaction_date`, and identifying scraps like `from_identifier`, `from_note`, and `payer_email`. It answers "money moved."
A member row answers "membership state" — the `status` enum (`current`, `late`, `inactive`, `unverified`), a `payment_status` field, and `last_paid_at`. Reconciliation is the bridge between the two: attaching a payment to a member, then letting that payment update the member's dues state.
Keeping them separate means a payment is never rewritten to fit a member. See the [payments & dues reference](/docs/reference/payments-and-dues) for every field.
## Why dues only advance
The core rule lives in `resolveDuesAdvance` (`lib/dues-payments-logic.ts`): a payment may move a member's dues state *forward* in time, never backward.
When a payment is applied, the logic compares the payment's `transaction_date` against the member's existing `last_paid_at` and keeps whichever is later. Only when the incoming payment is genuinely the most recent one on file does it also set the member's `payment_status` to `current`. A payment older than what is already recorded changes nothing — it returns the existing date and leaves the status untouched.
This matters because reconciliation is frequently retroactive. A treasurer imports six months of PayPal history, or belatedly links a cash payment from March. If applying an old payment could overwrite `last_paid_at`, a routine cleanup would rewind a member who is actually current back to "late." Advance-only makes the order of data entry irrelevant: you can link payments in any sequence and the member's standing always reflects their most recent real payment.
The comparison is done by instant, not by string, so timestamps from different sources (a PostgREST `+00:00` offset versus a JavaScript `Z` suffix) never disagree about which came first.
## Hand-matching and auto-matching
An imported or logged payment starts `unlinked` — its `link_status` is `unlinked` and it has no `member_id`. The `link_status` enum has exactly two values, `linked` and `unlinked`, and it is the canonical signal of whether a transaction has been reconciled. (An older `status` column mirrors it for backwards compatibility.)
**Hand-matching** is the treasurer's job. On [/payments](/payments), the unmatched pile shows each payment's amount, platform, and `from_identifier`, and the "Link to Member" action lets a treasurer, board member, or admin pick the right person. Linking sets `member_id` and flips `link_status` to `linked`, then runs the same advance-only logic so a historical payment cannot rewind dues. See [connect payments & reconcile dues](/docs/how-to/connect-payments) for the walkthrough.
**Auto-matching** happens for Stripe. When a Stripe invoice is paid, the webhook resolves the member automatically — from the subscription's `member_id` metadata, or by mapping the Stripe customer id to a member's stored `member_billing.stripe_customer_id` — and writes the payment already `linked`. The payer's email is recorded as `payer_email` only so a human can verify the match; it is never the match key. The member is identified from identifiers Stripe already carries, so no manual linking is needed.
Everything else is deliberately manual. Imports and cash payments land as `unlinked` rather than guessing, because a wrong auto-link silently credits the wrong member.
## Who is allowed to write dues state
Because dues state gates access and standing, only trusted actors may touch it. Migration `044` blocks a member from changing their own `payment_status`, `dues_paid_until`, `last_paid_at`, and related financial columns — even though row-level security otherwise lets members edit their own profile. A member cannot forge a "dues good" signal by writing directly to their row.
The only legitimate writers are the Stripe webhook and treasurer actions running as a privileged service client, or a privileged member (admin, board, or treasurer) editing *another* member. The [members reference](/docs/reference/members) covers how status flows through to access.
## Pay-here links, not payment processing
The `dues_payment_methods` table stores admin-configured pay-here links for PayPal, Zeffy, and Venmo — the URL a member clicks on [/me](/me) to pay off-platform. The platform never handles that money; it only hands the member a button. Because that URL is admin-entered and shown to members, it is constrained to absolute `https://` at three layers — a database `CHECK`, the admin form in [/settings](/settings), and one more check (`isSafeDuesUrl`) at the boundary that renders the clickable card — so a bad value can never become a downgrade or injection vector. After the member pays through the link, the transaction still comes back as an `unlinked` payment for a treasurer to reconcile by hand.
@@ -0,0 +1,42 @@
Governance in a hackerspace is mostly procedure: who gets to decide, how many people have to weigh in, and what counts as a pass. The platform's governance kernel encodes those procedures as data so they can't be argued about after the fact. This page explains why quorum, thresholds, recusal, anonymity, and SLAs are modeled the way they are.
The whole design follows one rule: **privacy default for individuals, transparency default for institutions.** A member's identity when reporting harassment is protected; the vote a board member cast on a proposal is public forever. Every schema choice below falls out of that principle plus a second one — no hardcoded politics. Quorum percentages, voting windows, thresholds, and SLAs are per-space **DB defaults** — columns on the `spaces` table (`default_quorum_percent`, `default_voting_window_hours`, `default_threshold`, `incident_sla_hours`) that ship sensible values but are not currently editable in the UI. Each proposal sets its own quorum, threshold, and window at creation, seeded from those defaults.
## Why quorum is a floor plus a percent
A single percentage breaks at the extremes. Ten percent of 400 members is a reasonable 40-vote bar; ten percent of 6 members is 0.6, which rounds to nothing and lets two people pass a bylaw change. So quorum is computed as the **greater of an absolute floor and a percentage**:
```
quorum_required = GREATEST(quorum_floor, CEIL(member_count * quorum_percent / 100))
```
The member count is not "everyone in the database" — it counts only members with status `current` or `late` who are `approved`. Unverified, inactive, and unapproved rows don't inflate the denominator. Defaults are `default_quorum_percent = 10` and `default_quorum_floor = 1`; a real space typically raises the floor to something like 8 ("10% or 8, whichever is greater").
Crucially, quorum is **computed once, when the proposal opens, and frozen** onto the proposal row (`quorum_required`, `quorum_percent`, `quorum_floor`). If three people join mid-vote, the bar doesn't move under the voters. That freezing is what makes the number un-disputable: it's stated on the proposal page before anyone votes.
## Why thresholds are separate from quorum
Quorum asks "did enough people show up?" Thresholds ask "of those who took a side, did enough agree?" They're independent, and abstentions reveal why. An abstain **counts toward quorum** (the person showed up) but is **excluded from the threshold math** (they took no side). Recused votes count toward neither.
| Threshold | Passes when |
|-----------|-------------|
| `simple_majority` | yes > no |
| `two_thirds` | yes × 3 ≥ (yes + no) × 2 |
| `three_fourths` | yes × 4 ≥ (yes + no) × 3 |
| `unanimous` | no = 0 and yes > 0 |
The denominator is always `yes + no`, never total voters. Using integer cross-multiplication instead of floating-point division avoids rounding disputes on the exact boundary. A proposal only passes if quorum is met *and* the threshold clears — both are recomputed on every vote so the proposal page shows a live, honest tally. See [Run a proposal](/docs/how-to/run-a-proposal) for the operator's view and [Governance reference](/docs/reference/governance) for the full enum tables.
## Why recusal is a first-class position, not a silence
A board member with a conflict of interest could just not vote — but then there's no record of *why* they stayed out, and the count can't distinguish a principled recusal from apathy. So `recused` is one of four vote positions (`yes`, `no`, `abstain`, `recused`), and a database constraint requires a non-empty `recusal_reason` to use it. The recusal is logged permanently and visible to every member, because a recusal record is exactly the kind of institutional transparency the model protects. Votes are never deleted; a member's voting history is a query, not a leak.
## Why anonymity is honest, not cryptographic
Incident reports (`/incidents`) can be filed anonymously, but the platform deliberately does **not** promise cryptographic anonymity — the database admin can always read the row. What it provides instead is an opaque, server-generated `reporter_token` that lets an anonymous reporter track their own case without exposing their identity in any list. When `is_anonymous` is set, `reporter_id` is left null. Named subjects of a report don't see it by default; dispositions stay board-only until a policy says otherwise, enforced by row-level security and the `incident_updates` visibility levels (`reporter_only`, `all_parties`, `board_only`). Promising more than the storage model can deliver would be dishonest, so the model promises exactly what it enforces. See [Handle incidents](/docs/how-to/handle-incidents).
## Why every incident has an SLA clock
The failure mode the incident system exists to prevent is the complaint that vanishes. So an incident isn't just filed — on insert it gets an `sla_response_by` deadline (`created_at + incident_sla_hours`, default 72 hours / 3 days). The status ladder (`received → under_review → decided → appealed → closed`) means action requires an explicit, timestamped transition, and a dismissed report exposes an appeal path that becomes a normal proposal with `parent_incident_id` set. Silence stops being a way to avoid a decision, because the schema records the absence of one.
The through-line: institutional memory is retained and public — proposals, votes, and policy versions are immutable and cross-referenced — while individual reporters keep the privacy the model was built to give them.
@@ -0,0 +1,68 @@
hackerspace.sh is multi-tenant: every space is an isolated tenant, and almost every row in the database belongs to exactly one space. This page explains how that isolation is enforced, how a member's role becomes a concrete set of things they can do, and why the right to exercise a role depends on the member's dues status.
## Everything is space-scoped
Every tenant table (spaces, members, payments, the knowledge base, secrets, and the rest) carries a `space_id`. Access is decided by one question: *is the current user a member of that space?*
That question is answered in Postgres by a `SECURITY DEFINER` helper, `get_user_space_ids(uid)`, which returns every `space_id` a user belongs to. Row Level Security (RLS) policies on read-heavy tables reduce to a single predicate:
```sql
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())))
```
RLS is enabled on all tenant tables. A logged-in user's queries are silently filtered to their own space — a member of space A cannot read, update, or delete a row belonging to space B, because that row's `space_id` is never in their set. The helper is `SECURITY DEFINER` with a fixed `search_path`, which both prevents recursive policy evaluation on `space_members` and closes the usual privilege-escalation vectors.
### Two enforcement layers
RLS is the backstop, not the only guard. The RLS-bound `server` and `client` Supabase clients are filtered automatically, but the `admin` (service-role) client bypasses RLS entirely. Any action that uses it must scope every query itself:
```ts
.eq('space_id', member.space_id)
```
So tenant isolation is enforced twice: once by Postgres RLS, and once by explicit `space_id` scoping in every server action. The redundancy is deliberate — the code-side scope is the *only* thing preventing a cross-tenant (IDOR) leak on the service-role path, where RLS will not save you. See [The security model](/docs/explanation/security-model) for the broader picture.
## From role to effective permission
Membership carries a `role` (one of `admin`, `board`, `treasurer`, `member`, `associate`), defined once in `lib/permissions.ts` in privilege order. Roles are the coarse gate; write-guarded server actions check them directly through helpers like `requireMemberWithRole(supabase, ADMIN_ROLES)`.
On top of roles sits a finer, per-space **permission** layer. The set of capabilities is fixed product data (`lib/permissions-catalog.ts` — codes like `ops.secrets.read`, `payments.manage`, `door.operate`, `classes.instruct`). Which subjects hold which permission is per-space, stored in `space_role_permissions` and editable at [/customize](/customize).
A "subject" is broader than a built-in role. `user_effective_roles(uid, sid)` unions three things:
| Subject kind | Source |
|---|---|
| Built-in role | the member's `role` |
| Custom role | slugs from `space_custom_roles` assigned to the member |
| Area lead | `area_lead:<id>` for each area the member leads |
`user_has_permission(uid, sid, perm)` then returns true when the member holds a permission grant whose subject is any of their effective roles. `admin` is the exception: it implicitly holds every permission and is never stored in the grant table, so it can never be accidentally revoked or locked out.
This layer is strictly **additive**. A permission grant can only *widen* what a subject can do through the surfaces that consult it (the Ops ACL, the customize UI). It never overrides RLS, so it cannot cross a tenant boundary.
## Why eligibility depends on status
A member also has a `status`: `current`, `late`, `inactive`, or `unverified`. Holding a privileged role is not enough — the member must be *privilege-eligible*, which means `current` or `late` only:
```ts
export const PRIVILEGE_STATUSES = ['current', 'late']
```
Both the role guard (`requireMemberWithRole` short-circuits when the member is not eligible) and the SQL functions (`user_has_permission`, `user_has_role_in_space`, `members_with_permission` all filter `status IN ('current','late')`) enforce this identically.
Two decisions are encoded here:
- **`unverified` holds nothing.** In a space with `require_approval` on, a new member is `unverified` until an admin approves them — even if they redeemed a role-bearing invite code. Without this gate, redeeming an admin-role invite into an approval-gated space would grant instant admin, silently voiding approval.
- **`late` keeps its role.** A dues lapse is a grace state, not an authorization downgrade. A treasurer who is one day late on dues does not lose the ability to do treasurer work.
## Tradeoffs
- **Single active space per user (today).** `getAuthMember` uses `.single()` and fails closed, so a user acts in exactly one space. True multi-space would need an explicit active-space selector before the isolation model changes.
- **Redundant scoping is verbose but safe.** Requiring `.eq('space_id', …)` on every admin-client query is easy to forget; the project locks it in with action-layer tests rather than trusting RLS alone.
- **Permissions widen, never narrow.** Because the permission layer is additive over role-based RLS, you cannot use it to take a capability *away* from a role that RLS already grants — only to extend a capability to additional subjects.
## Related
- [Roles and permissions reference](/docs/reference/roles-and-permissions)
- [Member statuses](/docs/reference/member-statuses)
- [Customizing roles and permissions](/docs/how-to/customize-permissions)
@@ -0,0 +1,70 @@
hackerspace.sh treats every space as an isolated tenant and assumes the network is hostile: the browser is never trusted to say who you are or what you may do. This page explains the layers that enforce that assumption — Postgres Row Level Security, server-derived identity, the encrypted secrets vault, the authorization gates inside every server action, and input validation — and why each layer exists even when another already covers it.
## Defense in depth, not a single wall
There is no one place where security "happens." A write travels through several independent checks, and any one of them can reject it:
```
browser → proxy (session) → (app) layout (re-check) → server action
→ validate → authorize → space-scope → Postgres RLS
```
No layer trusts the layer above it. The proxy gates routes, the layout re-checks the user, the action re-derives identity and re-authorizes, and Postgres RLS filters rows one last time. If a bug opens a hole in one layer, the next one is still standing.
## Server-derived identity
The browser never tells the server who the caller is. Every server action starts from the session cookie and asks Supabase Auth directly:
```ts
const { data: { user } } = await supabase.auth.getUser()
```
From that verified `user.id`, `getAuthMember` (in `lib/auth-helpers.ts`) loads the caller's active `space_members` row and returns their real `role`, `status`, and `space_id`. Client code cannot supply any of these. If a request body contained a `space_id` or `role` field, it would be ignored — the action uses the derived membership, not the payload.
Two details harden this:
- **Active-only.** `getAuthMember` accepts only the `ACTIVE_STATUSES` (`current`, `unverified`, `late`); an `inactive` member is blocked from every action.
- **Fail closed.** The lookup uses `.single()`. If a user somehow had two active memberships, the query errors and no action runs, rather than silently picking a space.
See [The permissions model](/docs/explanation/permissions-model) for how a role becomes a concrete set of capabilities.
## Authorization gates in server actions
Once identity is derived, the action authorizes it. `requireMemberWithRole(supabase, allowed)` enforces two conditions before returning the member:
1. The member's status is privilege-eligible — `PRIVILEGE_STATUSES` is `current` and `late` only. An `unverified` member awaiting approval in a `require_approval` space holds **no** privileged capability, even if they redeemed a role-bearing invite code. Without this gate, redeeming an admin invite would grant instant admin.
2. The member's role is in the `allowed` set.
Only then does the action run its query, and it must scope that query to the caller's own space with `.eq('space_id', member.space_id)`. This scope is mandatory on the `admin` (service-role) client, which bypasses RLS entirely — there, the code-side scope is the only thing standing between the caller and a cross-tenant (IDOR) leak.
## Row Level Security as the backstop
RLS is enabled on every tenant table. The RLS-bound `server` and `client` clients are filtered by Postgres regardless of what the application code does. Read policies reduce to one predicate, evaluated by a `SECURITY DEFINER` helper:
```sql
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())))
```
Sensitive tables narrow further by role via `user_has_role_in_space(...)` — `secrets` are admin/board only, `payments` is treasurer and up, and the `knowledge_base` `visibility` column (`all_members`, `board`, `admin_only`) is enforced in the SELECT policy itself.
### The self-role-change trap
RLS deliberately lets a member update *their own* `space_members` row so they can fix their display name or handle. That opens an escalation path: a member could issue a direct PostgREST `PATCH` setting `role = 'admin'` on themselves. The `prevent_member_self_role_change` trigger closes it. On every self-update by a non-privileged member, it rejects any change to `role`, `tier`, `status`, `approved`, `has_card_access`, or `space_id` with a `42501` error. Only a member who already holds `admin`/`board`/`treasurer` in that space may touch those columns.
## The encrypted secrets vault
Third-party credentials — Stripe keys, door-controller bearer tokens, API keys — are never stored in plaintext. `lib/secrets/crypto.ts` encrypts them with **AES-256-GCM**:
- The master key comes from `SECRETS_ENCRYPTION_KEY` (64 hex characters, 32 bytes). The module is server-only; the key is never reachable from the browser.
- Each secret gets a fresh 12-byte IV on every encrypt. The stored `encrypted_value` is `iv (12) || ciphertext || authTag (16)`, tagged with `encryption_version = 1`.
- GCM's authentication tag means decryption fails on any tampered ciphertext; payloads shorter than the IV-plus-tag minimum are rejected before the crypto primitive runs.
Secrets are written only through server actions and the `storeSecret`/`readSecret` vault helpers, and are never returned to a client. Fields are routed into the vault by naming convention (`isSecretConfigField`): keys like `api_key`, `client_secret`, or anything ending in `_secret`, `_token`, or `_password` are encrypted, while derived `_set`/`_ref` markers are not.
## Input validation
Before authorization even runs, every action validates its input with a Zod schema through `parseInput`, which returns a discriminated result and rejects malformed data with a first-error message. Validation is the outermost gate: bad input never reaches the authorization check or the database.
## Public endpoints self-authenticate
A handful of routes are intentionally session-less — Stripe webhooks, inbound door events, cron jobs, public forms under `/f/*`, and `/track`. Each carries its own proof instead of a cookie: Stripe verifies a per-space signing secret with a replay guard, door callbacks compare a per-connection vault bearer with `timingSafeEqual`, crons check `CRON_SECRET`, and `/track` uses a 192-bit token.