Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.9 KiB
Architecture
hackerspace-management is a Next.js 16 App Router app (TypeScript, Turbopack) on Supabase (Postgres + Auth). Every space is a tenant; the product is single-space-per-user today (see Known limitations). This document describes the layers and the exact recipe for adding a feature.
Request & auth flow
proxy.ts(repo root) is the middleware (Next 16 renamedmiddleware→proxy). It refreshes the Supabase session and gates every route: anything not in itsPUBLIC_ROUTESallowlist requires an authenticated user, else redirect to/login. Public routes (webhooks, cron,/f/*public forms,/track, the resources subsite) each self-authenticate (signature/secret/token) — they do not rely on a session.app/(app)/layout.tsxis the second gate (defense in depth): it re-checks the user, loads the activespace_membersrow, and redirects to signup/onboarding when appropriate. Everything underapp/(app)/renders inside it.
Layers
Data flows UI → server action → (validation, guard, domain logic) → data access → Postgres. Each layer has one job:
| Layer | Where | Responsibility |
|---|---|---|
| Generated types | types/database.ts |
Supabase-generated row/enum types. Never hand-edited — regenerate with supabase gen types typescript --schema public > types/database.ts. |
| Domain types | types/domain/* |
Hand-written friendly aliases (Space = Tables<'spaces'>), composites, and result shapes. Import via @/types/domain. |
| Validation | lib/validations/* |
Zod schemas, one file per feature + primitives.ts, re-exported from lib/validations/index.ts. Import via @/lib/validations. |
| Domain logic | lib/<feature>-logic.ts, lib/<feature>/logic.ts |
Pure, side-effect-free decision functions. Unit-tested with no DB (e.g. dues-payments-logic, door-logic, invite-claim). |
| Guards | lib/auth-helpers.ts, lib/<feature>-guard.ts, lib/actions/<feature>/_guard.ts |
requireMember / requireMemberWithRole / parseInput / logActivity; per-feature permission gates returning a Gate discriminated union. |
| Data access | lib/supabase/{server,client,admin,proxy}.ts |
Supabase clients. server/client are RLS-bound. admin uses the service role and bypasses RLS. |
| Server actions | lib/actions/<feature>[.ts | /] |
'use server' mutations/queries. Re-exported through lib/actions/index.ts. Client code imports from @/lib/actions. |
| UI | app/(app)/<route>/ |
page.tsx (server: fetch + guard) → <route>-client.tsx (thin orchestrator) → panels/ + components/ + types.ts. |
The server-action contract (memorize this)
Every action follows the same shape — see lib/actions/members.ts for the canonical example:
'use server'
export async function doThing(input: {...}) {
const v = parseInput(thingSchema, input) // 1. validate (Zod)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required') // 2. authorize
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { error } = await supabase
.from('things').update(v.data)
.eq('id', id)
.eq('space_id', member.space_id) // 3. SCOPE to the caller's space (mandatory)
if (error) return { error: error.message }
await logActivity(supabase, member, 'updated', 'thing', id) // 4. audit
revalidatePath('/things') // 5. revalidate
return { success: true as const }
}
The critical invariant: whenever you use createAdminClient() (service role, RLS off), every read,
update, and delete must be scoped with .eq('space_id', member.space_id) (or an id already proven to
belong to that space). RLS will not save you there — the scope is the only thing preventing a cross-tenant
(IDOR) leak. __tests__/actions.test.ts locks this in; __tests__/auth-helpers.test.ts covers the gates.
Feature-folder conventions
A large feature is a folder, not a file. The pattern already exists in the tree:
- Actions:
lib/actions/<feature>/with'use server'sub-files split by capability, plain_guard.ts/_helpers.ts(NOT'use server'— they export non-async values), and a plainindex.tsbarrel. Examples:lib/actions/{classes,door,forms}/. - UI:
app/(app)/<route>/=page.tsx+ a thin<route>-client.tsxorchestrator +panels/(one per tab/section) +components/(modals, rows) +types.ts. Exemplars:app/(app)/{customize,ops,settings,members}/.
Everything is wired through four barrels so features stay decoupled: @/lib/validations,
@/types/domain, @/lib/actions, and lib/permissions-catalog.ts.
Adding a feature (extension) — step by step
A correct feature touches one file per layer and imports cross-feature code only through the barrels.
- Migration — add
scripts/0NN_<feature>.sql(table + RLS policies + any RPC). Enable RLS and write per-operation policies scoped by space membership. Then regeneratetypes/database.ts. - Validation —
lib/validations/<feature>.tswithcreate<Feature>Schemaetc., reusinglib/validations/primitives.ts. Addexport * from './<feature>'tolib/validations/index.ts. - Domain types —
types/domain/<feature>.ts:export type Feature = Tables<'features'>+ composites. Barrel fromtypes/domain/index.ts. - Domain logic —
lib/<feature>/logic.ts: pure decision functions, unit-tested (no DB). - Guard —
lib/actions/<feature>/_guard.ts:require<Feature>Manager()returning theGateunion (copylib/actions/classes/_guard.tsand swap the permission string). Register the permission inlib/permissions-catalog.ts. - Actions —
lib/actions/<feature>/<capability>.ts('use server'). Follow the contract above: validate → authorize → space-scope every query →logActivity→revalidatePath. Barrel vialib/actions/<feature>/index.ts, thenexport * from './<feature>'inlib/actions/index.ts. - UI —
app/(app)/<feature>/page.tsx(server: fetch + guard, pass data as props) +<feature>-client.tsx(thin orchestrator) +panels/+components/+types.ts. Client components call server actions from@/lib/actions— never write to the DB from the browser client (that skips validation, the authz gate, the activity log, and — for encrypted data — encryption). - Tests — pure-logic unit tests in
__tests__/; an action-layer test asserting the space-scope and the guard rejection (mirror__tests__/actions.test.ts).
Security model (summary)
- Two enforcement layers: Postgres RLS (all 50 tables) and explicit code-side authz +
space_idscoping in every action. The admin (service-role) client bypasses RLS, so code-side scoping is mandatory there. - Roles/statuses live in
lib/permissions.ts.PRIVILEGE_STATUSESexcludesunverified— a member pending approval in arequire_approvalspace holds no privileged capability. - Secrets are AES-256-GCM encrypted at rest (
lib/secrets/*); always write them via thecreateSecret/updateSecretserver actions (the key is server-only). - Public endpoints self-authenticate: Stripe (per-space signing secret + replay guard), door inbound
(per-connection vault bearer,
timingSafeEqual), crons (CRON_SECRET),/track(192-bit token).
Build & CI
pnpm run typecheck(tsc --noEmit),pnpm run lint(ESLint 9 flat config,eslint.config.mjs),pnpm run test:run(Vitest)..github/workflows/ci.ymlruns all three on PRs andmain.next buildtype-checks (noignoreBuildErrors).maindeploys to production on every push (.github/workflows/deploy.yml→ SSH → the droplet'sdeploy.sh).
Known limitations / follow-ups
- Single active space per user (
getAuthMemberuses.single(), fail-closed). Multi-space needs an explicit active-space selector before that changes. - Regenerate
types/database.tsto drop the hand-added RPC entries and the// TODO(types)join casts, and to de-duplicate the governance row types intypes/domain/governance.tsintoTables<>. markOnboardingStepDoneread-modify-writes a JSON column; a concurrent double-submit can drop a step. A DB-side atomic append (RPC / jsonb operator) is the fix.- Integration + e2e suites need a DB service wired into CI (only unit tests + typecheck + lint run today).
- Full authenticated UI testing hasn't run in this environment (no local Docker/Supabase). The public
surface + routing were browser-smoke-tested; the authed screens are verified by
next build+ tsc + tests.
All client-component DB writes now go through server actions (the ops area-lead and comms message writes were the last direct-browser-write holdouts). The only browser-client usage that remains is auth (login/signup) and the comms realtime read, which the postgres_changes subscription requires.