diff --git a/README.md b/README.md index 38f7e56..9fbc039 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ The deploy script is idempotent and tracked: every numbered migration in `script | [docs/API_REFERENCE.md](./docs/API_REFERENCE.md) | Server actions and REST endpoints | | [docs/COMPONENT_REFERENCE.md](./docs/COMPONENT_REFERENCE.md) | UI components and props | | [docs/DEPLOY_DO_SELFHOSTED.md](./docs/DEPLOY_DO_SELFHOSTED.md) | End to end production deploy | +| [docs/SPINE_VALIDATION.md](./docs/SPINE_VALIDATION.md) | Owner runbook: provision + prove dues/email/door end to end | | [docs/LOCAL_DEV.md](./docs/LOCAL_DEV.md) | Fresh clone to running locally | | [docs/GOVERNANCE_FEATURES.md](./docs/GOVERNANCE_FEATURES.md) | Proposals, incidents, policies | | [docs/WEBHOOKS.md](./docs/WEBHOOKS.md) | Webhook payload, signing, verification | diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 68c1c4f..6cefb3c 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -28,6 +28,30 @@ Append-only. Newest entries on top. Keep each entry to one screen. --- +## 2026-05-21 (pass 70): Audit the runbook + lock the privilege-escalation RLS + +Branch `main`. Audited the freshly-written runbook against the code, then locked an untested core security invariant. + +**Audit (the runbook is owner-executed, so an error is a real bug).** Verified `docs/SPINE_VALIDATION.md`'s factual claims against the source: env var names, the two cron endpoints + their fail-closed codes (401/503), the Stripe webhook path + event list + the 400-on-unsigned, the Dues-tab fields + the displayed webhook URL, the Billing-Portal + change-email steps, Stripe test cards. **One inaccuracy fixed:** the notifications-cron response is `{scanned, sent, failed, retried, skipped}` (the runbook had omitted `skipped`). + +**Continued: locked the privilege-escalation guard (was untested).** The additive permission model's integrity rests on one RLS gate: only `admin`/`board` may WRITE `space_role_permissions` (insert WITH CHECK, update/delete USING `user_has_role_in_space(...,['admin','board'])`). There was NO integration test for it. Added `integration/permission-escalation.test.ts` (4): a plain member's INSERT of a self-grant is rejected; an admin's INSERT succeeds (positive control); a member's DELETE of a seeded grant is USING-filtered to 0 rows so the grant survives (no error, no effect); an admin cannot INSERT into another space (WITH CHECK pins space). (Dropped a 5th read-visibility case: `rowsAsUser` mis-parses a multi-row `select 1` projection -- a harness quirk, not an app issue, and low value next to the write guards.) + +**State.** 626 unit / 54 integration (was 50; +4) / build + lint green. This pass ships together with pass-69 (the runbook + README link) -- docs + one new test, no code/migration, no behavior change. + +--- + +## 2026-05-21 (pass 69): Diversified audit (public/API, clean) + spine-validation runbook + +Branch `main`. Fresh diversified audit on surfaces NOT recently touched, then took backlog item 1 (the owner-gated spine validation). + +**Audit (CLEAN, diversified).** Enumerated all 6 `app/api/**` route handlers (the 4 pre-existing + the 2 door ones) -- all accounted for and gated. XSS: the only `dangerouslySetInnerHTML` is `components/ui/chart.tsx` (developer CSS vars, not user data). SSRF: the ONLY variable-URL `fetch` outside the hardened door executor is `/api/paypal/sync`, and its `baseUrl` is a hardcoded constant gated by a boolean `sandbox` flag (`api-m[.sandbox].paypal.com`), not admin-controllable -- no SSRF. No `console.*` logs a secret/key/token. Combined with the converged door/api-button/mobile passes, no new finding. + +**Continued: backlog item 1 -> `docs/SPINE_VALIDATION.md` (new).** A precise owner runbook + copy-paste checklist that converts the shipped-but-inert spine into proven-working. Part A provisioning (Resend domain + `RESEND_API_KEY`/`EMAIL_FROM`; `CRON_SECRET` + BOTH crontabs incl. door-ingest, with 401/503/200 verifies; per-space Stripe test mode -- products/prices, secret key, the webhook endpoint URL the Dues tab displays + signing secret, Billing Portal activation, the Dues-tab save; the Supabase Change-Email template + `/auth/confirm` allowlist). Part B end-to-end validation (test-mode dues cycle incl. renewal/failed/lapse + the dispatcher email + in-app inbox; booking/class/form notifications; pref muting vs always-on billing; `/me` click-through; door grant/self-entry/inbound + API-button press). Grounded in the actual config (exact env vars, the `StripeBillingPanel` webhook URL, the route fail-closed codes), not invented. Linked from the README docs table. Owner executes; the agent cannot (external accounts + a live session + on-droplet env/cron). + +**State.** Docs-only this pass (no code, no migration). 626 unit / 50 integration / build + lint green (unchanged). Item 1 deliverable produced; remaining backlog: production observability (item 2, design-first), integration-harness extension (item 4), owner browser review (item 3, owner-only). + +--- + ## 2026-05-21 (pass 68): App-wide mobile-overflow audit + cron poll parallelized Branch `main`. Continued the mobile pass app-wide and cleared the tracked cron finding. Build + lint green; 626 unit / 50 integration. diff --git a/docs/SPINE_VALIDATION.md b/docs/SPINE_VALIDATION.md new file mode 100644 index 0000000..f3fe2a0 --- /dev/null +++ b/docs/SPINE_VALIDATION.md @@ -0,0 +1,127 @@ +# Spine Validation Runbook + +The product spine (Stripe recurring dues, the transactional-notification outbox + dispatcher, the `/me` self-serve portal, notification breadth + per-member preferences + the in-app inbox) and the door epic are all shipped to production but **inert** until an owner provisions a handful of external credentials. Nothing sends email, runs a dues cycle, or talks to a door until then. + +This runbook provisions everything in **test mode** and proves each path end to end. It is owner-executed: every step has an action and a **Verify** line. Do Part A (provisioning) in order, then Part B (validation). The copy-paste checklist is at the bottom. + +Conventions: `APP` = your app domain (e.g. `hackerspace.sh`). `SPACE_ID` = the id of the space you are validating. Commands run on the Droplet unless noted; the app `.env` and the crontab are edited as root. + +--- + +## Part A. Provisioning + +### A1. Resend (transactional email) + +1. Create a Resend account and **verify your sending domain** (add the SPF + DKIM DNS records Resend gives you). Until the domain is verified, `onboarding@resend.dev` only delivers to your own Resend account email, and `*@resend.dev` addresses only *simulate* outcomes. +2. Create an API key (starts with `re_`). +3. In the app `.env` set: + - `RESEND_API_KEY=re_...` + - `EMAIL_FROM=Your Space ` +4. Restart the app: `systemctl restart hackerspace-app`. + +**Verify:** with `CRON_SECRET` also set (A2), run the dispatcher (A2 verify). Before A1, the dispatcher records each row as `failed` with "transport not configured"; after, rows go to `sent`. A quick check after B1 enqueues a real email. + +### A2. CRON_SECRET + the two crontabs + +1. Generate a secret: `openssl rand -hex 32`. +2. In the app `.env` set `CRON_SECRET=`. Restart the app. +3. Add two root crontab entries (`crontab -e`): + +``` +* * * * * curl -fsS -m 30 -X POST http://127.0.0.1:3000/api/cron/notifications -H "Authorization: Bearer $CRON_SECRET" >/dev/null 2>&1 +* * * * * curl -fsS -m 30 -X POST http://127.0.0.1:3000/api/cron/door-ingest -H "Authorization: Bearer $CRON_SECRET" >/dev/null 2>&1 +``` + +(The crontab must have `CRON_SECRET` available, e.g. set it in the crontab file or inline the literal value.) + +**Verify:** +- `curl -s -o /dev/null -w "%{http_code}" -X POST https://APP/api/cron/notifications` (no auth) → **401**. +- With `CRON_SECRET` unset the same call returns **503** (fails safe). +- With the bearer: `curl -s -X POST https://APP/api/cron/notifications -H "Authorization: Bearer "` → **200** with `{"scanned":..,"sent":..,"failed":..,"retried":..,"skipped":..}`. +- The door poll: `curl -s -X POST https://APP/api/cron/door-ingest -H "Authorization: Bearer "` → **200** `{"ok":true,"polled":0,...}` (0 until a connection enables inbound, A5/optional). + +### A3. Per-space Stripe (test mode) + +Stripe is configured in exactly one place: **`/settings` → Dues tab** (the per-space `StripeBillingPanel`). Each space uses its OWN Stripe account (not Connect), so do this per space. + +In the **Stripe Dashboard, test mode** (`https://dashboard.stripe.com/test`): +1. **Products + prices.** Create a Product and a recurring **Price** for each dues tier you offer. Copy each Price id (`price_...`). +2. **Secret key.** Copy your test secret key (`sk_test_...`) from Developers → API keys. +3. **Webhook endpoint.** Developers → Webhooks → Add endpoint. For the URL, paste the exact value the **Dues tab shows** (`https://APP/api/stripe/webhook/SPACE_ID`). Subscribe to: `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.paid`, `invoice.payment_failed`. Copy the signing secret (`whsec_...`). +4. **Billing Portal.** Activate the test-mode Customer Billing Portal at `https://dashboard.stripe.com/test/settings/billing/portal` (this is what powers "Manage Billing" on `/me`). Save the default config. + +In the **app**, `/settings` → **Dues** tab: +5. Set **mode = test**, paste the **secret key** and the **webhook signing secret** (both are write-only and stored in the AES vault; a blank field on a later save keeps the existing value), map each **tier → Price id**, set **grace days** (default 7). Save. + +**Verify:** +- `curl -s -o /dev/null -w "%{http_code}" -X POST https://APP/api/stripe/webhook/SPACE_ID` → **400** (missing/invalid signature; the path is live and signature-gated). +- The Dues tab shows "Stripe configured" and the mapped prices. +- On `/me`, the dues card now shows a **Pay dues with card** button (it is hidden until Stripe is configured). + +### A4. Supabase Change-Email template + `/auth/confirm` allowlist + +Needed for the `/me` self-service email change. Full steps are in [DEPLOYMENT.md](./DEPLOYMENT.md#member-email-change-supabase-auth-config--required): +1. Auth → URL Configuration: add `{APP_URL}/auth/confirm` to the redirect allowlist. +2. Auth → Providers → Email: keep "Secure email change" on. +3. Auth → Email Templates → "Change Email Address": point the link at `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email_change`. + +**Verify:** `/me` → change email → the confirmation link lands on `/auth/confirm` and completes the change (both old and new addresses confirm). + +### A5. Door inbound (optional, only if a controller is reachable) + +On `/door/manage`, open a connection's **Inbound** panel, choose a webhook secret (a vault secret), and turn inbound on. For a native HeatSync controller the A2 `door-ingest` crontab will poll its `?z` log; any controller/relay can instead POST to the per-connection webhook URL shown in the panel (with `Authorization: Bearer `). + +**Verify:** present a card at the door, then on the next minute (poll) or immediately (webhook) an `entry` row appears in the access log on `/door/manage`, matched to the member. + +--- + +## Part B. End-to-end validation (test mode) + +Run these as a real member of the space. Stripe test card: `4242 4242 4242 4242`, any future expiry, any CVC, any ZIP. + +### B1. Dues cycle (the money + email path) +1. `/me` → dues card → **Pay dues with card** → complete the hosted Stripe Checkout with the test card. +2. **Verify activation:** `checkout.session.completed` + `customer.subscription.created` fire; the member's status becomes/stays `current`, a `stripe` row appears in `/payments`, and a **dues renewed** email is enqueued. Within a minute the dispatcher sends it: check the recipient inbox AND the `/me` in-app inbox (it appears there even if email is muted). +3. **Manage Billing:** `/me` → **Manage Billing** opens the Stripe Billing Portal; confirm you can update the card and cancel. +4. **Payment failure:** in the Portal (or via a new subscription) use a failing test card such as `4000 0000 0000 0341`; `invoice.payment_failed` → a **payment failed** email. +5. **Lapse (optional, uses a Stripe test clock):** advance time past the period end + grace; `customer.subscription.updated` (past_due) drives status to `late` and a **dues lapsed** email. Status never auto-drops below `late`. + +### B2. Booking notification +Reserve an item on `/equipment` (or its manage surface). **Verify:** a booking notification is enqueued and (per prefs) emailed + shown in the `/me` inbox. + +### B3. Class signup notification +Sign up for a scheduled class session. **Verify:** a class notification is enqueued + delivered. + +### B4. Form submission notification +Submit a form/waiver that has admin notification configured. **Verify:** the permission-holders receive a `form_submission_admin` notification. + +### B5. Notification preferences + inbox +On `/me` → Activity → Email preferences, mute a muteable category (bookings/classes/forms/admin_alerts). Trigger that category again. **Verify:** the dispatcher marks the row `skipped` (shown as "Muted" in the history), no email sends, but the row still appears in the in-app inbox. Confirm a **billing** email (B1) is NOT muteable. + +### B6. `/me` click-through +Profile editing (skills/interests chips), the dues card states (configured vs not), the Dues tab, inbox read/unread + "Mark all as read" + unread count. + +### B7. Door + API buttons (only if A5 / a controller is set up) +Grant a card (`/door/manage`), member self-entry (`/doors` or the dashboard panel), inbound ingest (A5), then create an API-call button (`/door/buttons`, e.g. the door-control preset) and press it from `/doors`. **Verify:** each action writes a redacted row to the door/api-call log. + +--- + +## Checklist + +Provisioning: +- [ ] A1 Resend domain verified; `RESEND_API_KEY` + `EMAIL_FROM` set; app restarted +- [ ] A2 `CRON_SECRET` set; both crontabs added; 401/503/200 verified +- [ ] A3 Stripe test: products/prices, secret key, webhook endpoint (URL from the Dues tab) + signing secret, Billing Portal activated; Dues tab saved; webhook returns 400 to an unsigned call; `/me` shows Pay-with-card +- [ ] A4 Supabase Change-Email template + `/auth/confirm` allowlist; test email change completes +- [ ] A5 (optional) door inbound enabled; an entry ingests + +Validation: +- [ ] B1 Checkout activates the member; dues-renewed email lands (real inbox + in-app inbox); Billing Portal opens; payment-failed email; (optional) lapse to `late` +- [ ] B2 booking notification +- [ ] B3 class-signup notification +- [ ] B4 form-submission admin notification +- [ ] B5 muting a category skips its email but keeps the inbox row; billing is never muteable +- [ ] B6 `/me` profile + dues + Dues tab + inbox all behave +- [ ] B7 (optional) door grant / self-entry / inbound / API button each audited + +When every box is checked, the spine is proven, not just shipped. diff --git a/integration/permission-escalation.test.ts b/integration/permission-escalation.test.ts new file mode 100644 index 0000000..4095ce6 --- /dev/null +++ b/integration/permission-escalation.test.ts @@ -0,0 +1,64 @@ +// The additive permission model's integrity rests on one RLS gate: only an +// admin/board may WRITE space_role_permissions. If a plain member could insert +// a grant, they could hand themselves any capability (settings.manage, etc.). +// This locks that gate against real Postgres -- previously untested. Same +// role-simulation harness as the other integration tests. +import { describe, it, expect, afterAll } from 'vitest' +import { dbReady, execAsUser, rowsAsUser, seedSpace, seedMember, dropSpace } from './db' + +const d = describe.skipIf(!dbReady) + +d('space_role_permissions write RLS (privilege-escalation guard)', () => { + const spaces: string[] = [] + function space(): string { + const { spaceId } = seedSpace() + spaces.push(spaceId) + return spaceId + } + afterAll(() => spaces.forEach(dropSpace)) + + it('a plain member cannot grant themselves a permission', () => { + const sid = space() + const member = seedMember(sid, { role: 'member' }) + const r = execAsUser( + member.userId, + `insert into space_role_permissions (space_id, subject, permission) values ('${sid}','member','settings.manage');`, + ) + expect(r.ok).toBe(false) + }) + + it('an admin can grant a permission (positive control)', () => { + const sid = space() + const admin = seedMember(sid, { role: 'admin' }) + const r = execAsUser( + admin.userId, + `insert into space_role_permissions (space_id, subject, permission) values ('${sid}','member','ops.kb.write');`, + ) + expect(r.ok).toBe(true) + }) + + it("a member's delete of an existing grant is filtered out (the grant survives)", () => { + const sid = space() + const admin = seedMember(sid, { role: 'admin' }) + const member = seedMember(sid, { role: 'member' }) + // The space seed already grants 'member' -> 'ops.kb.read'. A member's DELETE + // is USING-gated to admin/board, so it matches 0 rows (no error, no effect). + execAsUser(member.userId, `delete from space_role_permissions where space_id='${sid}' and subject='member' and permission='ops.kb.read';`) + const still = rowsAsUser( + admin.userId, + `select 1 from space_role_permissions where space_id='${sid}' and subject='member' and permission='ops.kb.read'`, + ) + expect(still.length).toBe(1) + }) + + it('an admin cannot write a grant into another space (WITH CHECK pins space)', () => { + const sidA = space() + const adminA = seedMember(sidA, { role: 'admin' }) + const sidB = space() + const r = execAsUser( + adminA.userId, + `insert into space_role_permissions (space_id, subject, permission) values ('${sidB}','member','settings.manage');`, + ) + expect(r.ok).toBe(false) + }) +})