docs: audit corrections, coverage pages, screenshots, and em-dash removal

Finalizes the docs section after an adversarial audit (code review + per-page
fact-check + coherence critic) and two content requests.

- Accuracy: fixed 10 invented cross-link slugs, the admin-vs-board gating claims
  the verifiers caught (permissions matrix, /settings, invites, onboarding,
  connect-payments, proposal withdraw, Stripe reconciliation, member fields,
  door.operate), and 2 issues in the new pages (project area-at-creation, forum
  thread-delete role).
- Coverage: added 5 how-to pages so every module is documented (recurring Stripe
  dues, tasks & chores, projects, comms & forum, ops knowledge base + secrets
  vault). 34 pages total.
- Screenshots: 12 captioned app screenshots under public/docs-media/, embedded
  as figures. Each tutorial now has 2-3 step screenshots.
- Style: removed all em-dashes/en-dashes/ellipses from the docs (natural ASCII
  punctuation).
- Handoff (docs/HANDOFF-audit-cleanup.md) updated with the current state.

typecheck 0 · lint 0 errors · build ok (all 34 /docs pages prerendered)
This commit is contained in:
Moheeb Zara
2026-08-05 03:15:07 -07:00
parent 16229ae523
commit 8edc9da611
50 changed files with 610 additions and 202 deletions
+8 -8
View File
@@ -2,11 +2,11 @@ hackerspace.sh is a Next.js 16 App Router app written in TypeScript, backed by S
## 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.
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, and every push to `main` deploys automatically: a GitHub Actions workflow runs the deploy script over SSH, which applies any new SQL migrations, rebuilds, and restarts the app.
## Route groups
The `app/` directory uses App Router route groups — parenthesized folders that organize routes without adding a URL segment:
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 `/`.
@@ -23,16 +23,16 @@ There are three ways to reach Postgres, and choosing the right one is the core s
| 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 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 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.
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
@@ -50,8 +50,8 @@ 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.
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.
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.
@@ -1,4 +1,4 @@
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.
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
@@ -6,7 +6,7 @@ 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.
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.
@@ -14,7 +14,7 @@ Keeping them separate means a payment is never rewritten to fit a member. See th
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.
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.
@@ -22,20 +22,20 @@ The comparison is done by instant, not by string, so timestamps from different s
## 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.)
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.
**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.
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.
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.
+7 -7
View File
@@ -1,6 +1,6 @@
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.
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
@@ -10,7 +10,7 @@ A single percentage breaks at the extremes. Ten percent of 400 members is a reas
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").
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.
@@ -25,18 +25,18 @@ Quorum asks "did enough people show up?" Thresholds ask "of those who took a sid
| `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.
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.
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).
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 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.
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.
@@ -10,7 +10,7 @@ That question is answered in Postgres by a `SECURITY DEFINER` helper, `get_user_
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.
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
@@ -20,13 +20,13 @@ RLS is the backstop, not the only guard. The RLS-bound `server` and `client` Sup
.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.
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).
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:
@@ -42,7 +42,7 @@ This layer is strictly **additive**. A permission grant can only *widen* what a
## 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:
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']
@@ -52,17 +52,17 @@ Both the role guard (`requireMemberWithRole` short-circuits when the member is n
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.
- **`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.
- **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)
- [Members: statuses, tiers & fields](/docs/reference/members)
- [Customize roles, tiers & areas](/docs/how-to/customize-space)
+7 -7
View File
@@ -1,4 +1,4 @@
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.
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
@@ -19,7 +19,7 @@ The browser never tells the server who the caller is. Every server action starts
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.
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:
@@ -32,10 +32,10 @@ See [The permissions model](/docs/explanation/permissions-model) for how a role
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.
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.
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
@@ -45,7 +45,7 @@ RLS is enabled on every tenant table. The RLS-bound `server` and `client` client
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.
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
@@ -53,7 +53,7 @@ RLS deliberately lets a member update *their own* `space_members` row so they ca
## 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**:
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`.
@@ -67,4 +67,4 @@ Before authorization even runs, every action validates its input with a Zod sche
## 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.
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.