Docs: API_REFERENCE + ARCHITECTURE updated to the owner-chosen email-match association model (incl. anonymous, tradeoff documented) and listMemberSubmissions; HANDOFF pass-32; corrected pass-31 header. Landing: tightened the <=600px breakpoint (nav/section/card padding) and added a <=400px block so the nav fits ~320px without overflow. CSS-only inside media queries; desktop unchanged.
138 KiB
Handoff Log
Append-only. Newest entries on top. Keep each entry to one screen.
2026-05-17 (pass 32) — Forms↔member email association + landing mobile; LOCAL, awaiting deploy
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
- LOCAL & undeployed: pass-32 (6 commits incl migration 039). Suite 464, build clean each phase. Awaiting deploy approval (migration 039 = data backfill; safe/idempotent). After push: confirm 039 ran (submissions linked), exercise public form submit by a member's email → appears under member's FORMS panel; eyeball the landing page at 320/375/414px widths. 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
[email protected]via 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.