docs: record spine A1+A2 prod provisioning; fix crontab CRON_SECRET footgun

This commit is contained in:
Moheeb Zara
2026-05-21 00:29:05 -07:00
parent 0b6ef20891
commit 95f79771ec
3 changed files with 33 additions and 18 deletions
+3 -2
View File
@@ -59,14 +59,15 @@ For initial server provisioning (Docker, the Supabase stack, Caddy, firewall, SS
Transactional email is an outbox drained by `POST /api/cron/notifications`. Add a once-a-minute root crontab entry on the Droplet (after `RESEND_API_KEY`, `EMAIL_FROM`, `CRON_SECRET` are set in the app `.env`): Transactional email is an outbox drained by `POST /api/cron/notifications`. Add a once-a-minute root crontab entry on the Droplet (after `RESEND_API_KEY`, `EMAIL_FROM`, `CRON_SECRET` are set in the app `.env`):
``` ```
CRON_SECRET=<the same value set in .env.production>
* * * * * 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/notifications -H "Authorization: Bearer $CRON_SECRET" >/dev/null 2>&1
``` ```
It hits the app locally (bypassing Caddy), self-throttles under Resend's rate limit, and is idempotent, so a missed or overlapping minute is harmless. Migration `041_notifications.sql` is applied automatically by `deploy.sh` like every other `scripts/0*.sql`; no manual database step. Define `CRON_SECRET=` on its own line at the top of the crontab. A crontab runs with a bare environment, so a bare `$CRON_SECRET` reference expands to empty and every call silently 401s (the `>/dev/null 2>&1` hides it). It hits the app locally (bypassing the reverse proxy), self-throttles under Resend's rate limit, and is idempotent, so a missed or overlapping minute is harmless. Migration `041_notifications.sql` is applied automatically by `deploy.sh` like every other `scripts/0*.sql`; no manual database step.
### Door inbound-log poll cron (optional, door epic Phase 4) ### Door inbound-log poll cron (optional, door epic Phase 4)
If a space runs a native-HeatSync controller and turns on inbound ingest (per connection, on `/door/manage`), the once-a-minute poll pulls its `?z` log into the access log and matches each card to a member. It reuses the same `CRON_SECRET`; add a second crontab entry: If a space runs a native-HeatSync controller and turns on inbound ingest (per connection, on `/door/manage`), the once-a-minute poll pulls its `?z` log into the access log and matches each card to a member. It reuses the same `CRON_SECRET`; add a second crontab entry under the same inline `CRON_SECRET=` definition as above:
``` ```
* * * * * 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 * * * * * 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
+28 -15
View File
@@ -4,27 +4,40 @@ Append-only. Newest entries on top. Keep each entry to one screen.
--- ---
## SESSION CLOSEOUT 2026-05-20 (read this first) ## SESSION CLOSEOUT 2026-05-21 (read this first)
**What this arc shipped (passes 56-62, all DEPLOYED).** Product spine is now Phases 1-5 complete: **What this arc shipped (passes 63-70, all DEPLOYED).** The **door epic is now complete (P1-P5)**. P4 inbound access-log ingest: a `CRON_SECRET`-guarded HeatSync `?z` poll (now parallelized via `Promise.allSettled`) PLUS a per-connection bearer-authenticated webhook (`/api/door/inbound/[connection]`), both matching the presented card to a member via the HeatSync hex-uid model (`cardMatchesEvent`) and deduping on `(connection_id, dedupe_key)`; config on `/door/manage`. P5 universal API-call button builder: admins define permission-gated `api_buttons` (any HTTP verb + headers + body + vault secret, default the new `apicall.invoke` code) fired through ONE shared hardened egress (`callDoor` + `callApi` over an internal `egress`), builder at `/door/buttons`, members press from `/doors`, every press in `api_call_log`. Migrations **053** (ingest) + **054** (buttons + perm). Plus: dashboard + comms + form-builder **mobile-overflow fixes** (`min-w-0` on flex-truncate rows), the **`docs/SPINE_VALIDATION.md`** owner runbook, and a **privilege-escalation RLS** integration test.
1 Stripe recurring dues, 2 transactional-notification outbox+dispatcher, 3 `/me` self-serve portal (now the single member page; `/profile` deleted + consolidated in), 4 notification breadth (bookings/classes/forms), 5 member notification preferences + in-app notification inbox. Plus: admin-configured **alternate dues payment links** (PayPal/Zeffy/Venmo, https-only, manual reconcile), a `/settings` **Dues tab**, and a `/me` theme realign.
**Production incident (resolved).** A migration-048 table (`notification_preferences`) with FKs to BOTH `space_members` and `spaces` made PostgREST read it as an M2M junction, so the auth layout's `space_members.select('*, spaces(*)')` embed went ambiguous (PGRST201) and bounced every logged-in user to `/signup`. Hotfixed (migration 052 dropped the redundant FK). Root rule, now understood + guarded: **PostgREST treats a 2-FK table as an ambiguous junction only when its PK covers both FK columns** (composite-PK join shape); surrogate-`id` tables are safe. Guarded by `integration/auth-embed.test.ts`. NEVER add a `spaces` FK to a `space_members`-referencing table (see DATABASE_SCHEMA 052 + the ARCHITECTURE gotcha note). **State.** `pnpm test` 626 unit (deploy gate, hermetic); `pnpm test:integration` 54 vs real Postgres. Build + lint clean. Prod healthy. Migrations through **054** applied. Many diversified audit passes this arc found no P0: the reals were the ingest matcher false-positive (fixed -> hex-uid anchored), the sequential poll (fixed -> parallel), stale docs (refreshed), and one runbook inaccuracy (fixed). The PGRST201 outage class is RE-VERIFIED contained (a fresh `pg_constraint`/`pg_index` query confirms the ONLY composite-PK 2-FK junction is the legitimate `space_member_custom_roles`; the new tables are surrogate-`id`, not junctions).
**State.** `pnpm test` 594 unit (deploy gate, hermetic); `pnpm test:integration` 40 vs real Postgres (auth-embed, dispatcher-skip, dues RLS, billing/webhook/RPC/constraint invariants). Build clean. Prod healthy (auth embed returns `[]`). Migrations through 052 applied. 4 audit passes this arc (deep + diversified across public/API, XSS, authz) found no P0; the reals they did find (Stripe-card config footgun, onboarding `payment_url` XSS, forum cross-space comment, joinSpace rate limit) are fixed + deployed. **Provisioning status (`docs/SPINE_VALIDATION.md`).** **A1 (Resend) + A2 (`CRON_SECRET` + both crontabs) are now DONE on the prod Droplet (pass 71 below)** and proven: crons flipped 503->401 public / 200 authed; a direct Resend send from the verified `hackerspace.sh` domain succeeded. **Still owner/external (the remaining gap):** A3 per-space Stripe test keys + Billing Portal + the Dues-tab config; A4 the Supabase Change-Email template + `/auth/confirm` allowlist; A5 (optional) an admin enabling a door connection; and **Part B** (a real test-mode dues cycle + booking/class/form notifications + `/me` click-through), all of which need external accounts or a live authenticated session.
**The single biggest gap: shipped != proven.** Phases 1-5 + payment links are code-proven but **INERT in prod** until the owner provisions: Resend (`RESEND_API_KEY` + `EMAIL_FROM` on a verified domain), `CRON_SECRET` + the once-a-minute droplet crontab, a space's per-space Stripe test-mode keys + activated Billing Portal, and the Supabase Change-Email template + `/auth/confirm` allowlist. Until then no email sends and no real dues cycle runs.
**Backlog (priority order for the next session):** **Backlog (priority order for the next session):**
1. **OWNER-GATED end-to-end spine validation.** Produce a precise runbook + checklist; owner provisions the above, then runs a real test-mode dues cycle + booking + class signup + form submit and clicks through `/me` (inbox, prefs, dues card, Dues tab). Converts the whole arc into proven-working. The #1 item for many passes. 1. **OWNER-GATED spine validation EXECUTION.** The runbook is written (`docs/SPINE_VALIDATION.md`); the owner now executes Part A (provision) + Part B (prove the dues cycle / notifications / `/me` / door). The agent cannot (external accounts + live session + on-droplet env/cron). This converts shipped -> proven.
2. **Production observability.** No error monitoring; money path (Stripe webhook) + dispatcher cron + every server action only `console.error`. Add a Sentry-equivalent (design-first on the tool). 2. **Production observability.** No error monitoring; money path (Stripe webhook) + both crons + every server action only `console.error`. Add a Sentry-equivalent (design-first on the tool; likely self-hosted GlitchTip).
3. **Owner browser review** of `/me` + the `/settings` Dues tab (auth-gated; the agent cannot do it). 3. **Owner browser review** (auth-gated, agent cannot): `/me`, the Dues tab, AND the new door surfaces (`/door/manage` inbound panel, `/door/buttons` builder, `/doors` Actions); plus a real-device check of the mobile flex-row fixes with a long name.
4. **Extend the integration harness**: payments link/import, role/permission management, presence, forms `submitForm`/`linkSubmissionsByEmail`. 4. **Extend the integration harness**: payments link/import, presence, forms `submitForm`/`linkSubmissionsByEmail`. (Door RLS, api-buttons RLS, and the permission-escalation guard are now covered.)
5. **`user_has_permission` EXECUTE-TO-PUBLIC triage** (pre-existing, flagged pass 56). 5. Defense-in-depth noted, not done: `.eq('space_id')` on equipment-cancel / door-slot writes (parent already space-checked); webhook signing secret shown pre-populated to admins (retrievable by design); door-ingest parallel poll bursts up to 50 concurrent DB calls (fine for a small fleet, bounded by `MAX_CONNECTIONS`). Deferred until product need: outbound webhooks epic, server-side search, multi-space-per-user, Zeffy/Venmo live APIs.
6. **Door epic Phases 4-5** (inbound log ingest + universal API-call UI builder).
7. Defense-in-depth noted, not done: add `.eq('space_id')` to equipment-cancel / door-slot writes (parent already space-checked); the webhook signing secret is shown pre-populated to admins (retrievable by design). Deferred until product need: outbound webhooks epic, server-side search, multi-space-per-user, Zeffy/Venmo live APIs.
**Locked decisions (do not relitigate without the owner):** single space per user; dues lapse is grace -> late, never auto-inactive/auto-approve; forms email-link may include anonymous typed emails (attribution only); external dues links are link-config only (no auto payment record on click); Stripe is configured in ONE place (the Dues tab). **Door-epic facts to keep (do not relitigate):** transport is NOT LAN-only (cloud-hosted; per-connection host pin, metadata/link-local always blocked, no redirects, resolve-once-connect-by-IP); HeatSync `?z` is a 40-slot ring with no per-entry id (poll is best-effort, the webhook is the reliable transport); the SSRF pin is enforced on the FINAL assembled URL incl. `url_template`; `card_uid` is hex (the grant encoder requires it) and `?z` reports decimal. Button management = `door.manage` (the catalog scopes it to "buttons"); invoke = each button's `required_permission`.
**Locked decisions (do not relitigate without the owner):** single space per user; dues lapse is grace -> late, never auto-inactive/auto-approve; forms email-link may include anonymous typed emails (attribution only); external dues links are link-config only (no auto payment record on click); Stripe is configured in ONE place (the Dues tab). PGRST: NEVER add a `spaces` FK to a `space_members`-referencing table (the 052 outage class), guarded by `integration/auth-embed.test.ts`.
---
## 2026-05-21 (pass 71): Spine provisioning A1+A2 executed on prod (owner-supported)
Branch `main`. Owner asked the agent to provision directly; did it over SSH on the Droplet. No code change, no deploy (config + crontab on the box only; the sole repo edit is this HANDOFF).
**Real prod layout (docs drift; corrected in memory `prod-ops-access`).** App is `/opt/hackerspace-ops` (NOT `/opt/hackerspace`), runs as systemd `hackerspace-app` via `next start` (native Node, NOT docker compose), env file `/opt/hackerspace-ops/.env.production` (`deploy:deploy 0600`), `deploy` has passwordless sudo. Supabase runs in docker on the same box.
**A1 Resend (DONE + proven).** `.env.production` was missing all three spine vars. Backed it up, added `RESEND_API_KEY` (from `secrets/base.env`) + `EMAIL_FROM="hackerspace.sh <[email protected]>"`. The key is a restricted send-only key (can't list domains), so proved the domain another way: a direct `POST https://api.resend.com/emails` from `[email protected]` returned a message id -> domain verified, transport works.
**A2 CRON_SECRET + crontabs (DONE + proven).** Generated `CRON_SECRET` on the box, added to `.env.production`; installed BOTH crontab lines in the `deploy` crontab with `CRON_SECRET=` defined inline at the top (a bare `$CRON_SECRET` in a crontab line is empty -> silent 401; the runbook example is a footgun). Restarted `hackerspace-app`. Verified: public no-auth + wrong-bearer now **401** (were 503); authed localhost **200** (`notifications {scanned:0..}`, `door-ingest {ok:true,polled:0..}`). Outbox now drains every minute.
**Security false-alarm RETRACTED.** Early in the pass I claimed `secrets/base.env` + the SSH key were committed to a public repo. That was a misread (I attributed `git check-ignore` output to `git ls-files`). Truth: `.gitignore:32 /secrets/` ignores them; they are NOT tracked / committed / on any of the 6 remote branches. No leak, no rotation needed.
**Still open (owner/external).** A3 Stripe (test keys, webhook endpoint from the Dues tab, Billing Portal, Dues-tab save), A4 Supabase Change-Email template + `/auth/confirm` allowlist, A5 (optional) door connection enable, and Part B end-to-end proof. The vault `secrets` table has 1 row (the one door connection); 0 integrations connected.
--- ---
+2 -1
View File
@@ -28,11 +28,12 @@ Conventions: `APP` = your app domain (e.g. `hackerspace.sh`). `SPACE_ID` = the i
3. Add two root crontab entries (`crontab -e`): 3. Add two root crontab entries (`crontab -e`):
``` ```
CRON_SECRET=<the same value you set in .env>
* * * * * 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/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 * * * * * 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.) Define `CRON_SECRET=` on its own line at the top of the crontab as shown. A crontab runs with a bare environment, so a `$CRON_SECRET` reference with no such line expands to empty: every call then silently 401s, and the `>/dev/null 2>&1` hides it.
**Verify:** **Verify:**
- `curl -s -o /dev/null -w "%{http_code}" -X POST https://APP/api/cron/notifications` (no auth) → **401**. - `curl -s -o /dev/null -w "%{http_code}" -X POST https://APP/api/cron/notifications` (no auth) → **401**.