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.
128 lines
9.5 KiB
Markdown
128 lines
9.5 KiB
Markdown
# 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.
|