Nullable FK classes.required_form_id -> forms(id) ON DELETE SET NULL.
Mirrored in schema.sql, types, DATABASE_SCHEMA, DB_SCHEMA_MAP. Additive
nullable column on the already-policied classes table (no RLS change);
the signup gate is enforced in the app, mirroring the equipment cert-gate.
selfEntry({connectionId}): any active member with >=1 active card on file
may trigger a momentary open on a connection that is enabled and has
allow_member_self_entry on (locked rule: any active card, no slot row
required). Membership/cards resolved server-side so a member can only open
for themselves; never unlock/lock/grant/revoke; never anonymous. Strict
5/min per-member rate limit, one redacted self_entry audit row (denials
audited too), through the existing hardened executor. listSelfEntryDoors
returns the enabled self-entry connections only when the caller has an
active card, so the dashboard 'Door access' panel is hidden for
ineligible members. API_REFERENCE + ARCHITECTURE + HANDOFF pass-28.
listDoorCards (door.operate, service client, masked last4 only) lists
active cards with the slot each holds on a connection. /door/manage now
passes canOperate (door.operate rpc) and, when set, shows per-connection
Open/Unlock/Lock and an expandable card list with Grant/Revoke. Every
live action is behind a confirm; grant shows the allocated slot.
door.operate, rate-limited, every attempt writes one redacted
door_access_log row. Operators have no RLS read on door_connections/
member_cards so reads use the service client after the permission check,
space-scoped. grantCard reserves the slot in the DB first (unique
constraints arbitrate concurrency), calls the controller, rolls the
reservation back on failure; idempotent re-grant; slot_exhausted when
full. revokeCard idempotent; frees the slot only on confirmed controller
success so the app map never diverges from the device. doorControl
open/unlock/lock via the single hardened executor. API_REFERENCE.
pickLowestFreeSlot fills gaps before extending (deterministic, compact,
predictable for admins reading the controller). Range is a parameter
(HeatSync passes 0-200) so it stays adapter-generic. Ignores duplicate/
out-of-range/non-integer used slots; reports slot_exhausted when full.
slotCapacity for 'controller full N/N' messaging. 8 unit tests.
The app is cloud-hosted, so a connection's target is not assumed to be
LAN-only: any pinned host (public controller/proxy or VPN-reachable LAN
device) is allowed except cloud-metadata/link-local. KNOWN_DOOR_
CONTROLLERS surfaces the controller type with source links (HeatSync/23b
native -> the heatsynclabs + zyphlar repos; otherwise generic HTTP).
The generic adapter form now explains the request model and every
{slot}/{tag}/{perm}/{door}/{pw} placeholder. Copy and the migration
comment corrected away from "LAN-only".
Landing page modules and README features now reflect forms/waivers,
certifications, classes, equipment, access control, onboarding/invites,
the permissions matrix, markdown ops docs, and anonymous incident
tracking, alongside the original members/tasks/projects/payments/
governance/comms.
Migration 035 adds door_connections (per-space controller integration:
secret_ref -> AES-256-GCM secrets vault, never a plaintext password;
pinned_host SSRF pin; native_heatsync|generic_http adapter;
allow_member_self_entry opt-in for phase 3) and door_access_log
(append-only, secrets redacted, service-client-only writes; immutable).
RLS additive/default-deny: connections CRUD = door.manage; log SELECT =
door.manage/operate.
Safety core is pure and heavily unit-tested (door-logic, suite 409):
validateDoorTarget (request host must equal the pinned host;
metadata/link-local always blocked; http(s) only), the
firmware-verified HeatSync encoders with fixed-width zero-padding
(slot 3 / perm 3 / tag 8 / 4-char password), generic template
substitution, and redactDoorSecrets. lib/door/executor.ts is the single
hardened egress: re-validates the pin, refuses redirects, caps time and
body, redacts before any audit write. The door password is read from the
secrets vault and decrypted server-side only, never returned or logged.
/door/manage (door.manage) configures connections, picks a vault secret,
and runs a safe status-only test. Sidebar Admin link added; "Learn"
moved before "People" per request. Docs updated.
Sidebar is now grouped into collapsible sections (Workspace,
Governance, People, Finance, Learn, Account, Admin). Finance splits
payments/financials into their own category; Learn groups
classes/equipment/forms. Each section header toggles open/closed and the
state persists per category in localStorage (defaults open so nothing is
hidden on first visit). Admin section stays admin/board-gated.
Migration 034 adds member_cards (RFID/NFC UID -> member). The card UID
is treated as a credential: door.manage-only RLS with no member SELECT
policy; the member's own "My access cards" view goes through a service
-client action that returns only count + last4 (never the raw UID).
New door.manage / door.operate permissions (group Access), seeded to
board and backfilled, introduced now for the whole Door epic. Pure
door-logic masking is unit-tested. A per-member "Cards" panel on
/members is gated to door.manage (independent of admin). No controller
calls and no anonymous path in this phase. Mirrored in schema.sql; types
and schema docs updated.
/me gains a "My equipment reservations" section (the member's own
reservations with status and window). ARCHITECTURE documents the
equipment module; HANDOFF pass-23 records Classes deployed and the
code-complete-but-local equipment module.
/equipment lists active equipment for any member with status and the
required-certification gate shown up front (Reserve is disabled when the
item is unavailable or the member is not certified). Reserving opens an
inline start/end/notes form; the action enforces eligibility and
no-overlap server-side. Sidebar gets a member-visible "Equipment" link.
/equipment/manage (guarded by equipment.manage) lists equipment with
inline create/edit, status (available/maintenance/retired), an optional
required-certification picker, asset tag, archive/restore and delete
(refused once reservations exist). Sidebar gets a "Manage equipment"
link in the Admin block. Member browse/reserve is the next phase.
equipment.ts: registry CRUD (equipment.manage; delete refused once
reservations exist, archive instead), listEquipmentForMembers (active
items with a member_certified flag computed via the service client),
reserveEquipment / cancelReservation as validated service-client writes
that enforce status, no-overlap and the required-certification gate with
an equipment.manage override (and manager booking on a member's behalf),
listEquipmentReservations and getMyReservations. equipment-guard mirrors
the other module guards. Barrel + API_REFERENCE updated.
Migration 033 adds equipment (registry; status, optional
required_certification_id, is_active archive) and equipment_reservations
(member time-window reservation; denormalized space_id). RLS is additive
and default-deny: equipment readable by managers (all) or members
(active only), reservations by managers (all) or the member (own) with
manager-only UPDATE and no INSERT/DELETE policy so reserve/cancel
funnels through a validated service-client action (status, no-overlap
and required-certification enforced there, with manager override). New
equipment.manage permission (group Equipment) seeded to board and
backfilled. Mirrored idempotently in schema.sql; types and schema docs
updated.
/me gains a "My classes" section (the member's own signups with status
and attendance). ARCHITECTURE documents the classes module; HANDOFF
pass-22 records the code-complete-but-local module and the reaffirmed
roadmap.
/classes lists upcoming sessions for any member with spots-left,
sign-up / join-waitlist and cancel (waitlist auto-promotes on cancel via
the action). Holders of classes.instruct/manage get an inline attendees
panel to mark attendance and complete the session; completion issues the
class's certification through the normal grant path and the UI reports
when certificates were skipped (no certifications.grant). Sidebar gets a
member-visible "Classes" link.
/classes/manage (guarded by classes.manage) lists classes with inline
create/edit, archive/restore, delete (refused once sessions exist), an
optional grants-certification picker and a generic payment link, plus
per-class session scheduling with cancel/delete. Sidebar gets a "Manage
classes" link in the Admin block. The member-facing catalog/signup is
the next phase.
classes.ts: class/session CRUD (classes.manage; delete refused once
sessions/signups exist, archive instead), member signUpForClass /
cancelMySignup as validated service-client writes enforcing eligibility,
capacity vs waitlist, de-dupe and earliest-waitlist promotion on cancel,
listUpcomingSessions (service-client counts so members see spots-left
without seeing who signed up), instructor markAttendance and
completeSession. completeSession awards a class's certification via the
normal grantCertification path, so it only issues when the acting
instructor also holds certifications.grant; otherwise completion still
succeeds and reports certificatesSkipped. classes-guard mirrors the
forms/certifications guards. Barrel + API_REFERENCE updated.
classes-logic.ts: effectiveCapacity (session override > class > unlimited),
computeSignupStatus (registered vs waitlisted), sessionTiming, canSignUp
eligibility, pickPromotion (earliest waitlisted when a seat frees).
Dependency-free, 10 unit tests (suite 372 -> 382).
validations.ts: class/session/signup/attendance schemas. payment_link is
a generic http(s) link only (empty -> null); capacity is a positive int;
no live payment integration.
Migration 032 adds classes (offering; optional payment_link and
grants_certification_id, is_active archive), class_sessions (scheduled
occurrence; denormalized space_id) and class_signups (member signup;
partial unique = one non-cancelled signup per member+session). RLS is
additive and default-deny: classes readable by managers (all) or members
(active only), class_sessions by any space member, class_signups by
managers/instructors (all) or the member (own) with instructor-only
UPDATE and no INSERT/DELETE policy so signup/cancel funnels through a
validated service-client action (capacity/waitlist/dedupe). New
classes.manage + classes.instruct permissions (group Classes) seeded to
board and backfilled. Mirrored idempotently in schema.sql; types and
schema docs updated.
KB/process content was shown via a bare unsanitized ReactMarkdown in the
ops view dialog (the /ops/[id] route is the editor, not a view). Route
it through the shared SafeMarkdown instead. New dependency-free
lib/markdown-anchors.ts (heading-text flatten, GitHub-ish slug,
per-render dedupe, in-doc href test; unit-tested) is wired into both
SafeMarkdown and MarkdownBody so headings get stable ids and a
[link](#heading) resolves. In-document links no longer force a new tab,
which previously broke the anchor jump for policies/proposals too.
rehype-slug was not added (no offline install); the helper is equivalent
and tested.
A regular member granted reveal access either via the ops.secrets.read
role permission or a per-secret ops_acl entry could not actually see or
reveal secrets: revealSecret hard-gated on built-in role admin/board
(ignoring both the permission and the ACL), and secrets_select RLS never
consulted the permission, and the Ops page short-circuited the secrets
fetch by built-in role.
Migration 031 additively adds an OR user_has_permission(...,
'ops.secrets.read') branch to secrets_select; the admin/board and
per-secret ops_acl branches are unchanged so deployment is access-neutral
unless an admin has granted the permission. revealSecret now resolves
the member and lets RLS be the boundary (a denied row simply does not
return). The Ops page always queries secrets (RLS filters the set) and
shows the section to admin/board, a permission holder, or anyone with an
ACL-granted secret. Write paths (create/update/delete) are unchanged.
Mirrored in schema.sql; migration docs and HANDOFF updated.
/me shows the signed-in member their own certifications (status badges
from the shared logic) and a read-only view of their effective
permissions. Sidebar gets a "My access" link. ARCHITECTURE documents the
module; HANDOFF pass-21 records the UX fixes (deployed) and the
code-complete-but-local certifications module.
A "Certs" column appears for anyone holding certifications.grant (the
Instructor capability), independent of admin/board, so a non-admin
instructor can reach it. The button opens a self-contained dialog that
loads the member's grants and the active cert catalog, and supports
award (with optional note + expiry override), soft revoke, and renew.
Status badges come from the shared certifications-logic. The members
server page resolves certifications.grant via the RPC and passes it in.
/certifications (guarded by certifications.manage) lists cert types with
inline create/edit, archive/restore (is_active), and delete (refused
server-side once granted). Sidebar gets a Certifications link in the
Admin block next to Forms & waivers. Awarding to members is a separate
permission handled from the Members page (next phase).
certifications.ts: create/update/delete/list cert types (gated by
certifications.manage; delete refused once granted, archive instead),
and grant/revoke/renew/listMemberCertifications/getMyCertifications
(gated by certifications.grant, the Instructor capability). Grant
verifies cert and target member are in-space and the cert is active,
computes expiry from validity_months unless overridden, and relies on
the partial unique index for one active grant per member+cert. Revoke
is soft; nothing is ever deleted. getMyCertifications takes no params
(member resolved from session; RLS restricts to own rows).
certifications-guard.ts mirrors the forms guard for the admin pages.
Barrel re-export and API_REFERENCE updated.
certifications-logic.ts: computeExpiry (month math with end-of-month
clamping and year rollover), isCertificationActive, certificationStatus
(revoked > expired > expiring_soon > active). Dependency-free and
unit-tested (16 cases incl. Jan-31 and leap-year boundaries).
validations.ts: create/update/grant/revoke/renew + id/list schemas.
grant's expires_at is an optional override (empty -> null); validity is
a positive integer count of months.
Migration 030 adds certifications (per-space cert types, optional
validity_months, is_active archive) and member_certifications (per-member
grants; expires_at snapshotted at grant time; soft revoke; partial unique
on one active grant per member+cert). RLS is additive and default-deny:
cert types readable by any space member, write-gated by
certifications.manage; grants readable by managers/granters (all) or the
member (own), written by certifications.grant, with no DELETE policy so
the history is immutable. No anonymous path.
Two new permission codes in the catalog (group Certifications):
certifications.manage and certifications.grant (the Instructor
capability, assignable to any role via the existing additive model).
Seeded to board and backfilled. Mirrored idempotently in schema.sql;
types and schema docs updated.
The first-run "Invite or add members" step links to /members, but
invite creation only lived in Customize, so the page dead-ended. Reuse
the existing InvitesPanel: the members server page loads invites + space
slug for admin/board and composes the panel into MembersClient via an
inviteSlot prop (MembersClient stays unaware of invites). First-run copy
updated to match.
GitHub/Google buttons on the login page pointed at providers that may
not be configured in Supabase, dead-ending in an error. Add
NEXT_PUBLIC_OAUTH_GITHUB / NEXT_PUBLIC_OAUTH_GOOGLE (opt-in, "true" to
show); a provider button renders only when enabled and the whole social
block is hidden when neither is. Pure resolveOAuthProviders helper is
unit-tested; flags documented in .env.example and DEPLOYMENT.md.
Gap: fileIncident minted a reporter_token and the filing UI showed it
("the only way to look it up later") but there was no lookup UI.
- lib/incident-logic.ts (pure, tested): publicIncidentView redacts —
board_only updates, subjects, decision-maker ids dropped; disposition
only surfaced once decided/closed; status label mapping
- trackIncident: public service-client action (token is a 192-bit
bearer credential on a UNIQUE column; anon has no RLS path so the
service client is used AFTER token validation; generic not-found is
enumeration-safe; board_only filtered at the query AND in logic)
- public /track page + client (also accepts ?token= deep link);
/track added to middleware PUBLIC_ROUTES
- filing UI now deep-links /track?token=<code>
- trackIncidentSchema; __tests__/incident-tracking.test.ts (6)
- docs: API_REFERENCE + HANDOFF pass-20 (session arc, web-verified
integration facts, locked forward roadmap, open items incl. the
still-open production incident-INSERT RLS bug)
343 tests; build verified (Compiled successfully, exit 0).
- shared components/forms/copy-link-button.tsx (one control, no drift):
copies the absolute /f/<space>/<slug> URL, check-mark + toast feedback
- admin /forms list: Copy link button on every published public form
- member /my-forms list: shows the /f/<space>/<slug> path and a Copy
button for public forms; row restructured so the button is not nested
in the row link
- members-only forms show no public link (correct: they have none)
337 tests; build verified. Local commit (deploy on your word).
- builder: draft/published/closed status banners; creating a form now
lands on its edit page so Publish is immediately visible; a
members-only form shows a "no public link" note instead of a dead URL
- member-facing forms: /my-forms list + /my-forms/[id] fill
(MemberFillClient), "Forms" sidebar item for all members. Fixes the
long-standing gap where members-only forms had no fill surface
- starter templates: lib/form-templates.ts + a picker on /forms/new
(Liability waiver, Membership application, Event RSVP, Photo release,
Blank) that seeds builder state (no table)
- forms-client relabels members-only forms (was showing a misleading
/f/<space>/<slug> that always 404s for members-only)
- docs corrected per the audit pass: DATABASE_SCHEMA / DB_SCHEMA_MAP /
ARCHITECTURE / API_REFERENCE now reflect migrations 014-029, per-space
slug, /f/[space]/[slug], getPublicForm({space,slug}), invite roles;
HANDOFF pass-19 added with residual gaps
337 tests; build verified (Compiled successfully, exit 0). No deploy.
Invites
- migration 029: space_invites.role (member_role enum, default
'member' — legacy invites and the spaces.invite_code path keep
granting member). schema.sql + types/database.ts mirrored
- lib/invite-logic.ts (pure, tested): canAssignInviteRole — admin may
grant any role; board may grant anything except admin; nobody else.
Privilege-escalation guard on top of the existing admin/board gate
- createInvite/updateInvite take + enforce role via that guard;
createInviteSchema/updateInviteSchema validate the enum
- joinSpace applies the invite's granted role (validated), default
member; uses_count/max_uses/expiry already enforced
- invites panel: role picker (filtered to what the creator may grant),
one-click "single use", per-invite role badge
- space-scoped invite link /join/<space>?code=… via a presentational
app/join/[space] landing that hands off to the proven
/signup?invite= flow; /join added to middleware PUBLIC_ROUTES
Audit hardening (from this pass's review)
- /login now honors a validated same-origin ?next= (was hardcoded to
/dashboard) so the public_auth form sign-in round-trips; OAuth
threads next through the callback (which already honored it)
- submitForm requires formId; submitFormSchema drops the now-ambiguous
slug path (form slugs are per-space after 028). Both callers already
pass formId. Tests updated
337 tests; build verified (Compiled successfully, exit 0). No deploy.
- migration 028: drop global UNIQUE(forms.slug), add
UNIQUE(space_id, slug); schema.sql mirrored. Form slugs are now
unique per space (fixes cross-tenant slug squatting)
- getPublicForm now takes { space, slug }: resolves the space by its
slug then the form by space_id + slug (maybeSingle)
- route moved app/f/[slug] -> app/f/[space]/[slug]; the public client
submits by formId (slug is no longer globally unambiguous) and the
sign-in link round-trips the full /f/space/slug path
- forms-guard returns the space slug; the builder + forms list show
the full /f/<space>/<slug> share URL; new/edit pass it through
- middleware /f allowlist already covers /f/<space>/<slug>
- getPublicFormSchema + tests updated (333 pass)
build verified (Compiled successfully, exit 0; new route present). No deploy.
The middleware PUBLIC_ROUTES allowlist did not include the public
form/waiver fill route, so unauthenticated visitors hitting
/f/<slug> were redirected to /login — making the entire public/
anonymous forms feature unreachable in production. Add '/f' to the
allowlist; only /f/[slug] lives under it (the /forms* management
routes do not match the '/f/' prefix and stay auth-gated). The page
already serves only published public forms and submitForm enforces
visibility, so this exposes nothing new.
The forms security/correctness logic was buried in DB-coupled server
actions and the builder component, so it had no real tests (the suite
is pure-logic only; nothing exercises Supabase). Extract the pure
parts behaviour-unchanged into lib/forms-logic.ts and test them for
real:
- csvCell (results-export escaping), parseClientIp (e-sign IP capture
filtering), shouldBumpFormVersion (waiver non-blocking re-sign),
evaluateRequiredFormStep (onboarding required/auto-satisfy/fail-open),
slugify + deriveFieldKeys (builder key derivation + dedupe)
- forms.ts / onboarding.ts / form-builder.tsx now consume these; the
local copies are removed (no behaviour change — verified by the full
suite staying green)
- __tests__/forms.test.ts expanded 16 -> 62: every validateAnswers
type/branch (type coercion, length caps, required, unknown-key
stripping, first-error ordering), parseFormSchema, every new Zod
schema incl. formSlug boundaries, and all extracted helpers
333/333 tests; build verified (Compiled successfully, exit 0). No deploy.
- claimMyAnonymousSubmissions(): exported but safe — no trusted params,
resolves member + email from the caller's session, gated on
email_confirmed_at, idempotent. Links the caller's own prior
anonymous submissions to their member row
- hooked best-effort from joinSpace (member insert now returns id;
links prior anon submissions for the verified email) and
finishOnboarding
- addMember/importMembers deliberately NOT auto-linked: those emails
are admin-asserted, not verified; the locked decision forbids
auto-link there, so the forms.manage linkSubmissionsForMember stays
the admin manual-link path
- also repairs a build break committed in 295d220: finishOnboarding
declared `const admin` twice (Phase 4 added a second). The probe and
the completion update now share one admin client
- docs: API_REFERENCE / ARCHITECTURE / HANDOFF
287/287 tests, build compiles clean (verified Compiled successfully). No deploy.
Phase 4 self-audit: alreadySubmitted was a page-load snapshot, so a
member who submitted a form/waiver step then navigated Back and
Forward would insert a second submission. Track submitted steps in
session state and treat them as already-submitted.