Files
Moheeb Zara 8edc9da611 docs: audit corrections, coverage pages, screenshots, and em-dash removal
Finalizes the docs section after an adversarial audit (code review + per-page
fact-check + coherence critic) and two content requests.

- Accuracy: fixed 10 invented cross-link slugs, the admin-vs-board gating claims
  the verifiers caught (permissions matrix, /settings, invites, onboarding,
  connect-payments, proposal withdraw, Stripe reconciliation, member fields,
  door.operate), and 2 issues in the new pages (project area-at-creation, forum
  thread-delete role).
- Coverage: added 5 how-to pages so every module is documented (recurring Stripe
  dues, tasks & chores, projects, comms & forum, ops knowledge base + secrets
  vault). 34 pages total.
- Screenshots: 12 captioned app screenshots under public/docs-media/, embedded
  as figures. Each tutorial now has 2-3 step screenshots.
- Style: removed all em-dashes/en-dashes/ellipses from the docs (natural ASCII
  punctuation).
- Handoff (docs/HANDOFF-audit-cleanup.md) updated with the current state.

typecheck 0 · lint 0 errors · build ok (all 34 /docs pages prerendered)
2026-08-05 03:15:07 -07:00

58 lines
5.9 KiB
Markdown

hackerspace.sh is a Next.js 16 App Router app written in TypeScript, backed by Supabase (Postgres plus Auth). Every space is a tenant, and the whole platform is built around one idea: reads flow through row-level security, and writes flow through server actions that authorize and scope themselves. This page explains that shape and why it is drawn this way.
## The stack
The app is a single Next.js codebase. The UI is React Server Components under `app/(app)/`, the database is Postgres managed by Supabase, and authentication runs on Supabase Auth (GoTrue). There is no separate API service, the browser talks to Next.js, and Next.js talks to Postgres. In production, both the app and a self-hosted Supabase stack run on one DigitalOcean droplet behind Caddy, and every push to `main` deploys automatically: a GitHub Actions workflow runs the deploy script over SSH, which applies any new SQL migrations, rebuilds, and restarts the app.
## Route groups
The `app/` directory uses App Router route groups, parenthesized folders that organize routes without adding a URL segment:
- `app/(app)/` holds the authenticated product: `/members`, `/customize`, `/proposals`, `/door`, and the rest. Its `layout.tsx` is the gate every one of those screens renders inside.
- `app/(landing)/` holds the public marketing page at `/`.
A screen under `app/(app)/<route>/` follows one convention: a server `page.tsx` fetches data and runs its guard, then hands the data as props to a thin `<route>-client.tsx` orchestrator that composes `panels/` and `components/`. Authorization and data-loading live in the server component; the client component only orchestrates interaction.
## Three Supabase clients
There are three ways to reach Postgres, and choosing the right one is the core security decision in any change.
| Client | Factory | Key | RLS | Use for |
|---|---|---|---|---|
| Server | `lib/supabase/server.ts` → `createClient()` | anon | Enforced | Server components and most server actions |
| Browser | `lib/supabase/client.ts` → `createClient()` | anon | Enforced | Auth (login/signup) and the comms realtime subscription only |
| Admin | `lib/supabase/admin.ts` → `createAdminClient()` | service role | **Bypassed** | Trusted server-only work that must cross a normal RLS boundary |
The server client is cookie-bound: it reads the caller's Supabase session from cookies and runs every query as that user, so RLS decides what rows come back. The browser client uses the same anon key and is deliberately confined, client components never write to the database directly, because a direct browser write skips validation, the authorization gate, the activity log, and (for encrypted data) encryption.
The admin client uses the service-role key and bypasses RLS entirely. That makes code-side scoping non-negotiable: **every read, update, and delete on the admin client must be constrained with `.eq('space_id', member.space_id)`** (or an id already proven to belong to that space). RLS will not catch a mistake there, the scope is the only thing standing between a query and a cross-tenant leak.
## The request path
A request to an authenticated screen passes two gates before it renders:
1. `proxy.ts` at the repo root is the Next 16 middleware (the framework renamed `middleware` to `proxy`). It refreshes the Supabase session cookie on every request and checks the path against a `PUBLIC_ROUTES` allowlist. Anything not on the list requires a session, or the request is redirected to `/login`. Public routes, webhooks, cron endpoints, the `/f/` public forms, `/track`, the resources subsite, are exempt from the session check because each one self-authenticates with its own signature, bearer secret, or token.
2. `app/(app)/layout.tsx` is the second gate, defense in depth. It re-checks the user with `auth.getUser()`, loads the active `space_members` row, and redirects to `/signup` when there is no space or `/onboarding` when onboarding is incomplete. It is marked `force-dynamic` so it never caches, the membership check runs on every request.
## Server actions are the write path
Every mutation is a `'use server'` action under `lib/actions/`, and they all follow one contract:
```ts
const v = parseInput(schema, input) // 1. validate with Zod
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES) // 2. authorize
if (!auth.ok) return { error: auth.error }
const { member } = auth
// 3. mutate, scoped by member.space_id
await logActivity(supabase, member, 'updated', 'thing', id) // 4. audit
revalidatePath('/things') // 5. revalidate
```
The shared helpers in `lib/auth-helpers.ts` make that uniform: `parseInput` runs a Zod schema and returns a discriminated result; `requireMember` loads the caller's active membership; `requireMemberWithRole` additionally checks role and rejects a member whose status is `unverified` (pending approval holds no privileged capability); `logActivity` writes an advisory row to `activity_log`. `getAuthMember` selects the single active membership with `.single()`, the platform is one active space per user today, and that call fails closed rather than guess if a user somehow has two.
## Why it is drawn this way
The design exists to make cross-tenant isolation hard to get wrong. RLS on all tables is the backstop, but RLS alone is fragile: the moment code reaches for the admin client to do something legitimate, the backstop is gone. So the platform pairs RLS with an explicit, repeated pattern, validate, authorize, scope by `space_id`, audit, revalidate, that a reviewer can check at a glance and a test can lock in. Keeping writes in server actions rather than the browser makes that pattern the *only* way data changes, so validation, permission checks, and the activity trail can never be skipped. The uniformity is the point: every feature is built the same way, so the security-critical steps stay boring and visible.