270 KiB
Handoff Log
Append-only. Newest entries on top. Keep each entry to one screen.
2026-10-11 (docker-compose = full local stack + secret generator)
Branch main, uncommitted.
docker-compose.ymlis now the all-in-one local stack (app + Postgres, GoTrue, PostgREST, Realtime, Kong, Studio). It is the formerdocker-compose.full.yml; compose project and network renamed tohackerspace/hackerspace_net.- The old app-only file is
docker-compose.app.yml.docs/DEPLOY_DO_SELFHOSTED.mdcommands updated to pass-f docker-compose.app.yml. .env.examplereplaced by the former.env.full.exampleplus app secrets (SECRETS_ENCRYPTION_KEY,CRON_SECRET, email, Sentry, OAuth,SETUP_*)..env.full.exampleremoved.- New
scripts/generate-env.sh: copies.env.exampleto.env, fills POSTGRES_PASSWORD, JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY (HS256 JWTs), SECRET_KEY_BASE, SECRETS_ENCRYPTION_KEY, CRON_SECRET. Asks for confirmation (default N;--yesskips)..envadded to.gitignore. deploy/deploy.shnow passes-f docker-compose.app.ymlso the CD path keeps deploying the app only.- Host port for Postgres is now
POSTGRES_HOST_PORT(wasPOSTGRES_PORT, which the db image also reads as its internal port; setting it to 5433 broke first-boot init). Existing.envfiles need the rename. supabase/db/*.sql+ dbcommandfix: the db container must start with the image's ownconfig_fileand mount the first-boot init SQL, or thepostgresrole is never created (found on first real deploy). Verified on a standalone db container only (roles, passwords,_realtime).- Booted locally (arm64): all services healthy after fixing the realtime healthcheck (needs the anon key header) and the studio healthcheck (listens on its hostname, not localhost).
scripts/schema.sqlalone creates all 52 tables; it records only migrations 014-017 as applied, soapply-migrations.shon a fresh DB fails at 018 (view column rename). Fresh installs: run schema.sql only. Not fixed. - Single-tenant flags (
NEXT_PUBLIC_SINGLE_TENANT,_SITE_NAME,_SINGLE_TENANT_SPACE_SLUG,_SINGLE_TENANT_OPEN_JOIN,_SHOW_MARKETING) are now Dockerfile build args and compose build args. Before this, the Docker image ignored them (Next inlinesNEXT_PUBLIC_*at build), so the createSpace guard was never active in containers. Not yet verified with a rebuild. - New
scripts/setup-space.sh: runsscripts/setup.mjs(same aspnpm setup) in a container built from the Dockerfiledepsstage onhackerspace_net, reading.env. Host needs only Docker. Appliesscripts/schema.sqlitself whenpublic.spacesis missing (fresh volume). Verifieddoctorafterdown -v; provision and create-admin not run. - Decision for you: the script writes
.env, not.env.example, since.env.exampleis committed. Use--output .env.exampleonly if you accept real secrets in a tracked file.
2026-08-05 (single-tenant mode + setup CLI + security fix)
Shipped single-tenant deployment mode so a hackerspace can run its own private instance (own DB, own
domain, one space) from the same codebase, with no schema fork. Also patched a pre-existing critical auth
vuln found during review. Branch feat/single-tenant-mode (2 commits); merging to main.
Approach. The app is pooled multi-tenant (many spaces per deploy, space_id-scoped) and already single
active space per user; resolution is membership-only (no subdomain routing). So single-tenant = a config-gated
MODE, not a rewrite. Decided AGAINST forking or stripping space_id.
Tier 1 (core). lib/tenant.ts is the single source of truth (pure resolveTenantConfig + tenantConfig()
appBaseUrl(), mirrorslib/auth-config.ts). New NEXT_PUBLIC_ flags (server re-enforced, never trusted from the client):NEXT_PUBLIC_SINGLE_TENANT,_SITE_NAME,_SINGLE_TENANT_SPACE_SLUG,_SINGLE_TENANT_OPEN_JOIN,_SHOW_MARKETING.createSpacerefused when!allowSpaceCreation;joinSpaceopen-join resolves the configured/sole space with an empty invite code (still honorsrequire_approval+ already-a-member guard);proxy.tshides the marketing shell; signup UI drops the create/join chooser and usessiteName. The four|| 'https://hackerspace.sh'base-URL fallbacks now go throughappBaseUrl()(default localhost) so a fork never leaks the platform domain. All no-ops when the env is unset, so hackerspace.sh (multi-tenant) is unchanged.
Tier 2 (productize). pnpm setup (scripts/setup.mjs): doctor / provision / create-admin / all-in-one,
idempotent, creates the first admin via the Supabase Admin API (createUser email_confirm, then the admin
space_members row); flag > env > .env.local precedence; masked prompts; --yes/--force (force also
resets an existing admin's password). scripts/apply-migrations.sh + deploy/deploy.sh commit the previously
droplet-only CD glue; _migrations_applied folded into schema.sql. docker-compose.full.yml +
.env.full.example = bundled app + self-hosted Supabase. docs/SINGLE_TENANT.md + docs/CUSTOMIZE.md
(the documented lib/tenant.ts pattern for white-labeling) + README pointer. __tests__/tenant.test.ts (19).
SECURITY (pre-existing, migration 055 -> 056). Dropped the stale handle_space_signup() AFTER-INSERT auth
trigger. It was SECURITY DEFINER and read role+space_id from client-controlled raw_user_meta_data, so a
crafted supabase.auth.signUp({options:{data:{role:'admin',space_id:'<uuid>'}}}) could self-insert an approved
admin member (surviving approval, since approveMember does not reset role), bypassing createSpace/joinSpace.
Real signups never set those keys (membership comes from the server actions), so it was dead code AND a
takeover vector. scripts/056_drop_stale_signup_trigger.sql (idempotent) drops it; the droplet deploy applies
pending scripts/0*.sql via _migrations_applied, so 056 lands in prod on this merge.
Gates. tsc 0, lint 0 errors, 664 unit tests (was 626; +tenant + fixed enqueue test), next build ok.
Verified by a 10-agent review workflow (build + gates + 4 adversarial lenses); all findings fixed. NOT
browser-smoke-tested against a live single-tenant DB (needs pnpm setup against real Supabase); compose is
syntax-validated only. Follow-up if desired: a live pnpm setup proof + an authed e2e for open-join.
SESSION CLOSEOUT 2026-05-21 (read this first)
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.
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).
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.
Backlog (priority order for the next session):
- 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. - Production observability. Phase 1 (app-side capture seam) is BUILT + inert, pending deploy (pass 72): backend-agnostic no-SDK fetch seam ->
SENTRY_DSN, secret-scrubbed, covers the money path + both crons + all server actions (viaonRequestError). Remaining = Phase 2 (the box): swap + self-hosted GlitchTip on the existing supabase-db + the DSN. Owner chose self-hosted GlitchTip. - Owner browser review (auth-gated, agent cannot):
/me, the Dues tab, AND the new door surfaces (/door/manageinbound panel,/door/buttonsbuilder,/doorsActions); plus a real-device check of the mobile flex-row fixes with a long name. - Extend the integration harness: payments link/import, presence, forms
submitForm/linkSubmissionsByEmail. (Door RLS, api-buttons RLS, and the permission-escalation guard are now covered.) - 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 byMAX_CONNECTIONS). Deferred until product need: outbound webhooks epic, server-side search, multi-space-per-user, Zeffy/Venmo live APIs.
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 72): Observability Phase 1 (app-side capture seam, inert)
Branch main. Took backlog item 2. Design-first: owner chose self-hosted GlitchTip (+ swap), so the app-side capture is a backend-agnostic seam pointed at a DSN. Implementation call (made, not asked): a no-SDK fetch envelope sender mirroring lib/email/send.ts, NOT @sentry/nextjs (keeps the client bundle + next.config untouched, zero dependency/supply-chain). NOT yet deployed; awaiting owner go.
Shipped (local):
lib/observability/scrub.ts(pure): redacts emails, JWTs, Bearer tokens, Stripesk_/rk_/whsec_, Resendre_, bare 32+ hex; leaves dashed UUIDs intact. Deep-walks objects (depth/breadth-capped, cycle-safe).lib/observability/capture.ts:parseDsn+ purebuildEvent(scrubbed) +captureException/captureMessage. Inert whenSENTRY_DSNunset (no-op). Best-effort fire-and-forget POST to…/api/<projectId>/store/with a 2.5s abort; never throws into a caller.instrumentation.tsonRequestError-> the broad net for any thrown route/RSC/server-action error (no per-action wiring). Manual capture at the swallow/500 sites that never reach it: Stripe webhook (dedupe + handler),/api/cron/notifications(candidates + prefs),/api/cron/door-ingest(load + poll-fail + threw),enqueueNotification(central),lib/door/ingest.tsinsert.__tests__/observability.test.ts(+13). Docs:.env.exampleSENTRY_DSN; ARCHITECTURE observability subsection.
Gate: pnpm test 639 (was 626; +13) green; pnpm build clean. Server-side capture only (no client SDK). No migration, no schema, no RLS.
Deployed (c89e4d9, run 26260262561 success); inert in prod (SENTRY_DSN unset). Phase 1 was a safe no-behavior-change deploy.
Phase 2 = a REPRODUCIBLE repo artifact, not droplet surgery (owner steer). Instead of hand-building containers on the specific droplet, the GlitchTip backend is now committed at deploy/glitchtip/ (docker-compose.yml + .env.example + README), self-contained (its OWN Postgres + Redis, shares nothing with the app stack), 127.0.0.1-bound, memory-capped, one-command docker compose up -d (migrate runs first via service_completed_successfully). Any self-hoster gets it the same way; the app stays generic (SENTRY_DSN works with this OR Sentry SaaS OR nothing). A 2GB swapfile was added to the production droplet (was RAM-tight: 3.8G, no swap) + persisted in fstab. Phase 2 is now LIVE on prod: the GlitchTip stack runs from /opt/hackerspace-ops/deploy/glitchtip bound to 127.0.0.1:8100 (port 8100 not 8000 to avoid Supabase Kong; the committed compose default was fixed to 8100 in ad4b9f5), web/worker/redis/postgres healthy, ~2.3G RAM available + light swap. SENTRY_DSN=http://<key>@localhost:8100/1 is set in .env.production, app restarted. Ingest proven: a verification event POSTed in the seam's exact request shape returned HTTP 200 + an event id (so DSN + auth + event format + GlitchTip ingest all work; the app uses the identical request from buildEvent, so real server errors now land automatically). Open registration locked off (ENABLE_OPEN_USER_REGISTRATION=False). Dashboard viewed via SSH tunnel -L 8100:127.0.0.1:8100. Verified in the GlitchTip UI: issue SH-1 VerificationError with tags surface=phase2-verify / environment=production (running GlitchTip 6.1.6). The committed compose image is now PINNED to glitchtip/glitchtip:6.1.6 (was latest) for reproducibility. A first hand-rolled bring-up attempt was scrapped after a psql-quoting bug; the committed compose superseded it. Observability item 2 = DONE (both phases).
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, incl. the full pipeline). .env.production was missing all three spine vars. Backed it up, added RESEND_API_KEY (from secrets/base.env) + EMAIL_FROM="hackerspace.sh <noreply@hackerspace.sh>". The key is a restricted send-only key (can't list domains), so proved the domain via a direct POST https://api.resend.com/emails (returned a message id). Then proved the FULL app outbox pipeline: injected one pending notifications row (member_id NULL = always-send, space HeatSync Labs) via the service client and ran the authed dispatcher on the box -> {scanned:1,sent:1}, row reached status=sent; deleted the test row (notifications back to 0). Remaining notification gap is only the ENQUEUE side on real domain events (Part B, needs a live session).
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.
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.
Audit: systematic mobile horizontal-overflow scan. Grepped every truncate in app/+components/ for the flex anti-pattern (a truncate flex child without min-w-0 will NOT shrink, so a long value pushes the row past the viewport). Triaged the 5 files that use truncate but never min-w-0: table-cell truncates in payments/import are fine (max-w-[…] + table-cell), the payments meta <p> is a block child (fine), and mini-previews is decorative landing. Two real instances fixed: comms channel-name spans (3, the channel sidebar/mobile drawer) -> truncate min-w-0; form-builder published-URL span -> flex-1 min-w-0 truncate. (Pass 67 already fixed the dashboard presence + door-self-entry spans.)
Cron poll parallelized (clears the pass-66 latent finding). /api/cron/door-ingest now polls connections with Promise.allSettled instead of a sequential for…await, so a tick is bounded to ~the slowest single poll rather than the SUM of all 6s timeouts (a slow/unreachable fleet could previously run minutes and overlap the next minute's tick). Each connection stays isolated (one bad controller can't 500 the run); MAX_CONNECTIONS=50 caps the fan-out; dedup keeps it idempotent. Inert until CRON_SECRET is provisioned.
Deploy. This pass + the uncommitted pass-66 (docs/README refresh) + pass-67 (dashboard mobile + API-button re-audit) ship together (see the deploy note below this entry once pushed). No migration; no behavior change for existing users (className + cron-internal only).
2026-05-21 (pass 67): API-buttons/door re-audit (clean) + dashboard mobile fixes
Branch main. Re-audited the API-call buttons + door subsystems with a fresh adversarial lens and did a dashboard mobile-responsiveness pass.
API-buttons + door audit (CLEAN, no code bug). Re-read the invoke path, executor, and validations. Confirmed: the SSRF pin is enforced on the FINAL assembled URL (base_url + url_template + query-secret -> validateDoorTarget -> resolve-once-connect-by-IP), so even a url_template like @evil.com cannot bypass it -- a host mismatch with the per-button pin rejects, and the metadata/link-local block is absolute. buildApiRequest drops a caller Host header and undici rejects CRLF in header names/values (no request smuggling). Invoke is rate-limited FIRST, loads the def service-side scoped to the member's space, gates on the per-row required_permission (denials audited), fails closed on disabled/unknown-permission, decrypts the secret server-side and never returns it; api_call_log detail is double-redacted (egress + action). Tenant isolation holds; all UI rendering is escaped JSX; no committed secrets. The pass-65 hex-uid matcher holds.
Dashboard mobile responsiveness. The shell is sound (fixed 52px mobile top bar + hamburger drawer; main clears it with pt-[52px] md:pt-0) and the dashboard grids already collapse (grid-cols-2 lg:grid-cols-4 stats; lg:grid-cols-[1fr_280px] -> single column; 44px touch targets on Quick Task / CLAIM; titles truncate with min-w-0). Found + fixed TWO real horizontal-overflow bugs: in dashboard/presence-panel.tsx and dashboard/door-self-entry.tsx the name <span> had truncate but no min-w-0, so in a flex row a long member/door name would NOT shrink and pushed the row past the viewport (horizontal scroll on a phone). Added flex-1 min-w-0 (+ shrink-0 on the door Open button). The task-row title was already correct. No other overflow risks found (banner links flex-wrap; proposals/incidents titles are block truncate; activity/project text wraps). Could not browser-test (auth-gated); verified by layout inspection + build. Note (not changed): shared Button size="sm" is ~36px tall, under the 44px touch guideline, but that is app-wide and consistent, not a dashboard regression.
State. Build + lint green; 626 unit / 50 integration unchanged (className-only + audit). Includes the pass-66 docs (still uncommitted).
2026-05-21 (pass 66): Ultrathink audit (broad) + docs/README refresh
Branch main. A fresh diversified audit (not re-defending the door work) plus the doc/README consolidation it surfaced. No code change this pass.
Audit (diversified surfaces; findings).
- Clean / re-verified. No committed secrets in the door/api-button files. No hardcoded permission-code list that adding
apicall.invokewould break (the only consumers areuser_has_permissioncalls + the door-template default). Theapicall.invokecatalog addition + 054 backfill are additive (board + admin-implicit only; member/treasurer/associate unchanged). API-button header injection is bounded:buildApiRequestdrops a callerhost, and undici rejects CRLF in header names/values (no request smuggling); admins are trusted for their own buttons and the metadata/link-local block is absolute regardless. The matcher fix (pass 65) holds. - Finding 1 (LATENT, inert): poll cron is sequential.
/api/cron/door-ingestdoesfor (const c of conns) await pollConnectionLog(c)with a 6s per-poll timeout andMAX_CONNECTIONS=50, so a fleet of slow/unreachable controllers can make one tick run up to ~300s (the crontab'scurl -m 30returns, but the Node handler keeps running), and overlapping minute-ticks can pile up. Safe for correctness (idempotent dedup) and a non-issue for the deploying org (1-2 doors), but a real scaling concern. Recommended fix when this matters:Promise.allSettledthe polls (bounded) so a tick takes ~max(single poll) not the sum, and/or lower the per-poll timeout. Inert today (CRON_SECRET unprovisioned). Not changed this pass (docs-focused; would need its own deploy). - Finding 2 (DOCS, fixed this pass): stale docs. ARCHITECTURE still framed the door epic as "P1-P3 built / migrations 034-036; epic in progress"; the README access-control bullet predated P4/P5; and the README pointed at
docs/AUDIT.md(a 2026-05-14 snapshot) as "the latest audit." Fixed below.
Docs/README refreshed (same-change). ARCHITECTURE: door section header + intro now read "migrations 034-036, 053-054; epic complete, P1-P5"; the migration parenthetical points to DATABASE_SCHEMA for the full history; "Last Updated" -> 2026-05-21. README: access-control feature bullet now covers grant/revoke/self-entry + inbound ingest (poll + webhook) + the API-call button builder; the project-status pointer now sends readers to HANDOFF.md for the running log and labels AUDIT.md as a historical 2026-05-14 snapshot. docs/AUDIT.md gained a top banner saying the same. CHANGELOG [Unreleased] / Added gained a door-epic-complete entry.
State. Suite 626 unit / 50 integration / build + lint green. Migrations through 054. Door epic feature-complete + deployed (P1-P5). One latent scaling finding (cron sequential poll) documented, not yet fixed.
2026-05-20 (pass 65): Door epic P4+P5 DEPLOYED + ultrathink audit
Branch main. Shipped the batched door-epic completion and ran a fresh diversified audit.
Deploy (DEPLOYED). Passes 63 (P4 inbound ingest) + 64 (P5 API-call builder) shipped together in 7ec90b0 (run 26204184395, success). Deploy log confirms applying 053_door_inbound_ingest.sql then applying 054_api_buttons.sql, no errors. Smoke: / 200, /login 200, /dashboard /doors /door/manage /door/buttons 307->login (the new builder route exists + is gated, not 404). New endpoints fail-closed: /api/cron/door-ingest 503 (CRON_SECRET still unprovisioned in prod -> poll inert), /api/door/inbound/<malformed> 404, /api/door/inbound/<uuid> no-bearer 401. Could not run the prod anon-key auth-embed (PGRST201) check from here (local .env.local holds the local-stack key, not the prod JWT); the regression class is covered by integration/auth-embed.test.ts (green vs the migrated 053+054 schema) and the schema reasoning (door_connections' 2nd secrets FK is a surrogate-PK table, not a junction; nothing embeds secrets; the auth embed is untouched). /dashboard 307 (no /signup bounce) is consistent with a healthy embed.
Ultrathink audit (fresh, diversified; no auto-defend). Surfaces: public/API, authz/RLS, XSS/rendering, schema/PGRST, the shared egress refactor, logActivity.
- Re-verified clean. The egress refactor preserves
callDoorexactly (the SSRF pin is enforced on the FINAL assembled URL forcallApitoo: base + url_template + query-secret ->validateDoorTarget-> resolve-once -> connect-by-IP; host header forced to the pin; metadata/link-local block is absolute even for an admin-configured button; url_template cannot inject a host past the pin). AuthZ/tenant isolation: button loads are space-pinned, invoke is per-row-permission-gated, the denial path is now rate-limited. XSS: all new rendering is escaped JSX. PGRST: no new junction (all new tables have surrogate PKs; api_buttons/api_call_log are never embedded).activity_logis free-text sologActivity('api_button', ...)inserts cleanly. Theapicall.invokeseedCREATE OR REPLACEis idempotent + backfilled. - One finding, FIXED this turn (was LOW; audit-only; inert). The original
cardUidsEquivalentmatched a card uid by exact string OR numeric equality across BOTH decimal and hex readings of the STORED uid, which both mis-attributed all-digit uids (stored"16"= hex 0x16 = 22 wrongly matched a decimal poll of card 16) and was conceptually wrong. Replaced with source-anchoredcardMatchesEvent(storedUid, ev): HeatSync stores the uid as hex (the grant encoder requires it) and?z/webhook report the DECIMAL card number, so a reported decimal matches whenhexInt(storedUid)equals it; a webhookcard_uidalso matches by exact (case-insensitive) string. The stored uid is never read as decimal, removing the false positive while keeping every legit match (hex9c40<-> poll40000; all-digit hex10<-> poll16). +tests; suite 626. Note for whoever enables ingest: this assumes stored uids are hex (true for the HeatSync grant path); confirm against a real controller, and if a space stores DECIMAL uids the anchoring would need revisiting. - Accepted trade-offs (re-confirmed, documented). Webhook decrypt-flood needs an unguessable connection uuid (post-auth rate limit kept to avoid a targeted relay lockout); the webhook content-length guard is best-effort behind the platform body limit; the cron automates an admin-configured outbound call within the existing door-connection trust model; audit inserts are best-effort (a call result still returns on a logging blip).
Backlog item 5 TRIAGED + CLOSED: user_has_permission EXECUTE-to-PUBLIC. Investigated the grant flagged pass 56. Findings: (1) user_has_permission (+ user_has_role_in_space) grant EXECUTE to anon + authenticated + service_role (explicit, not loose PUBLIC). (2) anon holds the DEFAULT Supabase broad grants (SELECT/INSERT/UPDATE/DELETE) on all 51 public tables; RLS is the SOLE gate, and ~40 policies call user_has_permission in their USING/CHECK. So anon genuinely EVALUATES those policies, which means revoking EXECUTE from anon would make anon queries error ("permission denied for function") rather than deny -- it is STRUCTURALLY REQUIRED, not removable. (3) The residual exposure is a boolean oracle (rpc user_has_permission(uid,sid,perm) -> does uid hold perm in sid), but it needs a known auth.uid (a victim's UUID), which anon cannot obtain (auth ids are not anon-readable; single-space-per-user makes cross-space probes mostly false). (4) The genuinely dangerous SET-returning enumeration variant (members_with_permission) is already locked (REVOKE FROM PUBLIC / GRANT service_role, migration 047). Conclusion: no change; the grant is required by the RLS model and the practical leak is a low-value boolean gated by uid secrecy. A harder lockdown (moving the function to a PostgREST-unexposed schema so it stays RLS-callable but is not a public RPC) is a large, risky refactor touching every policy and is NOT justified by the residual risk. Item 5 closed.
State. Suite 626 unit / 50 integration / build + lint green. Migrations through 054. Door epic feature-complete (P1-P5). Backlog item 5 closed (no change). Ingest attribution matcher hardened (cardMatchesEvent).
2026-05-20 (pass 64): Door epic Phase 5 (universal API-call UI builder) (DEPLOYED in 7ec90b0; see pass 65)
Branch main. Completes the door epic. Design-first via AskUserQuestion; the user chose full verbs (GET/POST/PUT/PATCH/DELETE) + per-button required permission. Suite 624 unit (was 615; +9 buildApiRequest tests) / 50 integration (was 44; +6 api-buttons) / build green / lint clean.
Shipped (local):
- Migration 054: new permission code
apicall.invoke(group Access) seeded intoseed_default_role_permissions(CREATE OR REPLACE) + backfilled for board on existing spaces (additive, like door.* in 034).api_buttons(label, button_group, sort_order, method, base_url, pinned_host, url_template, headers jsonb, body_template, auth_mode/auth_param, secret_ref -> vault, required_permission default apicall.invoke, confirm, is_enabled) +api_call_log(append-only press audit, redacted, service-client-only). All button CRUD =door.manage(the catalog already scopes door.manage to "buttons"); per-button required_permission gates pressing (enforced by the invoke action, members have no RLS read on defs). schema.sql + types + DATABASE_SCHEMA + DB_SCHEMA_MAP + catalog same-change. - Shared egress: refactored
lib/door/executor.tsso the SSRF+resolve+connect-by-IP+no-redirect+caps+redact core is one internalegress;callDoor(GET) is behavior-identical (guarded by the existing door tests),callApiadds full verbs + headers + body + per-auth_mode secret injection, host header forced to the pin. Pure request assembly inlib/api-call-logic.ts(buildApiRequest) + 9 unit tests. - Actions
lib/actions/api-buttons.ts: manage CRUD +listApiCallLog(door.manage);listInvokableButtons(curated, presentational-only) +invokeApiButton(rate-limit-first, per-row permission check, denials audited, secret server-side, redacted audit). Zod schemas (method enum, https? url, header count/length caps, required_permission refined against the catalog). - UI: builder
/door/buttons(grouped list, create form, door-template preset, secret + required_permission pickers, enable/delete, the call log) + sidebar "API buttons" link (door.manage). Member invoke: an "Actions" section on/doors(grouped buttons, confirm dialog). - Audit: SSRF egress reused (host pin + metadata block absolute even for admin config; member never supplies url/headers/body, only a buttonId); tenant-isolated; secret space-scoped + never returned + redacted; all rendering escaped JSX; single secrets FK (PGRST safe, auth-embed green). Fix: moved the invoke rate-limit before any DB work so a member can't flood denial-audit rows.
Deploy state. DEPLOYED in 7ec90b0 (run 26204184395; see pass 65). Migrations 053 + 054 live on prod. No new env var or cron for P5 (all in-app). Inert until an admin creates a button.
Open / next. Door epic is feature-complete (P1-P5). v1 limits (documented, not bugs): the builder supports create/enable/delete (full field edit = delete+recreate); buttons use static url/headers/body (no per-press placeholder substitution). Browser click-through of /door/buttons + the /doors Actions section still pending (auth-gated). Back to the main backlog after this: owner-gated spine validation, observability, etc.
2026-05-20 (pass 63): Door epic Phase 4 (inbound access-log ingest) (DEPLOYED in 7ec90b0; see pass 65)
Branch main. Took backlog item 6. Design-first via AskUserQuestion; the user chose BOTH transports. Built poll + webhook sharing one ingest core. Suite 612 unit (was 594; +18 parser tests) / 44 integration (was 40; +4 door-ingest) / build green / lint clean.
Web-verified first. Refetched zyphlar/Open_Access_Control_Ethernet.ino and characterized the ?z wire format (recorded in memory integration-api-facts): <pre>-wrapped K: N stream from a fixed 40-slot ring (no per-entry id), card number split across G+g/D+d with divisor 32767, emitted decimal vs hex-stored uid, H:M:E only at verbosity <2. Conclusion: poll is best-effort, webhook (explicit ids) is the reliable path.
Shipped (local):
- Migration 053 (additive only):
door_access_log.dedupe_key+ partial-unique(connection_id, dedupe_key)(re-poll / webhook-retry no-op; action rows stay NULL/unconstrained);door_connections.inbound_enabled(opt-in, off) +inbound_secret_ref -> secrets(inbound bearer secret, distinct from the outbound password). schema.sql + types + DATABASE_SCHEMA + DB_SCHEMA_MAP same-change. PGRST: 2 secrets FKs but surrogate PK = not a junction (safe re 052); auth-embed test still green. - Pure parser
lib/door-log-logic.ts(parseHeatSyncLog,normalizeWebhookEvents,cardMatchesEventsource-anchored hex-uid match -- see pass 65) + unit tests. - Ingest core
lib/door/ingest.ts(resolve card->member, dedupe-insert via service client). Extracted the sharedresolveDoorSecrettolib/door/secret.ts(door.ts now imports it). Executor gained an additivefullBodyoption (32KB cap; existing callers unchanged at 4096/500). - Poll
POST /api/cron/door-ingest(CRON_SECRET, proxy-whitelisted, native_heatsync + inbound_enabled only, ≤50 conns/run). WebhookPOST /api/door/inbound/[connection](proxy-whitelisted prefix, per-connection bearer constant-time, generic 401 non-oracle, 64KB body guard, 120/min, Zod ≤100 events). Both feed the ingest core. - Admin UI: per-connection "Inbound" panel on
/door/manage(secret picker, on/off toggle that requires a secret first, copyable webhook URL + payload hint, "inbound on" badge). - Audit hardening (3 passes): body-size guard before JSON parse;
occurred_atclamped not-in-future so a relay can't reorder the log. Fresh-pass real bug fixed: the?zbody is untrusted plaintext HTTP, andNumber('9'*400)===InfinitythenBigInt(Infinity)THROWS, which would 500 the whole poll and starve other connections; capped the parser value at 15 digits (firmware is int32) + bounded the G/g pairing window + wrapped each connection in the cron loop in try/catch. Verified: executor change behavior-identical for existing callers; inbound secret space-scoped on write + read; tenant isolation from the looked-up connection; ingesteddetailrenders as escaped JSX (no XSS); audit log stays immutable from the client (integration-tested). Accepted trade-offs (documented): decrypt-flood needs an unguessable uuid;resolvedcount is mild info to the already-trusted relay; the content-length guard is best-effort behind the platform body limit.
Deploy state. DEPLOYED in 7ec90b0 (run 26204184395; see pass 65). Migration 053 live on prod. No new env var (reuses CRON_SECRET); the door-ingest crontab line is documented in DEPLOYMENT but optional (only spaces that enable inbound need it). Inert until an admin turns on inbound_enabled for a connection AND the owner provisions CRON_SECRET + the crontab.
Open / next. Door epic Phase 5 (universal API-call UI builder, api_buttons) is the remaining door work; it needs its own design pass (HTTP-method scope + per-button vs fixed invoke permission forks) and extends the executor to method+headers+body. Browser click-through of the /door/manage inbound panel still pending (auth-gated; can't curl). Minor deferred: a far-future-date relay can't reorder but a near-now spoof is accepted (trusted relay); resolved count is mild info to the trusted relay.
2026-05-20 (pass 62): Diversified surface audit + fixes (DEPLOYED)
Branch main. Audited surfaces I had NOT recently touched, via three parallel independent reviews: (1) anonymous/public + all app/api/** route handlers, (2) XSS / unsafe rendering across the whole app, (3) authorization + tenant-isolation in legacy server actions (tasks/projects/members/kb/secrets/contacts/comms/classes/equipment/door/presence/certs/invites/roles/permissions/onboarding/forms/governance). No P0 on any surface. Suite 594 / build green.
Fixed
- P1 stored XSS (onboarding
payment_url).app/onboarding/onboarding-flow.tsxrendered the admin-enteredpayment_urlinto anhrefwith no protocol check (onboarding stepconfigisz.record(z.unknown())). Same class as the dues-url bug: an admin could storejavascript:...and XSS every new member in onboarding. Fixed at the sink withsanitizeUrl(http(s)-only; renders nothing otherwise). - P2 forum comment tenant check.
lib/actions/forum.ts addCommentonly space-verifiedforum_threadtargets;proposal/incident/policyentity_ids were unchecked, so a member could attach a comment to another space's entity (stamped with their ownspace_id; low impact since reads filter by space, but a real gap). Now verifies the target row exists in the caller's space (RLS-scoped read) for every entity type before insert. - P2 joinSpace rate limit. Added
checkRateLimit('joinspace:<uid>', 10/hr)for parity withcreateSpace/signIn, bounding invite-code guessing (codes are ~40-bit so not practically guessable, but it was the one unthrottled auth action).
Clean (verified by the reviews)
- Markdown rendering is centralized + hardened:
components/markdown.tsx(norehypeRaw),components/safe-markdown.tsx(rehypeRawTHENrehypeSanitizewith a tightened schema, href protocols allowlisted). Comments/forum/proposals/incidents/policies render inert. Member profile fields render as escaped JSX text. The onedangerouslySetInnerHTML(chart.tsx) is developer-config, not user data. - Public/API:
proxy.tswhitelist is narrow + exact;submitFormresolves space server-side + projects answers throughvalidateAnswers+ rate-limited;getPublicFormnever serves members-only;/auth/callbackopen-redirect guard +/auth/confirmOTP-type pin intact;paypal/syncrole-gated + constant fetch URLs (no SSRF). Nofetch()to a user-controlled URL anywhere. - AuthZ: every mutating action has an auth gate; every
createAdminClient()query is scoped tomember.space_id. (Defense-in-depth noted, not fixed: equipment cancel / door slot writes filter by space-unique id after a space-checked parent load; adding.eq('space_id')would be belt-and-suspenders.)
Deploy state (DEPLOYED)
- Passes 60 (Dues tab) + 61 (Stripe-card footgun + validation) + 62 (this) shipped together in
c0d6d49(run 26153548195, success; no migration). Prod re-verified: auth embedspace_members?select=*,spaces(*)returns[](not PGRST201);/200,/dashboard307->login,/login200. No regressions.
2026-05-20 (pass 61): Deep audit + fixes (LOCAL, NOT deployed)
Branch main. Deep audit of the app + the recent change set (passes 56-60), an independent subagent review of the new code, and fixes for what was real. Suite 594 unit / 40 integration / build green.
Systemic PGRST201 finding (root cause fully characterized, class contained)
The outage class is now precisely understood. PostgREST treats a 2-foreign-key table as an ambiguous many-to-many junction only when its PRIMARY KEY covers both FK columns (the classic join-table shape). That is why notification_preferences (PK space_id, member_id, category) broke the space_members <-> spaces embed, while the 23 other tables that FK to both space_members and spaces (notifications, payments, member_billing, classes, equipment, ...) do not — they have surrogate id PKs. Verified by querying pg_constraint/pg_index: the only remaining composite-PK 2-FK junction is space_member_custom_roles (space_members <-> space_custom_roles), which is a legitimate M2M (no competing direct relationship, so no ambiguity). The auth embed is guarded by integration/auth-embed.test.ts. Conclusion: the class is contained; the rule to remember is in DATABASE_SCHEMA 052 and the ARCHITECTURE gotcha note.
Fixed
- Stripe config footgun (real bug). The Integrations tab had a generic "Stripe" card writing via
saveIntegration, which overwritesintegrations.configwholesale -- it would clobber the dues config'smode/prices/grace_days(managed by the Dues-tabsaveStripeSettings) and could not actually configure dues. Removed the Stripe entry fromINTEGRATIONS_CONFIG; Stripe is now configured in exactly one place (the Dues tab). Integrations keeps PayPal/Zeffy/Venmo (separate sync surface). - Boundary validation (subagent P1/P2):
deleteDuesPaymentMethodnow validatesplatformagainstDUES_LINK_PLATFORMS(was passing a raw client string into the enum column);markNotificationsReaduses a strict uuid regex for the optional id filter.
Reviewed, no change needed
- Independent subagent review of notifications/dues/inbox/me/settings: no P0. Self-views correctly scoped to the caller's own
member_id; no cross-member/cross-space path; inbox rendersbody_text(neverbody_html) so no XSS; dues links double-validated https before render; cron auth constant-time; cron prefs fail open (correct for a billing-critical mailer). - Webhook signing secret is shown pre-populated to admins on
/settings(subagent P2): left as-is -- webhook secrets are meant to be retrievable, the page is admin-only.
Open / next
- NOT deployed: the Stripe-card removal + the two validation fixes are prod code, committed locally. Awaiting user go. (No migration.)
2026-05-20 (pass 60): /settings "Dues" tab (LOCAL, NOT deployed)
Branch main. The Stripe-dues config + external-payment-links panels were floating below the settings tab bar (rendered by page.tsx outside SettingsClient, shown regardless of the active tab). Moved both into a new Dues tab inside SettingsClient (tabs are now Space / Integrations / Dues / Webhooks); the panels render as standard bg-card rounded border tab cards instead of border-t page sections. page.tsx now renders only SettingsClient. UI-only, no schema/actions change. Build + 594 unit green. NOT deployed.
2026-05-20 (pass 59): PROD OUTAGE fix + in-app inbox + /me consolidation + style
Branch main. Suite 594 unit, 40 integration, build clean.
PROD OUTAGE (fixed + deployed, migration 052)
Migration 048 (notification_preferences) had FKs to BOTH space_members and spaces. PostgREST read it as a junction, so the auth layout's space_members.select('*, spaces(*)') embed went ambiguous (PGRST201) -> member null -> EVERY logged-in user bounced to /signup (anon landing was fine). Live from the pass-58 ab8c22a deploy until the hotfix. Fix: migration 052 drops the redundant space_id -> spaces FK (space still reachable + cascades via member_id -> space_members). Verified by reproducing PGRST201 on local PostgREST, then confirming [] after the drop; confirmed on PROD (run 26142626745, a7d880d) by testing the embed against supabase.hackerspace.sh with the anon key. Regression guard added: integration/auth-embed.test.ts runs the exact embed through PostgREST and fails on PGRST201. Root lesson: build/unit/psql-integration never exercised the PostgREST embed, so the schema-level ambiguity slipped through.
Shipped in this pass (LOCAL, NOT yet deployed beyond the 052 hotfix)
- In-app notification center (Phase 5; migration 051).
notifications.read_at+ partial unread index;/meNotifications becomes an inbox (unread dot/bold, click to expandbody_text+ mark read, "Mark all as read", unread count).getMyNotificationsreturns read state + body + unread count;markNotificationsRead({ids?})is a service-client write scoped to the caller'smember_id. Always-on channel: email-muted (skipped) rows still show. NOT deployed yet. - /me <- /profile consolidation. Deleted
app/(app)/profile/*;/meis the single member page. Ported the COI privileged-role nudge + last-disclosed date into/me's profile editor. Removed the "My profile" sidebar + command-palette entries; renamed the/menav label "My access" -> "My membership" (matches the page title). Repointed the recruitment empty-state link to/me. - Style: realigned
/meto the central theme. Reverted therounded-lgcards I introduced in the pass-58 visual pass back torounded(the app norm: 519roundedvs 53rounded-lg, and what/profileused). Routed/mesection headers through theSectionTitleprimitive instead of an inlinesectionHconst. (Self-critique: the pass-58 "visual pass" introduced therounded-lgdrift the user flagged.)
Deploy state (DEPLOYED)
- Shipped in
5d153b4(run 26144230714, success). Deploy log confirmsapplying 051_notification_read_state.sql. Auth re-verified on prod:space_members?select=*,spaces(*)returns[](NOT PGRST201) via the anon key, so the outage class stays fixed;notifications.read_atis live. Smoke:/200,/dashboard307->login,/profile307 (route deleted, no longer linked anywhere).
Open / next
- Browser review of
/mestill pending (auth-gated; can't curl). Worth a click-through of the inbox (read/unread, mark-all) and the consolidated profile editor.
2026-05-19 (pass 58): Dues payment options + /me visual pass (DEPLOYED)
DEPLOY STATE: pass-57 + pass-58 shipped together in commit
ab8c22a(run 26139360768, success). Deploy log confirmsapplying 048_notification_preferences.sqlthenapplying 049_dues_payment_methods.sql. Smoke clean: public 200; gated /me + /settings redirect to login; Stripe webhook 400 (sig rejected); cron 503 (CRON_SECRET unset, fails safe). No regressions. Inert until Resend + CRON_SECRET provisioned (notifications) and an admin adds dues payment links. Browser click-through of the /me visual pass still pending owner review. This deploy-state edit is held local and rides the next deploy (pass-56 convention).
Branch main. Same session as pass-57; addressed three follow-up requests the user raised after the notification-prefs work. Suite 593 (was 585; +8 dues-payments tests), build clean. NOT deployed. Stacks on top of the uncommitted pass-57 changes.
Design forks (locked via AskUserQuestion)
- /me polish scope: full visual pass (chips/presets restored AND broader tightening), staged for user review.
- Alternate payment model: platform-typed (tied to the
payment_platformenum, one URL per platform, pre-tagged for manual reconcile), not free-form.
Shipped (local, uncommitted)
- Alternate dues payment methods (migration 049). New
dues_payment_methodstable (one row per(space_id, platform), PayPal/Zeffy/Venmo), RLS SELECT = space member / write = admin/board. Pure logiclib/dues-payments-logic.ts(DUES_LINK_PLATFORMS, labels,isSafeDuesUrl= absolute-https only; the Zod schema reuses it). Actionslib/actions/dues-payments.ts(memberlistActiveDuesPaymentMethods; adminlistDuesPaymentMethods/upsertDuesPaymentMethod/deleteDuesPaymentMethod). Admin UIcomponents/settings/dues-payment-methods-panel.tsxappended to/settings. Member UI = the reworked dues card. Link configuration only: no payment record on click, treasurer reconciles manually via the existing payments flow (platform pre-typed). schema.sql + types + DATABASE_SCHEMA + DB_SCHEMA_MAP updated same-change. - Stripe dues UI gated on configuration.
getMyBillingnow returns aconfiguredflag (isStripeConfigured); the dues card shows the Stripe "Pay dues with card" button only when the space has Stripe set up. With no Stripe + no external links it shows a "contact an admin" note instead of a dead Checkout button. - /me full visual pass. The Phase-3
/meprofile editor had regressed to plain comma-separated text inputs; restored the chip/preset editor. PromotedChipInputto a sharedcomponents/chip-input.tsxandWILLING_TO_SUGGESTIONStolib/profile-presets.ts; both/meand/profilenow use them (deduped; deletedapp/(app)/profile/chip-input.tsx)./meprofile-form: skills/interests/willing-to/affiliations are now chips (willing-to with the recruitment-role suggestions); cards userounded-lg, consistent field labels. Dues card restyled.
Audit (one pass)
- Made
isSafeDuesUrlload-bearing (the Zod url field now refines on it, removing the duplicate inline https check). Verified ChipInput limits match the profile Zod schema (skills/interests 40×60, willing_to 20×60, affiliations 50×200). ConfirmedgetMyBilling's only real caller is/me(shape change fromnullto always-object is safe). External links rendertarget=_blank rel=noopener noreferrer; url validated absolute-https so no javascript:/data: scheme.
Post-deploy audit follow-up (DEPLOYED, run 26141570221)
Shipped in commits
897ff99+5ce6b29+f253432(run 26141570221, success). Deploy log confirmsapplying 050_dues_payment_methods_https.sql. Smoke clean: public 200; webhook 400; cron 503. Integration suite 38 green (local). The /me visual pass browser review by the owner is still the one open item.
Third ultrathink-audit finding (migration 050): stored-XSS defense gap on the dues url. dues_payment_methods.url is rendered to members as a clickable <a href>. The https requirement was enforced ONLY in the admin Zod action, but RLS lets an admin/board write the row directly via PostgREST (public anon key + their JWT), bypassing Zod, and the table had no url constraint. A malicious admin could store javascript:... and XSS any member who clicks. Fixed two ways: (a) migration 050 adds a CHECK url ~* '^https://' (the DB is now the source of truth; integration-tested that the service path can't store javascript:/http:), and (b) the member-read action filters with isSafeDuesUrl (defense-in-depth, immediate protection for current prod). Admin-gated and prod has no rows yet, so no live exploitation window, but it escalated "space admin" to "XSS any member" and is closed now.
Second ultrathink audit after the ab8c22a deploy found NO deploy-blocking bug. Re-read the dispatcher, dues actions, and dues card with fresh eyes: URL is Zod-validated absolute-https; the delete platform flows through a parameterized .eq filter (double-gated by the space pin + RLS); writes/reads are scoped to the authenticated member; billing is never muted (logic + test). Then hardened + polished (all local, post-ab8c22a):
- Integration coverage added (now 37, was 31).
integration/dues-payment-methods.test.ts(4: admin insert + member read + non-admin write denied + cross-space read denied + cross-space write denied + 046 unverified-admin gate) andintegration/notification-prefs-dispatch.test.ts(2: muted category ->skippedwhile billing always sends; no-pref-row -> sends). The dispatcher skip was unexercised in prod (cron gated off), so this is its first real exercise. All 37 pass vs local Postgres. - Harness bug fixed.
integration/db.tsrowsAsUserranbegin; set; select; commit;in onepsql -c, which prints only the LAST statement's result, so the SELECT output was swallowed (returned[]). It was never used before; now the SELECT is the last statement (sessionset, no trailingcommit). - Dues-card copy polish. Suppressed the "No active dues subscription" line for spaces with no Stripe (it read as confusing alongside the contact-admin note for external-only / unconfigured spaces).
Still open
- Browser click-through not done. Verified type-check + build + 593 unit + 38 integration only;
/meand/settingsare auth-gated and need a live Supabase session to render. The /me visual pass is staged for the owner to review in a browser. Flagging per the working agreement (do not claim UI success without rendering it). - Features remain inert until the owner provisions Resend + CRON_SECRET (notifications) and adds dues payment links (external pay options). Same posture as the prior phases.
2026-05-19 (pass 57): Product spine Phase 5: member notification preferences (DEPLOYED in ab8c22a)
Branch main. Took backlog item 2: per-member opt-out of muteable notification categories, doubling as the volume governor for the Phase 4 fan-outs. Design-first via AskUserQuestion. Suite 585 (was 572; +13 prefs tests), build clean. NOT deployed.
Design forks (locked via AskUserQuestion)
- Granularity: per-category (4 toggles: bookings, classes, forms, admin_alerts), not per-type or global.
- Critical email: billing always-on (dues renewed/failed/lapsed never muteable; a muted lapse notice would let a member silently lose access).
- Enforcement point: dispatch-time skip (rows still enqueue; dispatcher marks muted rows
skipped), not enqueue-time suppression. Keeps pref logic in one place; the 5 enqueue call sites + the money-path webhook are untouched. - Asserted (not asked): opt-out / default-on (opt-in would silently break transactional delivery).
Shipped (local, uncommitted)
- Pure logic
lib/notifications-prefs-logic.ts: 5 categories,TYPE_CATEGORYmap (all 11 types),categoryForType,isMuted(billing + unmapped types never mute),CATEGORY_METAfor the UI. 13 unit tests. - Migration 048 + schema.sql:
notification_preferences(PK(space_id, member_id, category)), RLS on, NO client policy (default-deny, same convention as notifications/member_billing). types/database.ts + DATABASE_SCHEMA.md + DB_SCHEMA_MAP.md updated same-change. - Dispatcher (
app/api/cron/notifications/route.ts): selects member_id + type, batch-loads prefs for the drain set (keyed by member_id, a globally-unique PK), marks muted rowsskipped(terminal, leaves pending pool, no send/Resend/spacing). Prefs lookup fails open (send everything) on error so a blip can't drop a wanted email. Response gains askippedcount. - Actions (
lib/actions/notifications.ts):getMyNotificationPreferences(defaults all muteable on, overlays stored rows) +setMyNotificationPreference(Zod enum excludes billing, upsert scoped to caller's own member row). Both service-client, requireMember-gated. - UI:
/meActivity tab gains an "Email preferences" section (4 Switch toggles, optimistic + revert-on-error); the notification history badge now rendersskippedas "Muted".
Audit (one pass)
No deploy-blocking bug found. Verified: write/read both scoped to the authenticated member (no cross-member write — member_id comes from session, not input); billing not settable (Zod) and never muted (logic + test); skipped is terminal so muted rows clear the pending pool in one run and never re-scan; overlapping dispatcher runs can't double-act (the existing .eq('status','pending') guard); member_id-keyed prefs map is unambiguous (space_members PK is global). Made one improvement during audit: explicit fail-open + log on the prefs-query error.
Open / not done
- NOT deployed. Awaiting user go (push to main = prod; migration 048 applies on deploy). Same-as-Phase-4: inert until Resend + CRON_SECRET + crontab are provisioned, but the skip logic is exercised the moment a member toggles + the dispatcher runs.
- No integration test yet for the dispatcher skip path. Pure
isMutedis unit-covered; the dispatcher wiring (prefs query + skip write) is not. Flagged as a recommended follow-up (the harness exists; pattern is stripe-webhook.test.ts). Deliberate, not an omission. - API_REFERENCE.md still predates the whole notification subsystem (Phases 2-5 self-view actions absent). Pre-existing drift, not introduced here; documented in ARCHITECTURE prose subsections instead (matches Phases 2-4).
NEW user requests this session (queued, address next, NOT started)
- /me UI degradation.
willing_to(likely also skills/interests) renders as a raw comma-separated list instead of the preset chip selector it used to have. Restore the preset editor. Own commit (separate from the prefs toggles even though same file). - Gate the Stripe dues UI on configuration. If a space hasn't set up Stripe, the pay-dues-via-Stripe UI on
/meshould not show (dead path today). - Admin-configurable alternate dues payment links. Admin sets payment URLs per method (PayPal/Zeffy/Venmo/etc); members see them as dues-payment options, click through to the external page, admin reconciles manually later. Money-adjacent new subsystem: design-first via AskUserQuestion before building. Separate deploy.
2026-05-19 (pass 56): Product spine Phase 4: notification breadth (DEPLOYED)
Branch main. Took item 3 from the pass-55 backlog: booking, class signup, and form-submission notifications, reusing the Phase 2 outbox + dispatcher unchanged. Built per sub-phase, gated each, small commit per sub-phase, single deploy at the end of the set. Suite 565 (was 532; +33 new tests), build clean. NOT deployed.
Design forks (locked via AskUserQuestion)
- Anonymous form submitter confirmation: auth-only. Recipient is the verified Supabase
user.email, never the typedbody.email; anonymous public submissions skip the submitter confirmation entirely (typed emails could belong to anyone, so confirming to them is a victim-spam vector). Admin alert still fires. - Form admin recipient set:
forms.managepermission holders (same gate the forms-guard / forms RLS use). - Cancel confirmations: only when someone other than the affected member cancelled (self-cancels stay silent; the actor already saw the UI confirm).
- Session-cancellation: fan out: when an instructor flips a session to
cancelled, every still-active signup gets an email (dedupe by(session, member)).
Shipped (commits 84d31ee, 64ffa4e, 105b62f, a2ea767, 6abac53, bd8f22a, 09f293e)
-
P4a (
84d31ee) Shared best-effort enqueue helperlib/notifications/enqueue.ts(resolveMemberContact,enqueueNotification,getSpaceName,buildManageUrl). Stripe webhook'senqueueDuesrefactored to compose them; zero behavior change (same recipient resolution, same upsert shape, same idempotency via(space_id, dedupe_key), same best-effort wrap). 12 new helper unit tests. -
P4b (
64ffa4e) Equipment:reserveEquipment→booking_confirmed(always, to the booked-for member).cancelReservation→booking_cancelledonly when actor ≠ affected member.renderBookingEmail+bookingDedupeKeyadded; existingrenderDuesEmailquietly refactored onto a sharedrenderShell(one place to fix the HTML wrapper across 6 templates). -
P4c (
105b62f) Classes:signUpForClass→class_signup_registeredorclass_signup_waitlistedfrom the RPC's returned status.cancelMySignup→class_signup_promotedto whoeverclass_cancel_txreturned aspromoted_id.updateSessionsettingstatus='cancelled'→class_session_cancelledfanned out to every still-active signup. Noclass_signup_cancelledtype:cancelMySignupis self-only, so under the rule it has no caller. -
P4d (
a2ea767) Forms:submitForm→form_submission_receivedonly when authenticated;form_submission_adminfanned out to every member withforms.manage. Migration 047 addsmembers_with_permission(sid, perm)SECURITY DEFINER (the inverted, set-returning form ofuser_has_permission, same current/late gate as 046) so the fan-out resolves recipients in one query, not N. Additive; existinguser_has_permissioncallers unchanged.types/database.ts+schema.sql+docs/DATABASE_SCHEMA.md+DB_SCHEMA_MAP.mdupdated same-change. -
P4f (
bd8f22a) first-audit fixes. Ultrathink audit of P4a-P4d before deploy surfaced two issues, both fixed pre-deploy:- RPC enumeration vector.
members_with_permissionwas callable by any PostgREST client. Postgres grants EXECUTE on new functions to PUBLIC by default; the codebase has no existing REVOKE pattern. The function returns a SET of member_ids for any (space, permission) pair, so without a lockdown any authenticated caller could enumerate "who has permission X in space Y" for spaces they are not in.user_has_permissionhas a narrower exposure (per-uid boolean) and is needed by anon/authenticated for RLS-policy evaluation, so it stays open; the new function is locked toservice_role(the only legitimate caller goes through the admin client). Migration 047 + schema.sql nowREVOKE EXECUTE FROM PUBLICandGRANT EXECUTE TO service_role. - Best-effort gap. P4b/P4c used the helpers but the helpers were not internally best-effort. The Stripe webhook's original
enqueueDueswrapped its entire body in try/catch so a transient member-lookup error could never bubble into the money path; the P4a refactor removed that inner wrap and relied onenqueueNotification's best-effort.resolveMemberContact+getSpaceNamecould still throw on supabase network errors, surfacing into the action's return after the underlying mutation already committed (the client would see an error even though their booking / signup / cancellation succeeded). Fixed at both layers: the helpers now swallow throws internally (returning null / empty), and each call site in equipment/classes is wrapped in try/catch around the lookup chain so secondary action-specific queries (equipment name+location, promoted-signup session lookup, session-cancellation signup fan-out) also cannot leak. forms.submitForm already had the wrap from P4d.
- RPC enumeration vector.
-
P4g (
09f293e) third-audit fix, render-layer phishing defense. renderShell auto-linkifies anyhttps?://in body lines so the manageUrl becomes a clickable link. The same regex turns any URL embedded in a user-controlled field into a clickable link. In Phase 2 dues this was self-only (member's own display_name appears in their own email), but Phase 4'sform_submission_adminis the first email type where one user's display_name lands in another user's inbox: an authenticated member can set display_name to a URL and submit a form, phishing everyforms.manageholder when their email client renders the linkified URL.body.emailis Zod-format-validated so the typed-email path is closed, butdisplay_nameis a free string (max 100). Fix: a newstripUrls(s)helper replaceshttps?://\S+with[link], applied to every user-controlled input across all four renderers (member name, equipment/class/form title, location, submitterLabel, space name).manageUrlstays untouched and remains the only linkified URL in the rendered email. 5 new tests cover the new attack vector and defense-in-depth across the other renderers.
Pre-existing exposure flagged for separate triage
user_has_permission is also callable by anon + authenticated via PostgREST RPC (same Postgres default; no REVOKE in the codebase). The exposure is narrower than the 047 case (you need to know a target uid, and uids are only learnable for co-members in your own spaces), and locking it down would require careful audit because RLS policies call it from anon/authenticated contexts and would break if EXECUTE were stripped. Out of Phase 4 scope; flagging for the owner to triage.
Test coverage
40 new __tests__/ cases (suite 572). Per-render unit tests cover subject lines, body copy, location + range formatting, the "someone" fallback in admin alerts, HTML escaping of injected names/titles, and (post-P4g) URL-strip defense across every renderer. Per-dedupe-key tests pin shape and per-member fan-out distinctness. The shared enqueue helper has hit/miss/error/throw coverage and (post-P4f) the resolveMemberContact / getSpaceName best-effort no-throw paths. Integration suite (31 vs real Postgres) unchanged: the existing Stripe-webhook integration test already exercises the outbox semantics end to end via the same shared upsert; P4 actions enqueue through the same path so the runtime contract is the same.
State
- DEPLOYED (run 26135623172, HEAD
a61e34d, 1m14s success). Deploy log confirmsapplying 047_members_with_permission.sqlthenREVOKEthenGRANT. Smoke clean: public 200 (landing, login render); gated 307 (/dashboard,/me); Stripe webhook 400 (signature rejected with no body); cron 503 (CRON_SECRET unset, fails safe). No regressions. - Inert post-deploy until owner provisions Resend (
RESEND_API_KEY+EMAIL_FROMon a verified domain) andCRON_SECRET+ the once-a-minute droplet crontab. Until then the outbox fills but nothing sends, same as Phase 2 today. New-event volume is bounded by user activity; the dispatcher's per-attempt ResendIdempotency-Keyand the(space_id, dedupe_key)collapse already make replay-safe. - No volume governor. Every form submission fans out one row per
forms.manageholder; a popular waiver could create N rows per submission. Acceptable today (the unique index makes it idempotent); a digest / throttle is a separate phase (lines up with member preferences and an in-app inbox).
Session closeout
Three audit passes within one phase set the new high-bar. P4a-P4d shipped the feature; the three audits then found and fixed (a) an RPC enumeration vector before any client could touch it, (b) a best-effort gap where the helpers could leak network errors into action returns after the underlying mutation already committed, and (c) a phishing vector exposed by form_submission_admin being the first email type to surface one user's display_name in another user's inbox. None of the three would have been caught by smoke testing the deployed code. The discipline future sessions should keep: design-first via AskUserQuestion, ship per-phase, then audit the change set BEFORE asking for the deploy, repeat the audit even when the change set "looks clean" because every repeat-audit on this work found something real.
Backlog after this
- Owner-gated end-to-end spine validation. Phase 2 (dues lifecycle) + Phase 4 (booking, classes, forms) are both deployed but proven only at the code level. Needs (a)
RESEND_API_KEY+EMAIL_FROM(verified domain, SPF/DKIM), (b)CRON_SECRET+ the once-a-minute droplet crontab (docs/DEPLOYMENT.md), (c) per-space Stripe test-mode keys + activated Billing Portal, (d) Supabase "Change Email Address" template +/auth/confirmallowlist. Then a real test-mode dues cycle + a booking + a class signup + a form submission, click-through the/meportal in a browser. Owner executes; this session produces the runbook + checklist. - Phase 5: member notification preferences. Per-type opt-in/out; the dispatcher checks prefs before sending. Doubles as the volume governor for Phase 4 fan-outs (a popular waiver currently fans out one row per
forms.manageholder per submission). - Phase 5: in-app notification center on
/me. Extend the read-only list to an inbox with unread/read state. - Production observability. No error monitoring exists; money path + dispatcher cron + every server action only
console.error. Add Sentry-equivalent. - Extend the integration harness. Add real-Postgres coverage for payments link/import, role/permission management, presence, forms
submitForm+linkSubmissionsByEmail. Pattern is proven; cost per new file is small. user_has_permissionEXECUTE-TO-PUBLIC triage. Pre-existing exposure flagged in this pass: the helper is callable by anon/authenticated by default (narrower than the 047 case since you need to know target uids, but still a per-uid probe). Locking it down requires careful audit because RLS policies call it from anon/authenticated contexts and would break if EXECUTE were stripped.- Door epic Phases 4-5. Inbound log ingest + the universal API-call UI builder.
- Owner product question (no code): confirm forms victim-email attribution (attribution only, no readback) remains acceptable in scope.
- Deferred until product need: generic outbound webhooks epic, server-side search, multi-space-per-user / space switcher, Zeffy/Venmo live APIs.
2026-05-19 (pass 55) — Doc drift fix + session closeout + new-session backlog
Branch main. Final pass of an extraordinarily long arc. Suite 532; integration suite 31 vs real Postgres; build clean.
This pass
Doc drift audit + fix (commit 19dc632, deployed run 26126231059, no-op runtime, prod 200): migration 046 row was missing from docs/DATABASE_SCHEMA.md + DB_SCHEMA_MAP.md; README.md Features list omitted the entire product spine (Stripe dues / transactional notifications / member self-serve portal); docs/ARCHITECTURE.md had zero mention of the D/F/G-era subsystem facts (integration harness, GiST exclusion constraint, advisory-lock RPCs, door DNS-rebind pinned-IP egress, dispatcher fair drain + per-attempt key + re-entrancy guard, zero-decimal currency, the RLS-layer status-gate, the out-of-order monotonic period guard). All fixed inline in the right existing sections.
Where the project is
- Production posture: strong + thoroughly audited. Every P0/P1/P2 from every audit this session is fixed; the require_approval/unverified-admin escalation (the single most serious finding) is closed at both layers (app via D2, RLS via 046). Door SSRF DNS-rebind closed. Equipment double-booking closed by DB exclusion constraint. Stripe webhook hardened against replay, out-of-order delivery, zero-decimal currencies. Notifications dispatcher is fair across spaces with per-attempt idempotency. Anonymous form submission rate-limited.
- Tests: root-cause fix in place.
pnpm test(532, hermetic) stays the deploy gate;pnpm test:integration(31 tests, real Postgres) covers the SQL/RLS/money paths end to end so correctness no longer depends on post-deploy manual auditing. Self-skips without a DB. - Docs: in sync (this pass cleared the lag).
Remaining work — priority order for the new session
- OWNER-GATED — end-to-end spine validation. The single largest remaining gap: shipped vs proven-in-prod. Owner must provision (a)
RESEND_API_KEY+EMAIL_FROM(Resend-verified domain with SPF+DKIM), (b)CRON_SECRET+ the once-a-minute droplet crontab (docs/DEPLOYMENT.md), (c) a space's per-space Stripe test-mode keys + activated Billing Portal, (d) the Supabase "Change Email Address" template +/auth/confirmredirect allowlist + Secure email change toggle. Then run a real Stripe test-mode dues cycle end to end and click through the/meportal in a browser. - Production observability. No error monitoring exists. Money path (Stripe webhook) + the dispatcher cron only
console.error. Add Sentry-equivalent for the webhook, dispatcher, and server actions. - Phase 4 notifications breadth. Outbox + dispatcher are built; add booking, class signup, form-submission notifications reusing the same outbox.
- Member notification preferences. Per-type opt-in/out; the dispatcher respects prefs.
- In-app notification center. Extend the
/melist to a bell/inbox with unread/read state. - Extend the integration harness. Add coverage for payments link/import, role/permission management, presence, forms
submitForm+linkSubmissionsByEmail. The pattern (psql + role/jwt user simulation, route handler invocation) is proven; the cost per added test file is small. - Door epic Phases 4-5. Inbound log ingest + the universal API-call UI builder.
- Owner product question (no code): confirm forms victim-email attribution (attribution only, no readback) remains acceptable in scope.
- Deliberate-future items (defer until product need): generic outbound webhooks epic (per-event delivery), server-side full-text search, multi-space-per-user / space switcher, Zeffy/Venmo live APIs.
Local-dev gotcha worth flagging
- The
payment_platformenum 'stripe' value:scripts/schema.sql's wrappedCREATE TYPEskips on an existing local DB; only migration 040'sALTER TYPE ADD VALUEadds it. Aftersupabase db reset+psql -f scripts/schema.sqlthe local DB may lack 'stripe', breaking any test that exercises theinvoice.paid→paymentsinsert path. Workaround: also runpsql -f scripts/040_stripe_billing.sql(or apply eachscripts/0*.sqlin order after schema.sql).
2026-05-19 (pass 54) — Lifted the deferred SECURITY DEFINER status-gate, harness-proven (LOCAL, RUNTIME — needs deploy)
Branch main. The pass-52 deferral of the RLS status-gate was conditional on "no way to verify it breaks nothing." The pass-53 harness resolves that, so the deferral is lifted and the change made + proven.
Change (migration 046, commit e954b08)
user_has_role_in_spaceanduser_has_permissiongated onstatus IN ('current','late')(mirrors lib/permissions PRIVILEGE_STATUSES). Closes the require_approval/unverified privilege gap at the RLS layer too (defense-in-depth beyond D2's app layer): an unverified/inactive member bypassing the app via direct PostgREST still cannot exercise a role/permission.- Deliberately NOT changed:
get_user_space_ids,user_effective_roles— gating SELECT-policy reads would break an unverified member's own /me + onboarding (the legitimate require_approval flow). Minimal blast radius: exactly the two privilege entrypoints. integration/privilege-status-gate.test.ts(5) proves BOTH halves: (a) gap closed — unverified admin has no role/perm, cannot do a privileged RLS-gated write; (b) nothing broken — current+late fully privileged, unverified member still resolves its own space, the approval flow (current admin → approve unverified) still works. 31 integration tests green.
State — DEPLOYED
- DEPLOYED (run 26125372468, HEAD
c00c82d, success). Deploy log confirmsapplying 046_privilege_status_gate.sql. Smoke clean: public 200 (landing renders), gated 307, Stripe webhook 400 — no regressions. Substantive safety proof was the PRE-deploy 31-test integration suite vs real Postgres (post-deploy auth-flow checks aren't curl-doable; the harness already proved current/late access + approval + unverified reads). - Default
pnpm test532/26 hermetic; build clean. - Commits ahead of origin (non-runtime backlog + this runtime one): F3 (
42f6509), pass-53 state (b0267bb), F4 (b56ef4e), doc/HANDOFF (ee7f59f), 046 (e954b08, RUNTIME), this.
Backlog after this
- Owner product question (forms attribution). Owner-gated end-to-end spine validation (the shipped≠proven gap) — the last real gap, needs owner actions. The autonomous engineering backlog is now genuinely exhausted.
2026-05-19 (pass 53) — DB-backed integration harness; shipped SQL now functionally validated (LOCAL, non-runtime)
Branch main. Assessment chose "integration test harness" to fix the root cause (correctness depending on post-deploy manual audits). Built + RAN it against the local Supabase Postgres. Default pnpm test still 532/26, hermetic; build clean.
Delivered
- F1 foundation:
vitest.integration.config.ts(separate runner;integration/is OUTSIDE__tests__/sopnpm testnever touches a DB),integration/db.tspsql-driven helpers (service path vsrole authenticated+request.jwt.claims.subuser path; throwing seed helpers),pnpm test:integrationscript. Self-skips when no DB. - F2 tests (18, all green on real Postgres): 045
class_signup_tx/class_cancel_tx— RETURNS TABLE→array shape the actions destructure, capacity/waitlist/dedupe/no_session, and the per-session advisory lock under TWO genuinely concurrent psql connections (exactly one registered — D11 proven); 042 exclusion constraint (overlap rejected 23P01, cancelled/adjacent allowed); 044 trigger (member self-edit of role + payment columns blocked, service path bypasses) + 043 WITH CHECK (cross-space move blocked). - Applying
scripts/schema.sqlto the local DB also validated the "idempotent, runnable top-to-bottom" invariant in practice (it was stale at 041; applied cleanly to 045). - Harness bug found+fixed during bring-up:
space_members.user_idFKsauth.users(my earlier FK introspection was wrong) — seeds now create the auth user first; seed helpers throw on failure. - Docs: LOCAL_DEV "Integration tests" section.
Significance
The concrete prod risk flagged in the pass-52 assessment — signup/cancel possibly broken because the action assumes supabase.rpc() returns an array for a RETURNS TABLE function — is disproven: the shape + behavior + concurrency are correct. Correctness of the riskiest shipped SQL no longer depends on me manually auditing after deploy.
State
- PUSHED (run 26122719148, success, no-op runtime — prod / = 200). Harness is on origin for CI/contributors. Audited the harness itself: verified it self-skips all 21 / exits 0 with no DB (never blocks CI) and the default
pnpm test(532/26) does not pick upintegration/. - F3 extension (commit
42f6509):integration/billing-idempotency.test.ts— stripe_webhook_events PK replay protection, member_billing UNIQUE onConflict upsert, notifications UNIQUE + ON CONFLICT DO NOTHING. 21 integration tests total, all green on real Postgres. Non-runtime; held local (rides next deploy) or push as desired. - F4 (commit
b56ef4e):integration/stripe-webhook.test.ts— the Stripe webhook ROUTE HANDLER end-to-end (real sig verify good/bad/missing, vault secret+config resolution, stripe_webhook_events replay idempotency, applySubscription→billing/status, D5 out-of-order guard proven e2e). Invokes the actual POST against local Supabase with Stripe-signedcustomer.subscription.updatedevents (no network, no payments enum). 26 integration tests total, 5 files, all green. The harness now covers every risky thing shipped this session: 045 RPCs+concurrency, 042 constraint, 043/044 RLS+trigger, billing/notification idempotency, AND the webhook wiring where the Phase-3 P0s + D5 lived. - Doc drift fixed: ARCHITECTURE limitation #2 now reflects Stripe is integrated.
- Finding (for future invoice.paid webhook test): the LOCAL DB's
payment_platformenum lacks 'stripe' — schema.sql's wrappedCREATE TYPEskips on an existing type; only migration 040'sALTER TYPE ADD VALUEadds it. The harness setup would need to apply 040 before testing theinvoice.paid(payments-insert) path. Avoided here by usingsubscription.updated. - Backlog now: deferred SECURITY DEFINER status-gate (own session, rationale in pass-52); owner product question (forms attribution). Natural next: more server-action path coverage in the harness; or owner provisioning + end-to-end spine validation (the shipped≠proven gap).
2026-05-19 (pass 52) — Backlog hardening E1-E3 + evidence-based deferral (LOCAL, NEEDS deploy)
Branch main. Worked the pass-51 small backlog. Suite 532, build clean. NOT deployed.
Done
- E1 (
c2ba958) Completed D10 defense-in-depth:auth_paramadded toConnRow+loadEnabledConnection+ the test/selfEntry selects and threaded into everycallDoor(+ optional onauditDoor), so a generic-adapter connection's custom auth-param value is scrubbed from the operator-readabledoor_access_logregardless of param name. Native HeatSynce=was already covered. - E2 (
f11f4b3+ fix9351969) Rate-limited anonymoussubmitForm(20/min per IP+form — deliberately loose; hackerspace public signups share NAT/wifi so a tight limit would false-block a room; captcha remains the deferred real control). NOTE:f11f4b3was committed before its build was verified and had aconst hredeclaration;9351969fixed it forward (local-only, never deployed). Process: commits must be strictly build-gated. - E3 (
6b84b09) Webhook returns generic error strings (Invalid signature/Webhook processing error/Webhook handler error); real detailconsole.error'd server-side. Closes raw signature-lib / Postgres text leak to the (partly unauthenticated) caller.
Deliberately deferred (evidence-based, NOT an omission) — own focused session
- SECURITY DEFINER status-gate. Diagnosed all four helpers:
get_user_space_idsbacks nearly every SELECT policy, so status-gating it would strip anunverified(pending-approval) member's access to their own/me+ onboarding — breaks the legitimate require_approval flow. The role/permission helpers have broad subtle blast radius (write policies + the self-change trigger's privileged-bypass + some read policies) for a defense-in-depth-only gain ALREADY closed at the app layer by D2. Per RLS-guardrail (diagnose-before-patch, presentational-when-possible) this is a deliberate deferral with a focused-session plan, not a rushed end-of-session RLS change.
Owner product question (no code)
- Forms victim-email attribution: an anonymous submitter can type a victim's email and the submission is attributed to that member (attribution only — no data readback, grants nothing; documented locked decision). Flagging for owner re-confirm that forged-waiver-attribution is acceptable in scope.
State
- DEPLOYED (run 26088940576, HEAD
099a103, success). No migration this batch. Smoke clean: public 200;/dashboard/me/door/manage307; Stripe webhook 400; cron 503. No regressions. (E3's generic signature/handler error bodies need a Stripe-configured space + bad sig to exercise — verified by build/code, not curl.) This deploy-state edit held local (docs-only, rides next deploy). - Backlog now essentially exhausted. Remaining: the deliberately-deferred SECURITY DEFINER status-gate (own focused session — see rationale above) and the owner product question on forms victim-email attribution. All P0s/P1s/P2s from every audit this session are resolved or consciously deferred with documented rationale.
2026-05-19 (pass 51) — Verify D1-D10 + clear the deferred heavy P1s (LOCAL, NEEDS deploy)
Branch main. Verified the D1-D10 batch (trust-but-verify my own fast change set) and worked the deferred deeper-audit backlog. Suite 532, build clean. NOT deployed.
D1-D10 verification
- D6 regression found + fixed (commit
6105047). The fair-drain pre-truncated each space's bucket to PER_SPACE=5 BEFORE the round-robin, so a single-space deployment (the common single-hackerspace case) drained only 5 emails/min instead of 20 (4x slowdown introduced by the fairness fix). Now per-space queues are bounded only by the CANDIDATES fetch; round-robin caps a space's share only under contention; a lone space drains the full BATCH. - D2 verified safe: member-facing onboarding (markOnboardingStepDone/finishOnboarding/skipOnboarding) uses permissive requireMember; only admin step-mgmt uses requireMemberWithRole. No legit unverified flow broken.
- D1 verified safe: reservations are create+cancel only (no reschedule/time-range UPDATE), so the exclusion constraint is only hit on INSERT (already 23P01-handled).
Deferred heavy P1s cleared
- D11 (commit
705f03a) Class signup/cancel concurrency: migration 045class_signup_tx/class_cancel_tx(SECURITY DEFINER, per-sessionpg_advisory_xact_lock) — capacity decision+insert and cancel+promote now atomic. Actions call the RPCs, keep all pre-checks; removed now-unused pure-fn imports (rules still live + tested in classes-logic). - D12 (commit
42fd385) Door SSRF DNS-rebind: executor resolves once, rejects if ANY resolved IP is blocked (isBlockedDoorIp: loopback/unspecified/link-local/metadata incl IPv4-mapped; RFC1918/LAN/ULA allowed), then connects to the validated IP literal (no 2nd resolution = TOCTOU closed). Dependency-free (chose this over the undocumented undici connect.lookup seam, verified via web research; fits plaintext-HTTP-LAN). redirect:'manual' retained.
State
- DEPLOYED (run 26086312156, HEAD
d5a282b, success). Deploy log confirmsapplying 045_class_signup_concurrency.sql; deploy succeeded (RPCs created cleanly). Smoke clean: public 200;/dashboard/me/classes/door/manage307; Stripe webhook 400; cron 503. No regressions. WATCH (not curl-testable without a session): a real class signup/cancel exercising class_signup_tx/class_cancel_tx, and a live door call exercising the resolve-then-pin path. This deploy-state edit held local (docs-only, rides next deploy). - Remaining smaller backlog: thread conn.auth_param through the door-call selects (audit-log defense-in-depth; client leak already closed by D10); SECURITY DEFINER user_has_role_in_space/user_has_permission still ignore status (RLS defense-in-depth for unverified — app layer closed by D2); P2s (anon-form rate-limit/captcha, forms victim-email attribution owner-confirm, webhook raw PG error text post-signature).
2026-05-18 (pass 50) — Deeper audit (3 specialists) + full priority remediation D1-D10 (LOCAL, NEEDS deploy)
Branch main. Ran 3 deep adversarial audits (concurrency+financial, privilege-escalation+RLS+trigger-verification, anonymous-surface+credentials) — the systemic classes prior structural passes missed. Owner chose "Full priority pass". 10 fixes landed, gated per-item (suite 528, build clean). NOT deployed.
Fixes (commits a513ad0,2239e7a,92170e4,705a932,d2b7aa6,c9c0909,7b0948d,26e54f1,785e8e0,66cb84b)
- D1 (P0) Equipment double-booking: migration 042 btree_gist +
equipment_reservations_no_overlapGiST EXCLUDE; reserveEquipment maps 23P01. - D2 (P1 sec)
require_approvalbypass:isPrivilegeEligible(current|late only);requireMemberWithRolerejects unverified before role check; joinSpace single-active-membership guard. - D3 (P1 sec)
/auth/callbacknextopen-redirect validated (same-origin path only). - D4 (P1 sec)
assignCustomRolepins both custom role + member to caller's space. - D5 (P1) Stripe out-of-order:
laterPeriodEndmonotonic guard — a stale event can't rewind current_period_end / false-lapse a paid member. - D6 (P1) Dispatcher fair per-space round-robin drain +
.eq('status','pending')re-entrancy guard. - D7 (P1) Zero-decimal currency: currency-aware
minorToMajor+formatMoney. - D8 (P1 dfd) Migration 043:
members_updateRLS gains WITH CHECK mirroring USING. - D9 (P1 dfd) Migration 044: trigger also blocks self-change of payment_status/payment_note/dues_paid_until/last_paid_at/last_payment_at/stripe_customer_id/joined_at.
- D10 (P1 sec) Door actions return a generic failure to the client (no raw controller reason); redactDoorSecrets/callDoor gained optional authParam (capability ready).
Deferred (deeper-audit backlog — own careful passes)
- Class signup capacity/waitlist concurrency (needs an RPC + advisory lock per session).
- Door SSRF DNS-rebind:
validateDoorTargetpins the hostname string;fetchre-resolves — resolve-once-and-pin-IP needed (highest-risk subsystem, isolate). - Thread
conn.auth_paramthrough the door-call selects so audit-log detail is auth-param-redacted (client leak already closed by D10). - SECURITY DEFINER
user_has_role_in_space/user_has_permissionignorestatus— RLS-layer defense-in-depth for the unverified case (D2 closed the app layer). - P2s: anonymous-form rate-limit/captcha; forms victim-email attribution (owner re-confirm — attribution only, no data readback); webhook echoes raw PG error text (post-signature only).
State
- DEPLOYED (run 26071186611, HEAD
a7c3a08, success). Deploy log confirms migrations 042/043/044 each applied; deploy.sh succeeded (042's exclusion constraint built cleanly = no pre-existing overlapping reserved rows). Smoke clean://login200;/dashboard/me307 (gate intact, D2 fine); Stripe webhook 400; cron 503;/auth/callback307. No regressions. The equipment P0 + the 2 live P1s (Stripe out-of-order, dispatcher starvation) are resolved in prod. - This deploy-state edit is held local (docs-only; rides the next deploy). Audit explicitly cleared: anonymous tenancy derivation, /track entropy, helper-fn fail-closed, R5 reconcile, startDuesCheckout customer race.
2026-05-18 (pass 49) — Phase 3 post-deploy adversarial audit: 2 P0 fixed (LOCAL, NEEDS deploy)
Branch main. Phase 3 shipped (pass 48) without a dedicated pre-deploy audit; ran an independent adversarial audit after the fact. Verdict was NOT SAFE as-is — 2 P0 + 1 P1 + 1 P2 found in the email-change surface; ALL fixed (commit f2df287). Suite 521, build clean. Currently deployed prod has the P0s; this fix is local and should deploy ASAP.
Findings -> fixes
- P0-1
/auth/confirmpassed an attacker-controllabletypequery param intoverifyOtpon a public route -> unintended auth entrypoint (recovery/magiclink/signup token replayed there mints a session, bypassing its real flow). Fixed:typepinned to literal'email_change', query param ignored. - P0-2 Email sync used a follow-up
supabase.auth.getUser()afterverifyOtp— not guaranteed to reflect the just-verified identity in the SSR route; on miss,space_members.emailstays stale forever (breaks payment auto-link / notifications / form linking), with no reconciliation anywhere. Fixed: use theverifyOtpresponsedata.user;/menow reconcilesspace_members.emailagainst the authoritativeauth.users.emailon load (self-heals). - P1-1
requestEmailChangederived the confirm-link origin from requestHost/X-Forwarded-Host(forgeable -> token_hash capture). Fixed: origin fromNEXT_PUBLIC_APP_URLonly. - P2-1
isSecretConfigFieldonly matched*_secret/client_secret/api_key/secret_key; a credential namedpassword/token/private_key/*_token/*_passwordwould land in plaintextintegrations.config. Widened (case-insensitive) + tests. - Audit PASS (no change):
getMyPaymentscaller-scoping, the tab refactor (no data dropped),proxy.tsgate, R1 credential-loss/recursion paths, comma-split profile fields.
State
- DEPLOYED (run 26068281306, HEAD
997e2e1, success). Smoke clean:/auth/confirm?type=recovery&token_hash=x-> safe 307 (invalid token rejected, query type no longer honored);/auth/confirmno-token 307;/auth/callback307;/me307;//login200; Stripe webhook 400 (no regression). The 2 P0s are closed in prod. - This deploy-state edit is the only commit ahead of origin (docs-only, held per pattern; rides the next deploy).
- Email change still needs the Supabase project config from pass-48 (template + redirect allowlist + Secure email change) to function at all; until then the action sends a link that won't land. The P0/P1 fixes harden the path for when it is enabled.
2026-05-18 (pass 48) — Product spine Phase 3: member self-serve portal (LOCAL, awaiting one reviewed deploy)
Branch main. Built Phase 3 (tabbed self-serve portal), gated per sub-phase. Suite 521, build clean. NOT deployed.
Shipped (commits 5a72832, ef210c9, 6189e20, 2c847be, 5edd778, + this docs)
- P3a
/merestructured into a 3-tab portal (Profile / Membership / Activity). Server page fetches;me-portal-client.tsxrenders. Read-only sections moved verbatim, no behavior change. - P3b Inline profile editing (Profile tab) via existing
updateMyProfile+discloseAffiliations; affiliations re-disclosed only when changed. Addedbioto theupdateMyProfileparam type (schema/column already supported it). - P3c Self-serve Cancel for registered/waitlisted class signups + reserved equipment reservations via existing actions (
CancelAction= AlertDialog + toast + refresh). Server-side ownership/scoping unchanged. - P3d
getMyPayments(service-client self-view, treasurer-scoped RLS, strictly caller-scoped) + read-only list in Membership tab. - P3e Self-service email change:
requestEmailChange->supabase.auth.updateUser(Secure email change = double confirm); newGET /auth/confirmverifyOtp(email_change)+ post-verification sync of denormalizedspace_members.email;proxy.tswhitelists/auth/confirm(exact). Profile-tab UI. - P3f Docs (API_REFERENCE, ARCHITECTURE Phase 3 section, DEPLOYMENT email-change config) + this entry.
Owner action required (P3e is inert until done) — Supabase project config
- Auth → URL Configuration: add
{APP_URL}/auth/confirmto the redirect allowlist. - Auth → Providers → Email: keep "Secure email change" ON.
- Auth → Email Templates → "Change Email Address": link to
{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email_change(NOT the default code-flow link). Full steps in docs/DEPLOYMENT.md.
State
- DEPLOYED (run 26066604148, HEAD
83ff54b, success). Smoke clean://login200;/me/dashboard307 (member-gated);/auth/confirm+/auth/callback307 (own no-token redirects, whitelisted); Stripe webhook 400 (money path intact). Portal UI itself behind member auth — NOT browser-tested (typecheck/build only); needs a logged-in click-through. - Behavior-preserving for existing read-only views; new: profile edit, cancels, my-payments, email change (email change needs the Supabase config above to function).
- Deferred (from the pass-47 audit, still open): P2 polish items + P3 doc cross-ref + optional CI guards.
2026-05-18 (pass 47) — Full-codebase audit + P1 remediation pass (LOCAL, awaiting one reviewed deploy)
Branch main. Ran a 4-agent read-only audit (server actions, DB/RLS/types, security, architecture/drift). Verdict: strong codebase, zero P0, no cross-tenant leaks/RLS gaps/injection/secret-to-client. Then fixed all P1s, gated per sub-phase. Suite 521, build clean.
Audit summary
- Server-action layer: ~120 actions all validate->auth->scope with universal space_id; no P0/P1.
- DB/RLS/types: 46 tables RLS-protected, idempotent, zero schema/migration/types drift.
- Security: defense-in-depth solid; findings were the P1s below.
- Architecture: logic-extraction+Vitest near-universal; gap was the two payment routes.
Remediation (commits 504586f, 0306157, 75686c0, b8edd77)
- R1 PayPal/integration secrets -> AES-256-GCM vault. New
lib/secrets/vault.ts(canonical generic store/read +isSecretConfigField);saveIntegrationvaults secret-named fields, keeps only*_ref, blank submit preserves ref, legacy plaintext auto-migrates on next save; PayPal route reads from vault with transitional plaintext fallback. The lone plaintext-credential path closed. - R2 Extracted the Stripe webhook's money/status logic (Basil relocations, period/customer extraction, status patch, invoice->payment row) to pure
lib/stripe/webhook-logic.ts+ 12 tests; route now I/O-only. The biggest untested critical path, now covered. - R3 Extracted PayPal sync mapping/dedupe to pure
lib/paypal-logic.ts+ 7 tests; route usesrequireMemberWithRole; fixed a latent throw on missing/invalid PayPal date. - R4 Narrowed
proxy.tspublic prefixes to exact paths (/auth/callback,/api/cron/notifications).
State
- Not deployed. R1-R4 + this pass committed locally; awaiting one reviewed deploy decision. Also still unpushed: pass-44 + pass-46 docs commits.
- Behavior-preserving except R1 (PayPal secret now vaulted; existing integrations keep working via fallback and self-migrate on next Settings save) and R4 (tighter public routing).
- Deferred (HANDOFF-tracked, from audit): P2s (members.ts self-update space_id belt-and-suspenders, ops/comms client-direct writes -> server actions, door SSRF resolve-IP recheck, pin zod/@supabase, loading/error UX convention, mutation return-shape standardization), P3 doc cross-ref, optional CI guards. Next planned feature work: Product spine Phase 3 (member self-serve portal).
2026-05-17 (pass 46) — Phase 2 EXTREME AUDIT: NOT-SAFE verdict, P1s fixed (LOCAL, still awaiting deploy)
Branch main. Owner asked for an extreme audit before deploy. Ran an independent code audit + self-audit + web re-verify of Resend idempotency semantics. Verdict was NOT SAFE as-is; all findings fixed. Suite 499, build clean.
Findings -> fixes (commit pending in this pass)
- P1 (P0 blast radius) dispatcher idempotency: row id alone as Resend Idempotency-Key; Resend's docs do not guarantee a failed response is excluded from the 24h key cache, so a cached transient failure could permanently suppress every retry (email silently lost). Fixed: key is now per-attempt
${row.id}:${attempts}(concurrent same-attempt runs still dedupe; next-minute retry is a fresh send).app/api/cron/notifications/route.ts. - P1 enqueue not isolated from money path: a persistent notifications-table/render failure could 500 the Stripe webhook on every retry and stall ledger finalization. Fixed:
enqueueDuesis fully wrapped (try/catch + non-throwing error log) so email infra can never wedge the money path.app/api/stripe/webhook/[space]/route.ts. - P1
dues_lapsednull-periodEnd collapse: canceled/deleted subs carry no item period; key collapsed todues_lapsed:<member>and suppressed every future lapse notice. Fixed: fall back to subscription id (duesDedupeKey), threadedsubscriptionIdthroughenqueueDues. New test (499 total). - P2 dispatcher concurrency: no row-level lock; documented the once/min + ~5s-drain assumption and why per-attempt key makes it safe.
- Audit PASS (no change needed): Phase-1 webhook guarantees intact, multi-tenant isolation, RLS + member self-view, HTML escaping, CRON_SECRET constant-time, migration idempotent + mirror exact, fresh-clone fails safe.
State
- DEPLOYED (run 25990457904, HEAD
f1bae44, success). All 7 local commits (pass-44 docs + 5 Phase 2 + audit-fix) pushed; origin == local. Smoke clean://login200; gated 307; Stripe webhook 400 (Phase 1 intact, not 307);/api/cron/notifications503 with/without bearer (proxy whitelist works; CRON_SECRET unset = fails safe). - Inert until owner provisions (Droplet): set
RESEND_API_KEY,EMAIL_FROM(Resend-verified domain + SPF/DKIM),CRON_SECRET; add the once-a-minute crontab line (docs/DEPLOYMENT.md). Until then the outbox fills but nothing sends. - Validation after provisioning: Stripe test-mode dues run ->
dues_renewedenqueued once, dispatcher sends,/meshows it; force card failure ->dues_payment_failed; let it lapse ->dues_lapsed.
2026-05-17 (pass 45) — Product spine Phase 2: transactional notifications (LOCAL, awaiting one reviewed deploy)
Branch main. Built Phase 2 dues-lifecycle email end to end, gated + committed per sub-phase. NOT deployed (ASK pending). Suite 498, build clean.
What shipped (commits ddb790a, 5295dc2, 2c88278, 75097a6, + this docs/UI)
- P2a migration 041 + schema.sql section 26:
notificationsoutbox. Service-only writer, SELECT = admin/board/treasurer, member self-view via validated action.(space_id, dedupe_key)unique = idempotent enqueue. Purelib/notifications-logic.ts(render + dedupe + terminal rule), 13 tests. - P2b
lib/email/send.ts: Resend HTTP transport seam (no SDK,fetch),retryableflag, unset-key = clean no-op. 6 tests (fetch mocked).RESEND_API_KEY/EMAIL_FROMin.env.example. - P2c Stripe webhook enqueues:
invoice.paid→renewed,invoice.payment_failed→failed, lapse→lapsed. Outbox only, never sends inline (keeps the just-hardened money path fast/retry-safe). - P2d
POST /api/cron/notifications: constant-timeCRON_SECRET, drains ≤20/min, row id as Resend Idempotency-Key (overlap-safe).proxy.tswhitelists/api/cron(same redirect trap as the Stripe webhook).CRON_SECRETin.env.example. - P2e
getMyNotifications+ read-only/mesection. Docs updated same change: DATABASE_SCHEMA, DB_SCHEMA_MAP, API_REFERENCE, ARCHITECTURE, DEPLOYMENT (env + crontab line).
Decision / context
- Transport = Resend (owner indicated an existing Resend token; none was wired in this repo). Behind a one-function seam so SMTP can replace it later without touching callers. Generic-platform constraint preserved.
- Scope locked to dues lifecycle; bookings/forms reuse the same outbox in a later phase.
Open / needs owner
- Not deployed. Awaiting one reviewed deploy (ASK). Also still local: pass-44 docs commit
dc76a86. - After deploy, prod needs:
RESEND_API_KEY,EMAIL_FROM(verified domain, SPF+DKIM),CRON_SECRETset on the Droplet, plus the once-a-minute crontab line (see DEPLOYMENT.md). Without them the outbox fills but nothing sends. - Validation (real test): Stripe test-mode dues run → confirm
dues_renewedenqueued once, dispatcher sends,/meshows it; force a card failure →dues_payment_failed; let it lapse →dues_lapsed.
2026-05-17 (pass 44) — Stripe Phase 1 DEPLOYED; post-deploy P0 (webhook routing) caught + fixed + redeployed
Branch main. Phase 1 shipped (run 25988762909, HEAD 8f74307, success). Post-deploy smoke caught a P0: an unauthenticated POST to /api/stripe/webhook/<space> returned 307, not 400.
Root cause
Next 16 renamed middleware to proxy.ts (root). It auth-gates every non-public path and NextResponse.redirect('/login') (307). The webhook is called by Stripe with no session, so it was redirected. Stripe does not follow redirects → every webhook delivery would have failed silently. The earlier audit's "no auth middleware in this app" was wrong; caught only by smoke-testing the live route.
Fix (commit 37d5b04, deployed run 25988926981, success)
- Added
/api/stripe/webhooktoPUBLIC_ROUTESinproxy.ts(scoped to the webhook path only — Stripe server actions stay auth-gated, they run from authenticated pages). - Corrected the route/header comments that wrongly claimed no auth middleware.
- Gate green (479 tests, build). Re-smoke: webhook POST now 400 (route reached, signature rejected);
//login200;/dashboard/me/settings307→login; prod healthy.
State
- DEPLOYED & live. Stripe dues Phase 1 is functional end to end at the code level. Inert until a space admin configures Stripe keys.
- Next: a space admin configures Stripe in test mode → member "Pay dues" → confirm webhook flips
member_billingstatus + records exactly onepaymentsrow + storescurrent_period_end. Then Phase 2 = transactional notifications (renewal / failed-payment / booking emails).
2026-05-17 (pass 43) — Stripe Phase 1 deep-audited + P0/P1 fixed; LOCAL, deploy-ready (pending approval)
Branch main. Owner asked for a deep audit before deploy. Ran an independent Stripe-focused audit (verified vs Stripe Basil 2025-03-31 changelog). Verdict was "NOT safe as-is" — 3 P0 + 1 P1 found; ALL fixed. suite 479, build clean.
Audit findings → fixes (all committed)
- P0 webhook lost events: dedupe row written before processing; a handler 500 + Stripe retry was suppressed as duplicate → event dropped. Fixed
ca34e53(delete dedupe row on handler error → retry reprocesses). - P0
current_period_endgone from top-level Subscription (Basil; carried into 2026-04-22.dahlia) → grace never fired, period never stored. Fixedb477c38: readsub.items.data[0].current_period_end. - P0 invoice
subscription_detailsmoved toinvoice.parent→ metadata member-resolution always failed (mislinked dues). Fixedb477c38: readinv.parent.subscription_details.metadata. - P1 duplicate ledger rows: Stripe fires invoice.paid AND invoice.payment_succeeded. Fixed
b477c38: handle onlyinvoice.paid+ pre-insert dedupe on(space_id,platform,external_id). - P1 migration txn note: documented (no manual BEGIN/COMMIT; safe on Supabase PG15).
- P2 customer-create race: fixed
b477c38—idempotencyKeyoncustomers.create. - Audit CONFIRMED SOUND as-built: secrets never client-exposed/logged; every query space-scoped; cross-space webhook forgery blocked (per-space signing secret + space-rechecked member resolution); RLS correct; status automation strictly current↔late.
Full Phase-1 commit set (LOCAL, undeployed; no migration to run beyond 040 which is already live)
P1c 5ca7ee6 · P1d f293e41 · P1e fa76176 · P1f 1f61029 · P1g docs ef9b0dc · audit fixes ca34e53,b477c38 (+ this docs).
Open
- LOCAL & undeployed: pass-42 + pass-43. Phase 1 is audited and the found issues fixed. Inert until a space admin configures Stripe. Awaiting deploy approval. After deploy: configure Stripe in test mode on a space → member "Pay dues" → confirm webhook flips status + records a single payment row + stores period end. Then Phase 2 = transactional notifications.
2026-05-17 (pass 42) — Stripe dues Phase 1 COMPLETE (P1f UI + P1g docs); LOCAL, one reviewed deploy pending
Branch main. P1a/P1b deployed (inert foundation). P1c–P1g built locally — Phase 1 is now a complete, reviewable unit. Owner asked to review the money/webhook code before its deploy; build is clean, suite 479.
Done this pass (gated, committed, NOT deployed)
1f61029P1f:StripeBillingPanel(admin, on /settings after SettingsClient) — mode, publishable key, write-only secret/webhook secret, per-tier price ids, grace days, shows the per-space webhook URL + events to subscribe + Portal-activation reminder.DuesCardon /me — status via getMyBilling, "Pay dues" (startDuesCheckout redirect) / "Manage billing" (startBillingPortal).- P1g: API_REFERENCE + ARCHITECTURE Stripe sections + this entry.
Full Phase-1 commit set (LOCAL, awaiting one reviewed deploy)
5ca7ee6 P1c (dep+client+vault config) · f293e41 P1d (checkout/portal/getMyBilling/listMemberBilling) · fa76176 P1e (per-space signed webhook + dues→status) · 1f61029 P1f (UI) · docs. (P1a 521df69 + P1b 3d7cf87 already deployed, inert.)
Reviewer notes (money-critical surface)
- Secrets: secret key + webhook signing secret only in the AES-256-GCM vault;
getStripeSettingsreturns booleans, never values; keys neverNEXT_PUBLIC_/client. - Webhook: signature-verified with the space's own secret; idempotent on
event.id; per-space path (no Connectevent.account); 400 bad-sig/config, 500 handler (Stripe retries; idempotent). No auth middleware exists so the path is reachable for Stripe by design. - Status automation only moves
current↔late(filteredIN ('current','late')) — never auto-inactive, never auto-approvesunverified;last_paid_aton paid. - Multi-tenant: every query space-scoped; member resolved by metadata then customer id, re-checked in-space.
- Inert until an admin actually configures Stripe in Settings (no
integrationsstripe row exists in prod).
Open
- LOCAL & undeployed: pass-42 (P1f + docs) on top of pass-41 (P1c–P1e). One reviewed deploy ships all of Phase 1 (no migration in this batch — 040 already live). ASK before deploy. After deploy: a space admin configures Stripe (test mode) → member "Pay dues" → webhook flips status. Then Phase 2 = transactional notifications (renewal/failed-payment/booking emails) — the next product-spine thread.
2026-05-17 (pass 41) — Stripe dues P1c–P1e (config + actions + webhook) built; LOCAL, REVIEW before deploy
Branch main. P1a/P1b deployed (run 25987779054, inert foundation live). Built the money-critical core; per the owner's choice this code should be reviewed before its deploy. 4 commits this pass, suite 479, build clean each.
Done (gated, committed, NOT deployed)
5ca7ee6P1c:stripe@22.1.1(exact).lib/stripe/client.tsper-requestgetStripe(pinned apiVersion, server-only).lib/stripe/config.ts(plain server module — takes admin client, NOT a 'use server' file): store/read secret key + webhook secret in the AES-256-GCM vault (door pattern),getStripeConfig.lib/actions/stripe.ts:getStripeSettings(status only, never returns secrets),saveStripeSettings(write-only secrets, integrations.config upsert). ZodstripeSettingsSchema.f293e41P1d:startDuesCheckout(self/tier-resolved, ensures Stripe Customer, hosted Checkout subscription mode, client_reference_id + metadata on session AND subscription_data),startBillingPortal(hosted self-serve),getMyBilling(service-client self-view),listMemberBilling(admin/board/treasurer). All Stripe calls try/caught.fa76176P1e:app/api/stripe/webhook/[space]/route.ts(nodejs/force-dynamic). Raw body, per-space vault secret+signing-secret,constructEvent, idempotency viastripe_webhook_eventsPK (replay→200). Handles checkout.session.completed / customer.subscription.* / invoice.paid|payment_succeeded → upsert member_billing + record stripepaymentsrow +duesMemberStatus/graceExceeded→ space_members status (only current↔late, never inactive/unverified; last_paid_at on paid). member resolved by metadata then customer id, always space-scoped. bad sig/config→400, handler error→500 (Stripe retries; idempotent).- No middleware in this app, so the webhook path is reachable unauthenticated by design (verify is signature-based).
Remaining Phase-1
- P1f UI: Settings (admin) Stripe config panel — mode, publishable key, secret key + webhook secret (write-only), per-tier price ids, grace days, show the per-space webhook URL
…/api/stripe/webhook/{space_id}+ reminder to activate the Customer Portal in Stripe. Member surface on/me: "Pay dues" (startDuesCheckout) / "Manage billing" (startBillingPortal) + status from getMyBilling; dashboard attention already shows dues issues via member status. Admin dues list via listMemberBilling. - P1g: API_REFERENCE + ARCHITECTURE (Stripe section, per-space-keys + webhook + status mapping + Portal-activation caveat) + HANDOFF; then Phase 2 = transactional notifications, Phase 3 = broader self-serve.
Open / REVIEW GATE
- LOCAL & undeployed: pass-41 (P1c/P1d/P1e + this docs). Money + webhook code — owner asked to review before deploying it. Nothing live can call Stripe yet (no config UI shipped, integrations has no stripe row). Recommended next: build P1f/P1g so Phase 1 is a complete reviewable unit, then one reviewed deploy. ASK before deploy.
2026-05-17 (pass 40) — Product spine started: Stripe dues P1a+P1b (DEPLOYED, run 25987779054, inert foundation)
Branch main. Pass-39 backlog fully DEPLOYED. Began the product spine (Stripe recurring dues) — money-critical, so design-first with a fresh web re-verify. 3 commits this pass, suite 479, build clean.
Locked design (owner-approved forks, pass-40)
Per-space OWN Stripe keys (NOT Connect); Subscriptions + hosted Checkout + Billing Portal; lapse = grace→late, NEVER auto-inactive; config in integrations.config + secrets in the AES-256-GCM vault. Re-verified Stripe facts saved to memory integration-api-facts (SDK v22.x, apiVersion 2026-04-22.dahlia, raw req.text() body, 300s tolerance, event.id idempotency, per-space webhook PATH routing — no Connect event.account, subscription_data.metadata gotcha, Portal needs per-account Dashboard activation, status map).
Done (gated, committed)
521df69P1a: migration 040 —payment_platform+=stripe;member_billing(member↔Stripe customer/sub/status; SELECT admin/board/treasurer, no client write);stripe_webhook_eventsidempotency ledger. Mirrored schema.sql §25 + CREATE TYPE list + types + DATABASE_SCHEMA/DB_SCHEMA_MAP (framing→040). Inert until used.3d7cf87P1b: purelib/stripe-logic.ts+ 10 tests —STRIPE_API_VERSION,stripeStatusIsPaid,duesMemberStatus(grace→late, never inactive, unknown→no-op),graceExceeded(fail-safe),priceIdForTier,isStripeConfigured.
Remaining Phase-1 plan (money-critical — review/checkpoint before/within)
- P1c: add
stripedep (pin exact, v22.x);lib/stripe/client.tsserver factorygetStripe(secretKey,{apiVersion});lib/actions/stripe.tsconfig get/save (publishable/mode/prices→integrations.config; secret key + webhook secret → encryptedsecretsrows, ids in config — reuselib/secrets/crypto+ the door secret_ref pattern); Zod + admin guard. - P1d: member actions —
startDuesCheckout(resolve tier→price, ensure Stripe Customer, Checkout subscription mode w/ client_reference_id + metadata + subscription_data.metadata),startBillingPortal,getMyBilling(self service-client view), adminlistMemberBilling. - P1e: webhook
app/api/stripe/webhook/[space]/route.ts— raw body, per-space signing secret,constructEvent, idempotent viastripe_webhook_events, handle checkout.session.completed / customer.subscription.* / invoice.paid / invoice.payment_failed → upsertmember_billing+ recordpaymentsrow +duesMemberStatus→space_members.status + last_paid_at. Exclude path from auth middleware. - P1f: Settings config UI (admin) + member "Pay dues / Manage billing" surface + dues status on /me & dashboard attention. P1g: docs (API_REFERENCE/ARCHITECTURE) + HANDOFF.
- Then product spine Phase 2 (transactional notifications) and Phase 3 (broader self-serve) per the pass-35 report.
Open
- LOCAL & undeployed: pass-40 (3 commits: P1a, P1b, this docs). Migration 040 is additive/inert (enum value + unused tables) — safe to deploy alone. Awaiting deploy approval. ASK before deploy; the P1d/P1e money+webhook code should be reviewed before its deploy.
2026-05-17 (pass 39) — Bulk member actions + palette trigger/a11y (backlog COMPLETE, DEPLOYED run 25987172786)
Branch main. Pass-38 (mobile card tables) DEPLOYED (run 25986426224, smoke clean). 2 feature commits this pass, suite 469, build clean. This closes out the analysis-driven backlog the user chose.
Done
390046a: bulk member select + Approve. NewbulkApproveMembersaction (ADMIN_ROLES, Zod ids, one batched space-scoped update flipping only still-unverified rows, audited). Selection checkboxes on the members table + mobile cards (admin only) with header select-all; sticky count bar (Approve selected / Clear); optimistic update.331a0c6: visible labeled command-palette trigger ("Jump to… ⌘K") in the sidebar dispatching acommandpalette:openevent the palette now also listens for. a11y assessment: sonner<Toaster>already provides the aria-live region; sidebar icon buttons alreadyaria-label'd; skip-link + drawer focus mgmt already present — so the top a11y items were already satisfied; this fixes the real gap (palette had no discoverable entry point).
Backlog status (the pass-35 analysis → user-chosen items)
- F1, F2, ⌘K palette + trigger, sortable members, route skeletons, modal-busy, dashboard "Needs attention", cached perms, mobile card tables, bulk approve, a11y assessment — ALL DONE (deployed through pass-38; pass-39 pending deploy).
PageHeaderdedupe: deliberately NOT done (mechanical ~21 files, no user-visible value, browser-unverifiable churn). Recommend leaving unless explicitly requested.- Deferred by user (separate future thread): product spine — Stripe recurring dues → transactional notifications → member self-serve portal. See the pass-35 report in session history; rationale grounded in Fabman/Cobot/Nexudus/Wild Apricot.
Open
- DEPLOYED pass-39: pushed
d348112..41193d3, run25987172786success, no migration. Smoke://login200;/members/dashboard/payments307->login. The full pass-35 analysis backlog the user chose is now LIVE end-to-end. NOT browser-verified. Next session: product spine (Stripe dues → notifications → self-serve portal) is the highest-leverage remaining work. Default ASK-before-deploy.
2026-05-17 (pass 38) — Mobile card tables (DEPLOYED, run 25986426224)
Branch main. Pass-37 (dashboard attention + cached perms) DEPLOYED (run 25986340615, smoke clean). 1 feature commit this pass, suite 469, build clean.
Done
9d419fe: mobile card layouts for the members + payments tables. Table ishidden md:block; undermda stacked card list renders the same data + key actions reusing existing handlers/dialogs (members: approve/edit/certs/cards/forms/remove; payments: link member). Desktop unchanged.
Remaining from the chosen backlog
- Bulk member actions: multi-select (table + cards) + a
bulkApproveMembersserver action (Zod, one batched call — avoid N round-trips) + sticky selected-count bar. Moderate; clear value for spaces with manyunverifiedjoiners. - a11y AA sweep (targeted): first verify sonner already renders an aria-live region (it does by default → that item is satisfied); then add
aria-label+ ≥24px target size to prominent icon-only buttons + visible focus ring. Do as a focused pass, not a blind global edit. PageHeaderdedupe (~21 hand-rolled headers): DEPRIORITIZED — mechanical, high churn, no user-visible value, risky without browser test. Skip unless explicitly requested.- Deferred by user: product spine (Stripe dues → notifications → self-serve portal).
Open
- LOCAL & undeployed: pass-38 (1 commit + this docs). Awaiting deploy approval (no migration). After deploy: open members/payments on a narrow viewport — cards, not a clipped table. Default ASK-before-deploy.
2026-05-17 (pass 37) — UX pack part 2: dashboard attention + cached perms (DEPLOYED, run 25986340615)
Branch main. Continues pass-36. Pass-36 batch (F1/F2 + palette + sortable + skeletons) is DEPLOYED (run 25985954925, smoke clean). 2 new commits this pass, suite 469, build clean.
Done (gated, own commits)
ca498d5: dashboard "Needs your attention" strip (admin/board) — members awaiting approval + unreconciled payments as deep links, only when non-empty (one extra count query).b8440e9:lib/permissions-cache.ts— Reactcache()-wrappedhasPermission(uid,sid,perm); layout nav-perm resolution + members page now dedupe overlappinguser_has_permissionRPCs per render. Pure dedupe, semantics unchanged (no permission-logic reimplementation — RLS-guardrail respected).
Remaining from the chosen backlog (NOT done — heavier UI work)
- Mobile card layouts for the members + payments tables (currently
overflow-x-autoonly; action buttons off-screen on phones). - Bulk member actions (multi-select + "Approve selected" / batch dues) with a sticky selected-count bar.
- a11y AA sweep: verify sonner already provides an aria-live region (likely yes — then this narrows to icon-button
aria-label+ ≥24px target size + focus ring/stickyscroll-padding); targeted, not a blind global edit. PageHeaderdedupe across ~21 hand-rolledbg-sidebar …headers (mechanical, high churn, low user-visible value — lowest priority; do last or skip).- Deferred by user: product spine (Stripe dues → notifications → self-serve portal).
Open
- LOCAL & undeployed: pass-37 (2 commits + this docs). Awaiting deploy approval (no migration; app only). After deploy: dashboard attention strip for an admin with unverified members/unlinked payments. Default ASK-before-deploy.
2026-05-17 (pass 36) — Analysis acted on: F1/F2 fixes + UX pack (part 1) (DEPLOYED, run 25985954925)
Branch main. The pass-35 deep analysis (3 agents: competitive, UX/a11y best practice, codebase UX/IA — full report in session history) produced a prioritized backlog. User chose: F1/F2 fixes + UX quick-win pack + bigger UX bets (product spine deferred). 6 commits this pass, suite 469, build clean each.
Done (gated, own commits)
7054b35F1: member CSV import was insertingspace_membersfrom the browser with a fabricateduser_id(FK-breaking, bypassed validation). Now routes through the existing admin-gated, Zod, batchedimportMembersaction; dropped unusedspaceIdprop.521fb1fF2: PayPal sync route returns inserted rows; client prepends them instead ofwindow.location.reload()(preserves scroll/filter).6c48780: ⌘K/Ctrl-K command palette (uses the previously-unused cmdk primitive; admin/manage entries gated by the same isAdmin+navPerms as the sidebar).4feecda: sortable members table (name/tier/joined/last-payment/status, asc→desc→none).c87cb35: per-route skeletons (dashboard/members/payments) + disable modal Cancel during submit.
Remaining from the chosen backlog (NOT yet done)
- UX pack leftovers: WCAG AA sweep (icon-button target size, aria-live for toasts — verify sonner first, missing labels, focus ring/sticky scroll-padding),
PageHeaderdedupe across ~21 hand-rolled headers. - Bigger UX bets (task #35): mobile card layouts for members+payments tables; bulk member actions (multi-select + Approve selected); dashboard "Needs attention" zone + role-scoping; per-request cached permission resolver (replace per-page user_has_permission RPC fan-out).
- Deferred by user: product spine (Stripe recurring dues → notifications → member self-serve portal).
Open
- LOCAL & undeployed: pass-36 (6 commits). Awaiting deploy approval (no migration; app only). After deploy: try ⌘K, sort the members table, watch route skeletons, re-run a CSV member import + PayPal sync. Default ASK-before-deploy.
2026-05-17 (pass 35) — CLEAN CHECKPOINT (all deployed) + UX/product analysis kickoff
State: origin/main == main, tree clean, latest deploy green (run 25985079088, pass-34). Migrations applied through 039. ~469 unit tests + e2e; pnpm build clean. Nothing local/undeployed.
Shipped this overall session (passes 26-34, all live; verify with git log, smoke-tested, NOT browser-verified)
- Door epic P3 (slot allocator + live grant/revoke/open-lock-unlock), member self-entry (dashboard),
/doorsmember page. - Classes optional required-form gate + signups roster; equipment reservations roster.
- Presence/attendance (migration 038): check-in/out + host + notes, dashboard "Who's here",
/attendance(search/day-filter/grouping),/mehistory. - Forms↔member email association (migration 039 + app): submit-time + member add/email-change + space-wide re-link + backfill; pass-34 fix: also derives the email from an email-type form field/answer (not just the dedicated
submitter_email) and backfills existing rows via "Re-link submissions". - Per-member FORMS panel on
/members; delete form (confirm+cascade) / delete submission / re-link button. - Full 4-agent audit + fixes (defense-in-depth, nav-permission, docs drift, +tests); landing 23b wording + mobile breakpoints.
Locked decisions / intentional (do NOT revert without explicit ask)
- Single space per user (
getAuthMember.single(), fails closed; documented). - Forms email-link includes raw anonymous typed emails (attribution-only tradeoff accepted; documented inline + ARCHITECTURE).
- Settings page loads admin's own secrets by design (admin-only, documented).
- ASK before every deploy (the one-time "deploy without asking" was pass-30 only).
Open / next
- In progress this pass: deep app + UI/UX analysis with web research → a prioritized improvement report (suggestions only, no code yet). When the user picks items, build them phase-by-phase per the usual cadence.
- Known deferred (from audit): multi-space support (needs space switcher; product call); PayPal tenant-scoped conflict key (near-zero risk; would need a migration); fuller ARCHITECTURE §7 prose rewrite; systemic test gap (no Supabase mock harness — action orchestration only e2e-able).
Kickoff prompt for next session (paste verbatim)
Continue the HeatSync/23b-and-any-hackerspace platform (hackerspace-management; Next.js+Supabase; push to main = prod deploy via GitHub Actions; https://hackerspace.sh). READ FIRST: CLAUDE.md; docs/HANDOFF.md top entry (pass-35 checkpoint); memory files collaboration-cadence, integration-api-facts, hackerspace-rls-guardrail, architecture-standards, platform-not-heatsync-only. VERIFY CLEAN:
git log --oneline origin/main..mainempty,gh run listlatest green,pnpm exec vitest run~469 green,pnpm buildclean, migrations through 039. METHOD (locked): phase-by-phase — idempotent numbered migration mirrored in scripts/schema.sql + pure unit-tested lib/-logic.ts + lib/actions/ + Zod + guard + pages + docs (DATABASE_SCHEMA/DB_SCHEMA_MAP/API_REFERENCE/ARCHITECTURE) in the same change; permissions additive via lib/permissions-catalog.ts; RLS additive/default-deny; gate (vitest + pnpm build) + small commit per phase; ASK before every deploy; after an approved push watch the Actions run + HTTP smoke test; end long sessions with an updated HANDOFF + kickoff prompt. The UX/product improvement report is in this session's history — ask the user which items to take, then build them in that order.
2026-05-17 (pass 34) — BUGFIX: email linking ignored email-as-form-field (DEPLOYED, run 25985079088)
Branch main. 1 commit 9f0ee18. User report: re-link said "already linked" but submissions weren't showing under the member.
Root cause: association only ever read the dedicated submitter_email (the public "your email" box, or the signed-in user's address). A form that collects email as an ordinary email-type field stored it in answers, so submitter_email stayed NULL → no match → relinkAllSubmissions updated 0 rows and toasted "already linked", and the submission never appeared under the member. Id plumbing was fine (all space_members.id); pure matching was the issue.
Fix (suite 469, build clean):
- New pure
deriveSubmitterEmail(fields, answers, explicit?)(precedence: explicit valid email > first email-type field answer > any email-looking answer; trimmed+lowercased) + 5 tests. submitForm: reordered so answers are validated first, then derives + persistssubmitter_emailfrom the answers when no dedicated one — so new submissions populate it and all existing linking (addMember/updateMember/relink) works.relinkAllSubmissions: now scans every unlinked submission, re-derives email from itsform_snapshot+ answers, matches the space email→earliest-member map, and backfills BOTHmember_idandsubmitter_email— repairs existing broken rows.
Open
- DEPLOYED pass-34: pushed
c2f8368..8befee9, Actions run25985079088success, no migration. Smoke://login200;/forms/members307->login. ACTION FOR USER: click "Re-link submissions" on /forms once to repair existing rows (backfills member_id + submitter_email from answers), then verify the member's FORMS panel. Default remains ASK-before-deploy.
2026-05-17 (pass 33) — Form/submission deletion + space-wide re-link (DEPLOYED, run 25984491717)
Branch main. 4 commits c4ae5d2.. (this entry). No migration, no browser verification here.
- DF1
c4ae5d2actions:deleteFormnow PERMANENTLY deletes the form + FK-cascades all submissions (the old "refuse if submissions" guard removed); requires explicitconfirm:true(deleteFormSchema) and audits the destroyed count.deleteSubmission({submissionId})— forms.manage, service client (form_submissions has no client write policy), space-scoped, audited.relinkAllSubmissions()— forms.manage, re-runs email→member linking space-wide (members earliest-joined first; reuseslinkSubmissionsByEmail; NULL-only). - DF2
1e6e5d5UI: forms list per-form Delete (destructive confirm spelling out permanent loss incl. signed waivers) + header "Re-link submissions"; results page per-response Delete (confirm) + "Delete form" header action. Optimistic updates. - DF3 (this): API_REFERENCE + ARCHITECTURE updated (deleteForm now cascades; immutability caveat); HANDOFF. Suite 464, build clean each phase.
Decision note
User explicitly wanted forms + results + individual entries deletable with a confirmation; this overrides the prior "submissions are immutable, close instead" stance. Hard delete (no archive); CSV export remains available and the confirm copy points to it. Documented so an audit doesn't revert it.
Open
- DEPLOYED pass-33: pushed
bff8dea..cec2d69, Actions run25984491717success, no migration. Smoke://login200;/forms/members/dashboard307->login. NOT browser-verified. Next live check: delete a draft form, delete a response, Re-link button; confirm cascade removed submissions. Default remains ASK-before-deploy.
2026-05-17 (pass 32) — Forms↔member email association + landing mobile (DEPLOYED, run 25984078567)
Branch main. 6 commits ea521de.. (this entry). No browser verification here.
Forms email-match association (owner-chosen looser model)
Owner explicitly chose to link by email match INCLUDING raw anonymous typed-email submissions (the question spelled out the impersonated-attribution tradeoff; accepted — attribution only, no access). This SUPERSEDES the codebase's prior verified-email-only stance; the decision is documented inline in submitForm, in API_REFERENCE, and ARCHITECTURE so a future audit does not "fix" it back.
- G1
ea521de: pureescapeLike(ILIKE-safe;_/%in emails literal) +pickMemberForEmail(earliest-joined deterministic) + 5 tests (suite 464).submitFormlinks member_id on any submitter_email match. SharedlinkSubmissionsByEmailhelper. Superseded verified-only comment updated. - G2
dbed9ac:addMember+ email-changingupdateMembercalllinkSubmissionsByEmail(service client, space-scoped). - G3
832a0ae: migration 039 — data-only, idempotent backfill of existingform_submissions.member_id(NULL only) by(space_id, lower email match), earliest-joined. Not in schema.sql (no structural change). Docs bumped to 039. - G4
ed8d629:listMemberSubmissions(forms.manage, RLS-honoring, metadata only) + FORMS column/button on/members→MemberFormsDialog(title/kind/version/date + link to/forms/[id]/results). - G5 (this): API_REFERENCE + ARCHITECTURE updated (decision documented), HANDOFF.
Landing mobile (L1, owner ask)
app/(landing)/landing.css: tightened the ≤600px breakpoint (nav padding/gap, section/cta/hero padding, card padding/min-height) and added a ≤400px block (nav gap/font, button padding, wordmark size) so the nav row fits ~320px without overflow. CSS-only inside media queries; desktop untouched. Next auto-injects the viewport meta (no override anywhere) so scaling is correct. Not device-tested here.
Open
- DEPLOYED pass-32: pushed
a364d15..61c97e3, Actions run25984078567success, migration 039 applied by the deploy. Smoke://login200;/members/forms/attendance/dashboard307->login; landing serves correctwidth=device-widthviewport. NOT browser/device-verified. Next live check: public form submit by a member's email → appears under that member's FORMS panel; eyeball landing at 320/375/414px. Default remains ASK-before-deploy.
2026-05-17 (pass 31) — Audit follow-ups + attendance polish (DEPLOYED, run 25982959375)
Branch main. Continuation of the pass-30 audit. User: handle the deferred items where confident + properly test/doc; "stick to single space for now". 5 commits 2dc0af5.. (this entry).
Done (each gated + own commit, suite 459, build clean)
2dc0af5PayPal sync:paymentshas NO unique index onexternal_id, so theonConflict:'external_id'upsert was unreliable and fell back to an insert that strippedexternal_id-> duplicate, non-idempotent rows, not tenant-scoped. Replaced with: look up existingexternal_ids in THIS space, insert only new rows (id preserved). No migration, no schema risk.1e1b93edoor IPv6 pin: SSRF pin parser didpinnedHost.split(':')[0], mangling any IPv6 pin (failed closed -> IPv6 controllers unusable). New pure exhaustively-testednormalizeHost(scheme/userinfo/port/path strip;[ipv6]/[ipv6]:port/bare-ipv6 unwrap) applied to both request host and pin. ALL prior behavior preserved (verified: IPv4, host:port, userinfo-spoof reject, trailing-dot reject, metadata always blocked, IPv4-mapped-IPv6 vs IPv4 fails closed). 29 door tests.d5a71c9/attendance polish (user ask): client view with a "Here now" section + inline self check-in / check-in-as-host / check-out + note; history with name search, day picker, grouped-by-day headers, per-visit duration/status/notes, live visits/members summary. New pure testeddayKey(tz-stable) +visitDurationMinutes.- docs (this commit): ARCHITECTURE project tree + §7 module paths corrected to
lib/actions/*reality; explicit note that/settingsadmin secret loading is by-design (not a leak);getAuthMembersingle-space invariant documented in code.
Decisions / not done (intentional)
- Single space per user: kept (user decision).
getAuthMember.single()is intentional and fails closed; documented in code + ARCHITECTURE Known Limitations #7. Not changed. - Settings
select('*'): NOT narrowed — verified the admin-only client legitimately useswebhook_secret+ integrationconfig; narrowing would be cosmetic and risk breaking the UI. Documented as intentional instead. - A full ARCHITECTURE §7 prose rewrite remains a nice-to-have (paths now correct; an authoritative top-of-doc note already added pass-30).
Open
- DEPLOYED pass-31: pushed
0bb934d..d0aef9a, Actions run25982959375success, no migration. Smoke://login200;/attendance/dashboard/doors/payments307->login (gated, expected). NOT browser-verified. Next live check:/attendancesearch/day-filter/check-in, PayPal re-sync idempotency. Default remains ASK-before-deploy next session.
2026-05-17 (pass 30) — Presence/attendance feature + full app audit pass
Branch main. Big session: landing wording fix, a new presence module, a 4-agent read-only audit, and audit fixes. User authorized deploying this work without the per-deploy ask (one-time, for this ready work; default reverts to ASK next session unless reaffirmed). DEPLOYED: pushed 4b29280..c12a103, Actions run 25982428014 success, migration 038 applied. Smoke: / /login 200; /dashboard /attendance /doors /classes /equipment/manage 307->login (gated, expected). NOT browser-verified.
Presence & attendance (migration 038) — F1-F5, suite 432
space_visits (check-in/out, is_host, in/out notes; partial UNIQUE one-open-per-member) + spaces.host_requires_card (bool default true). RLS additive/default-deny: SELECT = any space member (presence is social), NO client write policy (self-resolved service-client actions only; immutable). Pure lib/presence-logic.ts (presenceStatus/hostEligibility/summarizePresence, 11 tests). lib/actions/presence.ts: checkIn (host eligibility via host_requires_card + active-card count; blocks fresh double check-in; auto-closes a >18h stale visit, no cron), checkOut, listPresentNow, getMyVisits, listAttendance. UI: dashboard "Who's here" panel (self check-in/out + host + note), /attendance page (all members, by product decision), /me history, sidebar link. Locked decisions: host = per-space toggle (default require card); attendance report = all members; stale = auto-treat (no cron); report at /attendance.
Audit (4 parallel read-only agents) + fixes
No P0 anywhere. Door/secrets/multi-tenant clean. Fixes landed:
- harden
dd9e3ac: castVote now verifies proposal in-space + open before upsert; cancelMySignup promotion path scoped by space_id; setAreaLead rejects foreign lead_id. (All were RLS-safe; defense-in-depth.) - docs
3efa3cd: ARCHITECTURE status=production + authoritative note (no monolithic lib/actions.ts), rewrote stale Known Limitations, fixed env table (dropped phantom SUPABASE_JWT_SECRET); DATABASE_SCHEMA/DB_SCHEMA_MAP ~43 tables / through 038, space_members.status enum default 'unverified' (was wrongly 'active'); README webhook wording; API_REFERENCE real paths + 4 undocumented actions. NOTE: docs-agent's "createSecret should be Admin-only" was a FALSE POSITIVE (ADMIN_ROLES = admin+board) — left correct. - nav
c4cba10: Admin manage links now gated by actual permission (layout resolves forms/certifications/classes/equipment/door .manage), not role — fixes orphaned delegated-permission members + admins seeing dead links. Customize/Import/Settings stay admin-only. - test
dc96d6f: +16 (→448): permissions least-privilege guard, invite-logic suite, door SSRF host-spoof + redact edges, pickPromotion unlimited branch.
Open / deferred (NOT done — need a deliberate decision)
- getAuthMember single-space assumption (
lib/auth-helpers.ts:51.single()by user_id): a user in 2+ spaces fails every action (fails closed, no leak). The app has no space switcher — it is single-space-per-user by design. Documented in ARCHITECTURE Known Limitations #7. CHECKPOINT: decide if multi-space is a product goal (needs an active-space selector) before changing. - settings page
select('*')ships webhook_secret + integration config to an admin-only client. By-design admin exposure; recommend narrowing to explicit columns / reveal-pattern in a focused pass (not changed: risk of breaking settings UI without browser test). - PayPal sync
onConflict: 'external_id'not tenant-scoped (near-zero real collision; PayPal txn ids globally unique). Recommend a 039 migration adding UNIQUE(space_id, external_id) + route change. - door-logic IPv6 pin quirk:
pinnedHost.split(':')[0]mangles an IPv6 pin (fails closed → rejects). Fine for IPv4/hostnames; revisit if an IPv6 controller is ever pinned. - ARCHITECTURE.md §7 / project tree still describe the old monolithic layout; corrected with a top-of-doc authoritative note, full rewrite deferred.
- Systemic: no Supabase mock harness; e2e is mostly render smoke. Action orchestration (presence one-open/stale, form gate, door slot) unverified end-to-end.
2026-05-17 (pass 29) — Classes form-gate + rosters + /doors page DEPLOYED
Branch main. Pass-28 (self-entry) deployed. User: "continue, also polish, also add a doors page, also polish the classes and equipment reservation stuff, should be able to see who signed up and also should be able to require a form optionally for classes." Four design forks asked + LOCKED: roster = staff-only (members stay blind); form gate = hard gate + classes.manage override; "completed" = any submission on file (waiver model), form must be published; doors page = full at /doors.
Built (LOCAL, not deployed) — 6 commits, each gated + own commit
- P1
92c5737: migration037_class_required_form.sql(classes.required_form_idnullable FK -> forms ON DELETE SET NULL) + schema.sql + types + DATABASE_SCHEMA/DB_SCHEMA_MAP. ADD COLUMN IF NOT EXISTS; no RLS change (additive nullable col on already-policiedclasses). - P2
f3b05af: puresignupFormEligibility+ 4 tests (suite 417->421).signUpForClassnow has classes.manage override + sign-up-on-behalf (memberId) and the hard form gate (hasFormSubmissionvia service client since a class mgr need not hold forms.manage; manager override bypasses). create/updateClass validate the form is in-space + published.listUpcomingSessionsreturnsrequired_form {title,url,satisfied}. Manage UI: published-form picker; member UI: shows requirement, links the form, swaps Sign up for "Complete required form" until on file. - P3
69fa64c: reusedSessionAttendancebehind a per-session "Signups" toggle on/classes/manage(staff roster + attendance + complete). Members still see only spots-left. - P4
c93ee9b: newEquipmentReservationscomponent behind a per-item "Reservations" toggle on/equipment/manage(who reserved + window + status + manager Cancel;listEquipmentReservationsalready existed). - P5
2fdc275: newlistMyDoorActivity(service client after requireMember; rows where caller is actor/target; detail already redacted). New/doorsmember page = self-entry (reuses DoorSelfEntry) + masked own cards (getMyCards) + recent personal activity; empty-state. Sidebar "Doors" under Learn. - P6 (this): API_REFERENCE + ARCHITECTURE + this entry.
Open / next
- DEPLOYED pass-29: pushed
6d1c853..46b2942(6 pass-29 commits + carried pass-28 deploy-state268d78d), Actions run25981712253success, migration 037 applies on deploy. Smoke://login200;/doors/classes/classes/manage/equipment/manage/dashboard307->login (gated, expected). NOT browser-verified. Next live check: confirm 037 applied; create class w/ required form -> member blocked until form submitted -> manager override/on-behalf -> /classes/manage Signups roster -> /equipment/manage Reservations -> /doors page. This pass-29 deploy-state edit is a small follow-up docs commit, LOCAL/unpushed (carry or deploy next). - Gate green at build: suite 421,
pnpm buildclean. - Door epic remaining: P4 inbound access-log ingest; P5 universal API-call UI builder (
api_buttons). (Door P-numbers are separate from this pass's P1-P6.) - Residual/test gap unchanged: no Supabase mock harness so action orchestration (form gate, override, on-behalf) is not unit-tested; pure logic is.
2026-05-17 (pass 28) — Member self-entry DEPLOYED (dashboard surface)
Branch main. Pass-27 (Door P3) is deployed. User then directed: "add door access controls to dashboard if user has card access enabled" = build the locked-design member self-entry, surfaced on the dashboard. No new migration (reuses 035 allow_member_self_entry + member_cards).
Built (LOCAL, not deployed)
lib/actions/door.ts:selfEntry({connectionId})—requireMember(any active member, no permission code), strict rate limitdoor-self5/min, momentary OPEN only (HeatSync?o1/ genericopentemplate) through the existing hardened executor. Eligibility (locked rule): connectionis_enabledANDallow_member_self_entryAND caller has >=1 activemember_card(NOdoor_card_slotsrow required). Membership/cards resolved server-side (client sends onlyconnectionId); never unlock/lock/grant/revoke; never anon. One redacteddoor_access_logrowaction='self_entry',target_member_id=self (denials audited too).listSelfEntryDoors()returns enabled self-entry connections only if the caller has an active card (else empty → surface hidden).- Dashboard:
app/(app)/dashboard/door-self-entry.tsx(client, confirm + toast) + a "Door access" panel in the dashboard right column, rendered only whenlistSelfEntryDoors()is non-empty. First-run dashboard branch unaffected (query runs after that early return). - Docs: API_REFERENCE + ARCHITECTURE updated; this entry.
Open / next
- DEPLOYED pass-28: pushed
d047820..6d1c853(self-entry6d1c853+ carried pass-27 deploy-state docs14a2554), Actions run25981195445success, app-only (no migration). Smoke://login200;/dashboard/me/door/manage307->login (gated, expected). NOT browser/controller-verified. Next live check: with a self-entry-enabled connection + the member holding an active card, the dashboard "Door access" panel should appear and the confirm button fireselfEntry; verifyself_entryaudit rows are redacted. This pass-28 deploy-state edit is a small follow-up docs commit, LOCAL/unpushed (carry or deploy next). - Gate green at build time: suite 417,
pnpm buildclean. - Door epic remaining: P4 inbound access-log ingest (poll
?z/webhook → resolve card_uid→member); P5 universal API-call UI builder (api_buttons, same SSRF executor, door template). - Residual/test gap unchanged: SSRF pin is host-string equality (theoretical DNS-rebinding, mitigated); no Supabase mock harness so action orchestration (incl. self-entry eligibility) is not unit-tested; pure slot logic is.
2026-05-17 (pass 27) — Door P3 DEPLOYED (slot allocator + live actions); self-entry CHECKPOINT next
Branch main. Start state was the pass-26 clean checkpoint. Reaffirmed framing: this is a generic multi-space hackerspace platform, HeatSync is one tenant/adapter (memory platform-not-heatsync-only). Slot-allocator design presented and APPROVED before building; user decisions locked: slot range 0-200 no reservation, grant ordering reserve -> call -> roll back on failure, checkpoint before member self-entry: YES.
Door P3 — CODE-COMPLETE, LOCAL, 5 commits, NOT deployed
Phase-by-phase, each gated (vitest + pnpm build read in-run) + own commit:
- A
036_door_card_slots.sql+ schema.sql Section 23 + types + DATABASE_SCHEMA/DB_SCHEMA_MAP. Per-connection integer-slot map.UNIQUE(connection_id,slot)(DB arbitrates concurrent grants) +UNIQUE(connection_id,card_id)(idempotent re-grant). RLS additive/default-deny: SELECT = door.manage/operate; NO client write policy (service-client executor only). - B pure
lib/door-slots-logic.ts(pickLowestFreeSlotlowest-free, range a param so adapter-generic;slotCapacity) + 8 tests. Suite 409 -> 417. - C
lib/actions/door.ts:grantCard/revokeCard/doorControl(door.operate, rate-limited viacheckRateLimit, each writes one redacteddoor_access_logrow; reads via service client after the perm check since operators lack RLS read on door_connections/member_cards). Grant: reserve slot in DB first, call controller, roll back reservation on failure; idempotent;slot_exhaustedmessage. Revoke: idempotent (no slot = already revoked); frees slot only on confirmed controller success. Control open/unlock/lock via the existing hardenedlib/door/executor.ts. Zod schemas + API_REFERENCE. - D
listDoorCards(door.operate, service client, masked last4 only) +/door/managepassescanOperate(door.operate rpc) and renders per-connection Open/Unlock/Lock + expandable card list Grant/Revoke, every action behind a confirm. - E ARCHITECTURE + this entry.
Open / next
- DEPLOYED pass-27: pushed
1db9316..d047820(5 P3 commits + carried pass-26 HANDOFF), Actions run25981082371success. Smoke://login200;/door/manage/me307->login (gated, expected). Migration 036 should be applied by the deploy; NOT browser-verified and NOT exercised against a live controller (none reachable here). Next on-site/with-controller check: grant (slot assigned) -> revoke (slot freed) -> open/lock/unlock; verifydoor_access_logrows are redacted. This pass-27 HANDOFF deploy-state edit is a small follow-up docs commit, LOCAL/unpushed (carry or deploy next). - CHECKPOINT (next build step): member self-entry. Design reviewed pass-27; eligibility rule LOCKED by user = "any active card is enough" (NOT slot-on-connection). Scope:
selfEntry({connectionId})indoor.tsallowed only when connectionis_enabledANDdoor_connections.allow_member_self_entryAND caller is an active member AND caller owns an activemember_card(any active card in the space; does NOT require adoor_card_slotsrow). Resolve the caller's membership/cards server-side (never trust a member/card id from the client). Momentary open only (HeatSync?o1/ genericopentemplate) through the existing executor; never unlock/lock/grant/revoke; never anonymous. Strict per-member rate limit (tighter than operator control, ~5/min). One redacteddoor_access_logrowaction='self_entry'. Surface a confirm button on/me(and/or a/doormember page), hidden when no eligible connection. No new table/migration. Build on next "continue". - Then P4 inbound access-log ingest (poll
?z/webhook -> resolve card_uid->member into door_access_log); P5 universal API-call UI builder (api_buttons, same SSRF executor, door template). - Residual hardening (unchanged): SSRF pin is host-string equality; hostname pin -> theoretical DNS-rebinding (mitigated by no-redirect + typically-IP pin); consider resolve-and-check. Systemic test gap: no Supabase mock harness, so action orchestration (slot reserve/rollback, idempotency, RLS) is not unit-tested; pure slot logic is.
2026-05-17 (pass 26) — CLEAN CHECKPOINT: modules 1-3 + Door P1/P2 all DEPLOYED
Clean state: origin/main == main, tree clean, latest deploy (feat(door): flexible transport…, run 25980463354) green. Migrations applied through 035. 409 unit tests, pnpm build clean. Nothing local/undeployed except this HANDOFF commit (docs-only, unpushed; deploy or carry forward next pass).
Shipped & live this overall session (verify with git log, smoke-tested, NOT browser-verified)
- UX: OAuth buttons gated by env; members-page invite UI; collapsible sidebar categories (order: Workspace, Governance, Learn, People, Finance, Account, Admin).
- Bug fixes:
ops.secrets.readnow actually works for reveal/list (migration 031, additive); KB/processes render markdown with working in-document anchor links (sharedlib/markdown-anchors.ts, also fixes governance docs). Production incident-INSERT RLS bug CONFIRMED RESOLVED by user (closed). - Modules (locked roadmap): 1 Certifications (030), 2 Classes (032), 3 Equipment (033) — all deployed, each: migration + pure tested logic + actions + guard + admin pages + member surface +
/me+ docs. - Door epic P1 (034: member_cards, door.manage/operate perms, masked self-view) and P2 (035: door_connections + door_access_log + SSRF-safe
lib/door/executor.ts+ native HeatSync adapter + audit) — deployed. - Landing page + README refreshed to the full feature set.
Door epic — design APPROVED, locked decisions (do NOT re-litigate)
- Transport is NOT LAN-only (cloud-hosted). Per-connection exact-host pin (public or private allowed) + ALWAYS block metadata/link-local + no redirects + time/body caps + secret server-side only + redacted audit. Plaintext-HTTP firmware risk → recommend a TLS proxy for public exposure. (memory
integration-api-factshas this + the re-verified fixed-width HeatSync facts + GitHub links;lib/door-logic.tsKNOWN_DOOR_CONTROLLERS.) - Card UID = credential (count+last4 to owner). Member self "buzz me in" = ALLOWED but ELEVATED RISK, per-connection opt-in (
door_connections.allow_member_self_entry, default false): safeguards = active member + authorized active card + per-connection toggle + strict rate limit + full audit + confirm + never anon. - Build whole epic phase by phase, each gated + committed; ASK before each deploy.
Remaining (resume here)
- P3: live actions via
door.operate— grant/revoke a card (HeatSync needs an integer slot allocator per connection: assign/track a 0-200 slot per member_card; pure + tested), open/lock/unlock, all audited + rate-limited + confirm. Then the member self-entry action (own active card + connection.allow_member_self_entry + rate limit + audit), surfaced on/me/a member door page. All writes funnel through the existinglib/door/executor.ts+ service-client validated actions; door_access_log every attempt. - P4: inbound access-log ingest (poll
?z/ webhook) → door_access_log, resolve card_uid→member. - P5: universal API-call UI builder (
api_buttonstable: label/group/method/url_template/headers/body/secret_ref/confirm; same SSRF executor + host pin) with a door template. - Residual: SSRF pin is host-string equality; hostname pin → theoretical DNS-rebinding (mitigated by no-redirect + typically-IP pin). Consider resolve-and-check in P3 if hardening.
- Systemic test gap (unchanged): no Supabase mock harness; pure logic well covered, action orchestration not unit-tested.
- Slot allocation is the one genuinely new design question for P3 (HeatSync keys cards by integer slot 0-200): propose a
member_card.controller_slot(per connection) allocator + handle exhaustion, present before building.
2026-05-17 (pass 25) — Door P1 DEPLOYED; Door P2 code-complete LOCAL
Door P1 + nav reorg DEPLOYED (push 1cbea13, run 25980105959 success; migration 034 applied; smoke ok, not browser-verified). Nav further tweaked: collapsible categories; order Workspace, Governance, Learn, People, Finance, Account, Admin (user moved Learn before People).
Door P2 — CODE-COMPLETE, LOCAL, 1 commit, NOT deployed
Migration 035 (door_connections, door_access_log) mirrored schema.sql Section 22; types; Zod; docs (DATABASE_SCHEMA/DB_SCHEMA_MAP/API_REFERENCE/ARCHITECTURE). Safety core is pure + heavily unit-tested (lib/door-logic.ts; suite 394→409): validateDoorTarget (request host MUST equal pinned host; metadata/link-local always blocked; http/https only), HeatSync encoders with the re-verified fixed-width zero-pad (slot3/perm3/tag8/4-char pw), generic applyTemplate, redactDoorSecrets. lib/door/executor.ts = single hardened egress (re-validate pin, redirect:'manual', abort timeout, body cap, redact). lib/actions/door.ts: connection CRUD + listSecretTitles + testDoorConnection (status verb ONLY, writes one redacted door_access_log row) + listDoorAccessLog. Password read from AES-256-GCM secrets vault, decrypted server-side only, never returned/logged. /door/manage admin UI + sidebar Admin link. No live door verbs exposed yet → deploying P2 is low-risk (only door.manage can configure + status-test).
Open / next
- LOCAL & undeployed: Door P2 (1 commit). Awaiting deploy approval (migration 035). After push: confirm 035 applied; configure a connection + test (needs a reachable controller / VPN — likely only verifiable on-site).
- Door epic remaining: P3 live actions (grant/revoke/open/lock via
door.operate, audited + rate-limited + confirm) + the per-connection member self-entry opt-in (ELEVATED RISK, user-chosen; safeguards: active member + authorized card + per-connection toggle + strict rate limit + audit + confirm, never anon). P4 inbound access-log ingest (poll/webhook). P5 universal API-call UI builder + door template. - Queued user requests outstanding: update landing page + README with the full new feature set (certs/classes/equipment/forms/door/invites/incident-tracking) — task open.
- Residual: SSRF pin is host-string equality; if an admin pins a hostname (not an IP), DNS rebinding is a theoretical risk (no-redirect + LAN-only + typically-IP pin mitigate). Note for P3 hardening if needed.
2026-05-17 (pass 24) — Equipment DEPLOYED; Door epic design approved; P1 + nav reorg LOCAL
Branch main. Equipment module (pass-23) DEPLOYED this pass (push f32743d, Actions run 25979725237 success 1m19s; migration 033 applied; /equipment, /equipment/manage, /me smoke 307→login, not browser-verified). Modules 1-3 (Certifications, Classes, Equipment) all live.
Door epic — design APPROVED (full design in chat pass-24). Locked decisions:
- Transport/SSRF: per-connection host pin + "internal device" ack; executor allows only the pinned host, no redirects, size/time caps, secret server-side only.
- Card UID visibility to owner: count + last4 only (UID is a credential; managers full).
- Open door: user chose ALSO member self "buzz me in" (I advised against; build it opt-in per-connection, active-member + authorized-card check, strict rate limit, per-connection toggle, full audit, confirm, never anon — ELEVATED RISK, flagged).
- Build: whole epic straight through, phase by phase, each gated + committed; still ASK before each deploy.
- Permissions:
door.manage(config + cards) /door.operate(live actions) — additive, group Access.
Native HeatSync adapter facts RE-VERIFIED 2026-05-17 (saved to memory integration-api-facts)
Fetched live via gh api: zyphlar/Open_Access_Control_Ethernet.ino + heatsynclabs/.../app/models/card.rb. Unchanged vs memory; ADDED precision: firmware parses FIXED-WIDTH ZERO-PADDED — user slot 3 digits, &p perm 3 digits, &t tag 8 hex, ?e= password 4 chars. Canonical: GET {url}?m{slot}&p{perm}&t{tag}&e{pw}. Native adapter encoder MUST zero-pad 3/3/8 and be unit-tested. Cited in memory; no re-fetch needed through P2.
Shipped this pass (LOCAL, not deployed): Door P1 + nav reorg
- Door P1 (1 commit): migration
034_member_cards.sql(member_cards; UID is a credential —door.manage-only RLS, NO member SELECT policy; masked self-view via service-clientgetMyCards= count+last4) +door.manage/door.operateperms (group Access) seeded+backfilled, mirrored schema.sql Section 21, types, DATABASE_SCHEMA/DB_SCHEMA_MAP. Puredoor-logic.tsmask + 3 tests (suite 394).lib/actions/member-cards.ts(manage CRUD + masked getMyCards). Per-member "Cards" panel on/membersgated todoor.manage; "My access cards" (masked) on/me. No controller calls. Gated, committed. - Nav reorg (1 commit, user-requested): sidebar now collapsible categories (Workspace/Governance/People/Finance/Learn/Account/Admin), per-section open/closed persisted in localStorage; Finance + Learn split out. Build green.
Open / next
- LOCAL & undeployed: Door P1 + nav reorg (2 commits). Awaiting deploy approval. After push: confirm
034applied, board hasdoor.manage/door.operate, exercise add card → /me masked view. - Door epic continues: P2 =
door_connections(secret_ref → AES-256-GCM secrets vault, host pin, adapter native_heatsync|generic_http, verb templates) +door_access_log+ hardened SSRF-safe executor (pure URL/SSRF/HeatSync-encoder logic heavily unit-tested; zero-pad 3/3/8) + native adapter + door.manage admin UI + audit. Then P3 live actions (door.operate+ the member self-entry opt-in), P4 inbound log, P5 universal API-call UI builder + door template. - Systemic test gap unchanged (no Supabase mock harness; pure logic well covered).
2026-05-17 (pass 23) — Classes DEPLOYED; Equipment module (3) code-complete LOCAL
Branch main. Classes module (pass-22) was deployed this pass (push 2096228, Actions run 25979364584 success 1m? ; migration 032 applied; /classes, /classes/manage, /me smoke 307→login, not browser-verified). User then chose to continue the locked order → Equipment (module 3).
Equipment registry + reservations (locked module 3) — CODE-COMPLETE, LOCAL, 6 commits, NOT deployed
Phase-by-phase, each gated (vitest + pnpm build read in-run) + own commit:
- A: migration
033_equipment.sql(equipment,equipment_reservations) mirrored inschema.sql(in-place seed fn + appended Section 20);equipment.manageperm (group Equipment) seeded+backfilled; types; DATABASE_SCHEMA/DB_SCHEMA_MAP. Additive default-deny RLS; reservations have SELECT (manage or own) + manager UPDATE, NO INSERT/DELETE (reserve/cancel via validated service-client action). No DB overlap constraint (no btree_gist dep) — overlap enforced in the action via pure logic. - B: pure
lib/equipment-logic.ts(intervalsOverlap, hasConflict, reservationEligibility) + 9 tests (suite 391) + Zod. - C:
lib/actions/equipment.ts(registry CRUD; listEquipmentForMembers w/ member_certified flag via service client; reserve/cancel validated service-client w/ status+overlap+required-cert, equipment.manage override + book-for-member; listEquipmentReservations; getMyReservations) +lib/equipment-guard.ts+ barrel + API_REFERENCE. - D:
/equipment/manageadmin pages + sidebar Admin "Manage equipment". - E:
/equipmentmember browse + reserve (cert gate shown up front) + sidebar member "Equipment". - F: "My equipment reservations" on
/me+ ARCHITECTURE + this entry.
Cert-gate (user-approved): reserving cert-gated equipment requires an active member_certifications for the member; an equipment.manage holder overrides and may book on a member's behalf. Cert check reads member_certifications via the service client (boolean only) since a non-cert-permissioned equipment manager can't read it under RLS — no write/PII bypass of the guarded surface.
Open / not done
- Equipment is LOCAL (6 commits). Awaiting deploy approval. After push: confirm
033applied, board hasequipment.manage, exercise add equipment (+ required cert) → member reserve (cert gate / overlap) → manager override →/me. NOT browser-verified. - Pure logic unit-tested; action orchestration (overlap/cert/override/RLS) NOT (same systemic no-mock-harness gap).
- Roadmap remaining: module 4 Door epic (member_cards + configurable door integration + native HeatSync adapter + the universal API-call UI builder; largest/riskiest, design-first, do last). Cert/feature nav links for non-admin permission-holders still admin-block-only (known minor gap).
2026-05-17 (pass 22) — Classes module (2) code-complete LOCAL; roadmap reaffirmed
Branch main. Incident-INSERT RLS bug confirmed RESOLVED by the user (closed; see pass-21 note). User asked to continue the locked roadmap; chose Classes (module 2) next; reaffirmed the locked order (Certs done → Classes → Equipment → Door epic, which is where the "custom API-call UI builder" lives, module 4, design-first/last).
Classes + sessions + signups (locked module 2) — CODE-COMPLETE, LOCAL, 6 commits, NOT deployed
Phase-by-phase, each gated (vitest + pnpm build read in-run) + its own commit:
- A: migration
032_classes.sql(classes,class_sessions,class_signups) mirrored inschema.sql(in-place seed fn + appended Section 19);classes.manage/classes.instructperms (group Classes) seeded+backfilled; types; DATABASE_SCHEMA/DB_SCHEMA_MAP. Additive default-deny RLS;class_signupshas SELECT (manage/instruct or own) + instructor UPDATE, NO INSERT/DELETE (signup/cancel via validated service-client action); partial unique = one non-cancelled signup per member+session. - B: pure
lib/classes-logic.ts(effectiveCapacity, computeSignupStatus, sessionTiming, canSignUp, pickPromotion) + 10 tests (suite 382) + Zod. - C:
lib/actions/classes.ts(class/session CRUD; service-client signUp/cancel w/ capacity+waitlist+dedupe+promotion; listUpcomingSessions w/ service-client counts so members see spots-left but not who; markAttendance; completeSession) +lib/classes-guard.ts+ barrel + API_REFERENCE. - D:
/classes/manageadmin pages + sidebar Admin "Manage classes". - E:
/classesmember calendar (signup/waitlist/cancel) + instructor attendance/completion component + sidebar member "Classes". - F: "My classes" on
/me+ ARCHITECTURE + this entry.
Cert-on-completion (user-approved design): completeSession awards classes.grants_certification_id to attended members via the normal grantCertification path, so it only issues if the acting instructor also holds certifications.grant; else completion still succeeds and the UI says certificates were skipped. No service-role bypass of the guarded certifications surface.
Open / not done
- Classes is LOCAL (6 commits). Awaiting deploy approval. After push: confirm
032applied, board has both perms, exercise create class → schedule session → member signup/waitlist/cancel → attendance → complete (cert-on-completion) →/me. NOT browser-verified (no live browser). - Pure logic unit-tested; action orchestration (capacity/waitlist/promotion/RLS) NOT (same systemic no-mock-harness gap as forms/certs).
- Roadmap remaining: module 3 Equipment, module 4 Door epic (member_cards + configurable door integration + native HeatSync adapter + the universal API-call UI builder; design-first, do last). Noted cert refinement still open:
/certifications(and/classes/manage) sidebar links are in the admin-only block, so a non-admin permission-holder must reach them by URL.
2026-05-17 (pass 21) — Two UX fixes DEPLOYED; Certifications module (1) code-complete LOCAL
Branch main. Start state clean, deploys green (incident-tracking pass-20 live).
Deployed this pass (2 commits, pushed, Actions run 25978300957 success 1m28s, smoke-verified)
feat(auth): login OAuth buttons are now opt-in viaNEXT_PUBLIC_OAUTH_GITHUB/_GOOGLE(purelib/auth-config.tsresolveOAuthProviders, unit-tested;.env.example+ DEPLOYMENT.md). Prod/loginsmoke: 200, GitHub/Google + OR divider ABSENT (no env set = correctly hidden).fix(members): members page now renders the existingInvitesPanelfor admin/board (server page loads invites + slug, composes via a newinviteSlotprop; MembersClient stays invite-agnostic). First-run dashboard copy fixed. Prod/members307 -> /login (auth-gated; logged-in invite UI NOT browser-verified — needs a manual check).
Certifications + Instructor (locked module 1) — CODE-COMPLETE, LOCAL, 6 commits, NOT deployed
Built phase-by-phase, each gated (vitest + pnpm build read in-run) + its own commit:
- A: migration
030_certifications.sql(certifications,member_certifications) + mirrored inschema.sql(in-place seed fn updated + appended Section 18) +permissions-catalog(certifications.manage,certifications.grant, group Certifications, board defaults + backfill) +types/database.ts+ DATABASE_SCHEMA/DB_SCHEMA_MAP. RLS additive/default-deny: cert types readable by any space member; grants readable by managers/granters (all) or member (own); INSERT/UPDATE =certifications.grant; NO DELETE policy (immutable history, soft revoke); no anon path. Expiry snapshotted at grant. - B: pure
lib/certifications-logic.ts(computeExpirymonth/leap clamp;isCertificationActive;certificationStatus) + 16 tests (suite 348 -> 364) + Zod schemas. - C:
lib/actions/certifications.ts(create/update/delete/list types; grant/revoke/renew; listMemberCertifications; getMyCertifications — no-params, session+RLS scoped) +lib/certifications-guard.ts+ barrel + API_REFERENCE. - D:
/certificationsadmin pages (manage cert types) + sidebar Admin link. - E: per-member award/revoke/renew dialog reachable from a "Certs" column on
/membersshown to ANYcertifications.grantholder (non-admin instructors included; server resolves the perm via RPC). - F: member-facing
/me("My certifications & access": own grants + read-only effective permissions) + sidebar "My access" link + ARCHITECTURE.
Open / not done
- Certifications is LOCAL (6 commits ahead). Awaiting deploy approval. After push: confirm
030applied, board has both perms, exercise create-type -> grant -> revoke -> renew ->/me-> non-admin-instructor path; nothing is browser-verified (no live browser this session). - Pure logic is unit-tested; action orchestration (RLS/visibility/grant uniqueness) is NOT (no Supabase mock harness — same systemic gap as forms).
- Secrets-permission audit (user-requested) FOUND A REAL BUG and FIXED IT (user-approved, LOCAL):
revealSecrethard-gated on built-in role['admin','board']andsecrets_selectnever consultedops.secrets.read, so the permission was inert and even an ACL-granted member was pre-blocked by the action; the Ops page also short-circuited the secrets fetch by built-in role. Fix (additive, guarded-surface, approved): migration031addsOR user_has_permission(...,'ops.secrets.read')tosecrets_select(admin/board + per-secretops_aclbranches unchanged, access-neutral);revealSecretnowrequireMember+ RLS-bounded read (RLS is the boundary); Ops page always queries secrets (RLS filters) and shows the section for admin/board OR the perm OR any ACL-returned row. Writes unchanged. Mirrored in schema.sql + migration docs. NOT browser-verified. - KB/process markdown + anchors (user-requested) DONE, LOCAL: KB/process detail (the ops-client view dialog, the only render path;
/ops/[id]is the editor) rendered via a bare unsanitizedReactMarkdown; now uses the sharedSafeMarkdown. New dependency-freelib/markdown-anchors.ts(heading text flatten + GitHub-ish slug + per-render dedupe + in-doc href test; 8 unit tests, suite 364 -> 372) wired into BOTHSafeMarkdownandMarkdownBodyso headings get ids and[x](#heading)resolves; in-document links no longer force a new tab (was breaking the anchor jump in governance docs too). No new npm dep (rehype-slug unavailable offline). NOT browser-verified. - RESOLVED: the long-standing production incident-INSERT RLS bug is confirmed solved by the user (2026-05-17). No longer an open item; the
025re-assert + prior hardening held. Close it out in future passes.
2026-05-17 (pass 20) — Forms epic deployed; research; incident tracking; forward roadmap
Deployed & verified
- Pushed P6/P7/P8 + docs (commits up to
73eef72); deploy run succeeded; migrations028(forms slug unique per space) +029(space_invites.role) applied. Smoke-verified on prod:/f/<space>/<slug>returns the anonymous 200 page (NOT a login redirect — the long-unverified public-forms route is finally confirmed),/join/<space>public,/forms+/my-formscorrectly auth-gated. Forms+invites (migrations 026-029) fully shipped. - Local commits awaiting deploy at pass close:
2559166(forms public link + Copy button on member & admin lists) and the incident-tracking work in this entry.
Integration research (web-verified; saved to memory integration-api-facts)
- Payments are manual/CSV only today (no live API; known limitation). For real integration: Stripe is the one with a maintained official SDK + signature-verified webhooks (raw body, integer minor units). PayPal v2 works but official Node SDK has gaps (Subscriptions/webhook-verify need raw REST). Zeffy = read-only pull API + manual link. Venmo has no usable API (manual link / via PayPal-Braintree only). Canonical platform-agnostic payment record shape recorded in memory.
- HeatSync door = Arduino HTTP query-string API (
?m<slot>&p&t&e=<shared_pw>add card,?rrevoke,?o1/?uopen), no TLS, LAN/VPN only. Generic adapter pattern (configurable URL + auth + verbs grant/revoke/open + inbound log) recorded.
Anonymous incident tracking (this entry, LOCAL)
- Gap found:
fileIncidentmints areporter_tokenand the filing UI shows it ("the only way to look it up later") but there was NO lookup UI. Built it: purelib/incident-logic.tspublicIncidentView(redacts board-only updates, subjects, decision makers; disposition only after decided/closed; double-filtered at DB + logic);trackIncidentservice-client action (token = bearer credential, 192-bit UNIQUE, generic not-found = enumeration-safe); public/trackpage (+ middleware allowlist); filing UI deep-links/track?token=;__tests__/incident-tracking.test.ts.
Forward roadmap (decisions LOCKED with user — see memory heatsync-members-site, integration-api-facts, architecture-standards)
Build order, each as its own well-separated module (migration + pure *-logic.ts tested + lib/actions/* + pages + docs; permissions via the additive catalog; the guarded RLS rule applies):
- Certifications + Instructor (NEXT):
certifications+member_certificationstables/RLS;certifications.manage+certifications.grantpermissions (grant = the Instructor capability, assignable to roles/area-leads); admin pages + member-facing "My certifications & permissions" view. - Classes:
classes/class_sessions/class_signups;classes.manage/classes.instruct;payment_linkfield at minimum; calendar; a class may grant a cert. - Equipment: registry + availability + reservations; gated perms.
- Door epic ("all of it"):
member_cards(RFID/NFC) ↔ member; configurable per-space door integration (URL+auth+verbs+inbound log); native HeatSync adapter; universal API-call UI builder with a door template. Largest/riskiest; own design pass; build last; LAN-only transport caveat.
- Instructor/Host/Champion = PERMISSIONS, not new built-in roles. Payments: add a
payment_linkfield generally; a real Stripe integration is a separate, later, explicitly-scoped effort using the verified facts in memory.
Open / not done
- Production incident-INSERT RLS bug still OPEN (separate from the tracking UI just built): never retried/diagnosed since
025. Needs a prod retry; do not touch the permissions/RLS surface without diagnosis + approval. - Not browser-verified (no live browser this session): the forms-link Copy buttons, the
/trackflow end-to-end, and the broader forms/onboarding/retro-link chain. Smoke-test lists in pass-18/19. - Systemic test gap remains:
__tests__/actions.test.tslargely asserts its own mock; no Supabase mock harness, so action orchestration is not unit-tested (pure logic is well covered). Recommended discrete follow-up.
2026-05-17 (pass 19) — Forms URL restructure + role invites + member surface + audit, LOCAL
User redirected after Phases 1-5 shipped. Decisions (memory forms-feature updated): per-space form slug + /f/[space]/[slug] (reverses the original global-slug lock), space-scoped invites incl. role-granting + usage caps, member-facing forms surface, starter templates, obvious publish flow.
What shipped (local commits on top of origin/main)
5a49ecb(PUSHED, deploy triggered, NEVER VERIFIED — superseded):/fadded to middleware PUBLIC_ROUTES so the old/f/[slug]was anon-reachable. P6 replaces that route; re-verify the new one post-deploy.8bbe99dP6: migration 028 (drop globalUNIQUE(forms.slug), addUNIQUE(space_id,slug));getPublicForm({space,slug}); route movedapp/f/[slug]→app/f/[space]/[slug]; public client submits byformId; builder/list show/f/<space>/<slug>; forms-guard returnsspaceSlug.df6951cP7 + audit hardening: migration 029space_invites.role(member_role, default member);lib/invite-logic.tscanAssignInviteRole(admin→any, board→non-admin); createInvite/updateInvite enforce it;joinSpaceapplies invite role; invites panel role picker + single-use;/join/[space]landing (+/joinpublic);/loginhonors validated?next=(was hardcoded);submitFormrequiresformId(slug ambiguous post-028).- P8 (this entry's commit): builder publish-flow UX (draft/published/closed banners, create→edit redirect so Publish is right there, members-only "no public link" note); member-facing
/my-formslist +/my-forms/[id]fill (sidebar "Forms" for all members — fixes the long-standing members-only-forms-have-no-surface gap); starter templates picker on/forms/new(lib/form-templates.ts); forms-client relabels members-only forms (no misleading public URL). - Docs corrected (two background audit agents ran): DATABASE_SCHEMA / DB_SCHEMA_MAP / ARCHITECTURE / API_REFERENCE — migration list 014-029, per-space slug,
/f/[space]/[slug], getPublicForm signature, invite roles.next.configignoreBuildErrorsnoted (pre-existing).
Audit results (parallel read-only agents + my review)
- Forms/invites P0: none. RLS sound, anon cannot read submissions/drafts, snapshot immutable, public route genuinely anon, retro-link verified-email-gated (no hijack), onboarding fail-open verified, permissions surface additive/untouched.
- Fixed this pass:
/login?next=(P1),submitFormslug ambiguity (P1), members-only misleading public URL (P2).
Open / residual gaps (honest)
- NOTHING new deployed.
origin/mainis at5a49ecb(unverified). P6/P7/P8 + doc commits are LOCAL. Migrations 026/027 deployed; 028/029 ship with the next push. Awaiting deploy approval. - NOT browser-verified (no live browser): the whole builder→publish→public-submit→onboarding→retro-link→member-fill→role-invite→/join chain. Smoke-test list spans pass-18 + this entry.
- Systemic test gap:
__tests__/actions.test.ts(49 "tests") largely asserts its own Supabase mock, not real actions. New pure logic is well covered (forms-logic, invite-logic, validateAnswers, schemas — 337 tests); action orchestration (visibility/RLS/retro-link IO) is NOT unit-tested (no mock harness in repo). Recommended discrete follow-up: a Supabase mock harness, then real action tests. - Member fill has no "already submitted" indicator (members can't read form_submissions under RLS; would need a service-client probe). Duplicate member submissions possible; acceptable (waivers re-sign), noted.
- Production incident-filing RLS bug still open (not retried since
025).
2026-05-16 (pass 18) — Forms + waivers Phases 1-5 COMPLETE (schema/RLS + actions + builder UI + onboarding step + retro-link), LOCAL, awaiting deploy approval
Branch: main. Migration: 026 (not yet deployed). pnpm build + pnpm exec vitest run gate pending in this entry's session.
State reconciliation (pass-17 prose was stale)
- Pass-17 said Tier 2+3 (
a1ea92a,b7461f0) were "LOCAL/unpushed". They are in fact DEPLOYED:git log origin/main..mainis empty, and Actions run25973229017(commit3ace1af) succeeded. So at pass-18 start:origin/main == main, clean, all UX Tiers 1-3 live. The pass-17 entry is left as-written (append-only); this is the correction of record. - Branches present:
main,ux-polish-wip(deletable backup),hotfix-incident(stale, not deleted — flag for user).
Incident-filing RLS open item — STILL OPEN
- User confirms it has NOT been retried on prod since
025.025only fixed cause #2 (policy drift). Item stays open: before any RLS change, retry on prod; if it still fails, run the discriminating SQL in the pass-17 entry to separate cause #1 (auth.uid() null in SSR) vs #3 (membership data drift). Permissions/RLS surface NOT to be modified without that diagnosis + user approval (guardrail).
Forms + waivers Phase 1 (this session) — schema + RLS only, LOCAL
- New
scripts/026_forms.sql(idempotent) + mirrored asschema.sqlSection 17 + Section 15 seed-function updated. - Tables:
forms(space_id, globally-UNIQUEslug,kindform/waiver,visibilitypublic_anon/public_auth/members,statusdraft/published/closed,schemajsonb,legal_text,version,created_by) andform_submissions(form_id, denormalized space_id, member_id, submitter_email, answers,form_snapshot/legal_text_snapshot/form_versionimmutable snapshots, ip, user_agent). Indexes on space/slug/form/email/member. - RLS (additive, default-deny):
formsSELECT =forms.manageholders (all forms) or ordinary members (published only — drafts/closed hidden from non-managers); INSERT/UPDATE/DELETE =user_has_permission(..., 'forms.manage').form_submissionsSELECT =forms.manageonly; no write policy at all → RLS hard-denies every non-service client and makes submissions immutable. Every submission funnels through one validated service-client server action (Phase 2). Public/f/[slug]will be served by a service-client server action, soanongets no grant onforms(user-chosen option). - New
forms.managepermission added tolib/permissions-catalog.ts(group Community) + board default;seed_default_role_permissions()extended; backfillboard -> forms.managefor existing spaces (ON CONFLICT DO NOTHING). types/database.ts:forms+form_submissionsadded. Docs updated: DATABASE_SCHEMA (026 row), DB_SCHEMA_MAP (quick-map + migrations), ARCHITECTURE (forms note).
Phase 1 self-audit (commit 66dd9b7, local)
Audited the migration before continuing. Three fixes applied: dropped redundant idx_forms_slug (UNIQUE already indexes slug); tightened forms_select to forms.manage (all) OR member+status='published' (drafts/closed no longer exposed via raw PostgREST); made the submitter_email/member_id submission indexes partial. None weaken access; the SELECT change strengthens it. Safe because forms is a new table with no existing dependents.
Forms + waivers Phase 2 (this session) — server actions + Zod + tests, LOCAL
lib/validations.ts: form field/array schemas (8 field types, duplicate-key + select-options refinements),createForm/updateForm/setFormStatus/formId/submitForm/linkSubmissionsschemas. slug immutable on update.lib/forms-schema.ts(pure, unit-tested):parseFormSchema+validateAnswers(server-side answer validation against the stored schema; unknown keys discarded so no arbitrary jsonb injection).lib/actions/forms.ts:createForm,updateForm(waiver version bump on legal/schema change, non-blocking),setFormStatus,deleteForm(refused when submissions exist),listForms,getFormResults,exportFormResultsCsv(audited),submitForm(service-client read + write, visibility + consent enforcement, snapshot, IP/UA capture, no anon DB grant),linkSubmissionsForMember(forms.manage manual-link; auto verified-email path is Phase 5). forms.manage gate viauser_has_permissionRPC + RLS defense-in-depth.types/database.ts: typeduser_has_permission+user_effective_roles(were stale since 023).lib/actions/index.tsre-exports forms.__tests__/forms.test.ts(14 tests). API_REFERENCE updated.- Gate:
pnpm exec vitest run285/285;pnpm buildexit 0, clean.
Forms + waivers Phase 3 (this session) — builder UI + public page, LOCAL
components/forms/form-renderer.tsx: one controlled renderer for all 8 field types, shared by the builder preview and the public page so they cannot drift.components/forms/form-builder.tsx: the easy builder (locked first-class priority). Label-only field creation (keys auto-derived + de-duped at save, never typed), add via one-click type buttons, up/down reorder buttons (no integer order field), inline options editor, live preview pane. Edit mode adds publish/close/draft/delete (viauseConfirm) and a copy-public-link affordance.- Pages (server-gated by
requireFormsManagerPageinlib/forms-guard.ts— forms.manage via the same RPC, redirect non-managers; RLS still the real boundary):/formsdirectory (status/visibility badges, Empty state),/forms/new,/forms/[id]/edit,/forms/[id]/results(table + client CSV download viaexportFormResultsCsv). - Public
app/f/[slug]/page.tsx(top-level, outside(app)):getPublicForm(service client, published + public-visibility only — members-only forms are NOT exposed here; their schema stays in-app), waiver legal text + consent, optional email for anon, signed-in gate forpublic_auth, thank-you state. - Sidebar: "Forms & waivers" NavLink in the Admin block. Toasts use
sonner(matched the codebase convention; the custom use-toast has no mounted Toaster). NewgetPublicForm+formSlugexport; API_REFERENCE updated. - Gate:
pnpm exec vitest run285/285;pnpm buildexit 0;/forms,/forms/[id],/forms/new,/f/[slug]all compiled. - NOT browser-verified (no live browser this session): smoke-test the builder (add/reorder/remove fields, live preview, create + edit + publish + close + delete), the results CSV download, and a public submission for each visibility (anon, public_auth signed-out gate, waiver consent).
Forms + waivers Phase 4 (this session) — onboarding form step, LOCAL
scripts/027_onboarding_form_step.sql(idempotent constraint replace) +schema.sqlSection 14 inline CHECK now allowsstep_type = 'form'. Referenced form id lives in the step's existingconfig.form_id(no new column). validationsonboardingStepTypeSchema+ onboarding action union + customize types/casts include'form'.finishOnboarding: a requiredformstep is satisfied if a submission forconfig.form_idby this member exists (any version — non-blocking re-sign / auto-satisfy). Fails open if the form is missing or unpublished so a misconfig cannot trap a member out of their space. Submission probe uses the service client (submissions are forms.manage-only under RLS).app/onboarding/page.tsxenrichesformsteps with the published form def + a prior-submission flag;onboarding-flow.tsxrendersFormRenderer+ waiver legal text/consent and submits viasubmitFormthenmarkOnboardingStepDone; already-submitted or unavailable forms show a non-blocking notice.- Admin:
customize/page.tsxfetches the space's forms;onboarding-panel.tsxgets a "+ Form step" button and a form<select>(writesconfig.form_id); empty-state hint when no published forms. - Tests:
__tests__/forms.test.ts+2 (onboarding form-step contract). Gate:pnpm exec vitest run287/287;pnpm buildexit 0; onboarding + customize compiled. NOT unit-tested:finishOnboardingenforcement/auto-satisfy is DB-bound (no mocked test); covered by reasoning + manual. - NOT browser-verified: admin adds a form step + picks a form; member onboarding renders/sumbits the form/waiver; required-form blocks finish until submitted; auto-satisfy when a prior submission exists; fail-open on unpublished form.
Phase 4 self-audit + Phase 5 (this session) — LOCAL
- Phase 4 audit: verified migration
022uses an inline unnamed column CHECK, so the auto namespace_onboarding_steps_step_type_checkis correct and027's DROP targets it (no fix needed). Fixed a real bug:onboarding-flowcould double-insert a form/waiver submission on Back-then-Forward — added session-localsubmittedNowtracking so a step is not re-submitted (commit is the smallharden(forms)one). - Phase 5 (retro-link):
claimMyAnonymousSubmissions()informs.ts— exported but safe (no trusted params; resolves member + email from the caller's session; gated onemail_confirmed_at; idempotent). Hooked best-effort fromjoinSpace(member insert now returns id; links prior anon submissions for the verified email) andfinishOnboarding.addMember/importMembersare deliberately NOT auto-linked (admin-asserted emails are unverified — the locked decision forbids auto-link there); the forms.managelinkSubmissionsForMemberremains the admin manual-link. Interpretation flagged for the user: the brief listed addMember/import as hook points, but the locked verified-email rule wins; auto-linking unverified emails would risk mis-attributing a waiver. - Gate:
pnpm exec vitest run287/287;pnpm buildexit 0. Retro-link logic is DB-bound — not unit-tested (no mocked test); covered by reasoning + manual.
Test-coverage hardening (this session) — LOCAL
- Audit found the suite is pure-logic only (no test exercises Supabase;
actions.test.tsonly asserts its own mock). Forms security/correctness logic lived inside DB-coupled actions/components and was effectively untested. - New
lib/forms-logic.ts:csvCell,parseClientIp,shouldBumpFormVersion,evaluateRequiredFormStep,slugify,deriveFieldKeysextracted behaviour-unchanged;forms.ts/onboarding.ts/form-builder.tsxnow consume them (full suite stayed green = no behaviour change). Also collapsed the duplicate-adminbuild break noted earlier (already fixed in40d4cda). __tests__/forms.test.ts16 -> 62 tests (total 333): everyvalidateAnswersbranch (type coercion, length caps, required, unknown-key stripping, first-error ordering),parseFormSchema, all new Zod schemas incl.formSlugboundaries, and every extracted helper. Build re-verified properly (Compiled successfully, exit 0, routes present).- Still NOT covered (DB-bound, no mock harness in repo): the action orchestration itself (visibility enforcement in
submitForm, RLS, retro-link IO). The decision logic they call is now unit-tested; the IO wiring needs the manual smoke-tests listed above.
Open / next
- Forms + waivers feature is code-complete (Phases 1-5). All work is LOCAL; do NOT push without explicit go. Migrations
026+027ship with the first push. - NOT browser-verified (no live browser this session): the full builder/public-page/onboarding-form-step/retro-link paths. Smoke-test lists are in each phase subsection above.
- Retro-link not browser-verified: sign an anon public waiver, then join the space (or finish onboarding) with that verified email → the submission should attribute to the member; addMember with the same email should NOT auto-link (admin uses linkSubmissionsForMember).
- After an approved push: watch the Actions run; confirm
026+027applied (_migrations_applied), a space's board hasforms.manage, and exercise builder → publish → public submit → onboarding form step → retro-link end to end. - Unrelated, still open: the production incident-filing RLS bug — not retried since
025; do not touch the permissions/RLS surface without a prod retry + diagnosis.
2026-05-15/16 (pass 17) — UX polish Tier 1 (minus S3) + incident-RLS hotfix, both DEPLOYED
Branch: main, clean. origin/main advanced 4c9b551 -> 1340b4c (incident hotfix) -> b8bb213 (UX Tier 1 minus S3 + docs), all deployed via GitHub Actions. Migrations applied through 025. pnpm build clean, pnpm exec vitest run 271/271. Safety branch ux-polish-wip = pre-restructure stack (deletable once trust established).
Production incident-filing RLS report (resolved as far as possible blind)
User screenshot: a basic active member filing an incident at /incidents/new (anonymous checked) got new row violates row-level security policy for table "incidents". Pre-existing in production, not from this session.
- Not reproducible from source:
fileIncidentsetsreporter_id=NULLwhen anonymous andspace_id=member.space_id;getAuthMemberresolves byuser_id=auth.uid(); the 016/017incidents_insertpolicy allows exactly this. So it is a production deploy/data divergence. - Three candidate root causes: (1)
auth.uid()NULL inside the DB request (SSR session not reaching PostgREST); (2) deployedincidents_insertpolicy differs from 016/017; (3) actingspace_members.user_id!= JWT sub on production (data drift). - DEPLOYED as a minimal hotfix off
origin/main(commit1340b4c, GitHub Actions run 25953569904 success, 1m0s; deploy.sh ran the idempotent migration runner so025applied):scripts/025re-asserts the hardenedincidents_insertverbatim (access-neutral; fixes cause #2 only);fileIncidentmaps an RLS rejection to an actionable message and logs detail server-side.scripts/schema.sqlalready matched (no change). - Still needs user verification: retry filing an incident on prod. If it still fails, cause #2 is excluded; run the SQL below to confirm #1 vs #3.
UX polish — Tiers 1-3 ALL COMPLETE. S1-S8+S3 deployed; Tier 2 a1ea92a + Tier 3 b7461f0 LOCAL/unpushed
- Full UI/UX audit done (5 partitioned agents, code-level; no live browser). User chose: implement Tiers 1-3, pause before 4-5; sidebar nav stays as-is (no role-filtering); comments get one-level nesting + edit (Tier 5, deferred). Findings report grouped Tier 1 systemic / 2 first-run / 3 security-adjacent / 4 brand / 5 per-surface.
- Deployed commits (each was build+test green):
3360f54sidebar/drawer a11y + distinct Ops icon + skip link + BrandMark;92ba6fbsharedPageTitle/SectionTitle(21 files);f885ac7sharedEmpty(~18 states, projects gained one);1db9434shared accessible confirm (ConfirmProvider/useConfirm) replacing all 13window.confirm;075b274:focus-visiblering (unlayered, inapp/globals.css) + 44px tap targets at named hotspots + 28 form-field id/htmlFor + search aria-labels + claim/complete/delete/link handlers toastresult.error. Plus preexistingfa71d9a(a prior HANDOFF doc, not this session) and doc commits. - Not browser-verified (no live browser this session): smoke-test on prod the two behavior-changing pieces — the shared confirm dialog on every destructive action (delete member/role/tier/area/invite/onboarding-step/secret/KB/comment/project/contact/thread) and the mobile drawer (open/ESC/scroll-lock/focus); also eyeball the global focus ring and the dense-row tap-target
-my-2spacing. - Tier 1 COMPLETE. S3 done in commit
108c2a5(LOCAL, unpushed): every hand-rolled modal converted to Radixcomponents/ui/dialog.tsx(focus trap/ESC/aria) — ops (4), members/tasks/projects/contacts add-edit, settings integration-credentials, plus the payments Log-Cash AND Link-Member modals. Forms/handlers/validation/close-reset preserved;onOpenChangemirrors old Cancel/X. Build clean, 271/271. 0 residualfixed inset-0modal overlays. Not browser-verified: smoke-test each modal opens/submits/ESC-closes. - Tier 2 DONE (
a1ea92a, local): founder first-run guided dashboard when the space is empty (admin/board alone, no data) replaces the zero grid with a 4-step checklist; reverts once there is activity. Onboarding Skills is now a controlled field (survives Back/forward, no normalize surprise) + a hint when a required ack is unchecked. - Tier 3 DONE (
b7461f0, local, presentational only — RLS guardrail respected): secrets revealed value gets copy-to-clipboard + auto re-hide after 30s + re-hide on window blur + timer cleared on unmount; permissions matrix shows a view-only notice for non-admins + per-checkboxaria-label; OpsAclEditor pills arearia-pressedtoggles with a selected check + success toast. No server action / RLS / permission logic changed. - Open: deploy Tier 2+3 (
a1ea92a+b7461f0+ this doc commit, 2-3 commits ahead oforigin/main). Awaiting user push approval. Not browser-verified: smoke-test the first-run dashboard (new empty space) and the secrets copy/auto-hide. - Sequence: deploy Tier 2+3, THEN the forms feature (next major effort; design + decisions locked, see below).
Forms + waivers feature (designed 2026-05-16, build deferred until after polish Tiers 1-3)
Full design delivered in chat. Locked decisions: anonymous waivers allowed (capture IP/UA/timestamp; retro-link to a later account only on verified email; admin manual-link for mismatches); non-blocking re-sign on waiver version bump (old signatures stay valid against their snapshot). User guidance: waivers are the primary need but the feature must also support ANY arbitrary form with a VERY EASY designer (builder ergonomics are an explicit priority). Recommended defaults: global-unique slug /f/[slug]; reuse space_onboarding_steps for an onboarding form step; gate creation via lib/permissions-catalog.ts (forms.manage) + user_has_permission; funnel anon submissions through one validated server action + service client (pattern: finishOnboarding); snapshot schema+legal-text per submission for immutable waiver records. Two new tables (forms, form_submissions); the anon-write + retro-link + immutability RLS is security/legally sensitive — design under the guardrail. Memory: forms-feature / hackerspace-rls-guardrail.
Pending decision / next step
Run these on the production DB (service role) to disambiguate the incident report if it persists:
SELECT polname, pg_get_expr(polwithcheck, polrelid) AS with_check
FROM pg_policy WHERE polrelid = 'public.incidents'::regclass AND polcmd = 'a';
SELECT id, space_id, user_id, status, role FROM public.space_members WHERE user_id = '<auth_uid>';
SELECT public.get_user_space_ids('<auth_uid>'::uuid);
If query 1 already shows the 016/017 expression and query 2 shows a matching user_id with the space present, 025 will NOT fix it (cause #1 or #3); investigate the SSR Supabase client / membership data instead.
2026-05-15 — SESSION CLOSE: state of the app
One-screen status for the next session. Detailed history in the pass entries below.
Live
- Production:
https://hackerspace.sh, single self-hosted DigitalOcean Droplet (Supabase + Next.js behind Caddy). Push tomain-> GitHub Actions runs/opt/hackerspace-ops/deploy.sh(idempotent migrations viapublic._migrations_applied, build, restart). Latest deployed commit4c9b551. Migrations applied through024. - Branch
main, clean.pnpm test271/271.pnpm buildclean.
Shipped & solid
- Core: members, tasks/chores, projects, payments, ops (KB/processes/secrets/area-leads), comms, dashboard.
- Governance: proposals+votes, incidents, policies, forum + polymorphic comments.
- Per-space customization hub (
/customize): roles + labels, permissions matrix, tiers (priced), areas, area-lead roles (vacant/assign), multi-code invites, onboarding builder. - Configurable member onboarding (
/onboarding). - Security: AES-256-GCM secrets at rest with server-only reveal; additive Ops ACLs (
ops_acl) +user_has_permission/user_effective_roles; self-escalation trigger hardened (incl.tier_id,onboarding_completed_at); cross-tenant in-code scoping; crypto invite codes; sanitized markdown/HTML. - Landing rebuilt in the editorial
/resourcesaesthetic; terminal brand mark; resource showcase. - Docs current (DATABASE_SCHEMA / DB_SCHEMA_MAP / API_REFERENCE / CHANGELOG / UX_AND_PERSONAS / PERMISSIONS_DESIGN).
types/database.tsin sync.
Deliberate non-changes (not bugs)
next.config.mjskeepstypescript.ignoreBuildErrors: truefor pre-existing legacyapp/TS noise. New code is type-clean;tscis NOT a usable gate today (use build + tests + manual)./ops,/importreturn nullfor unauthorized are unreachable behind the(app)layout gate.
Next focus (planned, not started)
Deep UI/UX audit + polish. docs/UX_AND_PERSONAS.md has the persona model and the prior audit's deferred items: sidebar icon collision (Ops & Settings both Settings2), ~17 inconsistent empty states (no shared Empty component), heading-hierarchy primitives, in-app getting-started/help, role-filtered sidebar for non-privileged members. Newer surfaces (Customize hub, Permissions matrix, Onboarding flow, Forum, Ops ACL editor, Area leads) have not had a polish pass. Eliminating the legacy app/ TS debt so ignoreBuildErrors can be removed is a separate worthwhile initiative.
2026-05-15 (pass 16) — Full-app audit + security hardening
Branch: main. Migration: 024 (plus 023 from pass 15 not yet deployed).
Audit
Five parallel read-only audits (security/RLS, server actions/validation, routes/data, schema/types/migrations, plus a security-smell sweep). Verified before acting — two agent "blockers" were false alarms (ops_acl/space_role_permissions/member_custom_roles DELETE policies DO exist in schema.sql §15; m2m needs no UPDATE).
Fixed this pass (security)
- Self-escalation closed (BLOCKER). Migration 024 + schema.sql §16:
prevent_member_self_role_changenow also blocks a non-privileged member changing their owntier_id(self tier upgrade) andonboarding_completed_at(skip required code-of-conduct).finishOnboarding/skipOnboardingnow setonboarding_completed_atvia the service client AFTER their server-side required-steps check, so the legitimate path still works. - Cross-tenant write scoping. Added
.eq('space_id', member.space_id)toupdateForumThread,deleteForumThread,editComment,deleteComment,deleteChannel,renameChannel(was RLS-only; now defence-in-depth). - Invite codes.
auth-actions.generateInviteCodemoved offMath.random()tocrypto.getRandomValuesand widened to 8 chars (was ~21 bits, enumerable under the join rate limit). - SafeMarkdown hardened. Dropped
iframefrom allowed tags (no unsandboxed embed/token-theft frame) and the globalstyleattribute (no full-viewport UI-redress). Markdown + safe basic HTML still render. /settingsserver gate. Nowredirect('/dashboard')for non-admins BEFORE fetching space/integration config (was client-hidden only).app/(app)/layout.tsxmarkedexport const dynamic = 'force-dynamic'(auth/onboarding gate must never be cached).- Bounded queries.
loadComments.limit(500), tasks page.limit(2000).
Pass 16b follow-up (same day)
- Import validation done.
importMembersSchema/importPaymentsCsvSchemaadded; both actions validate per row (one bad row no longer rejects the file), lowercase emails, normalize dates viaflexibleDateTime(), enforce platform/tier enums and positive finite amounts, and return askippedcount instead of silently dropping rows. - KB/process per-item ACL wired.
OpsAclEditorrenders in the KB/process viewer modal (admin/board); entity type derived from theprocesstag. Per-item Ops ACLs now cover secrets + KB + processes. /opsand/importdenial paths left as-is: unreachable behind the(app)layout gate, or already a clear denial with no data leak.
Pass 16c — backlog cleared (same day)
types/database.tssynced. Added the 8 missing tables (forum_threads, comments, space_tiers, space_invites, space_onboarding_steps, space_role_labels, space_custom_roles, space_member_custom_roles), thecomment_entity_typeenum (both type union and Constants array), and the missing columns:space_members.tier_id/onboarding_completed_at/onboarding_progress,secrets.encrypted_value/encryption_version,knowledge_base.render_markdown.- Email normalized everywhere. New
emailField()helper (trim + lowercase) applied to signIn/signUp/addMember/updateMember/createContact and the import path. Prevents case-variant duplicate members/contacts. - Members-row "Make area lead". A per-row picker in the members directory (admin/board) assigns a member to any area-lead role via
assignAreaLead; same capability also in Customize -> Area leads. - Docs refreshed.
DATABASE_SCHEMA.md(header + migrations 014-024 summary),DB_SCHEMA_MAP.md(table quick-map + helpers),docs/API_REFERENCE.md(all server actions added in migrations 016-024). All re-dated 2026-05-15 withscripts/schema.sqlnamed as source of truth.
Backlog is now empty. Remaining known non-issues: /ops + /import return null are unreachable behind the (app) layout gate; next.config.mjs keeps ignoreBuildErrors (pre-existing legacy app/ TS noise) — the new code is type-clean.
Verification
pnpm buildclean;pnpm test271/271. Migrations 023 + 024 idempotent; apply via deploy.sh's tracked runner on next deploy.
2026-05-15 (pass 15) — Permissions, Ops ACLs, area-lead roles
Branch: main. Migration: 023 (applies on next deploy).
Pre-work audit (background agent)
No regressions from recent passes; routing now clean (the duplicate app/page.tsx was the only structural anti-pattern and it is gone). Verbatim current RLS captured. types/database.ts is stale for 8 prior-pass tables but masked by ignoreBuildErrors (type debt, not a runtime bug). Two latent Ops bugs found and fixed this pass.
What shipped
Security-sensitive subsystem, built ADDITIVE so a space with no permission/ACL rows behaves exactly as before.
- Migration
scripts/023_permissions.sql(folded intoschema.sqlsection 15):space_role_permissions(space_id, subject, permission) andops_acl(space_id, entity_type in secret|kb|process|area_lead, entity_id, role). Full RLS; writes admin/board.user_effective_roles(uid,sid)SECURITY DEFINER: built-in role + custom-role slugs +area_lead:<area_leads.id>for areas the member leads.user_has_permission(uid,sid,perm)SECURITY DEFINER: admin implicit-all, else a grant via an effective role.secrets/knowledge_baseSELECT rewritten asEXACT existing rule OR ops_acl match. Zero behavior change with no ACL rows.- Seed trigger + backfill of sensible default
space_role_permissions(board/treasurer/member/associate; admin never stored).
- Area-lead roles reuse the existing
area_leadstable (decision: do not duplicate the concept). A row is a role;lead_id IS NULL=> Vacant; assigning setslead_id; the member then holdsarea_lead:<id>viauser_effective_roles. lib/permissions-catalog.ts(fixed code list + per-role defaults + guardrails), Zod schemas,lib/actions/permissions.ts(setRolePermissions, setOpsAcl, createAreaLeadRole, assign/unassign/deleteAreaLeadRole).- UI: Customize gains Permissions (role x permission matrix; admin shown as implicit-all/locked) and Area leads (create role, Vacant state, member-picker assign/unassign) panels — new modular files under
customize/panels/. Reusablecomponents/ops/ops-acl-editor.tsxwired into the Secrets list ("Access" toggle, multi-role) viaops/page.tsxplumbing (custom roles + area-lead sentinels as options). - Latent bug fixes:
revealSecretnow errors on a corrupt encrypted-but-no-ciphertext row instead of silently returning the stale plaintext column;upsertAreaLeadrejects null/emptyarea_code(Postgres treats NULLs as distinct, which allowed duplicate rows). types/database.ts: addedspace_role_permissionsandops_acl. Catalog unit tests (5) added; 271 tests pass.
Deferred (tracked, honest)
- KB/process per-item ACL: infra (RLS, setOpsAcl with kb/process, OpsAclEditor) is complete; only the ops-client KB-modal wiring is not done yet (the Secrets path, the explicit ask, is wired). Mechanical follow-up.
- "Make area lead" directly from the members directory row: assignment is fully available in Customize -> Area leads; the members-list shortcut is deferred.
- Broad permission-based gating of every server action / RLS rewrite app-wide is intentionally NOT done; this pass keeps existing role checks and only OR-widens via ACLs.
user_has_permissionexists for incremental adoption. types/database.tsstill stale for the other 8 prior tables (loose casts; build green).
Verification
pnpm buildclean;pnpm test271/271. Migration 023 is idempotent and applies via deploy.sh's tracked runner.
2026-05-15 (pass 14) — Landing redesign + KB search fix
Branch: main.
What shipped
- Landing rebuilt in the resources editorial aesthetic. New scoped
app/(landing)/landing.cssmirrors the/resourcespalette (#0c0c0c/ lime#d4ff00/#252525borders), IBM Plex Mono + Libre Baskerville (loaded in(landing)/layout.tsx, which now wraps.landing-root+ grid overlay).app/(landing)/page.tsxwent from a 539-line'use client'monolith to a ~150-line server component. The animatedMini*previews and inlineIco*set were removed (they clashed with the editorial look). Modules extracted for separation of concerns:components/landing/icons.tsx,components/landing/resource-showcase.tsx.BrandMarknow acceptsstyle. - Resource showcase: a single row of six square tiles at the foot of the landing, each with the real project SVG logo (
AtlasLogo,CubeIcon,TraceIcon,SpaceIcon,GridIcon,KeyIcon), linking to /atlas, /space-after-dark, /proposal-duel, /zine, the external learner, /governance. Responsive 6 -> 3 -> 2 columns. - KB search crash fixed (
app/(app)/ops/ops-client.tsx). Root cause:filteredLeadsreferencedl.member_name, a column that does not exist (it islead_handle), soundefined.toLowerCase()threw. Becausesearchis shared state and all four filter memos recompute on every keystroke regardless of the active tab, typing in the KB box crashed the whole page. All four predicates are now null-safe via a sharedhas()helper;filteredSecretsalso no longer assumes a non-nulltitle.
Verification
pnpm buildgreen (/route present),pnpm test266/266.- No migration this pass.
Next pass (designed, not yet built): permissions + Ops ACLs + area-lead roles
User asked for: customizable permissions; per-secret and per-Ops-item multi-role access; area-lead roles that show "vacant" until a member is assigned (from a dedicated UI or by clicking a member in the directory) and that grant permissions wherever that area-lead role is selected. This is a security-sensitive subsystem (RLS rewrites on secrets and knowledge_base) and is deliberately NOT bundled with the landing redesign. Design is captured in docs/PERMISSIONS_DESIGN.md.
2026-05-15 (pass 13) — Customize hub, roles editor, logo revert
Branch: main.
Goal
Three asks: (1) build the custom-roles editor (label rename/recolor + create/edit/delete custom roles) that pass 11 left as data-layer-only; (2) move all the per-space customization (Roles, Tiers, Areas, Invites, Onboarding) out of the overloaded Settings tab strip into a dedicated /customize hub with its own nav, leaving Settings for space identity / visibility / integrations / webhooks; (3) revert the brand mark from AtlasLogo (the globe) back to the original terminal >_ glyph and give the favicon a matching terminal mark.
Decisions
- IA: a single sidebar entry "Customize" (admin/board) ->
/customizehub with a left section rail (Roles, Tiers, Areas, Invites, Onboarding). Keeps the sidebar lean (audit finding: 16+ items already) while giving customization a discoverable home distinct from operational Settings. Settings slimmed to Space, Visibility, Integrations, Webhooks. - Logo: restore
IcoTerminalfor nav/footer/onboarding header; replacepublic/logo.svgwith a terminal>_glyph for the favicon.
What shipped
- Brand mark reverted. New
components/brand-mark.tsx(a terminal>_glyph, currentColor). Swapped into the landing nav + footer and the onboarding header.public/logo.svgrewritten as the matching lime>_favicon. The dead localIcoTerminalin the landing page was removed.AtlasLogostays only on the(resources)page (it is that project's own art). /customizehub (app/(app)/customize/): serverpage.tsxgates admin/board and fetches everything; thincustomize-client.tsxshell with a left section rail; one file per panel underpanels/(roles-panel,tiers-panel,areas-panel,invites-panel,onboarding-panel, pluscard.tsxandtypes.ts). Smart separation: the shell is ~75 lines, each panel self-contained.- Roles editor (new). RolesPanel renames/recolors the five built-in roles via
upsertRoleLabel, and full CRUD on custom roles. The pass-11 actions were write-only; this also adds the read path:lib/role-labels.ts(getRoleLabelMap,roleDisplayName, defaults) is now consumed in(app)/layout.tsxso the sidebar user card shows the renamed role. - Settings slimmed. Removed the Roles/Tiers/Invites/Onboarding tabs and the Areas block from
settings-client.tsx(down ~700 lines) and the matching fetches fromsettings/page.tsx. Settings now owns only Space, Integrations, Webhooks. Dead props/state/types removed. - Sidebar. Added a "Customize" admin/board NavLink (
SlidersHorizontal) above Settings. - Invite share links.
InvitesPanelhas Copy code and Copy link; the link is/signup?invite=CODE.app/signup/page.tsxreads?invite=on mount, prefills the code, and preselects join mode.
Deferred (tracked)
types/database.tsis out of sync (missing space_tiers/space_invites/space_onboarding_steps/space_role_labels/space_custom_roles/space_member_custom_roles/forum_threads/comments and the two onboarding columns on space_members). Queries use loose casts so the build is green; this is type-hygiene debt, not a runtime bug.- Renamed-role display only reaches the sidebar so far. Members table/edit-select and badges still show the raw enum. The helper exists; wiring the remaining surfaces is a small follow-up.
settings-client.tsxis still ~700 lines (space form + visibility + integrations + webhooks + integration modal). Worth splitting next, same pattern as the customize panels.- Custom-role assignment to members has actions (
assignCustomRole/unassignCustomRole) but no UI yet.
Verification
pnpm test266/266.pnpm buildgreen;/customize,/onboarding, slimmed/settingsall in the route table.- Migration: none this pass (UI/IA only; pass-11/12 migrations already in prod).
2026-05-15 (pass 12) — Configurable member onboarding + UX research
Branch: main.
Context carried in
Pass 11's feature batch (forum, comments, tiers, invites, secrets encryption) deployed to production after one landmine: the .gitignore rule secrets/ also matched lib/secrets/, so the first deploy failed on Module not found: '@/lib/secrets/crypto'. Fixed by anchoring the rule to /secrets/ (commit 96a1fb9); redeploy succeeded. Migration 021 is applied in prod (_migrations_applied has 8 rows). SECRETS_ENCRYPTION_KEY is set in /opt/hackerspace-ops/.env.production on the Droplet.
What this pass does
UX research + persona work, a deep UI/UX audit, and the configurable member-onboarding feature.
What shipped
- Migration
scripts/022_onboarding.sql(folded intoschema.sqlSection 14):space_onboarding_steps(step_key, step_type in welcome|code_of_conduct|profile|payment|content, title, body, config jsonb, is_enabled, is_required, is_system, sort_order; unique per space+key).space_members.onboarding_completed_atandonboarding_progress jsonb. Trigger seeds 4 default steps per space; backfill seeds existing spaces and marks all existing members complete (so the new gate does not trap them). RLS: members read, admin/board write, admin delete non-system. lib/actions/onboarding.ts: admin CRUD (createOnboardingStep/updateOnboardingStep/deleteOnboardingStep), member progress (markOnboardingStepDone,finishOnboardingwhich server-side-enforces required steps,skipOnboardingwhich only works when nothing is required). Validations added;bioadded toupdateMyProfileSchema./onboardingroute (top-level, outside the(app)group so no redirect loop).OnboardingFlowclient stepper: progress bar, step counter, back, per-type rendering (welcome/coc/content viaSafeMarkdown, profile form ->updateMyProfile, payment nudge with optional link + "Remind me later"). Gate added inapp/(app)/layout.tsx: redirect to/onboardingwhenonboarding_completed_atis null.createSpacenow setsonboarding_completed_atfor the founder.components/safe-markdown.tsx: rehype-raw + rehype-sanitize. Addedrehype-rawandrehype-sanitizedeps.- Settings -> Onboarding admin tab: reorder (sort number), edit title/body/payment link, toggle enabled/required, add custom content step, delete non-system steps.
docs/UX_AND_PERSONAS.md: 4 personas (founder, new member, operator, casual) and prioritized UX findings. Onboarding was finding #1; findings 2-6 (sidebar icon collision, inconsistent empty states, heading hierarchy, getting-started affordance, role-filtered nav) deferred.
Open items / deferred
- UX findings 2-6 in
docs/UX_AND_PERSONAS.md(icon collision, empty-state component, page-title primitives, admin checklist, role-filtered sidebar). - Custom role labels / custom roles still have no Settings UI (data layer from pass 11 exists).
importMembers/importPaymentsCsvstill lack Zod schemas (pass-10 audit item).
Verification
pnpm test266/266.pnpm buildgreen,/onboardingin the route table.- Migration 022 applies via
deploy.sh's idempotent runner on next deploy.
2026-05-14 (pass 11) — Forum, comments, secrets encryption, custom tiers/invites, logo
Branch: main. Pushed.
What I did
Major feature expansion + security fix surfaced by audit.
Schema migration scripts/021_forum_tiers_roles_invites_secrets.sql, folded into scripts/schema.sql Section 13:
forum_threads(title, body, category, pinned, locked, comment_count, last_comment_at). Full RLS.commentspolymorphic over (forum_thread|proposal|incident|policy) withparent_idfor nesting. Trigger maintainsforum_threads.comment_countandlast_comment_at.space_tiersper-space (slug, name, description, monthly_price_cents, billing_cadence, is_system, sort_order). Backfillsplus/basic/associatefor every existing space and seeds them onINSERT spacesvia trigger.space_members.tier_idFK added and backfilled from the legacy enum.space_role_labels(rename + recolor built-in roles per space).space_custom_roles(non-privileged extra labels).space_member_custom_rolesm2m.space_inviteswithcode,label,expires_at,max_uses,uses_count,is_enabled. Legacyspaces.invite_codebackfilled as a permanent enabled invite.secrets.encrypted_value bytea+encryption_version smallint. Secrets RLS tightened.knowledge_base.render_markdownboolean (default true).comms_channelsINSERT policy: any member of the space can create. UPDATE/DELETE: admin/board OR creator (default channels protected).
Server actions:
lib/actions/forum.ts:createForumThread,updateForumThread(pin/lock moderator-gated),deleteForumThread,addComment,editComment,deleteComment.lib/actions/comms.ts:createChannel,renameChannel,deleteChannel.lib/actions/tiers.ts:createTier,updateTier,deleteTier.lib/actions/roles.ts:upsertRoleLabel,createCustomRole,updateCustomRole,deleteCustomRole,assignCustomRole,unassignCustomRole.lib/actions/invites.ts:createInvite,updateInvite,deleteInvite.lib/actions/secrets.ts: rewritten to use AES-256-GCM at rest. NewrevealSecret(id)returns plaintext only via server action and logssecret.revealedtoactivity_log. NewupdateSecret. List endpoint in/ops/page.tsxno longer selectsvalueorencrypted_value— fixes the screenshot bug where ciphertext was already in the page bundle before "Reveal".lib/secrets/crypto.ts: AES-256-GCM helpers. ReadsSECRETS_ENCRYPTION_KEYenv (64 hex chars). Falls back to plaintext storage if the env var isn't set.
UI:
/forum(index with pinned/locked badges, last-activity sort),/forum/new,/forum/[id].components/comments/CommentThread+loadCommentshelper. Embedded on/proposals/[id],/incidents/[id],/policies/[slug]./commssidebar: inline "+ New channel" form (name + description + type)./opsSecrets tab: Reveal now calls therevealSecretaction and respects encryption./opsKB: row click opens a modal that rendersentry.contentas markdown via react-markdown + remark-gfm./settings: new Tiers tab (list/create/edit/archive/delete custom tiers, inline price edit). New Invites tab (list/create/disable/delete; copy code).- Landing page nav + footer now use
AtlasLogoinstead ofIcoTerminal.public/logo.svgis the favicon (referenced byapp/layout.tsxmetadata). - Sidebar gets a
Forumlink belowComms.
Auth flow: joinSpace honors codes from space_invites first (enforces enabled/expired/max-uses), falls back to legacy spaces.invite_code, and increments uses_count on success.
Env: SECRETS_ENCRYPTION_KEY added to .env.example and to secrets/base.env locally (gitignored).
Validations: added updateSecretSchema, createForumThreadSchema, updateForumThreadSchema, createCommentSchema, updateCommentSchema, createTierSchema, updateTierSchema, upsertRoleLabelSchema, createCustomRoleSchema, updateCustomRoleSchema, createInviteSchema, updateInviteSchema, createChannelSchema.
Open items (deferred for follow-up)
- Custom role labels and custom roles UI in Settings. Data layer + RLS + server actions all in place; UI surface not yet added. Schema rows can be created via PostgREST or psql today.
importMembersandimportPaymentsCsvlack Zod schemas (audit-flagged). Logic still filters on type, but should adopt schemas.- Add
SECRETS_ENCRYPTION_KEYto the droplet's.env.productionbefore the next deploy. Without it, secrets fall back to legacy plaintext storage and the screenshot bug returns. (Update done locally; deploy env update pending.) - Migration 021 must be applied on production. The deploy script runs it idempotently; first deploy after this commit will write
_migrations_appliedrow.
Verification
pnpm test-> 266/266 pass.pnpm build-> green, all new routes in the route table (/forum,/forum/new,/forum/[id]).
2026-05-14 (pass 10) — Production deploy + open-source release prep
Branch: main. Pushed (8ad1ca4).
Self-hosted production deploy completed on a single DigitalOcean Droplet at https://hackerspace.sh. Supabase (db/auth/rest/realtime/kong/meta/studio) runs in Docker with the Postgres data directory on a persistent block volume at /mnt/data/postgres. Caddy 2 terminates TLS with automatic Let's Encrypt for the apex, www, supabase, and studio hostnames. Studio is gated by HTTP basic auth (bcrypt). Daily pg_dumpall cron writes to /mnt/data/backups with 14-day retention. GitHub Actions auto-deploys every push to main over SSH via /opt/hackerspace-ops/deploy.sh which applies pending migrations idempotently (via public._migrations_applied), rebuilds, and restarts the systemd service. Resend SMTP is wired into GoTrue.
Open-source release scaffolding shipped: LICENSE (MIT), CONTRIBUTING.md, SECURITY.md, CHANGELOG.md, docs/WEBHOOKS.md, refreshed README.md and docs/DEPLOYMENT.md. All v0/Vercel references removed from app code; @vercel/analytics dependency dropped. GitHub icon link to the public repo in the landing nav + footer.
Audit fixes in the same commit:
- Webhook signing secret now has show/hide + copy in
/settings; the broken externaldocs.hackerspace.sh/webhookslink replaced withdocs/WEBHOOKS.md. updateSpaceSettingsparameter type now includesmission_statement(was silently dropped)./membersenforcesmember_directory_visibilityfor non-admin viewers.- Mobile responsiveness pass: Projects Kanban, Members + Payments tables (column hiding on narrow), Proposal tally + voting buttons.
2026-05-14 (pass 9) — Datetime fix + configurable areas
Branch: main. Author: Claude (no commits made).
What I did
Two user-reported issues from running the local app:
- "Invalid datetime" error when creating a task with a due_date. HTML
<input type="date">sends"2026-05-22"(date-only) butz.string().datetime()requires full RFC 3339. Eight other schemas had the same latent bug (createProjectSchema.due_date,addMemberSchema.joined_at,logCashPaymentSchema.transaction_date, both proposal voting timestamps,createPolicySchema.effective_at,supersedePolicySchema.effective_at,meetingMinutesSchema.meeting_date). - Areas were hardcoded constants in
tasks-client.tsx(9 strings) andprojects-client.tsx(9 different strings). Not configurable per-space. No defaults seeded for new spaces.
Fix 1: shared flexibleDateTime preprocessor
lib/validations.ts exports flexibleDateTime(). A z.preprocess that maps ""/null/undefined → null, passes through full RFC 3339 unchanged, normalises YYYY-MM-DD and YYYY-MM-DDTHH:MM through new Date(v).toISOString(), and leaves unparseable input for Zod to reject. Wired into all nine date fields. The original bug screenshot is gone: "2026-05-22" validates and inserts as "2026-05-22T00:00:00.000Z". Added 7 new tests.
Fix 2: space_areas table + Settings UI
Migration scripts/020_areas.sql:
space_areastable:(id, space_id, code, name, icon, sort_order, is_archived). UNIQUE per(space_id, code)and(space_id, name).- Sort index
(space_id, sort_order, name). - RLS: members read, admin/board write, admin delete.
seed_default_areas()trigger fires after spaces INSERT, seeding ten defaults: 3D Printing, Electronics, Woodshop, Laser, Metal Shop, Facilities, Software, Kitchen, Admin, General. Codes are stable slugs; names are display labels.- Backfill populates every existing space that has no areas. Idempotent.
- Folded into
scripts/schema.sqlas Section 12.
Library surface:
lib/types.ts:SpaceAreatype.lib/validations.ts:createAreaSchema,updateAreaSchema(slug regex, length caps).lib/actions/areas.ts:createArea,updateArea,deleteArea. UserequireMemberWithRole(ADMIN_ROLES). Re-exported via the barrel.
UI wiring:
app/(app)/tasks/page.tsxandapp/(app)/projects/page.tsxfetchspace_areasin parallel and passareas: string[]to the client.tasks-client.tsxandprojects-client.tsxaccept the new prop; old hardcoded arrays renamedDEFAULT_AREASas a fallback.app/(app)/settings/page.tsxfetches the full area list and passes toSettingsClient.settings-client.tsx: new Areas card on the Space tab. Lists every area with inline-editable name + sort_order, archive/restore toggle, admin-only delete with confirm prompt. "Add area" form takes code + display name.
Verification (live local stack)
- Migration applied to the running Postgres. 20 area rows across 2 spaces (Demo Hackerspace + the user's HeatSync Labs space created earlier through the UI), 10 each.
- Test user fetched
space_areasvia REST with their JWT; got the 10 defaults sorted bysort_order. RLS clean, no cross-tenant leak. - 266 tests across 8 files (was 259; +7 for flexibleDateTime).
pnpm buildclean, 31 routes.- Dev server still running, hot-reloaded the changes.
Files changed
Added:
scripts/020_areas.sql
lib/actions/areas.ts
Edited:
lib/validations.ts (flexibleDateTime + area schemas)
lib/types.ts (SpaceArea)
lib/actions/index.ts (export areas)
scripts/schema.sql (Section 12)
scripts/README.md (020 listed)
app/(app)/tasks/page.tsx (fetch + pass areas)
app/(app)/tasks/tasks-client.tsx (accept areas prop)
app/(app)/projects/page.tsx (fetch + pass areas)
app/(app)/projects/projects-client.tsx (accept areas prop)
app/(app)/settings/page.tsx (fetch areas)
app/(app)/settings/settings-client.tsx (Areas management card)
__tests__/validations.test.ts (7 new tests)
types/database.ts (regenerated; includes space_areas)
docs/HANDOFF.md (this entry)
In the browser: /tasks → New Task → due_date 2026-05-22 saves cleanly. /settings → Space tab → scroll to Areas → add a custom area; it appears in the Tasks and Projects dropdowns on next render.
2026-05-14 (pass 8) — Local stack verified end-to-end, seed + LOCAL_DEV docs
Branch: main. Author: Claude (no commits made).
What I did
Brought the system from "passes tests + builds" to "runs against a real Supabase stack on this machine, end-to-end."
- Installed Supabase CLI via
brew install supabase/tap/supabase. Started a local stack (supabase start) — Postgres, GoTrue, PostgREST, Realtime, Storage, Studio, Mailpit all up in Docker. - Applied
scripts/schema.sqlfrom scratch against the empty local DB. 18 tables created. - Wrote
scripts/seed.sqland applied it: 1 demo space, 5 offline members across all roles, 1 active code-of-conduct policy v1, 3 tasks, 1 project, 1 KB entry, 3 proposals (open / draft / decided) with 7 votes already cast, 1 open incident, 8 activity-log rows. - Migration 019 (
scripts/019_proposal_expiry.sql):public.expire_proposals()flips open proposals past their deadline todecided(if quorum met) orexpired. Schedules itself hourly viapg_cronif the extension is enabled. Folded intoschema.sqlas Section 11. - Wrote
docs/LOCAL_DEV.md: 8-step quickstart, wipe + re-seed recipe, troubleshooting section. - Regenerated
types/database.tsfrom the live local DB. 1696 lines, 18 tables, 1 view, 3 RPC functions. - Wrote
.env.localwith the local Supabase URL and keys. - Started
pnpm dev. Health endpoint returns 200, public routes return 200,/dashboardcorrectly 307s to/login.
Verification done with real data
- Anon-key RLS gate confirmed (empty array on no-session).
- Service-role bypass confirmed.
- Created
tester@demo.localvia Auth Admin API, signed in via/auth/v1/token, got a real ES256 JWT, queried proposals + channels + policies — all RLS-scoped, all returning seeded data. - Privilege-escalation guard (migration 015): simulated a plain-member self-promotion via JWT-claimed authenticated role. Postgres rejected with the exact
Members cannot change their own role, tier, status, approval, card access, or space.message. expire_proposals()runs cleanly, returns 0.- HTML smoke:
/logintitle correct,/signupcontains expected copy. - 259 tests across 8 files passing.
Files changed
Added:
supabase/config.toml (from supabase init)
scripts/019_proposal_expiry.sql
scripts/seed.sql
docs/LOCAL_DEV.md
.env.local (gitignored)
Edited:
scripts/schema.sql (Section 11)
types/database.ts (regenerated)
2026-05-14 (pass 7) — Tier 2/3 UI closure + Markdown + proxy rename
Branch: main
Author: Claude (no commits made).
What I did
Finishing the user-facing surface for the schema work passes 5 and 6 added.
- Renamed
middleware.ts→proxy.tsfor Next.js 16. Renamed the exportedmiddlewarefunction toproxy. The deprecation warning that the build had been emitting since pass 4 is now gone. - Markdown rendering: installed
react-markdown+remark-gfm. Createdcomponents/markdown.tsx(MarkdownBody) with safe defaults — HTML escaped, norehypeRaw, every link getsrel="noopener noreferrer"andtarget="_blank". Wired it into the three governance detail pages so proposal bodies, incident bodies, incident dispositions, incident updates, and policy formal/plain text now render headings, lists, links, code, blockquotes, tables, strikethrough. /profileroute (Tier 3.1 + 3.2 UI closure):- Server page reads the caller's
space_membersrow. ProfileForm(client) lets a member edit display_name, handle, phone, skills, interests, willing_to. Uses a tiny customChipInputcomponent for tag-style multi-value fields withEnter/,to commit andBackspaceto delete.AffiliationsForm(client) lets the member disclose COI affiliations. Submit stampscoi_last_disclosed_atviadiscloseAffiliations. Surfaces an orange callout for privileged-role members who haven't disclosed yet.- The
willing_tofield ships with suggestion chips:board_candidate,treasurer_candidate,host_volunteer,area_lead_candidate,event_organizer,safety_committee,docs_steward. Members can also free-form.
- Server page reads the caller's
/recruitmentroute (Tier 3 succession view):- Board / admin only (server-side redirect for others).
- Queries
space_membersfor the current space; groups by eachwilling_totag; renders one card per tag with the willing members listed underneath. - For privileged-role members (admin / board / treasurer) the card shows a "COI on file" or "no COI" badge based on
coi_last_disclosed_at. - A separate "Skills declared (no role opt-in)" section catches members who declared skills without willing_to.
- Settings extensions (Tier 2.1 + Tier 2.2 + mission statement):
- Added
mission_statementfield toupdateSpaceSettingsSchema(max 5000 chars, nullable). - The Space tab on
/settingsnow has a Mission Statement textarea inside the existing space-settings form. - A new Visibility card on the Space tab exposes two dropdowns:
financial_visibility(3 options) andmember_directory_visibility(4 options). Saves via the existingupdateSpaceVisibilityaction from pass 6.
- Added
- Mission statement display in the app shell: when
space.mission_statementis set, the app layout renders it in a small italic footer on every protected page — the "stated-mission visibility on every page" principle fromdocs/GOVERNANCE_FEATURES.mdsection 1. - Dashboard widget: above the main grid, two side-by-side cards.
- Open proposals awaiting your vote: queries open proposals, joins against the caller's
proposal_votesto filter out ones they've already voted on, shows up to 5 with the voting-window close timestamp and a live quorum percentage. - Incidents needing response (admin/board only): shows
receivedandunder_reviewincidents, sorted newest first, with a red "respond by" date if the SLA has elapsed. - The whole section only renders if at least one of the two has content.
- Open proposals awaiting your vote: queries open proposals, joins against the caller's
- Sidebar: added "My profile" (
UserCircleicon) and admin-gated "Recruitment" (UserSearchicon) under the People group. - 4 new tests in
__tests__/governance.test.tsfor the mission_statement extension toupdateSpaceSettingsSchema. Total now 259 across 8 files.
Verification
pnpm vitest run→ 259 tests pass across 8 files.pnpm build→ success. Build emits 31 routes (added/profile,/recruitment).- The middleware-deprecation warning is gone. Only remaining build warning is the stray lockfile at
/Users/obsidian/Projects/package-lock.json, which is outside the repo and harmless on DO.
Open items, priority order
- Apply pending migrations on production (015, 017, 018) if not already done. Fresh deploys get them all via
scripts/schema.sql. - Regenerate
types/database.tswith the Supabase CLI to clear the ~217 TS errors thattypescript.ignoreBuildErrorsmasks. Once clean, removeignoreBuildErrorsso future drift surfaces in CI. - Configure OAuth providers in Supabase if you want the GitHub / Google buttons to work.
- Auto-expire open proposals: when
voting_closes_at < now(), status should auto-flip fromopentoexpired. Currently manual via the admin's "Mark decided" button. A cron-triggered server action would close this. - Mark KB entry as meeting minutes: schema (column added in 018) and validation schema in place, but no UI control yet.
- Notifications on incident status change: activity_log row written but no in-app comms message or email triggered. Add when notification infra is ready.
- Hosting calendar, sponsor / partner registry, community surveys: Tier 4, deferred indefinitely until adoption signals demand.
- Secrets encryption at rest:
secrets.valueandintegrations.configstill plain text. Address before storing real production credentials.
Files changed this pass
Added:
proxy.ts (replaces middleware.ts)
components/markdown.tsx
app/(app)/profile/page.tsx
app/(app)/profile/profile-form.tsx
app/(app)/profile/affiliations-form.tsx
app/(app)/profile/chip-input.tsx
app/(app)/recruitment/page.tsx
Removed:
middleware.ts (renamed to proxy.ts)
Edited:
app/(app)/layout.tsx (mission statement footer)
app/(app)/dashboard/page.tsx (governance widget)
app/(app)/settings/settings-client.tsx (mission statement + visibility section)
app/(app)/proposals/[id]/page.tsx (MarkdownBody)
app/(app)/incidents/[id]/page.tsx (MarkdownBody, ×3)
app/(app)/policies/[slug]/page.tsx (MarkdownBody, ×2)
components/app-sidebar.tsx (Profile, Recruitment links)
lib/validations.ts (mission_statement on updateSpaceSettingsSchema)
__tests__/governance.test.ts (4 new tests)
package.json (react-markdown, remark-gfm)
docs/HANDOFF.md (this entry)
How to verify locally
pnpm install # picks up react-markdown + remark-gfm
pnpm vitest run # 259 tests, 8 files
pnpm build # 31 routes, no middleware deprecation warning
In the browser:
- Visit
/profile, edit skills / willing_to (chip input), save. - Visit
/recruitmentas admin/board — see members grouped by willing_to. - Visit
/settings, scroll to "Visibility", change financial_visibility totreasurer_only, save, then visit/financialsas a plain member — should be blocked. Switch back toall_members_visible— visible again. - Set a mission statement on
/settings. It appears as a footer on every protected page. - Open a proposal, edit body to include
# heading\n\n- list\n- items\n\n[link](https://example.com). Renders rich.
2026-05-14 (pass 6) — Audit fixes + Tier 2/3 partial-ship
Branch: main
Author: Claude (no commits made).
Audit findings
Read my pass-5 work hard. Four real defects, plus several lower-severity items I documented but didn't change.
Critical (fixed in migration 017):
proposal_votes.votes_insertaccepted a vote wheremember_idwas aspace_membersrow from space A but the proposal was in space B, because the policy never joined them. Fixed: insert WITH CHECK now requiresm.space_id = p.space_idfor the joined member.votes_updatehad the same bug. Same fix.incidents.incidents_insertlet the reporter setreporter_idto anyspace_members.idthey owned — across spaces. Fixed: WITH CHECK requires the row'sspace_idmatch the incident's.incident_updates.incident_updates_insertdid not validateauthor_idat all. Fixed: WITH CHECK requires the author is the calling user and in the incident's space.
Plus: added partial unique index policies_one_active_per_slug ON policies (space_id, slug) WHERE status = 'active' so two admins concurrently activating different drafts cannot leave two active rows.
Lower-severity (documented, deferred):
requireMember().single()makes multi-space membership effectively impossible. The app design has always assumed one membership per user.appealIncidentdoes insert-then-update without a transaction; mid-flight failure leaves an orphaned proposal. Acceptable risk.supersedePolicyrace between two admins computingversion + 1simultaneously is caught by the existingUNIQUE (space_id, slug, version).- Reporter can post
visibility='board_only'on their own incident_update via raw API and then can't read it. Self-inflicted. /proposalsand/incidentslists don't paginate. Defer.- Incident detail never shows the reporter's name to admin/board. Future ergonomic improvement.
- Dashboard doesn't surface open proposals or pending incidents.
Built in this pass (audit fixes + Tier 2/3 schema + one Tier 2 UI)
- Migration 017
scripts/017_governance_rls_hardening.sql: the four RLS fixes + the partial unique index. Mirrored intoscripts/schema.sql. - Migration 018
scripts/018_member_state_and_visibility.sql:space_membersgainsskills,interests,willing_to,affiliations(alltext[]) andcoi_last_disclosed_at. GIN indexes onskillsandwilling_to.spacesgainsfinancial_visibilityandmember_directory_visibilityenums.knowledge_basegainsis_meeting_minutesandmeeting_date(partial index).schedule_card_reviewtrigger inserts a 180-day task wheneverhas_card_accessflips on.inactive_membersview: members in good standing with noactivity_logentry in 180 days.
- New server actions:
members.ts → updateMyProfile({...}): member self-edit of display_name, handle, phone, skills, interests, willing_to. The privilege-escalation trigger from migration 015 keeps role/tier/status off-limits.members.ts → discloseAffiliations({ affiliations }): stampscoi_last_disclosed_atand logs to activity_log.settings.ts → updateSpaceVisibility({...}): admin-only.
- Validation schemas:
updateMyProfileSchema,discloseAffiliationsSchema,updateSpaceVisibilitySchema,meetingMinutesSchema, plus exported tuples (financialVisibilities,directoryVisibilities). - 13 new tests in
__tests__/governance.test.ts. Total now 255 across 8 files. /financialsroute: server-component aggregations frompayments(this month, this year, lifetime, linked vs unlinked, by platform with sparkline-style bars, last 12 months). Gated byspace.financial_visibilityagainst caller's role.- Sidebar: added "Financials" under People with the
LineCharticon.
Verification
pnpm vitest run→ 255 tests pass across 8 files.pnpm build→ success. 29 routes emit (added/financials).pnpm exec tsc --noEmit→ same Supabase generic-drift count as before. No new error categories.
Deployment steps for this pass
Run on every existing database, in order:
\i scripts/017_governance_rls_hardening.sql
\i scripts/018_member_state_and_visibility.sql
Then restart PostgREST (docker compose exec rest restart on self-hosted; managed Supabase auto-refreshes).
Fresh deploys get both via scripts/schema.sql (sections 9.8 and 10). No additional config needed; new columns ship with sensible defaults.
Open items, priority order
- Apply 017 on production immediately if any chance of multi-space membership (admin client could have set it up).
- Apply 018 for the Tier 2/3 columns and the card-review trigger.
- Profile / COI / settings-visibility UI: server actions are in place, no UI yet. A
/profileroute + extending Settings would close Tier 3.1, 3.2 and the financial-visibility toggle. - Recruitment view: query
space_membersbywilling_tofor board-only succession planning. - Mark KB entry as meeting minutes from UI: schema ready, no UI control.
- Earlier-pass items still open: regenerate
types/database.ts, run migration 015 if not already, renamemiddleware.ts→proxy.tsfor Next.js 16.
Files changed this pass
Added (schema):
scripts/017_governance_rls_hardening.sql
scripts/018_member_state_and_visibility.sql
Added (UI):
app/(app)/financials/page.tsx
Edited:
scripts/schema.sql (sections 9.8 + 10)
scripts/README.md (017, 018 listed)
lib/types.ts (FinancialVisibility + DirectoryVisibility)
lib/validations.ts (4 new schemas + tuples)
lib/actions/members.ts (updateMyProfile, discloseAffiliations)
lib/actions/settings.ts (updateSpaceVisibility)
components/app-sidebar.tsx (Financials nav link)
__tests__/governance.test.ts (13 new tests)
docs/GOVERNANCE_FEATURES.md (Tier 2 + 3 marked partial-shipped)
docs/HANDOFF.md (this entry)
2026-05-14 (pass 5) — Tier 1 governance kernel shipped
Branch: main
Author: Claude (no commits made).
What I did
Built the full Tier 1 governance kernel from docs/GOVERNANCE_FEATURES.md. Three new tightly integrated modules: proposals, incidents, policies. Schema + server actions + validations + tests + UI + sidebar nav.
- Schema:
scripts/016_governance_kernel.sql(and mirrored intoscripts/schema.sqlas Section 9). 5 new tables (proposals,proposal_votes,incidents,incident_updates,policies), 8 new enums, 6 new columns onspacesfor governance defaults (quorum percent/floor, voting window, incident SLA, mission statement). Triggers:compute_proposal_quorum,refresh_proposal_tally,compute_incident_sla,touch_updated_at. Full RLS: any member proposes / files / reads; admin+board transition status; votes are immutable post-window; incidents readable only to reporter + admin/board. Idempotent re-run safe (DROP POLICY IF EXISTS,CREATE OR REPLACE,EXCEPTION WHEN duplicate_object). - Types in
lib/types.ts: hand-typedProposal,ProposalVote,Incident,IncidentUpdateRow,Policyand all 8 governance enums.types/database.tsshould be regenerated via Supabase CLI to fold these in. - Validations in
lib/validations.ts: 11 new Zod schemas (createProposalSchema,castVoteSchema,decideProposalSchema,withdrawProposalSchema,openProposalSchema,fileIncidentSchema,updateIncidentStatusSchema,addIncidentUpdateSchema,appealIncidentSchema,createPolicySchema,supersedePolicySchema,updatePolicyStatusSchema). Tested before being wired into actions (test-driven). - Server actions:
lib/actions/proposals.ts,lib/actions/incidents.ts,lib/actions/policies.ts. Use the existingrequireMember/requireMemberWithRole/parseInput/logActivityhelpers. Re-exported via the barrel inlib/actions/index.ts. Notable:fileIncidentgenerates an opaquereporter_tokenwhen anonymous;appealIncidentatomically spawns a draftmembership_voteproposal and flips the incident toappealed;supersedePolicyinserts a new version withprior_version_idset rather than mutating the prior row;updatePolicyStatus(... , 'active')atomically supersedes any prior active version with the same slug. - Tests:
__tests__/governance.test.tsadds 40 tests covering every new Zod schema (valid input, invalid enum values, required field enforcement, recusal-requires-reason cross-field validation, slug character set, etc). The cross-cutting "governance contract surface" suite enforces that the enum tuples we export match the DB enums in workflow order. - UI: server-component-driven, minimal client islands.
/proposals: open / draft / archive sections with live tally and quorum hints./proposals/[id]: title + body + status, tally panel with quorum progress, vote form (yes/no/abstain/recused-with-required-reason), management buttons (open / withdraw / decide) gated by role, vote list with comments and recusal reasons./proposals/new: form with type, threshold, "open immediately" checkbox./incidents: open / decided / closed sections. Members see only their own; admin/board sees all (RLS enforced)./incidents/[id]: title + body + disposition + update thread, status transition form (admin/board), post-update form (everyone with access), one-click "request membership appeal" for reporter on dismissed incidents./incidents/new: title, body, category, severity, optional subjects multi-select, anonymous checkbox with honest disclaimer; on anonymous submit, surfaces the opaque tracking token to the reporter once./policies: one row per slug showing the active (or latest) version./policies/[slug]: plain-language + formal text + version history, with "activate" / "supersede" / "deprecate" buttons for admins./policies/new: slug + title + section ref + plain + formal text. Admin-only route.
- Sidebar: new "Governance" section with three links (Proposals, Incidents, Policies). Lucide icons:
Vote,ShieldAlert,ScrollText. - Docs updated:
docs/GOVERNANCE_FEATURES.md: Tier 1 marked SHIPPED with implementation pointers.scripts/README.md: migration 016 listed.- This handoff entry.
Verification
pnpm vitest run→ 242 tests pass across 8 files (was 202 / 7). The 40 new tests are in__tests__/governance.test.ts.pnpm build→ success. 9 new routes emit cleanly (/proposals,/proposals/[id],/proposals/new,/incidents,/incidents/[id],/incidents/new,/policies,/policies/[slug],/policies/new). 28 routes total.pnpm exec tsc --noEmit→ 217 errors (was 157). All 60 new errors are the same Supabase generic-drift pattern (Property 'X' does not exist on type 'never') that the pre-existing 157 are. Runtime is unaffected. Regeneratingtypes/database.tswith the Supabase CLI clears them all.
Open items, priority order
- Apply
scripts/016_governance_kernel.sqlto any existing production database. Fresh deploys get it viascripts/schema.sqlautomatically. - Regenerate
types/database.tswithsupabase gen types typescript --project-id <ref>. Clears the 60 + 157 = 217 TS errors. Then removetypescript.ignoreBuildErrorsfromnext.config.mjs. - Pass 4 priority 1 still applies: run
scripts/015_prevent_member_self_role_change.sqlbefore external users sign in. - Auto-expiry of open proposals: when
voting_closes_at < now(), the status should flip fromopentoexpired(ordecidedif board acts). Currently manual. A cron-triggered server action or DB job can do this. - Markdown rendering: proposals' body is currently rendered as
<pre>whitespace-preserved text. Pulling inreact-markdownwould give links, lists, and the[policy:slug#section]citation syntax from the roadmap doc. Defer until adoption is real. - No notifications on incident status change. The activity_log row is inserted but no in-app comms message or email is sent. Add later when notification infra is ready.
- Rename
middleware.ts→proxy.tsfor Next.js 16 (still works, deprecation warning only).
Decisions pending
- Whether to back-port Tier 2 features (skill/interest fields, COI disclosure, mid-tenure card review trigger, financial dashboard visibility settings) in the next pass.
- Whether to add markdown rendering with citation hover-cards now or wait.
- Whether to add a worker / cron to auto-flip expired proposals.
Files changed this pass
Added (schema):
scripts/016_governance_kernel.sql
Added (lib):
lib/actions/proposals.ts
lib/actions/incidents.ts
lib/actions/policies.ts
Added (tests):
__tests__/governance.test.ts
Added (UI):
app/(app)/proposals/page.tsx
app/(app)/proposals/proposal-badges.tsx
app/(app)/proposals/new/page.tsx
app/(app)/proposals/new/new-proposal-form.tsx
app/(app)/proposals/[id]/page.tsx
app/(app)/proposals/[id]/proposal-actions.tsx
app/(app)/incidents/page.tsx
app/(app)/incidents/new/page.tsx
app/(app)/incidents/new/incident-form.tsx
app/(app)/incidents/[id]/page.tsx
app/(app)/incidents/[id]/incident-actions.tsx
app/(app)/policies/page.tsx
app/(app)/policies/[slug]/page.tsx
app/(app)/policies/[slug]/policy-actions.tsx
app/(app)/policies/new/page.tsx
app/(app)/policies/new/new-policy-form.tsx
Edited:
scripts/schema.sql (added Section 9: governance kernel)
scripts/README.md (listed migration 016)
lib/types.ts (governance entity types + enum unions)
lib/validations.ts (11 new Zod schemas + enum exports)
lib/actions/index.ts (re-export the three new modules)
components/app-sidebar.tsx (Governance section with 3 links)
docs/GOVERNANCE_FEATURES.md (Tier 1 marked SHIPPED)
docs/HANDOFF.md (this entry)
Deployment steps for this pass
On any existing database:
-- In Supabase SQL editor or psql:
\i scripts/016_governance_kernel.sql
For a fresh deploy, the canonical scripts/schema.sql already includes it (Section 9). No app-level config changes needed; the new spaces columns ship with sensible defaults (default_quorum_percent=10, default_quorum_floor=1, default_voting_window_hours=216 (9 days), default_threshold='simple_majority', incident_sla_hours=72).
After applying, restart PostgREST (docker compose restart rest on self-hosted) so the schema cache refreshes.
2026-05-14 (pass 4) — Deploy readiness + self-hosted DO guide
Branch: main
Author: Claude (no commits made).
What I did
- Verified deploy readiness end to end:
pnpm vitest run: 202 tests pass across 7 files.pnpm build: success. 19 routes emitted (including/api/health,/api/paypal/sync,/auth/callback,(landing) /, all(app)/*routes).pnpm exec tsc --noEmit: 157 errors, all pre-existing supabase-js generic drift, all masked bytypescript.ignoreBuildErrors: trueinnext.config.mjs. No new errors introduced.
- Fixed one self-inflicted test bug:
__tests__/auth-helpers.test.tswas written in pass 3 using the oldr.erroraccess pattern, which does not narrow now thatResult<T>is a tagged union (ok: true | false). Rewrote each test to gate onif (r.ok)/if (!r.ok)before readingdataorerror. - Surfaced two non-blocking build warnings that the user should know about:
- Next.js 16 deprecates the
middlewarefile convention in favour ofproxy. Build still works withmiddleware.ts; rename toproxy.tswhenever convenient. - Next.js detected a stray
/Users/obsidian/Projects/package-lock.jsonoutside the repo (workspace root inference). Local-only nuisance, does not affect CI or DO builds.
- Next.js 16 deprecates the
- Wrote
docs/DEPLOY_DO_SELFHOSTED.md: complete end-to-end guide for running the entire stack on one DigitalOcean Droplet with self-hosted open-source Supabase. 16 sections, ~700 lines, copy-pasteable commands throughout. Covers:- Architecture and cost (~$48/mo for 8 GB Droplet).
- Droplet provisioning, hardening, Docker/Nginx/Certbot install.
- DNS records for
yourdomain.com,supabase.yourdomain.com,studio.yourdomain.com. - Self-hosting Supabase: cloning
supabase/supabase, generatingJWT_SECRET/ANON_KEY/SERVICE_ROLE_KEYvia a Node one-liner, full.envtemplate, port-remap override (Studio defaults to 3000 and collides with the Next.js app; doc rebinds it to 3001 and forces all internal ports to127.0.0.1to keep them off the public internet). - Applying
scripts/schema.sql,014_*.sql,015_*.sqlviadocker compose exec db psql. - Building the app image with
NEXT_PUBLIC_*build args (otherwise the client bundle ships withundefinedfor them — common gotcha). - Nginx server blocks for app, Supabase API, and basic-auth-gated Studio.
- Let's Encrypt SSL covering all four hostnames.
- Post-deploy verification including a regression test for migration 015's privilege-escalation guard.
- Daily
pg_dumpbackups with 14-day local rotation and an off-site path to DO Spaces. - Operations: app upgrades, incremental schema migrations (with
docker compose restart restto refresh PostgREST's schema cache), Supabase version upgrades. - Hardening checklist (14 items) and a troubleshooting section covering the eight problems people actually hit.
- Linked the new doc from
README.md(deployment table + body section) and from the top ofdocs/DEPLOYMENT.md. Made it explicit thatDEPLOYMENT.mdcovers managed-Supabase paths andDEPLOY_DO_SELFHOSTED.mdcovers full self-hosting.
Deployment quick reference (so the next session does not have to re-read everything)
| Path | When to use | Guide |
|---|---|---|
| Vercel + managed Supabase | Fastest path, no infra to manage | docs/DEPLOYMENT.md section 4 |
| DO App Platform + managed Supabase | Want DO but not infra | docs/DEPLOYMENT.md section 5, spec at .do/app.yaml |
| DO Droplet + managed Supabase | Container-only on your VPS, Supabase Cloud for DB | docs/DEPLOYMENT.md section 6 |
| DO Droplet + self-hosted Supabase | Full data ownership, single Droplet | docs/DEPLOY_DO_SELFHOSTED.md |
Migrations to run on every fresh deploy, in order:
scripts/schema.sql(canonical, idempotent)scripts/014_member_user_id_nullable.sqlscripts/015_prevent_member_self_role_change.sql(security; do not skip)
Open items, priority order
- Run migration 015 on any live database before external users sign in. Without it, any authenticated member can
UPDATE space_members SET role = 'admin'on their own row via direct PostgREST. - Rename
middleware.tstoproxy.tsto clear the Next.js 16 deprecation. Single-file rename; the export and matcher contract is unchanged. - Regenerate
types/database.tsviasupabase gen types typescript --project-id <ref>(managed) or against the self-hosted Postgres. Clears the 157 TS errors. Then droptypescript.ignoreBuildErrors: truefromnext.config.mjsso future type drift surfaces in CI. - Wire OAuth providers in Supabase dashboard (GitHub, Google). The client code already calls
supabase.auth.signInWithOAuth; only the provider configuration is missing. - Encrypt
secrets.valueandintegrations.configbefore storing real production credentials. - Stale docs:
DB_SCHEMA_MAP.mdanddocs/ARCHITECTURE.mdstill claim 4 default channels. Schema creates 3 (general,announcements,ops). signUptrigger metadata mismatch inscripts/schema.sql(handle_space_signupreadsspace_idbut the app never passes it). Benign; trigger short-circuits. Either remove the trigger or rewire it.
Decisions pending from the user
- Whether to rename
middleware.ts→proxy.tsnow or wait for a future Next.js major version that removes the alias. - Whether to regenerate
types/database.tsagainst managed Supabase (supabase gen typesrequires the project ref) or wait until self-hosting is up and run it against that. - Whether to commit the pass 1–4 changes as one large commit, four sequenced commits matching the pass boundaries, or branch-and-PR per concern.
How to verify locally
pnpm install
pnpm vitest run # 202 tests, 7 files
pnpm build # success in ~3s; warnings about middleware/lockfile are non-blocking
pnpm exec tsc --noEmit 2>&1 | grep -v never | wc -l # remaining non-Supabase errors should be 0
Files changed this pass
Added:
docs/DEPLOY_DO_SELFHOSTED.md
Edited:
__tests__/auth-helpers.test.ts
README.md
docs/DEPLOYMENT.md
docs/HANDOFF.md
No application code, no schema, no UI, no business rules were changed in pass 4. The pass added a major deployment guide and confirmed the work from passes 1–3 produces a clean build and a green test suite.
2026-05-14 (pass 3) — Architecture refactor + real tests
Branch: main
Author: Claude (no commits made).
What I did
- Split
lib/actions.ts(914 lines, 29 functions) into 10 domain files underlib/actions/. Each file owns one entity:tasks.ts,projects.ts,members.ts,contacts.ts,payments.ts,knowledge-base.ts,secrets.ts,area-leads.ts,settings.ts,imports.tslib/actions/index.tsre-exports everything viaexport * from './foo'- Every client import
from '@/lib/actions'still resolves correctly.
- Created
lib/permissions.ts: role constants (ROLES,ADMIN_ROLES,TREASURER_ROLES,ACTIVE_STATUSES),Roletype alias,hasRole(role, allowed)helper. Removes magic-string role chains. - Created
lib/auth-helpers.ts:getAuthMember,requireMember,requireMemberWithRole,parseInput,logActivity. Result types use the canonicalok: booleandiscriminator so TypeScript narrows onif (!r.ok)reliably. - Wired validation into every server action that has a schema (was previously only
createTaskandclaimTask). 18 more actions now reject bad input at the boundary instead of hoping the database rejects it. - Expanded
lib/types.tswith re-exports forRole,Member,MemberSummary,Result,MemberResult,ActionResult, and the role/permission constants. One-stop import for typed work. - Deleted dead code:
app/auth/login/page.tsx(duplicate login linking to nowhere),components/app-shell.tsx(unused, hardcoded badge counts). - Wrote real tests: 76 new tests across four files exercise real project code instead of inline assertions.
__tests__/validations.test.ts: 37 tests verifying every Zod schema accepts real DB enums and rejects legacy ones.__tests__/permissions.test.ts: 10 tests for role constants andhasRole.__tests__/security.test.ts: 25 tests forsanitize*,escapeHtml,checkRateLimit,isValidUuid.__tests__/auth-helpers.test.ts: 4 tests forparseInput.- Full suite: 202 tests, 7 files, all passing.
- Fixed vitest config:
vitest.setup.tshad inline JSX in a.tsfile (esbuild refuses). Replaced withReact.createElement.vitest.config.tswas picking up Playwright e2e specs. Addedinclude: ['__tests__/**/*.{test,spec}.{ts,tsx}']andexclude: ['e2e/**'].
- Added
scripts/README.mddocumenting the migration convention (canonicalschema.sql+ numbered incremental files, idempotency rules, security baseline, verification queries).
Open items, priority order
- TypeScript drift between
@supabase/supabase-jsgenerics and ourDatabasetype: 38 errors inlib/actions/*of the form'X' is not assignable to parameter of type 'never'. These existed in the originallib/actions.tstoo (the build masks them vianext.config.mjs: typescript.ignoreBuildErrors: true). Fix is to regeneratetypes/database.tswith the latest supabase CLI so the generatedDatabaseshape matches what@supabase/ssrexpects. - 103 app/ TypeScript errors in client components (
comms-client.tsx,members-client.tsx, etc). Pre-existing v0 codegen output; same root cause as item 1. Untouched in this pass to avoid UI regressions. getCurrentMemberwas removed fromlib/actions.tswhen I split it; no caller referenced it. If anything starts to need it, define a server action inlib/actions/index.tsor usegetAuthMemberfromlib/auth-helpers.ts.- Pass 2 priority 1 (run
scripts/015_*.sqlon production) is still outstanding until the user runs it. signUptrigger metadata mismatch remains documented but unfixed.- Stale docs:
DB_SCHEMA_MAP.mdanddocs/ARCHITECTURE.mdsay 4 default channels. Schema creates 3.
Decisions pending
- Whether to regenerate
types/database.tsvia the Supabase CLI to clear the remaining 141 TS errors. - Whether to also run
next.config.mjswithouttypescript.ignoreBuildErrors: trueonce types are clean. - Whether to add CI to run
pnpm vitestandpnpm exec tsc --noEmiton every push.
Files changed this pass
Added:
lib/permissions.ts
lib/auth-helpers.ts
lib/actions/tasks.ts
lib/actions/projects.ts
lib/actions/members.ts
lib/actions/contacts.ts
lib/actions/payments.ts
lib/actions/knowledge-base.ts
lib/actions/secrets.ts
lib/actions/area-leads.ts
lib/actions/settings.ts
lib/actions/imports.ts
lib/actions/index.ts
__tests__/validations.test.ts
__tests__/permissions.test.ts
__tests__/security.test.ts
__tests__/auth-helpers.test.ts
scripts/README.md
Edited:
lib/types.ts
vitest.config.ts
vitest.setup.ts
Removed:
lib/actions.ts (replaced by lib/actions/ directory)
app/auth/login/page.tsx (dead duplicate)
components/app-shell.tsx (dead, unused)
How to verify locally
pnpm install
pnpm vitest run
# expect: 202 tests passing across 7 files
2026-05-13 (pass 2) — Application repairs
Branch: main
Author: Claude (no commits made).
What I did
- Fixed dashboard "Quick Chores" bug (
app/(app)/dashboard/page.tsx:49). The filter was.neq('status','done')butcompleteTask()writes'completed'. Completed tasks were leaking into the open list. Replaced with a positive filter on the open statuses. - Fixed signup copy: said default channels were "general, announcements, random". Schema creates "general, announcements, ops". Aligned.
- Fixed
lib/validations.tsenum drift: nine schemas had values that did not match the database. Now aligned: task type and recurrence, project status, member role, contact type, KB visibility, cash payment shape, settings, area lead. - Wired validation into high-risk server actions:
createProject,updateProjectStatus,addMember,logCashPayment,linkPaymentToMember. Other actions still pass rawformData; documented indocs/AUDIT.mdsection 4.4. - Patched privilege escalation in
space_members: added aBEFORE UPDATEtrigger (prevent_member_self_role_change) that rejects self-updates ofrole,tier,status,approved,has_card_access,space_idunless the user is already admin/board/treasurer in that space. Applied toschema.sql. Shipped as incremental migrationscripts/015_prevent_member_self_role_change.sqlfor existing databases. Run this on any production database immediately. - Removed duplicate auth functions in
lib/actions.ts(signIn,signOut,getUser). Canonical versions live inlib/auth-actions.ts. Verified no caller imports them fromactions.ts. - Deleted dead scripts:
fix-actions.js,fix-sidebar.js,fix-sidebar.mjs,patch-dashboard.mjs. All had hardcoded/vercel/share/v0-projectpaths. - Synced
types/database.tsforspace_members.user_idtostring | nullmatching the schema fix from pass 1.
Open items, priority order
- Run
scripts/015_prevent_member_self_role_change.sqlon production before any external user can authenticate. Until then, any member can self-promote to admin via direct PostgREST. app/auth/login/page.tsxis a dead duplicate login page linking to non-existent/auth/onboarding. Delete the directory.components/app-shell.tsxis dead (no importer). Delete.- Wire validation into remaining actions listed in
docs/AUDIT.md4.4 (updateMember, contacts, KB, secrets, area lead, settings, integrations, imports). - Secrets at rest: still plain text.
signUptrigger metadata mismatch is benign (trigger short-circuits) but should be removed or re-aligned. Seedocs/AUDIT.md4.5.- Stale docs:
DB_SCHEMA_MAP.mdanddocs/ARCHITECTURE.mdstill say 4 default channels. Schema creates 3.
Decisions pending
- Whether to wire validation into the remaining actions in the next session.
- Whether to delete
app/auth/login/andcomponents/app-shell.tsxnow. - Whether to regenerate
types/database.tsproperly viasupabase gen typesinstead of the manual edit applied here.
How to verify the security fix
-- Connect to your Supabase project's SQL editor as a regular user (NOT service role):
-- The following should now fail with "Members cannot change their own role..."
UPDATE public.space_members SET role = 'admin' WHERE user_id = auth.uid();
Files changed this pass
Edited:
app/(app)/dashboard/page.tsx
app/signup/page.tsx
lib/validations.ts
lib/actions.ts
scripts/schema.sql
types/database.ts
docs/AUDIT.md
docs/HANDOFF.md
Added:
scripts/015_prevent_member_self_role_change.sql
Removed:
scripts/fix-actions.js
scripts/fix-sidebar.js
scripts/fix-sidebar.mjs
scripts/patch-dashboard.mjs
2026-05-13 (pass 1) — Deep audit + fresh-deploy hardening
Branch: main
Author: Claude (per CLAUDE.md, no commits made this session).
What I did
- Wrote
CLAUDE.mdat the repo root. It is the standing brief: no Claude attribution in commits, no emojis, no em dashes, no AI tells, test-driven where practical, always document, handoff after long sessions, smart separation of concerns. - Audited every source file. Findings in
docs/AUDIT.md. - Fixed three idempotency / fresh-deploy issues in
scripts/schema.sql:space_members.user_idis now nullable (offline members work).- Realtime
ALTER PUBLICATIONwrapped inDO ... EXCEPTIONblocks. - All RLS policies preceded by
DROP POLICY IF EXISTS.
- Added
scripts/014_member_user_id_nullable.sqlfor upgrading existing databases. - Added every missing deployment artifact:
Dockerfile,.dockerignore,docker-compose.yml,.do/app.yaml,.env.example,app/api/health/route.ts. - Updated
middleware.tsto let/api/healththrough without auth. - Updated
docs/DEPLOYMENT.mdto reference bundled artifacts instead of asking the user to write their own.
What I did not touch
- Application logic (
lib/actions.ts,lib/auth-actions.ts, all*-client.tsx). - UI components.
- Tests.
- Validation schemas (they are wrong in places, see audit section 4.1, but fixing them is a behavior change).
Open items, in priority order
- Privilege escalation on
space_members.role(audit 4.11). A self-update via themembers_updateRLS policy lets a member rewrite their own row, includingrole. Fix before production. - Validation schema enum mismatches (audit 4.1). Six schemas in
lib/validations.tsreject valid database values. Wire schemas into actions and align the enums. - Dead code: four
scripts/fix-*.{js,mjs}files with hardcoded/vercel/sharepaths, andcomponents/app-shell.tsxwhich no page imports. Delete on next cleanup. - Duplicate auth functions:
lib/actions.tslines 30 to 47 redeclaresignIn,signOut,getUserthat already live inlib/auth-actions.ts. Consolidate. - Secrets at rest:
secrets.valueandintegrations.configare plain text. Encrypt before production. - UI-only features: OAuth buttons, CSV import processor, webhook endpoint, payment platform syncs (except PayPal), email notifications. See audit 4.7.
package.jsonname is"my-project". Cosmetic.- Older docs reference 4 default chat channels; schema creates 3 (
general,announcements,ops).DB_SCHEMA_MAP.mdanddocs/ARCHITECTURE.mdare stale on this point.
Decisions pending from the user
- Whether to delete dead scripts now (low risk) or in a separate change.
- Whether to apply
scripts/014_member_user_id_nullable.sqlagainst the live database (production may already have user_id nullable perDB_SCHEMA_MAP.md, this migration is idempotent). - Whether to wire validation schemas into existing server actions in the next session.
How to verify the fresh-deploy fixes locally
# 1. Spin up a fresh Supabase project at supabase.com
# 2. In Supabase SQL Editor, paste scripts/schema.sql and run it. Should report no errors.
# 3. Re-run scripts/schema.sql. Should still report no errors (idempotent).
# 4. Copy .env.example to .env.local, fill in Supabase URL + anon + service role.
pnpm install
pnpm dev
# 5. Visit http://localhost:3000, sign up, create a space, confirm /dashboard renders.
# 6. curl http://localhost:3000/api/health -> {"status":"ok"}
How to verify the Docker build
# Requires .env.local populated.
docker compose build
docker compose up -d
curl http://localhost:3000/api/health
docker compose logs -f app
Files changed this session
Edited:
middleware.ts
scripts/schema.sql
docs/DEPLOYMENT.md
Added:
CLAUDE.md
Dockerfile
.dockerignore
docker-compose.yml
.do/app.yaml
.env.example
app/api/health/route.ts
scripts/014_member_user_id_nullable.sql
docs/AUDIT.md
docs/HANDOFF.md
No git commits were made. The working tree carries all changes; the user decides what to commit and when.