docs: fix doc drift from D/F/G work — migration 046, harness, subsystem-level facts

DATABASE_SCHEMA + DB_SCHEMA_MAP: 046 migration row (was missing — added
last turn but not table-rowed; CLAUDE.md doc-with-change rule).
README Features: Stripe recurring dues, transactional notifications,
member self-serve portal (Phase 1/2/3 — months of shipped product
spine were absent from the first thing a reader sees).
ARCHITECTURE: Known Limitations #1 now describes the DB-backed
integration harness (the previous 'no harness' claim was misleading);
Classes section notes the advisory-lock RPCs (045); Equipment notes
the GiST exclusion constraint + btree_gist (042); Door notes the
DNS-rebind defense (resolve-once, pin validated IP) + no-raw-reason
client policy + auth-param-aware redaction; Stripe webhook notes the
out-of-order monotonic period guard (D5), zero-decimal currency
handling (D7), and generic error responses (E3); Notifications notes
the per-space fair drain + per-attempt Idempotency-Key + .eq('status',
'pending') re-entrancy guard; Authorization adds the RLS-layer
privilege-eligible status gate (046).
This commit is contained in:
Moheeb Zara
2026-05-19 14:19:44 -07:00
parent 3703c2a76c
commit 19dc6321be
4 changed files with 60 additions and 8 deletions
+1
View File
@@ -411,6 +411,7 @@ Default channels created by trigger on space INSERT: `general`, `announcements`,
| 043 | `members_update` RLS gains a WITH CHECK mirroring its USING (defense-in-depth: a privileged caller can't mutate a member row into a space they don't administer). No new table/column | | 043 | `members_update` RLS gains a WITH CHECK mirroring its USING (defense-in-depth: a privileged caller can't mutate a member row into a space they don't administer). No new table/column |
| 044 | `prevent_member_self_role_change` also blocks self-change of payment_status, payment_note, dues_paid_until, last_paid_at, last_payment_at, stripe_customer_id, joined_at (member could otherwise forge a dues-good signal via PostgREST). Service-client/privileged writes still bypass. No new table/column | | 044 | `prevent_member_self_role_change` also blocks self-change of payment_status, payment_note, dues_paid_until, last_paid_at, last_payment_at, stripe_customer_id, joined_at (member could otherwise forge a dues-good signal via PostgREST). Service-client/privileged writes still bypass. No new table/column |
| 045 | `class_signup_tx` / `class_cancel_tx` SECURITY DEFINER functions: capacity-decision+insert and cancel+waitlist-promote run under a per-session `pg_advisory_xact_lock` (concurrent signups can't over-enroll; concurrent cancels can't double-promote). Mirrors the pure computeSignupStatus/pickPromotion rule; RPC is runtime authority. No new table/column | | 045 | `class_signup_tx` / `class_cancel_tx` SECURITY DEFINER functions: capacity-decision+insert and cancel+waitlist-promote run under a per-session `pg_advisory_xact_lock` (concurrent signups can't over-enroll; concurrent cancels can't double-promote). Mirrors the pure computeSignupStatus/pickPromotion rule; RPC is runtime authority. No new table/column |
| 046 | `user_has_role_in_space` + `user_has_permission` gated on `status IN ('current','late')` (RLS-layer defense-in-depth for D2): unverified/inactive can no longer exercise a role/permission via direct PostgREST. `get_user_space_ids` + `user_effective_roles` intentionally NOT gated (would break unverified self-reads / onboarding). No new table/column |
--- ---
+3 -1
View File
@@ -20,7 +20,9 @@ A multi-tenant operating system for hackerspaces, makerspaces, and member-run sh
- **Equipment.** Tool and equipment registry with time-window reservations, no-overlap enforcement, and an optional required certification per item. - **Equipment.** Tool and equipment registry with time-window reservations, no-overlap enforcement, and an optional required certification per item.
- **Access control.** Member access cards (the card UID is treated as a credential), a configurable per-space door integration (native HeatSync controller or generic HTTP) behind an SSRF-guarded executor, and an immutable, secret-redacted access log. - **Access control.** Member access cards (the card UID is treated as a credential), a configurable per-space door integration (native HeatSync controller or generic HTTP) behind an SSRF-guarded executor, and an immutable, secret-redacted access log.
- **Onboarding and invites.** A configurable member onboarding flow; multi-use, expiring, role-granting invite links. - **Onboarding and invites.** A configurable member onboarding flow; multi-use, expiring, role-granting invite links.
- **Payments and financials.** Per-member payment ledger, integration credentials per space, exports, monthly financial summary. - **Payments and financials.** Per-member payment ledger, integration credentials per space, exports, monthly financial summary. **Stripe recurring dues** are a first-class integration: each space configures its own Stripe account (not Connect), members pay through hosted Checkout and self-serve cancel/update through the hosted Billing Portal (no card data on our servers), and a per-space signed webhook keeps `member_billing` and member status (`current` / `late`) in sync, never auto-downgrading past `late`.
- **Transactional email and notifications.** An idempotent notifications outbox writes dues-lifecycle emails (renewal receipt, payment failed, lapse) from the Stripe webhook; a small dispatcher cron drains them through Resend with a fair per-space queue and per-attempt idempotency. Adding other domains (bookings, forms) reuses the same outbox.
- **Member self-serve portal.** Every member's `/me` is a tabbed portal: profile editing, dues management (Pay / Manage Billing), cancel class signups and equipment reservations, a personal payment history, and a verified self-service email change.
- **Governance.** Proposals with quorum, vote tracking and expiry; incident reports with anonymous reporter tracking by token; versioned policy library; member forum with polymorphic comments. - **Governance.** Proposals with quorum, vote tracking and expiry; incident reports with anonymous reporter tracking by token; versioned policy library; member forum with polymorphic comments.
- **Communications.** Multi-channel chat with realtime delivery. - **Communications.** Multi-channel chat with realtime delivery.
- **Recruitment.** Public-facing recruitment page per space. - **Recruitment.** Public-facing recruitment page per space.
+55 -7
View File
@@ -231,6 +231,17 @@ Admin (full access)
| `payments` | Treasurer/Board/Admin | Treasurer/Admin | Treasurer/Admin | Treasurer/Admin | | `payments` | Treasurer/Board/Admin | Treasurer/Admin | Treasurer/Admin | Treasurer/Admin |
| `comms_*` | Members | Members | - | Board/Admin | | `comms_*` | Members | Members | - | Board/Admin |
**Privilege-eligible status (migration 046).** `user_has_role_in_space`
and `user_has_permission` additionally require the caller's
`space_members.status` to be `current` or `late` (mirrors
`lib/permissions` `PRIVILEGE_STATUSES`). An `unverified` member of a
`require_approval` space (or an `inactive` one) cannot exercise a role
or permission at the RLS layer even via direct PostgREST, complementing
the same gate at the app layer in `requireMemberWithRole`. Deliberately
NOT applied to `get_user_space_ids` / `user_effective_roles`: gating
SELECT-policy reads would break an unverified member's own `/me` and
onboarding, which IS the legitimate require_approval flow.
--- ---
## 7. Server Actions Reference ## 7. Server Actions Reference
@@ -531,7 +542,13 @@ session can award the class's certification, but only through the normal
path, so it still requires the acting instructor to hold path, so it still requires the acting instructor to hold
`certifications.grant`; otherwise completion succeeds and the result `certifications.grant`; otherwise completion succeeds and the result
reports the certificates were skipped (no service-role bypass of the reports the certificates were skipped (no service-role bypass of the
guarded certifications surface). No anonymous path. guarded certifications surface). No anonymous path. Concurrent
signup/cancel is serialized per session by SECURITY DEFINER functions
`class_signup_tx` / `class_cancel_tx` (migration 045) holding
`pg_advisory_xact_lock(hashtext(session_id))`, so two simultaneous
signups at the capacity boundary cannot over-enroll and two simultaneous
cancels cannot double-promote a waitlister. The pure logic is the
documented rule; the RPC is the runtime authority.
### Equipment (migration 033) ### Equipment (migration 033)
@@ -551,7 +568,12 @@ INSERT/DELETE policy** so reserve/cancel funnels through one validated
service-client action that enforces the equipment status, the no-overlap service-client action that enforces the equipment status, the no-overlap
rule, and the required-certification gate (checked against the normal rule, and the required-certification gate (checked against the normal
`member_certifications` data; an `equipment.manage` holder gets the `member_certifications` data; an `equipment.manage` holder gets the
override and may book on a member's behalf). No anonymous path. override and may book on a member's behalf). The app-side check is a
fast pre-check; the database is the concurrency arbiter via a GiST
`EXCLUDE (equipment_id =, tstzrange(starts_at,ends_at,'[)') &&) WHERE
status='reserved'` constraint (migration 042 + `btree_gist`), so two
simultaneous requests for the same window cannot both insert. No
anonymous path.
### Door / access control (migrations 034-036; epic in progress) ### Door / access control (migrations 034-036; epic in progress)
@@ -595,7 +617,17 @@ member `/doors` page (self-entry + masked own cards via `getMyCards` +
the member's own recent activity via `listMyDoorActivity`, a service-client the member's own recent activity via `listMyDoorActivity`, a service-client
read after `requireMember` filtered to that member), hidden entirely for read after `requireMember` filtered to that member), hidden entirely for
ineligible members. Phases 4-5 add inbound log ingest and the ineligible members. Phases 4-5 add inbound log ingest and the
universal API-call UI builder. No anonymous path. universal API-call UI builder. No anonymous path. The executor is the
single hardened egress: it resolves the controller host once via
`dns.lookup`, rejects if any resolved IP is loopback / unspecified /
link-local / metadata (IPv4-mapped IPv6 normalized; RFC1918 / LAN / ULA
allowed since controllers live there), then connects to the validated
IP literal so `fetch` performs no second resolution, closing the
DNS-rebind TOCTOU. Redirects are refused (`redirect: 'manual'`).
Controller errors never bubble the upstream reason to the client (a
generic message; the redacted detail goes only to `door_access_log`);
the redaction is auth-param-aware so a generic-adapter custom param
value is scrubbed regardless of its name.
--- ---
@@ -642,8 +674,16 @@ space's signing secret, is idempotent on Stripe's event id
(grace → `late`, **never** auto-inactive, never auto-approves `unverified`). (grace → `late`, **never** auto-inactive, never auto-approves `unverified`).
`member_billing` SELECT = admin/board/treasurer with **no client write `member_billing` SELECT = admin/board/treasurer with **no client write
policy** (webhook/service-client only); the member self-view is a validated policy** (webhook/service-client only); the member self-view is a validated
action. No new permission code. Phases 2-3 (transactional notifications, action. No new permission code. The webhook is hardened against three
broader self-serve) build on this. classes of real-world Stripe behavior: replay (Stripe retries reuse the
event id, the unique `stripe_webhook_events` PK collapses them); out-of-
order delivery (a stale `customer.subscription.updated` carrying an
older period must NOT rewind `current_period_end` and false-lapse a
paid member, so `laterPeriodEnd` keeps the monotonic max); and
zero-decimal currencies (JPY/KRW/VND/etc. are NOT rescaled `/100`, via
`isZeroDecimalCurrency`). Error responses to the caller are generic;
the real cause is logged server-side. Phases 2-3 (transactional
notifications, broader self-serve) build on this.
### Transactional notifications (migration 041; product spine Phase 2) ### Transactional notifications (migration 041; product spine Phase 2)
@@ -665,7 +705,15 @@ run cannot double-send; transient failures stay `pending` until the attempt
budget is spent, permanent ones go `failed`. `notifications` SELECT = budget is spent, permanent ones go `failed`. `notifications` SELECT =
admin/board/treasurer, no client write policy; the member self-view admin/board/treasurer, no client write policy; the member self-view
(`getMyNotifications`, surfaced on `/me`) is a validated action. The (`getMyNotifications`, surfaced on `/me`) is a validated action. The
droplet's crontab POSTs once a minute. No new permission code. droplet's crontab POSTs once a minute. The drain is fair across spaces:
oldest-first candidates are bucketed per `space_id` and round-robined,
so one tenant's billing burst cannot head-of-line-block another space's
mail; a lone-tenant deployment still drains the full batch. Resend's
`Idempotency-Key` is per-attempt (`<row.id>:<attempts>`) so a
cross-minute retry is a deliberately fresh send, not a cached prior
response. The status write is guarded with `.eq('status','pending')`
so two overlapping dispatcher runs cannot flip a row another already
marked sent. No new permission code.
### Member self-serve portal (`/me`; product spine Phase 3) ### Member self-serve portal (`/me`; product spine Phase 3)
@@ -675,7 +723,7 @@ droplet's crontab POSTs once a minute. No new permission code.
## 13. Known Limitations ## 13. Known Limitations
1. **Test suite** - Extensive Vitest unit coverage of pure logic (`__tests__/`, run with `pnpm test`) plus Playwright smoke e2e (`e2e/`). Gap: server-action orchestration is not unit-tested (no Supabase mock harness); e2e is mostly page-render checks, not full write/RLS flows. 1. **Test suite** - Extensive Vitest unit coverage of pure logic (`__tests__/`, run with `pnpm test`, hermetic), Playwright smoke e2e (`e2e/`), and a DB-backed integration suite (`integration/`, run with `pnpm test:integration` against a real Postgres; self-skips without one) that drives the shipped SQL through `psql` and the route handlers through their actual exports: the advisory-lock signup/cancel RPCs (045), the equipment exclusion constraint (042), the self-change trigger + `members_update` WITH CHECK (043/044), the billing/notification idempotency invariants, the Stripe webhook end to end (real signed events, vault secret resolution, replay, out-of-order period guard), and the RLS-layer privilege-status gate (046). Remaining gap: server-action orchestration beyond those critical paths still has no integration coverage, and e2e is mostly page-render checks.
2. **Payment integrations** - Stripe recurring dues IS integrated (product spine Phase 1: per-space keys, hosted Checkout/Portal, the per-space signed webhook). A PayPal sync endpoint exists (`app/api/paypal/sync/route.ts`). Manual import/CSV reconciliation remains the path for ad-hoc/other-platform payments; Zeffy/Venmo live APIs are not integrated. 2. **Payment integrations** - Stripe recurring dues IS integrated (product spine Phase 1: per-space keys, hosted Checkout/Portal, the per-space signed webhook). A PayPal sync endpoint exists (`app/api/paypal/sync/route.ts`). Manual import/CSV reconciliation remains the path for ad-hoc/other-platform payments; Zeffy/Venmo live APIs are not integrated.
3. **Social auth** - GitHub/Google sign-in is wired, gated by the `NEXT_PUBLIC_OAUTH_GITHUB`/`NEXT_PUBLIC_OAUTH_GOOGLE` env flags. 3. **Social auth** - GitHub/Google sign-in is wired, gated by the `NEXT_PUBLIC_OAUTH_GITHUB`/`NEXT_PUBLIC_OAUTH_GOOGLE` env flags.
4. **Webhooks** - The HMAC signing contract and secret rotation exist; per-event delivery is not implemented (see `docs/WEBHOOKS.md`). 4. **Webhooks** - The HMAC signing contract and secret rotation exist; per-event delivery is not implemented (see `docs/WEBHOOKS.md`).
+1
View File
@@ -726,6 +726,7 @@ migration is mirrored as a section in it. Tables/columns added:
| 043 | `members_update` RLS policy gains a `WITH CHECK` mirroring its `USING` (`user_id = auth.uid()` OR admin/board/treasurer in `space_id`). Defense-in-depth: the post-image is re-validated so a privileged caller cannot move a member row into a space they do not administer. No new table/column | | 043 | `members_update` RLS policy gains a `WITH CHECK` mirroring its `USING` (`user_id = auth.uid()` OR admin/board/treasurer in `space_id`). Defense-in-depth: the post-image is re-validated so a privileged caller cannot move a member row into a space they do not administer. No new table/column |
| 044 | `prevent_member_self_role_change` extended to also block a non-privileged member changing their own `payment_status`, `payment_note`, `dues_paid_until`, `last_paid_at`, `last_payment_at`, `stripe_customer_id`, `joined_at` (RLS lets a member UPDATE their own row, so these were self-settable to forge a dues-good signal). Service-client (`auth.uid()` NULL) and privileged-on-another-member writes still bypass. No new table/column | | 044 | `prevent_member_self_role_change` extended to also block a non-privileged member changing their own `payment_status`, `payment_note`, `dues_paid_until`, `last_paid_at`, `last_payment_at`, `stripe_customer_id`, `joined_at` (RLS lets a member UPDATE their own row, so these were self-settable to forge a dues-good signal). Service-client (`auth.uid()` NULL) and privileged-on-another-member writes still bypass. No new table/column |
| 045 | `class_signup_tx(p_session_id,p_space_id,p_member_id)` + `class_cancel_tx(...)` SECURITY DEFINER, `search_path=public`. Each takes `pg_advisory_xact_lock(hashtext(session_id))` then does the capacity-decision+dup-check+insert (signup) or cancel+conditional waitlist-promote (cancel) atomically. Closes the concurrent over-enroll / double-promote races (dynamic capacity can't be a constraint). Mirrors `computeSignupStatus`/`pickPromotion`; the SQL is the runtime authority. No new table/column/policy | | 045 | `class_signup_tx(p_session_id,p_space_id,p_member_id)` + `class_cancel_tx(...)` SECURITY DEFINER, `search_path=public`. Each takes `pg_advisory_xact_lock(hashtext(session_id))` then does the capacity-decision+dup-check+insert (signup) or cancel+conditional waitlist-promote (cancel) atomically. Closes the concurrent over-enroll / double-promote races (dynamic capacity can't be a constraint). Mirrors `computeSignupStatus`/`pickPromotion`; the SQL is the runtime authority. No new table/column/policy |
| 046 | `user_has_role_in_space` and `user_has_permission` extended with `status IN ('current','late')` (mirrors `lib/permissions` `PRIVILEGE_STATUSES`). RLS-layer defense-in-depth for D2 (the require_approval / unverified-admin escalation): a non-privilege-eligible member who somehow bypassed the app guards still cannot exercise a role or permission. `get_user_space_ids` and `user_effective_roles` are deliberately NOT gated so unverified members keep their own /me + onboarding reads (the legitimate require_approval flow). No new table/column/policy |
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`. New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.