add spine-validation owner runbook; lock privilege-escalation RLS test

docs/SPINE_VALIDATION.md: a precise owner runbook + checklist that provisions
the shipped-but-inert spine (Resend, CRON_SECRET + both crontabs, per-space
Stripe test mode + Billing Portal, the Supabase change-email template) and
proves each path end to end (dues cycle, notifications, prefs/inbox, /me,
door). Grounded in the actual config and route fail-closed codes; linked from
the README docs table.

integration/permission-escalation.test.ts: lock the additive permission
model's core guard, previously untested -- only admin/board may write
space_role_permissions, so a plain member cannot grant themselves a
capability, and the WITH CHECK pins the space.

No app code or migration; suite 626 unit / 54 integration green.
This commit is contained in:
Moheeb Zara
2026-05-20 23:22:48 -07:00
parent 32934124c2
commit 0b6ef20891
4 changed files with 216 additions and 0 deletions
+1
View File
@@ -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/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/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/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/LOCAL_DEV.md](./docs/LOCAL_DEV.md) | Fresh clone to running locally |
| [docs/GOVERNANCE_FEATURES.md](./docs/GOVERNANCE_FEATURES.md) | Proposals, incidents, policies | | [docs/GOVERNANCE_FEATURES.md](./docs/GOVERNANCE_FEATURES.md) | Proposals, incidents, policies |
| [docs/WEBHOOKS.md](./docs/WEBHOOKS.md) | Webhook payload, signing, verification | | [docs/WEBHOOKS.md](./docs/WEBHOOKS.md) | Webhook payload, signing, verification |
+24
View File
@@ -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 ## 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. Branch `main`. Continued the mobile pass app-wide and cleared the tracked cron finding. Build + lint green; 626 unit / 50 integration.
+127
View File
@@ -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, `[email protected]` 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 <noreply@your-verified-domain>`
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=<that value>`. 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 <CRON_SECRET>"` → **200** with `{"scanned":..,"sent":..,"failed":..,"retried":..,"skipped":..}`.
- The door poll: `curl -s -X POST https://APP/api/cron/door-ingest -H "Authorization: Bearer <CRON_SECRET>"` → **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 <that secret>`).
**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.
+64
View File
@@ -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)
})
})