Files

50 KiB

Hackerspace.sh - Architecture Documentation

Last Updated: 2026-05-21
Version: 1.0.0
Status: In production (serving live traffic)

Note: Sections 7 and 13 and the project tree describe an earlier layout. Server actions are split per-domain under lib/actions/*.ts (re-exported from lib/actions/index.ts); there is no monolithic lib/actions.ts. The per-module behavior is current; the module-feature sections later in this document (Certifications, Classes, Equipment, Door, Presence, etc.) are the authoritative reference.


1. System Overview

Hackerspace.sh is a comprehensive member management platform designed for hackerspaces, makerspaces, and community workshops. It provides tools for:

  • Member Management: Registration, approval workflows, role assignment, payment tracking
  • Task & Chore System: Recurring tasks, assignment, claiming, completion tracking
  • Project Management: Kanban-style project tracking with area assignments
  • Financial Operations: Payment reconciliation, multi-platform integration (PayPal, Zeffy, Venmo)
  • Communications: Real-time chat via Supabase Realtime
  • Knowledge Base: Documentation, secrets vault, area lead management
  • Contacts Management: Vendor, supplier, and partner directory

2. Technology Stack

Frontend

Technology Version Purpose
Next.js 16.x React framework with App Router
React 19.2 UI library
TypeScript 5.x Type safety
Tailwind CSS 4.x Styling
shadcn/ui Latest Component library
Lucide React Latest Icons

Backend

Technology Version Purpose
Supabase Latest PostgreSQL database, Auth, Realtime
Next.js Server Actions - Server-side mutations
Row Level Security (RLS) - Database-level authorization

Infrastructure

Service Purpose
DigitalOcean Droplet Self-hosted runtime (Docker, systemd, Caddy with Let's Encrypt)
Self-hosted Supabase Postgres, GoTrue (auth), PostgREST, Realtime, Studio
Resend Transactional email (SMTP)

3. Project Structure

/
├── app/                          # Next.js App Router
│   ├── (app)/                    # Protected app routes (require auth)
│   │   ├── comms/               # Real-time chat
│   │   ├── contacts/            # Vendor/supplier directory
│   │   ├── dashboard/           # Main dashboard
│   │   ├── import/              # CSV/database import
│   │   ├── members/             # Member management
│   │   ├── ops/                 # Knowledge base, secrets, area leads
│   │   ├── payments/            # Payment reconciliation
│   │   ├── projects/            # Project kanban
│   │   ├── settings/            # Space settings, integrations
│   │   ├── tasks/               # Tasks & chores
│   │   └── layout.tsx           # App shell with sidebar
│   ├── auth/                    # Auth callback routes
│   ├── login/                   # Login page
│   ├── signup/                  # Signup flow (create/join space)
│   ├── globals.css              # Global styles
│   ├── layout.tsx               # Root layout
│   └── page.tsx                 # Landing page (redirect)
├── components/
│   ├── ui/                      # shadcn/ui components
│   ├── app-shell.tsx            # App wrapper
│   ├── app-sidebar.tsx          # Navigation sidebar
│   ├── task-claim-button.tsx    # Task claiming component
│   └── theme-provider.tsx       # Dark/light theme
├── lib/
│   ├── supabase/
│   │   ├── admin.ts             # Service role client (bypasses RLS)
│   │   ├── client.ts            # Browser client
│   │   ├── proxy.ts             # Middleware session refresh
│   │   └── server.ts            # Server Component client
│   ├── actions/                 # Server actions, one module per domain
│   │   ├── index.ts             # Barrel (re-exports every domain module)
│   │   ├── tasks.ts projects.ts members.ts payments.ts …
│   │   └── classes.ts equipment.ts door.ts presence.ts …
│   ├── *-logic.ts               # Pure, unit-tested decision logic
│   ├── *-guard.ts               # Per-feature server-component guards
│   ├── auth-helpers.ts          # requireMember / permission gates
│   └── utils.ts                 # Utility functions (cn)
├── scripts/                     # SQL
│   ├── schema.sql               # Canonical, idempotent (source of truth)
│   ├── 0NN_*.sql                # Numbered incremental migrations (→ 038)
│   └── seed.sql                 # Optional seed data
├── middleware.ts                # Auth middleware
├── docs/                        # This documentation
└── [config files]               # next.config, tsconfig, etc.

4. Authentication Flow

4.1 Sign Up (New User)

[User] → /signup
    │
    ├─→ Step 1: Account info (name, email, password)
    ├─→ Step 2: Choose Create or Join space
    │
    ├─→ [Create Space Flow]
    │   └─→ supabase.auth.signUp() with metadata:
    │       {
    │         space_action: 'create',
    │         space_name, space_slug, space_city, invite_code
    │       }
    │       └─→ DB Trigger: handle_space_signup()
    │           └─→ INSERT spaces → INSERT space_members (admin)
    │
    └─→ [Join Space Flow]
        └─→ supabase.auth.signUp() with metadata:
            {
              space_action: 'join',
              join_invite_code
            }
            └─→ DB Trigger: handle_space_signup()
                └─→ INSERT space_members (member, status based on require_approval)

4.2 Sign In

[User] → /login
    │
    └─→ supabase.auth.signInWithPassword()
        └─→ Middleware refreshes session
            └─→ App layout checks space_members
                └─→ Has membership? → /dashboard
                └─→ No membership? → /signup

4.3 Returning User (Authenticated, No Space)

[User] → /signup (with active session)
    │
    └─→ Detects user via supabase.auth.getUser()
        └─→ Shows space selection (create/join) only
            └─→ Calls server action: createSpace() or joinSpace()
                └─→ Uses admin client (bypasses RLS)

5. Database Schema

5.1 Core Tables

Table Purpose Key Fields
spaces Hackerspace instances id, name, slug, invite_code, require_approval
space_members User membership records user_id, space_id, role, tier, status, approved
tasks Tasks and chores type, status, area, claimed_by, recurrence
projects Project tracking status (kanban), area, tags, due_date
knowledge_base Documentation visibility, is_pinned, area
secrets Encrypted credentials value, area (admin only)
area_leads Area leadership area_code, lead_id, status
contacts Vendor directory contact_type, group_label
payments Payment records platform, amount, member_id, link_status
comms_channels Chat channels channel_type, area_reference
comms_messages Chat messages channel_id, user_id, content
integrations Platform connections platform, is_connected, config
activity_log Audit trail action, entity_type, entity_id

5.2 Enums (Exact Values)

Enum Values
member_role admin, board, treasurer, member, associate
member_tier plus, basic, associate
member_status current, late, inactive, unverified
task_type chore, task
task_status open, claimed, in_progress, overdue, due_today, completed, done, blocked
recurrence_type none, daily, weekly, biweekly, monthly
project_status backlog, in_progress, review, done, blocked
kb_visibility all_members, board, admin_only
contact_type vendor, supplier, partner, landlord, city
payment_platform paypal, zeffy, venmo, cash
payment_link_status linked, unlinked
channel_type general, area, ops, project

6. Authorization Model

6.1 Role Hierarchy

Admin (full access)
  │
  ├─ Board (members, payments, projects, board-level secrets)
  │   │
  │   ├─ Treasurer (payments, financial data)
  │   │
  │   └─ Member (tasks, projects, comms, public KB)
  │       │
  │       └─ Associate (read-only, can claim chores)

6.2 RLS Policies Summary

Table SELECT INSERT UPDATE DELETE
spaces Own space Admin client Admin -
space_members Same space Self/Admin Self/Admin Admin
tasks Members Members Members Members
projects Members Members Members Members
knowledge_base Members (visibility-scoped) Members Members Board/Admin
secrets Admin/Board Admin Admin Admin
payments Treasurer/Board/Admin Treasurer/Admin Treasurer/Admin Treasurer/Admin
comms_* Members Members - Board/Admin

Privilege-eligible status (migration 046). user_has_role_in_space and user_has_permission additionally require the caller's space_members.status to be current or late (mirrors lib/permissions PRIVILEGE_STATUSES). An unverified member of a require_approval space (or an inactive one) cannot exercise a role or permission at the RLS layer even via direct PostgREST, complementing the same gate at the app layer in requireMemberWithRole. Deliberately NOT applied to get_user_space_ids / user_effective_roles: gating SELECT-policy reads would break an unverified member's own /me and onboarding, which IS the legitimate require_approval flow.


7. Server Actions Reference

7.1 Authentication (lib/auth-actions.ts)

Action Parameters Role Required Description
signIn email, password None Authenticate user
signUp email, password, fullName, action, ... None Create account
signOut - Authenticated End session
getUser - Authenticated Get current user
getCurrentMembership - Authenticated Get member + space
createSpace spaceName, spaceSlug, spaceCity, displayName Authenticated Create new space
joinSpace inviteCode, displayName Authenticated Join existing space

7.2 Tasks (lib/actions/tasks.ts)

Action Parameters Role Required
createTask title, description?, type, area?, recurrence?, due_date? Member
claimTask taskId Member
completeTask taskId Claimant
deleteTask taskId Member

7.3 Projects (lib/actions/projects.ts)

Action Parameters Role Required
createProject title, description?, area?, tags?, due_date? Member
updateProjectStatus projectId, status Member
deleteProject projectId Member

7.4 Members (lib/actions/members.ts)

Action Parameters Role Required
addMember display_name, email, phone?, handle?, tier, role, ... Admin/Board
updateMember memberId, updates Admin/Board
approveMember memberId Admin/Board
removeMember memberId Admin
importMembers rows[] Admin/Board

7.5 Payments (lib/actions/payments.ts)

Action Parameters Role Required
logCashPayment amount, from_note, member_id?, transaction_date? Treasurer/Admin
linkPaymentToMember paymentId, memberId Treasurer/Admin
importPaymentsCsv rows[] Treasurer/Admin

7.6 Knowledge Base & Secrets (lib/actions/knowledge-base.ts, lib/actions/secrets.ts)

Action Parameters Role Required
createKbEntry title, content, area?, visibility?, is_pinned?, tags? Member
updateKbEntry entryId, updates Member
deleteKbEntry entryId Board/Admin
createSecret title, value, description?, area? Admin/Board
deleteSecret secretId Admin
upsertAreaLead area_code, area_name, lead_id?, lead_handle?, status? Admin/Board

7.7 Contacts (lib/actions/contacts.ts)

Action Parameters Role Required
createContact name, contact_type, email?, phone?, ... Member
updateContact contactId, updates Member
deleteContact contactId Member

7.8 Settings (lib/actions/settings.ts)

Note: /settings is admin-only and intentionally loads the space's own webhook_secret and integration config so an admin can view/rotate them. That is by-design same-space admin secret management, not a leak; the page redirects non-admins before any of it is fetched.

Action Parameters Role Required
updateSpaceSettings name?, slug?, city?, require_approval?, public_member_directory? Admin
saveIntegration platform, config Admin
disconnectIntegration platform Admin
rotateWebhookSecret - Admin

8. Real-time Features

8.1 Comms (Chat)

The comms feature uses Supabase Realtime for live messaging:

// Client-side subscription
const subscription = supabase
  .channel(`channel:${channelId}`)
  .on('postgres_changes', {
    event: 'INSERT',
    schema: 'public',
    table: 'comms_messages',
    filter: `channel_id=eq.${channelId}`
  }, payload => {
    setMessages(prev => [...prev, payload.new])
  })
  .subscribe()

8.2 Default Channels

Created automatically via DB trigger when a space is created:

  • general - General discussion
  • announcements - Official announcements
  • random - Off-topic
  • facilities - Building/equipment issues

9. File Structure Details

9.1 Page Components Pattern

Each feature follows a consistent pattern:

feature/
├── page.tsx          # Server Component - fetches data
└── feature-client.tsx # Client Component - UI + interactivity

Example: Tasks

  • page.tsx: Fetches tasks, members, passes to client
  • tasks-client.tsx: Renders tabs, modals, handles CRUD actions

9.2 Supabase Client Usage

Location Client Use Case
Server Components lib/supabase/server.ts Data fetching with RLS
Client Components lib/supabase/client.ts Realtime, client mutations
Server Actions lib/supabase/server.ts Protected mutations
Admin Operations lib/supabase/admin.ts Bypasses RLS (signup)
Middleware lib/supabase/proxy.ts Session refresh

10. Security Considerations

10.1 RLS (Row Level Security)

All tables have RLS enabled. Policies enforce:

  • Users can only see data from their space
  • Role-based write permissions
  • Admin client used only for initial space/member creation

10.2 Admin Client Safety

The admin client (SUPABASE_SERVICE_ROLE_KEY) is used ONLY for:

  1. Creating spaces during signup (trigger handles member creation)
  2. Adding first member to space
  3. Operations where RLS would create circular dependencies

Never expose to client-side code.

10.3 Secrets Storage

Integration credentials:

  • Secret-named fields (client_secret, *_secret, api_key, secret_key) are encrypted at rest in the AES-256-GCM secrets vault (lib/secrets/vault.ts, same vault Stripe + door use); only a <field>_ref id is kept in integrations.config. Server-side decrypt only, never returned to the client.
  • Non-secret fields stored in integrations.config (JSONB) with _set indicators; UI shows connected state via the flags, never the value.
  • Legacy plaintext secrets (pre-vault) are auto-migrated into the vault on the next saveIntegration.

11. Environment Variables

Variable Required Description
NEXT_PUBLIC_SUPABASE_URL Yes Supabase project URL
NEXT_PUBLIC_SUPABASE_ANON_KEY Yes Supabase anon key
SUPABASE_SERVICE_ROLE_KEY Yes Admin operations
SECRETS_ENCRYPTION_KEY Yes AES-256-GCM key for the secrets vault
NEXT_PUBLIC_OAUTH_GITHUB / NEXT_PUBLIC_OAUTH_GOOGLE No Enable social sign-in buttons

.env.example and docs/DEPLOYMENT.md are the authoritative env list. (SUPABASE_JWT_SECRET is not used by the app and was removed from this table.)


12. Migration History

Script Description
001 Initial schema (13 tables, all enums)
002 Schema fixes, create_default_channels trigger
003 Signup trigger (handle_space_signup)
004 RLS helper functions
005 Additional RLS fixes
006 Comprehensive RLS rewrite
007 Added task_status 'done', contacts note/group_label
008 space_members.approved default=true
009 Member INSERT RLS, UNIQUE(space_id,email)
010 Fixed channel trigger (removed description column)
011 Fixed enum values (active→current, pending→unverified)

Migrations 012-030 are tracked authoritatively in docs/DATABASE_SCHEMA.md (governance kernel, areas, forum/tiers/roles/invites, configurable onboarding, customizable permissions + Ops ACLs, self-change hardening, 026-029 custom forms + waivers + onboarding form step + per-space form slug + invite roles, 030 certifications + Instructor capability, 031 secrets SELECT honors ops.secrets.read, 032 classes + sessions + signups, 033 equipment + reservations, 034 member access cards + door permissions, 035 door connections + access log, 036 door card slots; later migrations through 054 add classes/equipment/Stripe-dues/notifications and the door epic's inbound access-log ingest (053) + universal API-call buttons (054) — see docs/DATABASE_SCHEMA.md for the full, authoritative migration history).

Forms & waivers (migrations 026-029; complete Phases 1-5)

forms and form_submissions back a custom form/waiver builder. The feature is complete (Phases 1-5): schema + RLS; server actions in lib/actions/forms.ts; the builder UI under /forms (with starter templates) + the public /f/[space]/[slug] page + a member-facing /my-forms list; onboarding form step type with required-waiver enforcement + auto-satisfy (migration 027); per-space form slug (028); verified-email retro-link of anonymous submissions (claimMyAnonymousSubmissions, hooked from joinSpace and finishOnboarding; admin manual-link for unverified/admin-added members). Invite codes can grant a role with usage caps (029; /join/[space]). Security shape: all submissions (anonymous, public-authenticated, or members) are written by a single validated server action using the service client AFTER server-side schema validation and snapshotting (same pattern as finishOnboarding); the form_submissions table has no INSERT/UPDATE/DELETE policy, so RLS hard-denies every non-service client; submissions are immutable to clients but a forms.manage holder can permanently delete an individual submission (deleteSubmission) or a whole form (deleteForm, which FK-cascades all its submissions including signed waivers) through validated service-client actions behind destructive UI confirms (deleteForm also requires an explicit confirm:true). The public fill page is served by a service-client server action, so the anon Postgres role gets no grant on forms. Form management is gated by the new additive forms.manage permission via user_has_permission.

Email-match association (migration 039 + app, owner-chosen 2026-05): a submission is linked to a member whenever submitter_email matches a member in the space — at submit time, on member add/email-change (linkSubmissionsByEmail), via self-claim at join/onboarding, and a one-time backfill. This intentionally includes raw anonymous submissions (attribution only, no access); the tradeoff is documented inline in submitForm so it is not mistaken for a bug. forms.manage holders see a member's submissions on the /members per-member panel (listMemberSubmissions, RLS-honoring, metadata only).

Certifications & Instructor (migration 030)

certifications (per-space cert types; optional validity_months; is_active archive) and member_certifications (per-member grants) back a certification tracker. Pure decision logic is isolated in lib/certifications-logic.ts (computeExpiry with month/leap-year clamping; certificationStatus = revoked > expired > expiring_soon > active), unit-tested. Server actions in lib/actions/certifications.ts. Two new additive permissions: certifications.manage (cert types, gates /certifications admin pages via lib/certifications-guard.ts) and certifications.grant (award/revoke = the Instructor capability, assignable to any role/area-lead through the existing space_role_permissions model, NOT a new built-in role). The per-member award/revoke/renew panel is reachable from a "Certs" column on /members shown to any certifications.grant holder (independent of admin/board). Members see their own grants + effective permissions read-only at /me. RLS is additive and default-deny: cert types readable by any space member; grants readable by managers/granters (all) or the member (own); member_certifications has no DELETE policy so grant/revoke history is immutable (revoke is a soft revoked_at UPDATE). Expiry is snapshotted at grant time so later edits to the cert type never retroactively change an existing grant. There is no anonymous path.

Classes (migrations 032, 037)

classes (offering), class_sessions (scheduled occurrence), and class_signups (member signup) back a class scheduler. Pure logic in lib/classes-logic.ts (effectiveCapacity, computeSignupStatus, canSignUp, signupFormEligibility, pickPromotion), unit-tested. Server actions in lib/actions/classes.ts. Two additive permissions: classes.manage (class/session CRUD, gates /classes/manage via lib/classes-guard.ts) and classes.instruct (attendance, completion, attendee list). Member signup needs only membership. /classes is the member calendar (sign up / waitlist / cancel); the member's own signups also appear on /me. RLS is additive and default-deny: classes readable by managers (all) or members (active only), sessions by any space member, signups by managers/instructors (all) or the member (own) with instructor-only UPDATE and no INSERT/DELETE policy so signup/cancel funnels through one validated service-client action that enforces capacity/waitlist/dedupe and promotes the earliest waitlisted member when a seat frees. A class may optionally set required_form_id (migration 037): signup is then hard-gated on the member having a form_submissions row for that form on file (waiver model), checked via the service client since a class manager need not hold forms.manage; a classes.manage holder gets the override and may also sign a member up on their behalf (mirrors the equipment required-cert gate). The member calendar shows the requirement and links to the public form; the per-session signup roster is reused on /classes/manage behind a toggle for staff (members never see who else signed up). Completing a session can award the class's certification, but only through the normal grantCertification path, so it still requires the acting instructor to hold certifications.grant; otherwise completion succeeds and the result reports the certificates were skipped (no service-role bypass of the guarded certifications surface). No anonymous path. Concurrent signup/cancel is serialized per session by SECURITY DEFINER functions class_signup_tx / class_cancel_tx (migration 045) holding pg_advisory_xact_lock(hashtext(session_id)), so two simultaneous signups at the capacity boundary cannot over-enroll and two simultaneous cancels cannot double-promote a waitlister. The pure logic is the documented rule; the RPC is the runtime authority.

Equipment (migration 033)

equipment (registry; status available/maintenance/retired; optional required_certification_id; is_active archive) and equipment_reservations (member time-window reservation) back a tool reservation system. Pure logic in lib/equipment-logic.ts (intervalsOverlap, hasConflict, reservationEligibility), unit-tested. Server actions in lib/actions/equipment.ts. One additive permission, equipment.manage (registry CRUD + adjust/cancel any reservation, gates /equipment/manage via lib/equipment-guard.ts). Members reserve with only membership. /equipment is the member catalog (reserve), /me lists the member's own reservations. 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 one validated service-client action that enforces the equipment status, the no-overlap rule, and the required-certification gate (checked against the normal member_certifications data; an equipment.manage holder gets the override and may book on a member's behalf). The app-side check is a fast pre-check; the database is the concurrency arbiter via a GiST EXCLUDE (equipment_id =, tstzrange(starts_at,ends_at,'[)') &&) WHERE status='reserved' constraint (migration 042 + btree_gist), so two simultaneous requests for the same window cannot both insert. No anonymous path.

Door / access control (migrations 034-036, 053-054; epic complete, P1-P5)

Phased and complete: P1-P5 built + deployed, including member self-entry (reviewed at the locked checkpoint, then built). member_cards (034) associates RFID/NFC UIDs to members; the UID is a credential (door.manage-only RLS, no member SELECT; masked count+last4 self-view via a service-client action). door.manage / door.operate permissions (group Access). door_connections (035) is a per-space controller integration: the shared password is NOT stored on the row — secret_ref points at the existing AES-256-GCM secrets vault and is decrypted server-side only. pinned_host is the SSRF pin. Pure logic in lib/door-logic.ts (SSRF validateDoorTarget, always-blocked metadata/ link-local, native HeatSync encoders with the firmware-verified fixed-width zero-padding, generic template substitution, secret redaction) is heavily unit-tested. lib/door/executor.ts is the single hardened egress: it re-validates the target against the pin, refuses redirects, caps time and body, and redacts secrets before any audit write. door_access_log is an append-only, secrets-redacted audit with no client write policy (validated service-client executor only). /door/manage (door.manage) configures connections, picks a vault secret, and runs a safe status-only test. Phase 3 (036) adds the live actions, all door.operate, rate-limited, each writing one redacted door_access_log row, reading through the service client after the permission check (operators have no RLS read on door_connections/member_cards). door_card_slots is the per-connection integer-slot allocation map for controllers that key cards by slot (HeatSync 0-200); the lowest-free policy is pure unit-tested logic (lib/door-slots-logic.ts), UNIQUE (connection_id, slot) lets the DB arbitrate concurrent grants, UNIQUE (connection_id, card_id) makes re-grant idempotent. grantCard reserves the slot in the DB first, calls the controller, and rolls the reservation back if the call fails; revokeCard is idempotent and frees the slot only on confirmed controller success so the app map never diverges from the device; doorControl (open/unlock/lock) touches no slot. /door/manage surfaces these to door.operate holders. Member self-entry (selfEntry) is built: any active member with at least one active card on file may trigger a momentary OPEN on a connection that is enabled and has allow_member_self_entry on (opt-in, off by default; the locked eligibility rule is "any active card", no door_card_slots row required). Membership/cards are resolved server-side, strict per-member rate limit, one redacted self_entry audit row; surfaced as a "Door access" panel on the dashboard and on the member /doors page (self-entry + masked own cards via getMyCards + the member's own recent activity via listMyDoorActivity, a service-client read after requireMember filtered to that member), hidden entirely for ineligible members. Phase 4 (053) adds inbound access-log ingest: real entry/denied events are pulled INTO door_access_log and matched to a member, via two transports that share one ingest core (lib/door/ingest.ts). Poll: POST /api/cron/door-ingest (constant-time CRON_SECRET, proxy.ts-whitelisted, once-a-minute crontab) reads each inbound_enabled native-HeatSync connection's ?z log through the same hardened executor (fullBody, capped) and parses it with the pure, firmware-characterized lib/door-log-logic.ts (<pre> ring-buffer dump, G+g/D+d card-number reconstruction with the 32767 divisor, best-effort H:M:E). Webhook: POST /api/door/inbound/[connection] (also proxy.ts-whitelisted, session-exempt) accepts pushed event JSON from any controller/relay, authenticated by a per-connection bearer secret (inbound_secret_ref, a distinct vault secret from the outbound password, constant-time compared); body-size guarded, rate-limited, Zod-validated (≤100 events). Both resolve a presented card to a member via the HeatSync hex-uid model (cardMatchesEvent: a stored hex card_uid matches when hexInt(uid) equals the reported decimal number, or a webhook uid matches exactly) and dedupe-insert through the service client: door_access_log.dedupe_key + the partial-unique (connection_id, dedupe_key) index make a re-poll or webhook retry a no-op, while NULL-keyed action rows stay unconstrained. The poll is inherently best-effort (the HeatSync ring buffer has no per-entry sequence id); the webhook (explicit event ids) is the reliable transport. The audit log stays no-client-write/immutable; a webhook-supplied occurred_at is clamped to not-in-the-future so a relay cannot reorder the operator's log. Phase 5 (054) is the universal API-call UI builder: admins (door.manage, which the catalog already scopes to "buttons") define api_buttons (label, group, method GET/POST/PUT/PATCH/DELETE, base_url, pinned_host, url_template, headers, body_template, auth_mode/auth_param, vault secret_ref, confirm, and a per-button required_permission). A member presses only buttons whose required_permission they hold (one new generic code apicall.invoke, the default; a door-flavored button can require door.operate). listInvokable Buttons is a service-client read returning only presentational fields (never url/headers/secret); invokeApiButton is rate-limited-first, loads the definition service-side scoped to the member's space, checks the per-row permission (denials audited), decrypts the secret server-side and fires through callApi. The egress is now shared: the SSRF+resolve+connect-by-IP+ no-redirect+caps+redact core is factored into one internal egress, with callDoor (GET, query secret) and callApi (full verbs + headers + body + secret injected per auth_mode, host header forced to the pin) on top; pure request assembly + secret placement is unit-tested in lib/api-call-logic.ts (buildApiRequest). The metadata/link-local block is absolute even for an admin-configured button. api_call_log is the append-only, redacted, service-client-only press audit (SELECT = door.manage). The builder lives at /door/buttons (with a door-template preset); members press from /doors. No anonymous path. The executor is the single hardened egress: it resolves the controller host once via dns.lookup, rejects if any resolved IP is loopback / unspecified / link-local / metadata (IPv4-mapped IPv6 normalized; RFC1918 / LAN / ULA allowed since controllers live there), then connects to the validated IP literal so fetch performs no second resolution, closing the DNS-rebind TOCTOU. Redirects are refused (redirect: 'manual'). Controller errors never bubble the upstream reason to the client (a generic message; the redacted detail goes only to door_access_log); the redaction is auth-param-aware so a generic-adapter custom param value is scrubbed regardless of its name.


Presence & attendance (migration 038)

space_visits records one row per visit (check-in/out, is_host, in/out notes); an open row (checked_out_at IS NULL) means the member is here. A partial unique index keeps at most one open visit per member. Pure logic in lib/presence-logic.ts (presenceStatus, hostEligibility, summarizePresence), unit-tested. Server actions in lib/actions/presence.ts are self-only (member resolved server-side) and funnel through the service client; space_visits has SELECT for any space member (presence is social) and no client write policy. There is no new permission code: current presence is visible to all members, the org-wide /attendance history is all-members by product decision, and every member sees their own history on /me. Forgotten check-outs are handled without a cron: an open visit older than PRESENCE_MAX_OPEN_HOURS (18h) is treated as not-present in pure logic and auto-closed on the member's next check-in. Checking in as a host is gated by spaces.host_requires_card (default true): when set, the member needs an active member_card on file (reuses the access-card model); a space may relax it to let anyone self-mark host. Surfaced as the dashboard "Who's here" panel, the /attendance page, and "My recent visits" on /me. No anonymous path.


Stripe recurring dues (migration 040; product spine Phase 1)

Per-space OWN Stripe account (NOT Connect): the secret key + webhook signing secret live encrypted in the AES-256-GCM secrets vault (the door secret pattern), referenced by id from integrations.config which also holds the mode, publishable key, tier→Price map and grace days. Pure mapping logic in lib/stripe-logic.ts (pinned API version, duesMemberStatus, graceExceeded) is unit-tested; lib/stripe/client.ts builds a per-request Stripe client (never global, never client-exposed); lib/stripe/config.ts holds the vault/config helpers (plain server module, not 'use server', since they take the admin client). Members pay via a hosted Checkout Session (subscription mode) and self-serve via the hosted Billing Portal — card data never touches the app (SAQ A). The per-space webhook POST /api/stripe/webhook/[space] verifies the signature with that space's signing secret, is idempotent on Stripe's event id (stripe_webhook_events), and maps the subscription lifecycle onto member_billing + a stripe payments row + space_members.status (grace → late, never auto-inactive, never auto-approves unverified). member_billing SELECT = admin/board/treasurer with no client write policy (webhook/service-client only); the member self-view is a validated action. No new permission code. The webhook is hardened against three classes of real-world Stripe behavior: replay (Stripe retries reuse the event id, the unique stripe_webhook_events PK collapses them); out-of- order delivery (a stale customer.subscription.updated carrying an older period must NOT rewind current_period_end and false-lapse a paid member, so laterPeriodEnd keeps the monotonic max); and zero-decimal currencies (JPY/KRW/VND/etc. are NOT rescaled /100, via isZeroDecimalCurrency). Error responses to the caller are generic; the real cause is logged server-side. Phases 2-3 (transactional notifications, broader self-serve) build on this.

Alternate dues payment methods (migration 049)

Not every space uses Stripe. An admin can configure external pay-here links per platform (dues_payment_methods, one row per (space_id, payment_platform) for PayPal / Zeffy / Venmo) under /settings; members see the active ones as click-out buttons in the /me dues card and pay off-platform; a treasurer reconciles the resulting payment manually through the existing payments flow. The row carries the payment_platform tag precisely so that manual reconcile is pre-typed. This is link configuration only: there is NO automated payment record on click, no webhook, no money handled in-app. Pure logic in lib/dues-payments-logic.ts (the url-based platform subset + labels + isSafeDuesUrl, which requires an absolute https: URL so an admin-entered value cannot become an XSS or plaintext-downgrade vector; the Zod schema reuses it) is unit-tested. RLS: SELECT = any space member (they render the buttons), INSERT/UPDATE/DELETE = admin/board (user_has_role_in_space); the member-read and admin-write server actions are in lib/actions/dues-payments.ts. Links are rendered with target="_blank" rel="noopener noreferrer".

The /me dues card (components/billing/dues-card.tsx) gates the Stripe "Pay dues with card" button on whether the space actually has Stripe configured (getMyBilling now returns a configured flag from isStripeConfigured), so a space with no Stripe shows only its external links (or, if neither is set up, a "contact an admin" note) instead of a dead Checkout button.

Transactional notifications (migration 041; product spine Phase 2)

Outbox + dispatcher, not inline send. The Stripe webhook only writes a notifications row (invoice.paid → dues_renewed, invoice.payment_failed → dues_payment_failed, lapse-to-late → dues_lapsed); it never calls the mail provider, so the money path stays fast and retry-safe. The (space_id, dedupe_key) unique index makes a Stripe event replay a no-op (ignoreDuplicates). Pure render + dedupe logic in lib/notifications-logic.ts is unit-tested (HTML-escaped, brand-neutral copy). lib/email/send.ts is a one-function transport seam over Resend's HTTP API (no SDK; fetch) returning a retryable flag; a self-hosted deploy can swap it for SMTP without touching callers, and an unset RESEND_API_KEY is a clean non-retryable no-op. The dispatcher POST /api/cron/notifications (constant-time CRON_SECRET bearer, no session — proxy.ts whitelists /api/cron) drains ≤20 pending rows per run, passing each row id as Resend's Idempotency-Key so an overlapping run cannot double-send; transient failures stay pending until the attempt budget is spent, permanent ones go failed. notifications SELECT = admin/board/treasurer, no client write policy; the member self-view (getMyNotifications, surfaced on /me) is a validated action. The droplet's crontab POSTs once a minute. The drain is fair across spaces: oldest-first candidates are bucketed per space_id and round-robined, so one tenant's billing burst cannot head-of-line-block another space's mail; a lone-tenant deployment still drains the full batch. Resend's Idempotency-Key is per-attempt (<row.id>:<attempts>) so a cross-minute retry is a deliberately fresh send, not a cached prior response. The status write is guarded with .eq('status','pending') so two overlapping dispatcher runs cannot flip a row another already marked sent. No new permission code.

Notification breadth: bookings, classes, forms (product spine Phase 4)

Phase 2 (dues lifecycle) shipped the outbox + dispatcher. Phase 4 extends event coverage to bookings, class signups, and form submissions, reusing the same machinery unchanged (no new table, no dispatcher change, no new env var). The notifications.type column is text, so adding event types costs no migration.

A shared lib/notifications/enqueue.ts helper centralizes member-contact lookup and the idempotent best-effort upsert; the Stripe webhook and every new call site now go through it instead of inlining the same pattern.

New event types and their triggers:

  • Equipment (lib/actions/equipment.ts)
    • booking_confirmed on reserveEquipment (always; a manager booking on behalf still emails the target member).
    • booking_cancelled on cancelReservation only when the actor is not the affected member (self-cancels stay silent; the actor already saw the UI confirm).
  • Classes (lib/actions/classes.ts)
    • class_signup_registered / class_signup_waitlisted on signUpForClass, picked from the RPC's returned signup status.
    • class_signup_promoted on cancelMySignup when class_cancel_tx returns a promoted_id (the bumped waitlist member).
    • class_session_cancelled on updateSession when status flips to cancelled. Fans out one row per still-active signup (registered or waitlisted), dedupe by (session, member) so a re-cancel is a no-op.
  • Forms (lib/actions/forms.ts, submitForm)
    • form_submission_received only when the submitter is authenticated (members or public_auth). Recipient is the verified Supabase auth email, never the typed body.email. Pure anonymous public submissions skip this confirmation (typed emails could belong to anyone; confirming to them would be a victim-spam vector).
    • form_submission_admin fans out to every member who holds forms.manage, dedupe by (submission, admin). The admin link goes to /forms/<id>/results. Finding the recipient set efficiently is what migration 047 (members_with_permission(sid, perm)) exists for: an inverted, set-returning form of user_has_permission that respects the same current/late status gate (046) and the same admin shortcut + space-role-permissions / effective-roles fallback. Additive; existing user_has_permission callers unchanged.

Dedupe-key scheme (all keyed under (space_id, dedupe_key)): booking_*:<reservation_id>; signup-keyed class events class_signup_*:<signup_id> (stable across promotion); class_session_cancelled:<session_id>:<member_id>; form_submission_received:<submission_id>; form_submission_admin:<submission_id>:<admin_id>.

Every enqueue is best-effort and wrapped: a notifications-table or permission-lookup failure can never throw into the calling action, so the underlying domain mutation always finalizes.

Member notification preferences (migration 048; product spine Phase 5)

Per-member opt-out of muteable notification categories. The 11 event types map to five categories in pure logic (lib/notifications-prefs-logic.ts, unit-tested): billing (the three dues types), bookings, classes, forms (submitter receipts), and admin_alerts (the form_submission_admin fan-out). billing is deliberately NOT muteable: dues renewed / payment-failed / lapsed are membership-critical (a muted lapse notice would let a member silently lose access for non-payment), so they always send and never render a toggle. The other four are muteable, default-on (opt-out model: a member who never touches the toggles keeps today's behavior). Adding a new event type only needs a line in TYPE_CATEGORY and a renderer; the storage and dispatcher do not change.

The preference is enforced at dispatch time, not enqueue time: the domain actions and the Stripe webhook are unchanged and still enqueue every row. The dispatcher batch-loads the preferences for the members in the drain set (keyed by member_id, a globally-unique space_members PK) and, via the pure isMuted(prefs, type), marks a muted row skipped (a terminal status that leaves the pending pool) instead of calling the mail provider. No send, no Resend call, no rate-limit spacing for a muted row. The prefs lookup fails open (send everything) on error, so a transient blip can never silently drop a wanted email. Billing and unmapped types are never muted regardless of stored prefs (defense-in-depth, tested). This governs emails sent and inbox noise (the real cost and the volume governor for the Phase 4 fan-outs), not table rows.

notification_preferences (PK (space_id, member_id, category)) has RLS enabled and no client policy (default-deny), the same convention as notifications / member_billing: the member self-view (getMyNotificationPreferences) and the toggle write (setMyNotificationPreference, Zod-validated to the muteable categories only, upsert scoped to the caller's own member row) both go through validated service-client actions, and the dispatcher reads via the service client. The toggles render on the /me Activity tab; a skipped row shows as "Muted" in the member's own notification history. No new permission code.

Member self-serve portal (/me; product spine Phase 3)

/me is a 3-tab portal (Profile / Membership / Activity). The server page does all data fetching and passes plain data to a single client portal component (me-portal-client.tsx) that owns tab state and rendering; the read-only sections were moved verbatim from the prior flat page (no behavior change in the shell). Profile editing and inline cancels reuse existing server actions (updateMyProfile, discloseAffiliations, cancelMySignup, cancelReservation) — no new mutation surface, server-side ownership/space scoping unchanged. getMyPayments follows the established service-client self-view convention (treasurer-scoped RLS, strictly scoped to the caller). Self-service email change goes through Supabase Auth (updateUser + /auth/confirm verifyOtp); the denormalized space_members.email is synced only post-verification. The email-change flow depends on Supabase project config (template + redirect allowlist; see DEPLOYMENT) and is inert until that is set. No new permission code, no schema change.


Observability / error monitoring (Phase 1: app-side seam)

A backend-agnostic capture seam, modeled on the lib/email/send.ts transport pattern (a one-purpose fetch transport, no SDK). lib/observability/capture.ts sends Sentry-format events to a self-hosted GlitchTip (or any Sentry-compatible) ingest endpoint named by SENTRY_DSN. It is inert by default: with SENTRY_DSN unset every captureException/captureMessage is a no-op, so the seam ships to prod dark until the backend is provisioned (Phase 2). Capture is always best-effort and never throws into a caller (a logging-backend blip must not be able to break the money path, a cron, or a request); the send is fire-and-forget with a short abort timeout. All event content (message, exception value + stack, tags, extra) is run through the pure, unit-tested lib/observability/scrub.ts (redacts emails, JWTs, Bearer tokens, Stripe sk_/rk_/whsec_ keys, Resend re_ keys, and bare high-entropy hex while leaving dashed UUIDs intact) so no credential or PII leaves the box.

Coverage is two-pronged: instrumentation.ts onRequestError is the framework-wide net for anything thrown out of a route handler, server component, or server action (broad server-action coverage with no per-action wiring); swallowed best-effort failures that never reach that hook are captured manually at their sites: the Stripe webhook (dedupe + handler 500), both crons (/api/cron/notifications, /api/cron/door-ingest), the central enqueueNotification, and the door-ingest insert. Server-side only (no client SDK / bundle impact) in this phase. Phase 2 stands up the GlitchTip backend on the droplet and sets the DSN.

13. Known Limitations

  1. Test suite - Extensive Vitest unit coverage of pure logic (__tests__/, run with pnpm test, hermetic), Playwright smoke e2e (e2e/), and a DB-backed integration suite (integration/, run with pnpm test:integration against a real Postgres; self-skips without one) that drives the shipped SQL through psql and the route handlers through their actual exports: the advisory-lock signup/cancel RPCs (045), the equipment exclusion constraint (042), the self-change trigger + members_update WITH CHECK (043/044), the billing/notification idempotency invariants, the Stripe webhook end to end (real signed events, vault secret resolution, replay, out-of-order period guard), and the RLS-layer privilege-status gate (046). Remaining gap: server-action orchestration beyond those critical paths still has no integration coverage, and e2e is mostly page-render checks.
  2. Payment integrations - Stripe recurring dues IS integrated (product spine Phase 1: per-space keys, hosted Checkout/Portal, the per-space signed webhook). A PayPal sync endpoint exists (app/api/paypal/sync/route.ts). Manual import/CSV reconciliation remains the path for ad-hoc/other-platform payments; Zeffy/Venmo live APIs are not integrated.
  3. Social auth - GitHub/Google sign-in is wired, gated by the NEXT_PUBLIC_OAUTH_GITHUB/NEXT_PUBLIC_OAUTH_GOOGLE env flags.
  4. Webhooks - The HMAC signing contract and secret rotation exist; per-event delivery is not implemented (see docs/WEBHOOKS.md).
  5. Email notifications - Dues-lifecycle (Phase 2; migration 041) plus booking, class signup, and form-submission events (Phase 4; migration 047) all reuse the same outbox + dispatcher. Member notification preferences (Phase 5; migration 048) let a member opt out of the muteable categories (bookings, classes, forms, admin alerts); billing is always-on. Enforced at dispatch time (muted rows are marked skipped, never sent). The in-app notification center (Phase 5; migration 051) gives /me an inbox with read/unread state (notifications.read_at) and is the always-on channel: a notification whose email was muted still appears in-app. The single member self-service page is /me (the former /profile was consolidated into it).

Schema gotcha (migration 052, learned from a production outage): the auth layout resolves membership with space_members.select('*, spaces(*)'). Do NOT create a table with foreign keys to BOTH space_members and spaces -- PostgREST reads it as a junction and makes that embed ambiguous (PGRST201), which nulls every logged-in member and redirects them to /signup. notification_preferences keeps only its member_id -> space_members FK for this reason; space_id is a plain column. integration/auth-embed.test.ts guards the embed.

  1. Search - Client-side filtering only, no server-side full-text search.
  2. Single space per user - The auth resolver assumes one active membership per user; a user in 2+ spaces is not supported (no space switcher). Fails closed.