feat: governance kernel, deployment hardening, local-dev stack
Adds proposals + proposal_votes + policies + incidents + incident_updates
tables with full RLS, async voting flow, dismissal-appeal pathway,
versioned policies with provenance, and an anonymous-incident token mode.
Adds per-space configurable areas, financial visibility settings, member
self-profile + COI disclosure, and a mission statement that renders in
the app footer.
Reorganises lib/actions.ts into per-domain files (tasks, projects,
members, payments, contacts, knowledge-base, secrets, area-leads,
settings, imports, proposals, incidents, policies, areas) plus a barrel.
Adds lib/permissions.ts and lib/auth-helpers.ts so action bodies stop
duplicating the user-to-member-to-role check. Wires every action that has
a Zod schema; introduces a flexibleDateTime preprocessor so HTML date
pickers no longer hit "Invalid datetime".
Hardens RLS: drops NOT NULL on space_members.user_id for offline members,
adds a privilege-escalation trigger that blocks self-edits to role / tier
/ status / approved / has_card_access / space_id, closes four
cross-tenant holes in the governance kernel, and adds a partial unique
index so two admins cannot concurrently activate two drafts of the same
policy slug.
Ships every deployment artifact: Dockerfile, docker-compose,
.do/app.yaml, .env.example, /api/health, plus a managed-Supabase guide
(docs/DEPLOYMENT.md), an end-to-end self-hosted Droplet guide
(docs/DEPLOY_DO_SELFHOSTED.md), and a Supabase-CLI local-dev quickstart
(docs/LOCAL_DEV.md). Replaces middleware.ts with proxy.ts for Next.js 16.
Adds scripts/schema.sql as the canonical idempotent full deploy plus
fourteen numbered incremental migrations.
Adds scripts/seed.sql so a fresh local DB renders something instead of
empty pages: a demo space, five members across all roles, a v1 code of
conduct, three proposals in different states with seven votes already
cast, an open incident, a project, three tasks, a KB entry, and matching
activity-log rows.
Replaces v0-generated test scaffolding with 266 real tests across
__tests__/{actions,auth-helpers,components,governance,permissions,
security,utils,validations}.test.ts. Adds react-markdown + remark-gfm
for proposal / incident / policy body rendering. Deletes four dead v0
fix-* scripts, the unused components/app-shell.tsx, and the duplicate
app/auth/login/page.tsx.
Routes (31 total): /api/health, /api/paypal/sync, /auth/callback, /,
/login, /signup, /signup/confirm, /dashboard, /tasks, /projects,
/members, /payments, /financials, /contacts, /ops, /ops/[id], /ops/new,
/proposals, /proposals/[id], /proposals/new, /incidents, /incidents/[id],
/incidents/new, /policies, /policies/[slug], /policies/new, /profile,
/recruitment, /settings, /comms, /import.
Full pass-by-pass history is in docs/HANDOFF.md; the roadmap is in
docs/GOVERNANCE_FEATURES.md.
This commit is contained in:
+471
@@ -0,0 +1,471 @@
|
||||
# Codebase Audit
|
||||
|
||||
Date: 2026-05-13 through 2026-05-14 (three passes)
|
||||
Scope: every file outside `node_modules`, `.next`, and `.git`.
|
||||
Method: read each source file, cross-check against the live database type definitions in `types/database.ts`, the canonical schema in `scripts/schema.sql`, and the column map in `DB_SCHEMA_MAP.md`.
|
||||
|
||||
This document supersedes the older `AUDIT.md`, `IMPLEMENTATION_STATUS.md`, and `docs/PRODUCTION_AUDIT.md` for findings as of this date. Those files remain as historical context.
|
||||
|
||||
- **Pass 1** (2026-05-13): fresh-deploy blockers and deployment artifacts.
|
||||
- **Pass 2** (2026-05-13): validation schema enum drift, RLS privilege escalation, dashboard task filter bug, dead duplicate routes, auth function duplication.
|
||||
- **Pass 3** (2026-05-14): architecture refactor. Split monolithic `lib/actions.ts` by domain, introduced `lib/permissions.ts` and `lib/auth-helpers.ts`, wired validation into every action with a schema, added 76 real tests, deleted remaining dead code, added `scripts/README.md`.
|
||||
|
||||
All three passes are reflected below.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the system is
|
||||
|
||||
A multi-tenant member-management app for hackerspaces. Each tenant is a `spaces` row; every other table is keyed by `space_id`. Authentication uses Supabase Auth (email + password, OAuth wiring is stubbed). All data access goes through Supabase with row-level security enforced at the database. The Next.js app uses server components for reads, server actions for writes, and one client-side Realtime subscription for chat.
|
||||
|
||||
Stack:
|
||||
|
||||
| Layer | Stack |
|
||||
|-------|-------|
|
||||
| Framework | Next.js 16 (App Router), React 19, TypeScript 5.7 |
|
||||
| Styling | Tailwind v4 (CSS-native config), shadcn/ui, Lucide icons |
|
||||
| Backend | Next.js server actions, one REST route under `/api/paypal/sync`, one `/api/health` route |
|
||||
| Data | Supabase Postgres + Auth + Realtime |
|
||||
| Tests | Vitest (unit), Playwright (E2E) |
|
||||
| Hosting | Vercel, DigitalOcean App Platform, or self-hosted Droplet (Dockerfile + compose ship with the repo) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Repo layout
|
||||
|
||||
```
|
||||
/
|
||||
app/
|
||||
(app)/ Protected routes. Layout enforces auth + membership.
|
||||
dashboard/ Server component, parallel counts.
|
||||
tasks/ Server page + client component.
|
||||
projects/ Kanban-style board.
|
||||
members/ Admin/board only mutations.
|
||||
payments/ Treasurer/admin/board only.
|
||||
comms/ Realtime chat (Supabase channel subscription).
|
||||
contacts/ Member-writable CRUD.
|
||||
ops/ Knowledge base, secrets, area leads.
|
||||
ops/[id]/ KB entry detail + editor.
|
||||
ops/new/ KB entry create form.
|
||||
import/ CSV import UI (partial).
|
||||
settings/ Space + integrations.
|
||||
(landing)/ Public landing page.
|
||||
auth/callback/route.ts OAuth code exchange.
|
||||
api/
|
||||
health/route.ts Liveness probe (added this audit).
|
||||
paypal/sync/route.ts Per-space PayPal pull.
|
||||
login/, signup/ Public auth pages.
|
||||
layout.tsx, page.tsx Root layout, root redirect.
|
||||
components/
|
||||
ui/ shadcn primitives.
|
||||
app-sidebar.tsx The actual sidebar (role-gated).
|
||||
app-shell.tsx DEAD: not imported by any page.
|
||||
task-claim-button.tsx Used by tasks-client.
|
||||
theme-provider.tsx next-themes wrapper.
|
||||
hooks/
|
||||
use-mobile.ts, use-toast.ts
|
||||
lib/
|
||||
supabase/
|
||||
client.ts Browser client.
|
||||
server.ts Server component client (cookie-bound).
|
||||
admin.ts Service-role client. Bypasses RLS.
|
||||
proxy.ts Middleware session refresher.
|
||||
actions.ts All mutation server actions.
|
||||
auth-actions.ts Auth-specific server actions.
|
||||
validations.ts Zod schemas (most are not wired up, see findings).
|
||||
security.ts Sanitisation, rate limiting, helpers.
|
||||
utils.ts Tailwind classname merge.
|
||||
types.ts Re-exports from generated database types.
|
||||
types/
|
||||
database.ts Generated Supabase types. Source of truth for column shape.
|
||||
middleware.ts Auth gate. Public routes list lives here.
|
||||
scripts/
|
||||
schema.sql Canonical, idempotent, full-schema deploy.
|
||||
014_member_user_id_nullable.sql Most recent incremental migration.
|
||||
patch-dashboard.mjs, fix-actions.js, fix-sidebar.js, fix-sidebar.mjs DEAD scripts.
|
||||
__tests__/ Vitest specs.
|
||||
e2e/ Playwright specs.
|
||||
docs/ Architecture, deployment, database, API, audit.
|
||||
Dockerfile, docker-compose.yml, .dockerignore Self-host artifacts.
|
||||
.do/app.yaml DigitalOcean App Platform spec.
|
||||
.env.example Env var template.
|
||||
CLAUDE.md Working agreement.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Issues fixed in this audit
|
||||
|
||||
### 3.1 `space_members.user_id` was NOT NULL but the app needs it nullable
|
||||
|
||||
The schema declared:
|
||||
|
||||
```sql
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE
|
||||
```
|
||||
|
||||
But `addMember()` (`lib/actions.ts:302`) and `importMembers()` (`lib/actions.ts:897`) insert rows with no `user_id`. These are "offline" members added by an admin or CSV-imported, who have not signed up yet. On a fresh Supabase project, both code paths fail with a NOT NULL violation.
|
||||
|
||||
Fix applied:
|
||||
- `scripts/schema.sql`: dropped `NOT NULL` on `space_members.user_id`. Added a comment explaining the choice.
|
||||
- `scripts/014_member_user_id_nullable.sql`: new incremental migration for existing databases.
|
||||
|
||||
The `(space_id, user_id)` unique constraint still works because Postgres treats NULL as distinct, so multiple offline members with `user_id IS NULL` coexist. The partial unique index on `(space_id, email)` (`WHERE email IS NOT NULL`) prevents duplicate offline-member emails.
|
||||
|
||||
### 3.2 Realtime publication statements were not idempotent
|
||||
|
||||
The closing two lines of `schema.sql` were:
|
||||
|
||||
```sql
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_messages;
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_channels;
|
||||
```
|
||||
|
||||
`ALTER PUBLICATION ... ADD TABLE` errors if the table is already in the publication, which breaks the "idempotent" promise the file makes in its header. Wrapped both in `DO $$ ... EXCEPTION WHEN duplicate_object THEN NULL; END $$` blocks.
|
||||
|
||||
### 3.3 RLS policies were not idempotent
|
||||
|
||||
Section 6 of `schema.sql` does `CREATE POLICY ...` without `IF NOT EXISTS` (which Postgres 15 does not support). Re-running the file on a database that already has policies errored with `policy ... already exists`. Added a block of `DROP POLICY IF EXISTS` statements before the `CREATE POLICY` block.
|
||||
|
||||
Net result of 3.1, 3.2, 3.3: `scripts/schema.sql` now runs cleanly on both a fresh Supabase project and an existing one. The header comment claiming idempotency is now true.
|
||||
|
||||
### 3.4 Missing deployment artifacts
|
||||
|
||||
The deployment guide instructed users to "create" a Dockerfile, a docker-compose file, and to manually edit `next.config.mjs`. None of these artifacts existed in the repo. App Platform users had no spec to reference.
|
||||
|
||||
Added:
|
||||
- `Dockerfile` (multi-stage, standalone output, runs as non-root, healthcheck).
|
||||
- `docker-compose.yml` (service definition with healthcheck, env_file).
|
||||
- `.dockerignore` (excludes git, node_modules, dev artifacts, secrets).
|
||||
- `.do/app.yaml` (App Platform spec with health check path).
|
||||
- `.env.example` (every variable the app reads, with comments).
|
||||
- `app/api/health/route.ts` (liveness probe used by Docker, App Platform, and operators).
|
||||
- Updated `middleware.ts` to include `/api/health` in the public routes list so the healthcheck does not redirect to `/login`.
|
||||
- Updated `docs/DEPLOYMENT.md` to reference the bundled artifacts instead of asking users to write their own. Also removed the incorrect note about manually editing `next.config.mjs` (the file already keys `output: 'standalone'` off `DOCKER_BUILD=1`, which the Dockerfile sets).
|
||||
|
||||
A fresh clone is now: `pnpm install`, copy `.env.example` to `.env.local`, run `scripts/schema.sql` in Supabase, `pnpm dev`.
|
||||
|
||||
### 3.5 Dashboard "Quick Chores" filter was wrong
|
||||
|
||||
`app/(app)/dashboard/page.tsx:49` filtered tasks with `.neq('status', 'done')` to find open work. The actual task_status enum has both `done` (legacy) and `completed`, and `completeTask()` writes `'completed'`. Completed tasks therefore leaked into the dashboard's open-tasks list. Replaced with a positive filter on the open statuses: `open, claimed, in_progress, overdue, due_today, blocked`.
|
||||
|
||||
### 3.6 Zod validation schemas had wrong enum values
|
||||
|
||||
`lib/validations.ts` declared enums that disagreed with the database in six places. The schemas were mostly unused (only `createTask` and `taskId` were wired), so the bugs were latent. Both fixed:
|
||||
|
||||
- `createTaskSchema.type`: was free-form string, now `enum(['task','chore'])`.
|
||||
- `createTaskSchema.recurrence`: missing `biweekly`. Added.
|
||||
- `updateProjectStatusSchema.status`: had `planning, active, paused, completed, cancelled`. Replaced with the real `backlog, in_progress, review, done, blocked`.
|
||||
- `addMemberSchema.role` and `updateMemberSchema.role`: missing `associate`. Added.
|
||||
- `createContactSchema.contact_type`: had `vendor, sponsor, partner, city, media, other`. Replaced with the real `vendor, supplier, partner, landlord, city`.
|
||||
- `createKbEntrySchema.visibility`: had `public, members, board, admin`. Replaced with the real `all_members, board, admin_only`.
|
||||
|
||||
Also tightened: `logCashPaymentSchema` (removed unused `from_identifier`, added `member_id`), `updateSpaceSettingsSchema` (added `slug`), `upsertAreaLeadSchema` (matched the action's parameter shape).
|
||||
|
||||
### 3.7 Privilege escalation on `space_members` self-update
|
||||
|
||||
The `members_update` RLS policy let a member update their own row including `role`, `tier`, `status`, `approved`, and `has_card_access`. An authenticated member could PATCH their own membership via PostgREST and become an admin.
|
||||
|
||||
Fixed with a `BEFORE UPDATE` trigger (`prevent_member_self_role_change`) that rejects any change to protected columns when the row belongs to the current user and the current user is not already admin/board/treasurer in that space. Service-role calls (no `auth.uid()`) and privileged self-edits are unaffected. Applied to `schema.sql` and shipped as incremental migration `scripts/015_prevent_member_self_role_change.sql`.
|
||||
|
||||
### 3.8 Validation wired into the highest-risk actions
|
||||
|
||||
The Zod schemas were imported by `lib/actions.ts` but never actually invoked except by `createTask` and `claimTask`. Wired the now-correct schemas into:
|
||||
|
||||
- `createTask` (already)
|
||||
- `claimTask` (already)
|
||||
- `createProject`
|
||||
- `updateProjectStatus`
|
||||
- `addMember`
|
||||
- `logCashPayment`
|
||||
- `linkPaymentToMember`
|
||||
|
||||
Remaining unwired actions are noted in section 4.
|
||||
|
||||
### 3.9 Duplicate auth functions in `lib/actions.ts`
|
||||
|
||||
Lines 30 to 47 of `lib/actions.ts` redeclared `signIn`, `signOut`, and `getUser`, each a slightly worse copy of the version in `lib/auth-actions.ts`. No call site imported them from `actions.ts`. Removed.
|
||||
|
||||
### 3.10 Dead one-off scripts removed
|
||||
|
||||
Deleted `scripts/fix-actions.js`, `scripts/fix-sidebar.js`, `scripts/fix-sidebar.mjs`, and `scripts/patch-dashboard.mjs`. All four had hardcoded `/vercel/share/v0-project` paths and were one-time patches that have long since landed in the tree.
|
||||
|
||||
### 3.11 `types/database.ts` did not match the schema for `user_id`
|
||||
|
||||
After making `space_members.user_id` nullable in `scripts/schema.sql`, the generated types were stale. Updated `Row.user_id` to `string | null` and `Insert.user_id` / `Update.user_id` to optional nullable. Regenerate properly with `supabase gen types typescript` on next schema sync.
|
||||
|
||||
### 3.12 Signup page copy referenced channels that are not created
|
||||
|
||||
The "Create Space" panel told users "Default channels (general, announcements, random) will be created automatically." The schema's `create_default_channels` trigger creates `general`, `announcements`, `ops`. Updated the copy to match.
|
||||
|
||||
### 3.13 Monolithic `lib/actions.ts` split by domain
|
||||
|
||||
`lib/actions.ts` was 914 lines and held 29 exported server actions for every entity in the system. Maintenance friction was high. The duplication was severe: 25+ copies of "get user, then look up active member, then check role" and "insert activity_log row" boilerplate.
|
||||
|
||||
Refactored into `lib/actions/` containing:
|
||||
|
||||
- `tasks.ts`, `projects.ts`, `members.ts`, `contacts.ts`, `payments.ts`, `knowledge-base.ts`, `secrets.ts`, `area-leads.ts`, `settings.ts`, `imports.ts` — one file per entity, each carrying `'use server'`.
|
||||
- `index.ts` — barrel that re-exports every action so the public import path `@/lib/actions` remains stable.
|
||||
|
||||
Every client component that imports from `@/lib/actions` was verified to keep working. The barrel is plain re-exports; Next.js preserves the `'use server'` marking from the source files.
|
||||
|
||||
### 3.14 Shared helpers in `lib/auth-helpers.ts` and `lib/permissions.ts`
|
||||
|
||||
Two new modules absorb the repeated boilerplate.
|
||||
|
||||
`lib/permissions.ts`:
|
||||
- `ROLES`, `ADMIN_ROLES`, `TREASURER_ROLES`, `ALL_ROLES`, `ACTIVE_STATUSES` as const tuples.
|
||||
- `Role` and `MemberStatus` type aliases.
|
||||
- `hasRole(role, allowed)` helper.
|
||||
|
||||
`lib/auth-helpers.ts`:
|
||||
- `getAuthMember(supabase)`: returns the active member or null.
|
||||
- `requireMember(supabase)`: returns `{ ok: true, member } | { ok: false, error }`.
|
||||
- `requireMemberWithRole(supabase, allowed, label?)`: same plus role gate.
|
||||
- `parseInput(schema, input)`: Zod parse with a discriminated result.
|
||||
- `logActivity(supabase, member, action, entityType, entityId, details?)`: fire-and-forget activity log insert.
|
||||
|
||||
The result types use the canonical `ok: boolean` discriminator so TypeScript narrows reliably under `strict: true`.
|
||||
|
||||
### 3.15 Validation wired into every action that has a schema
|
||||
|
||||
After pass 2, only 7 actions called their schemas. Pass 3 wired the remaining 11:
|
||||
|
||||
- `updateMember`, `approveMember`, `removeMember`
|
||||
- `createContact`, `updateContact`, `deleteContact`
|
||||
- `createKbEntry`, `updateKbEntry`, `deleteKbEntry`
|
||||
- `createSecret`, `deleteSecret`
|
||||
- `upsertAreaLead`
|
||||
- `updateSpaceSettings`, `saveIntegration`
|
||||
- `disconnectIntegration` (inline string check, no Zod schema needed)
|
||||
- `importMembers`, `importPaymentsCsv` (array-level guard + filter)
|
||||
|
||||
Bad input now returns a clean error message instead of a database-level failure.
|
||||
|
||||
### 3.16 Real tests, not assertion theatre
|
||||
|
||||
The existing `__tests__/actions.test.ts` and `__tests__/utils.test.ts` were inline value assertions that never imported project code. Kept for compatibility, but four new files exercise the real units:
|
||||
|
||||
| File | What it covers |
|
||||
|------|----------------|
|
||||
| `__tests__/validations.test.ts` | Every Zod schema, asserting real DB enum values pass and legacy ones fail. 37 tests. |
|
||||
| `__tests__/permissions.test.ts` | Role constants, `ACTIVE_STATUSES`, `hasRole`. 10 tests. |
|
||||
| `__tests__/security.test.ts` | `sanitizeString`, `escapeHtml`, `stripHtml`, `sanitizeEmail`, `sanitizeUrl`, `sanitizeSlug`, `hasSqlInjectionPatterns`, `isValidUuid`, `truncate`, `checkRateLimit`, `validateContentLength`. 25 tests. |
|
||||
| `__tests__/auth-helpers.test.ts` | `parseInput` parse/error path. 4 tests. |
|
||||
|
||||
Total: 202 tests across 7 files, all green.
|
||||
|
||||
### 3.17 Vitest config fixed
|
||||
|
||||
- `vitest.setup.ts` had JSX inside a `.ts` file (esbuild refused: "Expected '>' but found '{'"). Replaced the `next/image` mock with `React.createElement`.
|
||||
- `vitest.config.ts` was picking up Playwright e2e specs and trying to run them. Added explicit `include` and `exclude` globs.
|
||||
|
||||
### 3.18 Final dead-code sweep
|
||||
|
||||
Deleted:
|
||||
- `app/auth/login/page.tsx` (dead duplicate login at `/auth/login` linking to non-existent `/auth/onboarding`).
|
||||
- `components/app-shell.tsx` (no importer; hardcoded badge counts; no role gating).
|
||||
|
||||
### 3.19 Shared types consolidated in `lib/types.ts`
|
||||
|
||||
`lib/types.ts` already re-exported the generated row and enum types. Added:
|
||||
- `MemberSummary`: common projection used by sidebars and props.
|
||||
- `ActionResult<T>`: standard server-action return shape.
|
||||
- Re-exports of `Role`, `ROLES`, `ADMIN_ROLES`, `TREASURER_ROLES`, `ALL_ROLES`, `ACTIVE_STATUSES`, `hasRole` from `lib/permissions.ts`.
|
||||
- Re-exports of `Member`, `Result`, `MemberResult`, `ServerSupabase` from `lib/auth-helpers.ts`.
|
||||
|
||||
One-stop import for any consumer of typed entities.
|
||||
|
||||
### 3.20 Migration conventions documented
|
||||
|
||||
Added `scripts/README.md` covering: canonical vs incremental files, naming, the idempotency rules (`IF NOT EXISTS`, `DO ... EXCEPTION`, `DROP POLICY IF EXISTS`, etc), the security baseline for new tables, and verification queries for fresh deploys.
|
||||
|
||||
---
|
||||
|
||||
## 4. Open issues (not fixed in this pass)
|
||||
|
||||
These are documented for the next person, not silently shipped. Each one has a concrete location and a concrete fix.
|
||||
|
||||
### 4.1 (was open: dead scripts) RESOLVED in pass 2. See 3.10.
|
||||
|
||||
### 4.2 (was open: dead `components/app-shell.tsx`) RESOLVED in pass 3. See 3.18.
|
||||
|
||||
### 4.3 (was open: dead `app/auth/login`) RESOLVED in pass 3. See 3.18.
|
||||
|
||||
### 4.4 (was open: validation not wired everywhere) RESOLVED in pass 3. See 3.15.
|
||||
|
||||
### 4.4a Supabase TypeScript generic drift
|
||||
|
||||
`@supabase/ssr` and `@supabase/supabase-js` emit `SupabaseClient` with 3 generics. Our `types/database.ts` was generated against an older signature. The result: 38 `lib/actions/*` and 103 `app/(app)/*` TypeScript errors of the form `Argument of type '{...}' is not assignable to parameter of type 'never'`.
|
||||
|
||||
These errors already existed in the pre-refactor codebase. They are masked by `next.config.mjs: typescript.ignoreBuildErrors: true`. The runtime is unaffected: Supabase's PostgREST client accepts the values fine.
|
||||
|
||||
Fix: regenerate `types/database.ts` with the latest Supabase CLI (`supabase gen types typescript --project-id <ref>`). Then drop `typescript.ignoreBuildErrors`.
|
||||
|
||||
### 4.5 `signUp` metadata is not consumed by the auth trigger
|
||||
|
||||
`lib/auth-actions.ts:signUp` passes metadata keys `full_name, action, space_name, space_slug, city, invite_code`. The `handle_space_signup` trigger in `schema.sql` reads `space_id, role, display_name`. Different keys, so the trigger short-circuits and does nothing. That is actually fine because `createSpace` and `joinSpace` insert the membership themselves using the admin client. But the trigger is then dead code unless someone passes `space_id` in `data`. Either:
|
||||
|
||||
- Document the trigger as a future-use safety net and add a code comment, or
|
||||
- Remove the trigger and stop pretending it does anything.
|
||||
|
||||
### 4.6 Several documented features are UI-only or unfinished
|
||||
|
||||
| Feature | Status | Location |
|
||||
|---------|--------|----------|
|
||||
| GitHub / Google OAuth | Client wired (`signInWithOAuth`) but needs provider config in Supabase Auth | `app/login/page.tsx:31` |
|
||||
| Webhook endpoint | URL displayed in settings, no handler | (none) |
|
||||
| Zeffy, Venmo, Stripe sync | Config storage only, no API code | `lib/actions.ts:saveIntegration` |
|
||||
| CSV import | UI uploads a file but does not process it | `app/(app)/import/import-client.tsx` |
|
||||
| Email notifications | None | (none) |
|
||||
| Task recurrence auto-rollover | Cron job not implemented | (none) |
|
||||
| Secrets encryption | Plain text in DB | `secrets.value` |
|
||||
|
||||
None of these are runtime errors. They are missing-feature stubs. They are listed here so deployers know what is and is not real. Pass 1's audit incorrectly listed OAuth as "UI only, no handlers"; the client-side OAuth call is actually wired. What is missing is the provider configuration in the Supabase dashboard.
|
||||
|
||||
### 4.7 `package.json` name is `"my-project"`
|
||||
|
||||
Cosmetic. Should be `"hackerspace-management"`. Not a deploy blocker.
|
||||
|
||||
### 4.8 `tsconfig.json` has `"strict": true` but `next.config.mjs` has `typescript.ignoreBuildErrors: true`
|
||||
|
||||
The build does not fail on type errors. This is a v0 default that should be removed once the codebase passes `pnpm exec tsc --noEmit` cleanly. The pass-2 schema and types edits move the needle. Remaining issues are minor.
|
||||
|
||||
### 4.9 Default channels: 3 vs 4
|
||||
|
||||
`schema.sql` `create_default_channels` inserts 3 channels: `general`, `announcements`, `ops`. Older docs (`DB_SCHEMA_MAP.md`, `docs/ARCHITECTURE.md`) say 4 channels (`general`, `announcements`, `random`, `facilities`). The schema is current; the older docs are stale. The signup page copy was fixed in pass 2. Update `DB_SCHEMA_MAP.md` and `docs/ARCHITECTURE.md` on next docs pass.
|
||||
|
||||
### 4.10 (was open: members RLS privilege escalation) RESOLVED in pass 2. See 3.7.
|
||||
|
||||
---
|
||||
|
||||
## 5. Database column map versus code use
|
||||
|
||||
I cross-checked every column accessed in `lib/actions.ts`, `lib/auth-actions.ts`, `app/api/paypal/sync/route.ts`, and the client components named in `app/(app)/*`. Findings:
|
||||
|
||||
- `space_members.last_paid_at`: present in schema, used by `linkPaymentToMember` and `importMembers`. OK.
|
||||
- `space_members.last_payment_at`: legacy duplicate. Schema keeps both. Code reads either with a fallback (`members-client.tsx`).
|
||||
- `knowledge_base.is_pinned` vs `pinned`: schema has both, code uses `is_pinned`. OK.
|
||||
- `secrets.title` vs `label`: schema has both, code writes both fields (`createSecret` at `lib/actions.ts:701`). OK.
|
||||
- `payments.status` vs `link_status`: schema has both, code uses `link_status`. OK.
|
||||
- `integrations.config` vs `credentials` / `settings`: schema has all three for legacy compatibility, code uses `config`. OK.
|
||||
- `tasks.task_type`: column matches enum name. Server actions write `task_type`, never `type`. OK.
|
||||
- `member_status` filter in app uses `('current', 'unverified', 'late')`. Schema enum matches. OK.
|
||||
|
||||
No drift between active code paths and the schema beyond what is in section 4.
|
||||
|
||||
---
|
||||
|
||||
## 6. Security posture
|
||||
|
||||
| Control | State |
|
||||
|---------|-------|
|
||||
| RLS on every table | Yes |
|
||||
| Service role key isolated to server | Yes, but `lib/actions.ts:30-47` and other call sites should be reviewed periodically |
|
||||
| Input validation | Partial. `createSpace` sanitises. `createTask` and `claimTask` use Zod. The rest do not |
|
||||
| Rate limiting | Present in `lib/security.ts` as in-memory map. Wired into `signIn` and `createSpace`. Not persistent. For multi-instance deploys, switch to Redis (Upstash) |
|
||||
| Secrets encryption at rest | None. `secrets.value` and `integrations.config` are plain text. Encrypt before merging to production |
|
||||
| CSRF / Origin check | Implicit through Next server actions + same-origin cookies. No explicit Origin check |
|
||||
| Audit log | `activity_log` table is populated by most write actions |
|
||||
| Privilege escalation on `space_members` update | Possible per 4.11, fix before production |
|
||||
|
||||
---
|
||||
|
||||
## 7. Test posture
|
||||
|
||||
- `__tests__/utils.test.ts`: assertion-only tests of helpers, no real imports of project code. Useful as smoke tests, not as coverage.
|
||||
- `__tests__/actions.test.ts`: mocked Supabase client, tests the call shape of server actions. Does not exercise RLS, does not hit a database.
|
||||
- `__tests__/components.test.tsx`: not present, but listed in `TESTING.md`. The doc lies.
|
||||
- `e2e/auth.spec.ts` and `e2e/critical-flows.spec.ts`: present, Playwright. Not verified to pass.
|
||||
|
||||
Recommended next steps: stand up a Supabase local instance for integration tests, add a CI job that runs `pnpm test` and `pnpm test:e2e`, fix `TESTING.md` to reflect reality.
|
||||
|
||||
---
|
||||
|
||||
## 8. Performance notes
|
||||
|
||||
- `app/(app)/layout.tsx` makes three sequential Supabase calls (member, taskCount, paymentCount). The latter two can run in `Promise.all`.
|
||||
- `app/(app)/dashboard/page.tsx` runs multiple queries; check whether they parallelise. (Not audited line-by-line, see file.)
|
||||
- Realtime: only `comms_messages` and `comms_channels` are in the publication. Good.
|
||||
- Indexes: 22 indexes are defined in `schema.sql`, covering every foreign key and common filter (`tasks.status`, `payments.transaction_date`, `comms_messages.channel_id`). Good.
|
||||
|
||||
---
|
||||
|
||||
## 9. Deployment readiness summary
|
||||
|
||||
| Target | Ready? | Notes |
|
||||
|--------|--------|-------|
|
||||
| Local dev | Yes | `pnpm install && pnpm dev`, with `.env.local` filled |
|
||||
| Vercel | Yes | Push to GitHub, connect, set env vars |
|
||||
| DigitalOcean App Platform | Yes | Use `.do/app.yaml` or the UI flow |
|
||||
| DigitalOcean Droplet (Docker) | Yes | Dockerfile and compose ship with the repo |
|
||||
| Database fresh deploy | Yes | One file: `scripts/schema.sql` |
|
||||
| Database upgrade from existing | Yes | Run new incremental migrations in `scripts/` only |
|
||||
|
||||
A fresh deploy will succeed. The open issues in section 4 are quality and feature gaps, not deploy blockers.
|
||||
|
||||
---
|
||||
|
||||
## 10. What changed across the three passes
|
||||
|
||||
### Pass 1 (deployment hardening)
|
||||
|
||||
Edited:
|
||||
- `scripts/schema.sql`: user_id nullable, realtime idempotent, RLS policies idempotent.
|
||||
- `middleware.ts`: `/api/health` added to public routes.
|
||||
- `docs/DEPLOYMENT.md`: env section, App Platform section, Droplet section (no longer asks user to write the Dockerfile/compose), post-deploy checklist (health probe), upgrading section.
|
||||
|
||||
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` (this file)
|
||||
- `docs/HANDOFF.md`
|
||||
- `README.md` rewritten
|
||||
|
||||
### Pass 2 (application repairs)
|
||||
|
||||
Edited:
|
||||
- `app/(app)/dashboard/page.tsx`: open-task filter now lists open statuses positively instead of `neq('status','done')` (was leaking `completed` tasks).
|
||||
- `app/signup/page.tsx`: default channel list in copy now reads `general, announcements, ops` to match the trigger.
|
||||
- `lib/validations.ts`: aligned enums for tasks, projects, members, contacts, KB; tightened settings, area-lead, and cash-payment schemas.
|
||||
- `lib/actions.ts`: removed duplicate `signIn`, `signOut`, `getUser` (kept in `auth-actions.ts`); wired `createProject`, `updateProjectStatus`, `addMember`, `logCashPayment`, `linkPaymentToMember` to their Zod schemas.
|
||||
- `scripts/schema.sql`: added `prevent_member_self_role_change` trigger to stop privilege escalation on self-update.
|
||||
- `types/database.ts`: `space_members.user_id` now `string | null` to match the schema.
|
||||
|
||||
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`
|
||||
|
||||
No business rules, no UI styling, no test code were changed. Pass 2 edits are bug fixes, security hardening, schema alignment, and dead-code removal.
|
||||
|
||||
### Pass 3 (architecture refactor + real tests)
|
||||
|
||||
Added:
|
||||
- `lib/permissions.ts` (role constants, `hasRole`)
|
||||
- `lib/auth-helpers.ts` (`requireMember`, `requireMemberWithRole`, `parseInput`, `logActivity`)
|
||||
- `lib/actions/` directory with 11 files (one per domain, plus `index.ts` barrel)
|
||||
- `__tests__/validations.test.ts` (37 tests)
|
||||
- `__tests__/permissions.test.ts` (10 tests)
|
||||
- `__tests__/security.test.ts` (25 tests)
|
||||
- `__tests__/auth-helpers.test.ts` (4 tests)
|
||||
- `scripts/README.md`
|
||||
|
||||
Edited:
|
||||
- `lib/types.ts` (now re-exports helpers + permissions for one-stop import)
|
||||
- `vitest.config.ts` (proper include/exclude globs)
|
||||
- `vitest.setup.ts` (replaced inline JSX in a .ts file with `React.createElement`)
|
||||
|
||||
Removed:
|
||||
- `lib/actions.ts` (replaced by `lib/actions/` directory; barrel preserves the public import path)
|
||||
- `app/auth/login/page.tsx` (dead duplicate)
|
||||
- `components/app-shell.tsx` (unused, hardcoded badges)
|
||||
|
||||
Test suite: **202 tests, 7 files, all passing**.
|
||||
|
||||
Pass 3 changes UI nothing, schema nothing, business rules nothing. They are pure organisation, test coverage, and dead-code removal.
|
||||
+76
-120
@@ -1,6 +1,8 @@
|
||||
# Hackerspace.sh — Deployment Guide
|
||||
|
||||
Complete instructions for every supported deployment target.
|
||||
Complete instructions for every supported deployment target that uses **managed Supabase Cloud** as the database / auth layer.
|
||||
|
||||
For a fully self-hosted deployment where you run Supabase yourself on a DigitalOcean Droplet (Postgres, GoTrue, PostgREST, Realtime, Storage, Studio — all on your own server), see [DEPLOY_DO_SELFHOSTED.md](./DEPLOY_DO_SELFHOSTED.md). That guide is end-to-end and assumes nothing else.
|
||||
|
||||
---
|
||||
|
||||
@@ -111,27 +113,27 @@ WHERE email = 'your@email.com';
|
||||
|
||||
## 3. Environment Variables
|
||||
|
||||
Create `.env.local` for local dev. For deployed environments, set these in your host's dashboard.
|
||||
The repo ships an `.env.example` file. Copy it locally:
|
||||
|
||||
```bash
|
||||
# ── Supabase ──────────────────────────────────────────────────
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://xxxxxxxxxxxx.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci...
|
||||
SUPABASE_SERVICE_ROLE_KEY=eyJhbGci...
|
||||
|
||||
# ── App ───────────────────────────────────────────────────────
|
||||
NEXT_PUBLIC_APP_URL=https://yourdomain.com
|
||||
|
||||
# ── PayPal (optional — set in app Settings UI too) ────────────
|
||||
# These are also stored per-space in the integrations table.
|
||||
# The API route reads from the DB, not from env vars.
|
||||
# No env vars needed for PayPal.
|
||||
|
||||
# ── Node ──────────────────────────────────────────────────────
|
||||
NODE_ENV=production
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
**Never commit `.env.local` to git.** The `.gitignore` already excludes it.
|
||||
Then fill in real values. For deployed environments, set the same keys in the host's dashboard.
|
||||
|
||||
Required variables:
|
||||
|
||||
| Key | Where it is used |
|
||||
|-----|------------------|
|
||||
| `NEXT_PUBLIC_SUPABASE_URL` | Browser + server, in every request |
|
||||
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser + server |
|
||||
| `SUPABASE_SERVICE_ROLE_KEY` | Server only. Bypasses RLS. Used by createSpace / joinSpace |
|
||||
| `NEXT_PUBLIC_APP_URL` | OAuth callback URLs |
|
||||
| `NODE_ENV` | Runtime mode |
|
||||
|
||||
Per-space integration credentials (PayPal, Zeffy, Venmo, Stripe) are stored in the `integrations` table, not in env. The Settings UI writes them; `/api/paypal/sync` reads them.
|
||||
|
||||
`.env.local` is git-ignored. Never commit real secrets.
|
||||
|
||||
---
|
||||
|
||||
@@ -190,24 +192,35 @@ git push origin feat/my-feature # triggers preview deploy
|
||||
|
||||
Managed container hosting. No Docker knowledge required. Scales automatically.
|
||||
|
||||
The repo ships an App Platform spec at `.do/app.yaml`. You can either apply it directly with `doctl` or use the UI flow below.
|
||||
|
||||
### 5.1 Push your repo to GitHub
|
||||
|
||||
DigitalOcean App Platform deploys directly from GitHub.
|
||||
App Platform deploys directly from GitHub.
|
||||
|
||||
```bash
|
||||
git remote add origin https://github.com/YOUR_ORG/hackerspace-management.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
### 5.2 Create the app
|
||||
### 5.2a Create the app via `doctl` (recommended)
|
||||
|
||||
Edit `.do/app.yaml` and replace `REPO_OWNER/REPO_NAME` and the placeholder env values. Then:
|
||||
|
||||
```bash
|
||||
doctl apps create --spec .do/app.yaml
|
||||
```
|
||||
|
||||
### 5.2b Or create via the UI
|
||||
|
||||
1. Go to [cloud.digitalocean.com/apps](https://cloud.digitalocean.com/apps)
|
||||
2. Click **Create App** → **GitHub** → authorize and select your repo
|
||||
2. Click **Create App** then **GitHub**, authorize and select your repo
|
||||
3. Select branch: `main`
|
||||
4. DigitalOcean auto-detects Next.js — confirm the build settings:
|
||||
- **Build command**: `pnpm install && pnpm build`
|
||||
4. App Platform auto-detects Next.js. Confirm the build settings:
|
||||
- **Build command**: `corepack enable && pnpm install --frozen-lockfile && pnpm build`
|
||||
- **Run command**: `pnpm start`
|
||||
- **HTTP port**: `3000`
|
||||
- **Health check path**: `/api/health`
|
||||
|
||||
### 5.3 Set environment variables
|
||||
|
||||
@@ -305,102 +318,38 @@ sudo chown -R deploy:deploy /opt/hackerspace
|
||||
cd /opt/hackerspace
|
||||
```
|
||||
|
||||
### 6.6 Create the Docker Compose file
|
||||
### 6.6 Use the bundled Dockerfile and Compose file
|
||||
|
||||
```bash
|
||||
cat > /opt/hackerspace/docker-compose.yml << 'EOF'
|
||||
version: "3.9"
|
||||
Both files ship in the repo root:
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: hackerspace_app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- .env.production
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: hackerspace_net
|
||||
EOF
|
||||
```
|
||||
Dockerfile (multi-stage build, standalone output, healthcheck)
|
||||
docker-compose.yml (service definition, env_file, healthcheck)
|
||||
.dockerignore
|
||||
```
|
||||
|
||||
### 6.7 Create the Dockerfile
|
||||
You do not need to write your own. The Dockerfile sets `DOCKER_BUILD=1` during build so `next.config.mjs` emits standalone output. The compose file reads `.env.local` by default; for production swap that to `.env.production` (next step) or edit the compose file inline.
|
||||
|
||||
### 6.7 Create the production env file
|
||||
|
||||
```bash
|
||||
cat > /opt/hackerspace/Dockerfile << 'EOF'
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable pnpm
|
||||
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
EOF
|
||||
cp /opt/hackerspace/.env.example /opt/hackerspace/.env.production
|
||||
nano /opt/hackerspace/.env.production
|
||||
```
|
||||
|
||||
### 6.8 Enable standalone output in Next.js config
|
||||
|
||||
Edit `next.config.mjs` and ensure it contains:
|
||||
|
||||
```js
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
// ...rest of your config
|
||||
}
|
||||
```
|
||||
|
||||
### 6.9 Create the production env file
|
||||
Fill in the real Supabase URL, anon key, service role key, and your app URL. Then lock the file:
|
||||
|
||||
```bash
|
||||
cat > /opt/hackerspace/.env.production << 'EOF'
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://xxxxxxxxxxxx.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci...
|
||||
SUPABASE_SERVICE_ROLE_KEY=eyJhbGci...
|
||||
NEXT_PUBLIC_APP_URL=https://yourdomain.com
|
||||
NODE_ENV=production
|
||||
EOF
|
||||
|
||||
# Lock down permissions — this file contains secrets
|
||||
chmod 600 /opt/hackerspace/.env.production
|
||||
```
|
||||
|
||||
### 6.10 Build and start the container
|
||||
Update `docker-compose.yml` to point at `.env.production` instead of `.env.local`, or symlink:
|
||||
|
||||
```bash
|
||||
ln -sf .env.production /opt/hackerspace/.env.local
|
||||
```
|
||||
|
||||
### 6.8 Build and start the container
|
||||
|
||||
```bash
|
||||
cd /opt/hackerspace
|
||||
@@ -409,6 +358,10 @@ docker compose up -d
|
||||
|
||||
# Check logs
|
||||
docker compose logs -f app
|
||||
|
||||
# Verify health endpoint
|
||||
curl http://localhost:3000/api/health
|
||||
# {"status":"ok","timestamp":"..."}
|
||||
```
|
||||
|
||||
### 6.11 Configure Nginx as reverse proxy
|
||||
@@ -526,31 +479,34 @@ sudo ufw status
|
||||
|
||||
Run through this after every fresh deployment:
|
||||
|
||||
- [ ] App loads at your domain with HTTPS
|
||||
- [ ] App loads at your domain over HTTPS
|
||||
- [ ] `GET /api/health` returns `{ "status": "ok" }`
|
||||
- [ ] `/signup` creates a user and redirects to `/dashboard`
|
||||
- [ ] Dashboard shows "No members yet" (not an error)
|
||||
- [ ] Supabase dashboard → **Authentication → Users** shows the signup
|
||||
- [ ] `space_members` table has a row for the new user
|
||||
- [ ] Chat page loads channels (general, announcements, ops)
|
||||
- [ ] Send a message — it appears immediately without page refresh
|
||||
- [ ] Upgrade your user to admin via SQL (see section 2.6)
|
||||
- [ ] Settings page → save space settings → no error toast
|
||||
- [ ] PayPal integration → save credentials → "LIVE" badge appears
|
||||
- [ ] Dashboard renders without errors (counts may be zero)
|
||||
- [ ] Supabase dashboard, Authentication > Users shows the signup
|
||||
- [ ] `space_members` table has a row for the new user, with `role = 'admin'` for the first user (`createSpace` sets this)
|
||||
- [ ] Chat page loads the three default channels (`general`, `announcements`, `ops`)
|
||||
- [ ] Send a message and watch it appear in another tab without refresh (verifies realtime)
|
||||
- [ ] Settings page saves without error
|
||||
- [ ] PayPal integration accepts credentials and shows the connected badge
|
||||
|
||||
---
|
||||
|
||||
## 8. Upgrading
|
||||
|
||||
When the schema changes, a new numbered script will be added to `scripts/`. For example `scripts/012_add_feature.sql`. Run only the new script in the Supabase SQL editor — do not re-run `schema.sql` on an existing database.
|
||||
When the schema changes, a new numbered script is added to `scripts/` (for example `scripts/014_member_user_id_nullable.sql`). On an existing database, run only the new script in the Supabase SQL editor.
|
||||
|
||||
```
|
||||
scripts/
|
||||
schema.sql ← fresh deployment only
|
||||
012_add_feature.sql ← incremental upgrade
|
||||
013_...sql
|
||||
schema.sql fresh deployment, full schema
|
||||
001_create_schema.sql historical, not used for fresh deploys
|
||||
...
|
||||
014_member_user_id_nullable.sql most recent incremental
|
||||
```
|
||||
|
||||
For Vercel / App Platform: push to `main`, the build auto-deploys.
|
||||
`scripts/schema.sql` is also idempotent: it uses `IF NOT EXISTS` for tables and indexes, `DROP POLICY IF EXISTS` before recreating policies, and `EXCEPTION WHEN duplicate_object` for enums and realtime publications. Re-running it on an existing database is safe but unnecessary.
|
||||
|
||||
For Vercel and App Platform: push to `main`, the build auto-deploys.
|
||||
|
||||
For Droplet: `ssh deploy@IP '/opt/hackerspace/deploy.sh'`
|
||||
|
||||
|
||||
@@ -0,0 +1,886 @@
|
||||
# Full self-hosted deployment on DigitalOcean
|
||||
|
||||
End-to-end guide to run the entire stack on your own hardware: self-hosted Supabase plus the Next.js app, both on a single DigitalOcean Droplet. No managed Supabase, no Vercel, no third-party SaaS for application data.
|
||||
|
||||
What you get at the end:
|
||||
- `https://yourdomain.com` serves the Next.js app.
|
||||
- `https://supabase.yourdomain.com` serves the Supabase API gateway (auth, REST, realtime).
|
||||
- `https://studio.yourdomain.com` serves Supabase Studio (password-protected).
|
||||
- Postgres, GoTrue (auth), PostgREST, Realtime, Storage, and Studio all run as Docker containers on the same Droplet.
|
||||
- Automatic SSL via Let's Encrypt.
|
||||
- Nightly Postgres backups to local disk (and optionally DigitalOcean Spaces).
|
||||
|
||||
If you want the managed-Supabase version instead, see [DEPLOYMENT.md](./DEPLOYMENT.md). That path is simpler but locks you into Supabase Cloud.
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
1. [Architecture](#1-architecture)
|
||||
2. [Cost](#2-cost)
|
||||
3. [Prerequisites](#3-prerequisites)
|
||||
4. [Provision the Droplet](#4-provision-the-droplet)
|
||||
5. [Initial server hardening](#5-initial-server-hardening)
|
||||
6. [Install Docker, Nginx, Certbot](#6-install-docker-nginx-certbot)
|
||||
7. [DNS records](#7-dns-records)
|
||||
8. [Self-host Supabase](#8-self-host-supabase)
|
||||
9. [Apply the app schema and migrations](#9-apply-the-app-schema-and-migrations)
|
||||
10. [Deploy the Next.js app](#10-deploy-the-nextjs-app)
|
||||
11. [Nginx reverse proxy + SSL](#11-nginx-reverse-proxy--ssl)
|
||||
12. [Post-deploy verification](#12-post-deploy-verification)
|
||||
13. [Backups](#13-backups)
|
||||
14. [Operations: upgrades, logs, monitoring](#14-operations-upgrades-logs-monitoring)
|
||||
15. [Hardening checklist](#15-hardening-checklist)
|
||||
16. [Troubleshooting](#16-troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
```
|
||||
Internet
|
||||
|
|
||||
v
|
||||
DigitalOcean Droplet (Ubuntu 24.04)
|
||||
|
|
||||
|-- Nginx (80, 443)
|
||||
| |
|
||||
| |-- yourdomain.com -> localhost:3000 (Next.js app)
|
||||
| |-- supabase.yourdomain.com -> localhost:8000 (Kong, Supabase API gateway)
|
||||
| `-- studio.yourdomain.com -> localhost:3001 (Supabase Studio, basic-auth gated)
|
||||
|
|
||||
|-- Docker network: hackerspace_net
|
||||
|
|
||||
|-- hackerspace_app (Next.js, port 3000)
|
||||
|
|
||||
`-- Docker network: supabase_default
|
||||
|-- supabase-db (Postgres 15)
|
||||
|-- supabase-auth (GoTrue)
|
||||
|-- supabase-rest (PostgREST)
|
||||
|-- supabase-realtime (Realtime)
|
||||
|-- supabase-storage (Storage API)
|
||||
|-- supabase-kong (API gateway, port 8000)
|
||||
|-- supabase-studio (Dashboard, port 3000 -> host 3001)
|
||||
|-- supabase-meta (Postgres meta API)
|
||||
|-- supabase-imgproxy
|
||||
`-- supabase-vector (logs)
|
||||
```
|
||||
|
||||
The Next.js app and Supabase live in separate Docker networks. Nginx is the only public entry point. The app talks to Supabase over `https://supabase.yourdomain.com` (public URL) so it works the same locally and in production.
|
||||
|
||||
You can also expose Supabase only over the internal docker network and have the app talk to `http://supabase-kong:8000` directly. That is faster but breaks browser-side Supabase calls because the browser cannot resolve internal hostnames. Stick with the public URL pattern unless you understand the trade-off.
|
||||
|
||||
---
|
||||
|
||||
## 2. Cost
|
||||
|
||||
| Item | Spec | Monthly |
|
||||
|------|------|---------|
|
||||
| Droplet | 8 GB RAM, 4 vCPU, 160 GB SSD (Regular) | $48 |
|
||||
| Reserved IP | static IPv4 | included |
|
||||
| Domain | from any registrar | ~$1 |
|
||||
| **Total** | | **~$49/mo** |
|
||||
|
||||
You can run on a 4 GB / 2 vCPU Droplet ($24/mo) if you tune Postgres and disable a few Supabase services (Storage, Edge Functions). 8 GB is the comfort floor.
|
||||
|
||||
For larger spaces (200+ members, heavy chat, file uploads), split into two Droplets: one 8 GB for Supabase, one 2 GB for the app, joined via a VPC.
|
||||
|
||||
---
|
||||
|
||||
## 3. Prerequisites
|
||||
|
||||
- DigitalOcean account.
|
||||
- A domain you control. Two A records (or one A record with two CNAMEs) point to the Droplet IP. See section 7.
|
||||
- An SSH public key on your local machine (`~/.ssh/id_ed25519.pub` or similar).
|
||||
- Local tools: `ssh`, `git`, `curl`, `openssl`, `docker` (for testing builds), `node` (for the JWT generator one-liner).
|
||||
- An SMTP provider for auth emails (Resend, SendGrid, AWS SES, Postmark, Mailgun). Free tiers are fine. You need:
|
||||
- SMTP host, port (587 or 465), username, password
|
||||
- A verified `from` address
|
||||
|
||||
---
|
||||
|
||||
## 4. Provision the Droplet
|
||||
|
||||
1. cloud.digitalocean.com → Droplets → Create.
|
||||
2. Image: **Ubuntu 24.04 (LTS) x64**.
|
||||
3. Plan: **Basic — Regular — 8 GB / 4 vCPU / 160 GB SSD**.
|
||||
4. Datacenter: closest to your users.
|
||||
5. Authentication: **SSH Key** (add your public key, never use password auth).
|
||||
6. Hostname: `hackerspace-prod` (or whatever).
|
||||
7. Click **Create Droplet**.
|
||||
8. Note the public IPv4 address. Reserve it under Networking → Reserved IPs if you want it permanent.
|
||||
|
||||
---
|
||||
|
||||
## 5. Initial server hardening
|
||||
|
||||
SSH in as root:
|
||||
|
||||
```bash
|
||||
ssh root@<droplet-ip>
|
||||
```
|
||||
|
||||
Update and create a deploy user:
|
||||
|
||||
```bash
|
||||
apt-get update && apt-get -y upgrade
|
||||
adduser deploy # set a strong password
|
||||
usermod -aG sudo deploy
|
||||
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
|
||||
```
|
||||
|
||||
Disable root SSH and password auth:
|
||||
|
||||
```bash
|
||||
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
|
||||
sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
|
||||
systemctl restart ssh
|
||||
```
|
||||
|
||||
From a NEW terminal (keep the old one open until you confirm), test:
|
||||
|
||||
```bash
|
||||
ssh deploy@<droplet-ip>
|
||||
```
|
||||
|
||||
If that works, close the root session and continue as `deploy`.
|
||||
|
||||
Set up a firewall (allow SSH, HTTP, HTTPS only):
|
||||
|
||||
```bash
|
||||
sudo ufw allow OpenSSH
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
Confirm: `sudo ufw status`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Install Docker, Nginx, Certbot
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
sudo usermod -aG docker deploy
|
||||
newgrp docker
|
||||
|
||||
# Compose v2 plugin is bundled with the install above. Verify:
|
||||
docker --version
|
||||
docker compose version
|
||||
|
||||
# Nginx and Certbot
|
||||
sudo apt-get install -y nginx certbot python3-certbot-nginx git
|
||||
|
||||
# pnpm for occasional ad-hoc commands (optional; the app runs from Docker)
|
||||
curl -fsSL https://get.pnpm.io/install.sh | sh -
|
||||
```
|
||||
|
||||
Log out and back in so the `docker` group takes effect.
|
||||
|
||||
---
|
||||
|
||||
## 7. DNS records
|
||||
|
||||
At your registrar, add A records pointing to the Droplet IP:
|
||||
|
||||
| Host | Type | Value |
|
||||
|------|------|-------|
|
||||
| `yourdomain.com` | A | `<droplet-ip>` |
|
||||
| `www.yourdomain.com` | A | `<droplet-ip>` |
|
||||
| `supabase.yourdomain.com` | A | `<droplet-ip>` |
|
||||
| `studio.yourdomain.com` | A | `<droplet-ip>` |
|
||||
|
||||
Wait for propagation (`dig +short supabase.yourdomain.com` should return your IP).
|
||||
|
||||
---
|
||||
|
||||
## 8. Self-host Supabase
|
||||
|
||||
### 8.1 Clone the Supabase repo
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt && cd /opt
|
||||
sudo git clone --depth 1 https://github.com/supabase/supabase.git
|
||||
sudo chown -R deploy:deploy /opt/supabase
|
||||
cd /opt/supabase/docker
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### 8.2 Generate secrets
|
||||
|
||||
You need four critical secrets: `POSTGRES_PASSWORD`, `JWT_SECRET`, `ANON_KEY`, `SERVICE_ROLE_KEY`. Generate them on your **local machine** (not the Droplet) and paste them into `.env`.
|
||||
|
||||
```bash
|
||||
# Strong random Postgres password (32 chars)
|
||||
openssl rand -base64 24
|
||||
|
||||
# Strong JWT secret (40+ chars, alphanumeric)
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Now generate the JWTs. Pick the JWT_SECRET you just generated and run this Node one-liner on your local machine:
|
||||
|
||||
```bash
|
||||
# Save the JWT_SECRET from above into a variable first.
|
||||
export JWT_SECRET='paste-your-jwt-secret-here'
|
||||
|
||||
# Generate ANON_KEY (10-year expiry):
|
||||
npx -y -p jsonwebtoken@9 node -e "
|
||||
const jwt = require('jsonwebtoken');
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + 60 * 60 * 24 * 365 * 10;
|
||||
console.log(jwt.sign({ role: 'anon', iss: 'supabase', iat, exp }, process.env.JWT_SECRET));
|
||||
"
|
||||
|
||||
# Generate SERVICE_ROLE_KEY (10-year expiry):
|
||||
npx -y -p jsonwebtoken@9 node -e "
|
||||
const jwt = require('jsonwebtoken');
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + 60 * 60 * 24 * 365 * 10;
|
||||
console.log(jwt.sign({ role: 'service_role', iss: 'supabase', iat, exp }, process.env.JWT_SECRET));
|
||||
"
|
||||
```
|
||||
|
||||
Save all four values somewhere safe. The service role key is a god-mode key; treat it like root credentials.
|
||||
|
||||
Also generate two more secrets:
|
||||
|
||||
```bash
|
||||
# Dashboard credentials (for Supabase Studio basic auth)
|
||||
openssl rand -base64 16 # use as DASHBOARD_PASSWORD
|
||||
|
||||
# Logflare API keys (Supabase logs analytics; required even if you don't use Logflare cloud)
|
||||
openssl rand -hex 32 # use as LOGFLARE_API_KEY
|
||||
openssl rand -hex 32 # use as LOGFLARE_LOGGER_BACKEND_API_KEY
|
||||
```
|
||||
|
||||
### 8.3 Edit `/opt/supabase/docker/.env`
|
||||
|
||||
Open the file: `nano /opt/supabase/docker/.env`. Set:
|
||||
|
||||
```bash
|
||||
# Postgres
|
||||
POSTGRES_PASSWORD=<from step 8.2>
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_HOST=db
|
||||
POSTGRES_DB=postgres
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=<from step 8.2>
|
||||
JWT_EXPIRY=3600
|
||||
ANON_KEY=<JWT from step 8.2>
|
||||
SERVICE_ROLE_KEY=<JWT from step 8.2>
|
||||
|
||||
# Public URLs (set to your domain, NOT the Droplet IP)
|
||||
SITE_URL=https://yourdomain.com
|
||||
ADDITIONAL_REDIRECT_URLS=https://yourdomain.com,http://localhost:3000
|
||||
API_EXTERNAL_URL=https://supabase.yourdomain.com
|
||||
SUPABASE_PUBLIC_URL=https://supabase.yourdomain.com
|
||||
|
||||
# Studio
|
||||
STUDIO_DEFAULT_ORGANIZATION=hackerspace
|
||||
STUDIO_DEFAULT_PROJECT=hackerspace
|
||||
STUDIO_PORT=3000 # internal; we remap on the host in docker-compose.override.yml
|
||||
DASHBOARD_USERNAME=admin
|
||||
DASHBOARD_PASSWORD=<from step 8.2>
|
||||
|
||||
# Kong
|
||||
KONG_HTTP_PORT=8000
|
||||
KONG_HTTPS_PORT=8443
|
||||
|
||||
# SMTP for auth emails (use your provider's values)
|
||||
SMTP_ADMIN_EMAIL=admin@yourdomain.com
|
||||
SMTP_HOST=smtp.resend.com # or sendgrid, ses, etc.
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=resend
|
||||
SMTP_PASS=<your SMTP API key>
|
||||
SMTP_SENDER_NAME=hackerspace.sh
|
||||
|
||||
# Auth behavior
|
||||
ENABLE_EMAIL_SIGNUP=true
|
||||
ENABLE_EMAIL_AUTOCONFIRM=false # set to true for dev; false in prod
|
||||
ENABLE_ANONYMOUS_USERS=false
|
||||
ENABLE_PHONE_SIGNUP=false
|
||||
ENABLE_PHONE_AUTOCONFIRM=false
|
||||
|
||||
# Logs / analytics
|
||||
LOGFLARE_API_KEY=<from step 8.2>
|
||||
LOGFLARE_LOGGER_BACKEND_API_KEY=<from step 8.2>
|
||||
LOGFLARE_PUBLIC_ACCESS_TOKEN=<reuse LOGFLARE_API_KEY or generate another>
|
||||
|
||||
# Postgrest
|
||||
PGRST_DB_SCHEMAS=public,storage,graphql_public
|
||||
|
||||
# Functions (you probably don't use these; keep default)
|
||||
FUNCTIONS_VERIFY_JWT=true
|
||||
|
||||
# Pooler (Supavisor) — keep defaults unless you know you need to change
|
||||
POOLER_TENANT_ID=hackerspace
|
||||
POOLER_DEFAULT_POOL_SIZE=20
|
||||
POOLER_MAX_CLIENT_CONN=100
|
||||
POOLER_DB_POOL_SIZE=5
|
||||
POOLER_PROXY_PORT_TRANSACTION=6543
|
||||
POOLER_PROXY_PORT_SESSION=5432
|
||||
SECRET_KEY_BASE=<openssl rand -hex 32>
|
||||
VAULT_ENC_KEY=<openssl rand -hex 32>
|
||||
```
|
||||
|
||||
Lock the file:
|
||||
|
||||
```bash
|
||||
chmod 600 /opt/supabase/docker/.env
|
||||
```
|
||||
|
||||
### 8.4 Remap Studio off port 3000
|
||||
|
||||
The Next.js app uses host port 3000, and Supabase Studio also defaults to host port 3000. Create an override so Studio binds to 3001 instead.
|
||||
|
||||
```bash
|
||||
cat > /opt/supabase/docker/docker-compose.override.yml << 'EOF'
|
||||
services:
|
||||
studio:
|
||||
ports:
|
||||
- "127.0.0.1:3001:3000/tcp"
|
||||
kong:
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000/tcp"
|
||||
- "127.0.0.1:8443:8443/tcp"
|
||||
EOF
|
||||
```
|
||||
|
||||
Binding to `127.0.0.1` means these ports are only reachable locally; Nginx will proxy them publicly. The default Supabase compose binds to `0.0.0.0`, which would expose them on the public internet. This override fixes that.
|
||||
|
||||
### 8.5 Start Supabase
|
||||
|
||||
```bash
|
||||
cd /opt/supabase/docker
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
docker compose ps # all services should be "running" or "healthy"
|
||||
```
|
||||
|
||||
First boot pulls about 4 GB of images and takes 5–10 minutes.
|
||||
|
||||
Once everything is up, the API is at `http://localhost:8000` and Studio is at `http://localhost:3001`. Both are private to the host (we bound them to `127.0.0.1`). We'll expose them with Nginx in section 11.
|
||||
|
||||
### 8.6 Confirm Postgres is healthy
|
||||
|
||||
```bash
|
||||
docker compose exec db psql -U postgres -c '\dt public.*'
|
||||
```
|
||||
|
||||
You should see an empty table list. Postgres is ready; the application schema goes in next.
|
||||
|
||||
---
|
||||
|
||||
## 9. Apply the app schema and migrations
|
||||
|
||||
The hackerspace-management app stores everything in Postgres. We apply the schema directly to the self-hosted Postgres instance.
|
||||
|
||||
### 9.1 Copy the schema files onto the Droplet
|
||||
|
||||
From your local machine:
|
||||
|
||||
```bash
|
||||
scp scripts/schema.sql deploy@<droplet-ip>:/tmp/
|
||||
scp scripts/014_member_user_id_nullable.sql deploy@<droplet-ip>:/tmp/
|
||||
scp scripts/015_prevent_member_self_role_change.sql deploy@<droplet-ip>:/tmp/
|
||||
```
|
||||
|
||||
(Or `git clone` the repo on the Droplet, see section 10.1.)
|
||||
|
||||
### 9.2 Run the schema
|
||||
|
||||
```bash
|
||||
cd /opt/supabase/docker
|
||||
|
||||
# Pipe each file into psql inside the Postgres container.
|
||||
docker compose exec -T db psql -U postgres -d postgres < /tmp/schema.sql
|
||||
docker compose exec -T db psql -U postgres -d postgres < /tmp/014_member_user_id_nullable.sql
|
||||
docker compose exec -T db psql -U postgres -d postgres < /tmp/015_prevent_member_self_role_change.sql
|
||||
```
|
||||
|
||||
No errors expected. Verify:
|
||||
|
||||
```bash
|
||||
docker compose exec db psql -U postgres -d postgres -c '\dt public.*'
|
||||
docker compose exec db psql -U postgres -d postgres -c "SELECT count(*) FROM pg_policies WHERE schemaname = 'public';"
|
||||
docker compose exec db psql -U postgres -d postgres -c "SELECT tablename FROM pg_publication_tables WHERE pubname = 'supabase_realtime';"
|
||||
```
|
||||
|
||||
Expect: 13 tables, ~45 policies, `comms_messages` and `comms_channels` in the realtime publication.
|
||||
|
||||
### 9.3 Restart PostgREST so it picks up the new schema
|
||||
|
||||
PostgREST caches the schema on start. After applying tables, restart it:
|
||||
|
||||
```bash
|
||||
docker compose restart rest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Deploy the Next.js app
|
||||
|
||||
### 10.1 Clone the app repo on the Droplet
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/hackerspace
|
||||
sudo chown deploy:deploy /opt/hackerspace
|
||||
cd /opt
|
||||
git clone https://github.com/<owner>/hackerspace-management.git hackerspace
|
||||
cd hackerspace
|
||||
```
|
||||
|
||||
### 10.2 Create the production env file
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
nano .env.local
|
||||
```
|
||||
|
||||
Fill in:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://supabase.yourdomain.com
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<ANON_KEY from step 8.2>
|
||||
SUPABASE_SERVICE_ROLE_KEY=<SERVICE_ROLE_KEY from step 8.2>
|
||||
NEXT_PUBLIC_APP_URL=https://yourdomain.com
|
||||
NODE_ENV=production
|
||||
```
|
||||
|
||||
Lock the file:
|
||||
|
||||
```bash
|
||||
chmod 600 /opt/hackerspace/.env.local
|
||||
```
|
||||
|
||||
### 10.3 Build and start the app
|
||||
|
||||
The repo ships with a Dockerfile that produces a standalone Next.js image, and a docker-compose.yml that runs it. The Dockerfile expects build-time access to the public Supabase URL and anon key (Next.js inlines `NEXT_PUBLIC_*` at build time), so pass them as build args.
|
||||
|
||||
```bash
|
||||
cd /opt/hackerspace
|
||||
docker compose build \
|
||||
--build-arg NEXT_PUBLIC_SUPABASE_URL=https://supabase.yourdomain.com \
|
||||
--build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY="<ANON_KEY>" \
|
||||
--build-arg NEXT_PUBLIC_APP_URL=https://yourdomain.com
|
||||
|
||||
docker compose up -d
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
Wait for `Ready in <ms>`. Then verify locally on the Droplet:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/health
|
||||
# {"status":"ok","timestamp":"..."}
|
||||
```
|
||||
|
||||
The app is now running on `127.0.0.1:3000`. Public access still requires Nginx, which we set up next.
|
||||
|
||||
---
|
||||
|
||||
## 11. Nginx reverse proxy + SSL
|
||||
|
||||
Create one server block per public hostname.
|
||||
|
||||
### 11.1 App: `yourdomain.com`
|
||||
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/hackerspace-app
|
||||
```
|
||||
|
||||
Paste:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name yourdomain.com www.yourdomain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 11.2 Supabase API: `supabase.yourdomain.com`
|
||||
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/supabase-api
|
||||
```
|
||||
|
||||
Paste:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name supabase.yourdomain.com;
|
||||
|
||||
# Generous body size for storage uploads
|
||||
client_max_body_size 50m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Realtime / WebSockets
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 11.3 Studio (admin dashboard): `studio.yourdomain.com`
|
||||
|
||||
Studio is a god-mode dashboard. Gate it behind HTTP basic auth in addition to its own login.
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y apache2-utils
|
||||
sudo htpasswd -c /etc/nginx/.htpasswd_studio admin # set a strong password
|
||||
sudo nano /etc/nginx/sites-available/supabase-studio
|
||||
```
|
||||
|
||||
Paste:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name studio.yourdomain.com;
|
||||
|
||||
# Restrict to your office/home IPs if you have a static one.
|
||||
# allow 1.2.3.4;
|
||||
# deny all;
|
||||
|
||||
auth_basic "Restricted";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd_studio;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 11.4 Enable and reload
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/hackerspace-app /etc/nginx/sites-enabled/
|
||||
sudo ln -s /etc/nginx/sites-available/supabase-api /etc/nginx/sites-enabled/
|
||||
sudo ln -s /etc/nginx/sites-available/supabase-studio /etc/nginx/sites-enabled/
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 11.5 SSL with Let's Encrypt
|
||||
|
||||
Make sure all four hostnames resolve to the Droplet before running:
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx \
|
||||
-d yourdomain.com -d www.yourdomain.com \
|
||||
-d supabase.yourdomain.com \
|
||||
-d studio.yourdomain.com
|
||||
```
|
||||
|
||||
Pick option `2` (redirect HTTP to HTTPS) when prompted. Certbot rewrites the Nginx configs to add `listen 443 ssl;` blocks and sets up auto-renewal via a systemd timer.
|
||||
|
||||
Confirm renewal works:
|
||||
|
||||
```bash
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Post-deploy verification
|
||||
|
||||
Run through this list. Everything should pass on the first try.
|
||||
|
||||
```bash
|
||||
# 1. App health
|
||||
curl https://yourdomain.com/api/health
|
||||
# -> {"status":"ok","timestamp":"..."}
|
||||
|
||||
# 2. Supabase API health (Kong should reject without an apikey)
|
||||
curl https://supabase.yourdomain.com/rest/v1/
|
||||
# -> {"hint":"...","message":"Invalid API key"} or similar (proves Kong is reachable)
|
||||
|
||||
# 3. Supabase API with anon key (should return empty array or a hint)
|
||||
curl https://supabase.yourdomain.com/rest/v1/spaces \
|
||||
-H "apikey: <ANON_KEY>" -H "Authorization: Bearer <ANON_KEY>"
|
||||
# -> [] (empty because RLS filters out everything for an unauthenticated request)
|
||||
|
||||
# 4. Studio
|
||||
# Open https://studio.yourdomain.com in a browser. Pass basic auth (admin / your password).
|
||||
# Studio's own login uses DASHBOARD_USERNAME / DASHBOARD_PASSWORD.
|
||||
```
|
||||
|
||||
App flow:
|
||||
|
||||
- [ ] Visit `https://yourdomain.com/signup`. Create an account, create a space, land on `/dashboard`.
|
||||
- [ ] In Studio, Table Editor, `space_members`: confirm a row exists with `role = 'admin'`, `status = 'current'`.
|
||||
- [ ] `comms_channels`: confirm three rows (`general`, `announcements`, `ops`).
|
||||
- [ ] Open `/comms`, send a message, open a second tab, watch the message appear without refresh (realtime works).
|
||||
- [ ] In Studio, run as the `anon` role:
|
||||
```sql
|
||||
UPDATE public.space_members SET role = 'admin' WHERE user_id = auth.uid();
|
||||
```
|
||||
This must fail with `Members cannot change their own role, tier, status, approval, card access, or space.` That confirms migration 015 is active.
|
||||
|
||||
---
|
||||
|
||||
## 13. Backups
|
||||
|
||||
### 13.1 Daily Postgres dump to local disk
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/backups/supabase
|
||||
sudo chown deploy:deploy /var/backups/supabase
|
||||
|
||||
cat > /opt/hackerspace/backup-db.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
TS=$(date +%Y%m%d_%H%M%S)
|
||||
DEST=/var/backups/supabase/postgres_${TS}.sql.gz
|
||||
|
||||
cd /opt/supabase/docker
|
||||
docker compose exec -T db pg_dump -U postgres --clean --if-exists postgres | gzip > "$DEST"
|
||||
|
||||
# Keep last 14 days
|
||||
find /var/backups/supabase -name "postgres_*.sql.gz" -mtime +14 -delete
|
||||
|
||||
echo "[$(date)] backup -> $DEST ($(du -h "$DEST" | cut -f1))"
|
||||
EOF
|
||||
|
||||
chmod +x /opt/hackerspace/backup-db.sh
|
||||
```
|
||||
|
||||
Schedule daily at 3 AM:
|
||||
|
||||
```bash
|
||||
( crontab -l 2>/dev/null; echo "0 3 * * * /opt/hackerspace/backup-db.sh >> /var/log/db-backup.log 2>&1" ) | crontab -
|
||||
```
|
||||
|
||||
### 13.2 Off-site backups to DigitalOcean Spaces (recommended)
|
||||
|
||||
Local backups die with the Droplet. Push them to Spaces (S3-compatible).
|
||||
|
||||
1. Spaces dashboard → Create Space → note the bucket name and endpoint URL.
|
||||
2. Spaces → Access Keys → generate one. Save the key and secret.
|
||||
3. Install `s3cmd`:
|
||||
```bash
|
||||
sudo apt-get install -y s3cmd
|
||||
s3cmd --configure
|
||||
# Endpoint: nyc3.digitaloceanspaces.com (or your region)
|
||||
# Use HTTPS, no encryption
|
||||
```
|
||||
4. Add an upload line to the backup script:
|
||||
```bash
|
||||
echo "s3cmd put $DEST s3://<your-space>/db/" >> /opt/hackerspace/backup-db.sh
|
||||
```
|
||||
5. Lifecycle policy on the Space: rotate to cold storage / delete after N days.
|
||||
|
||||
### 13.3 Restoring
|
||||
|
||||
```bash
|
||||
gunzip -c /var/backups/supabase/postgres_20260514_030000.sql.gz | \
|
||||
docker compose -f /opt/supabase/docker/docker-compose.yml exec -T db psql -U postgres -d postgres
|
||||
```
|
||||
|
||||
Test restores at least quarterly on a staging Droplet. A backup you have not restored is not a backup.
|
||||
|
||||
---
|
||||
|
||||
## 14. Operations: upgrades, logs, monitoring
|
||||
|
||||
### 14.1 App upgrades
|
||||
|
||||
```bash
|
||||
cd /opt/hackerspace
|
||||
git pull origin main
|
||||
docker compose build \
|
||||
--build-arg NEXT_PUBLIC_SUPABASE_URL=https://supabase.yourdomain.com \
|
||||
--build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY="<ANON_KEY>" \
|
||||
--build-arg NEXT_PUBLIC_APP_URL=https://yourdomain.com
|
||||
docker compose up -d --no-deps app
|
||||
docker image prune -f
|
||||
```
|
||||
|
||||
Wrap in `/opt/hackerspace/deploy.sh` and trigger from GitHub Actions on push to `main` if you want CI auto-deploy.
|
||||
|
||||
### 14.2 Database migrations
|
||||
|
||||
When the app ships a new `scripts/NNN_*.sql`, apply it:
|
||||
|
||||
```bash
|
||||
cd /opt/supabase/docker
|
||||
docker compose exec -T db psql -U postgres -d postgres < /opt/hackerspace/scripts/NNN_*.sql
|
||||
docker compose restart rest # PostgREST caches the schema
|
||||
```
|
||||
|
||||
Never re-run `scripts/schema.sql` against a live database; it is the canonical full file for fresh deploys. Use the numbered incrementals on existing databases.
|
||||
|
||||
### 14.3 Supabase upgrades
|
||||
|
||||
```bash
|
||||
cd /opt/supabase/docker
|
||||
git fetch origin && git pull origin master
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Read the [release notes](https://github.com/supabase/supabase/releases) before pulling major versions. The Supabase docker-compose has occasional breaking env-var renames.
|
||||
|
||||
### 14.4 Logs
|
||||
|
||||
```bash
|
||||
# App
|
||||
docker compose -f /opt/hackerspace/docker-compose.yml logs -f app
|
||||
|
||||
# Supabase
|
||||
cd /opt/supabase/docker
|
||||
docker compose logs -f kong auth rest realtime
|
||||
docker compose logs -f db | grep -v 'connection received' # noisy
|
||||
|
||||
# Nginx
|
||||
sudo journalctl -u nginx -f
|
||||
sudo tail -f /var/log/nginx/access.log
|
||||
```
|
||||
|
||||
### 14.5 Resource monitoring
|
||||
|
||||
```bash
|
||||
# Quick view
|
||||
docker stats
|
||||
|
||||
# Disk
|
||||
df -h
|
||||
docker system df
|
||||
|
||||
# Postgres size
|
||||
docker compose -f /opt/supabase/docker/docker-compose.yml exec db \
|
||||
psql -U postgres -c "SELECT pg_size_pretty(pg_database_size('postgres'));"
|
||||
```
|
||||
|
||||
For real monitoring, install [Netdata](https://www.netdata.cloud/) (`bash <(curl -Ss https://my-netdata.io/kickstart.sh)`) or wire DigitalOcean Monitoring to send alerts on CPU > 80%, disk > 80%, or memory > 85%.
|
||||
|
||||
---
|
||||
|
||||
## 15. Hardening checklist
|
||||
|
||||
Before you point real users at this:
|
||||
|
||||
- [ ] Root SSH disabled, password auth disabled (section 5).
|
||||
- [ ] UFW allows only 22, 80, 443.
|
||||
- [ ] All four hostnames have valid Let's Encrypt certs (`sudo certbot certificates`).
|
||||
- [ ] `/opt/hackerspace/.env.local` and `/opt/supabase/docker/.env` are mode 600, owned by `deploy`.
|
||||
- [ ] `SERVICE_ROLE_KEY` is set as a Docker secret or env var only, never logged.
|
||||
- [ ] Studio (`studio.yourdomain.com`) is behind HTTP basic auth and ideally IP-allowlisted.
|
||||
- [ ] `DASHBOARD_PASSWORD` is strong and stored in a password manager.
|
||||
- [ ] `JWT_SECRET` is 32+ random bytes, never committed.
|
||||
- [ ] Migration 015 (`prevent_member_self_role_change`) is applied (verify by trying the self-promotion query in section 12).
|
||||
- [ ] Daily Postgres backups running, off-site copies enabled.
|
||||
- [ ] `unattended-upgrades` installed for OS security patches:
|
||||
`sudo apt-get install -y unattended-upgrades && sudo dpkg-reconfigure -plow unattended-upgrades`
|
||||
- [ ] Fail2Ban for SSH:
|
||||
`sudo apt-get install -y fail2ban && sudo systemctl enable --now fail2ban`
|
||||
- [ ] SMTP configured and sending: trigger a password reset email and confirm it arrives.
|
||||
- [ ] Realtime works end-to-end (verified in section 12).
|
||||
- [ ] Disk has at least 50 GB free; Postgres grows over time.
|
||||
|
||||
---
|
||||
|
||||
## 16. Troubleshooting
|
||||
|
||||
### App returns 500 on every page
|
||||
|
||||
Almost always wrong env vars. Confirm `/opt/hackerspace/.env.local` has the four required values and that `NEXT_PUBLIC_SUPABASE_URL` exactly matches the URL you used in `API_EXTERNAL_URL` in the Supabase `.env`.
|
||||
|
||||
`docker compose -f /opt/hackerspace/docker-compose.yml logs app | tail -50` will surface the actual error.
|
||||
|
||||
### "Invalid API key" from every Supabase call
|
||||
|
||||
The `ANON_KEY` you put in the app does not match the `JWT_SECRET` Supabase signed it with, or you regenerated `JWT_SECRET` without regenerating the keys. Regenerate both JWTs from the current `JWT_SECRET`, paste into both env files, restart both stacks.
|
||||
|
||||
### Signup succeeds but `/dashboard` redirects to `/signup` forever
|
||||
|
||||
The signup completed in `auth.users` but `space_members` insertion failed (most often migration 014 not applied). Check Studio → Authentication → Users (you should see the new user) and Table Editor → `space_members` (you should see a row). If the row is missing, the trigger or the `createSpace` server action returned an error, look in the app logs.
|
||||
|
||||
### Realtime is silent
|
||||
|
||||
Two things to check:
|
||||
1. `comms_messages` and `comms_channels` are in the realtime publication:
|
||||
```sql
|
||||
SELECT tablename FROM pg_publication_tables WHERE pubname = 'supabase_realtime';
|
||||
```
|
||||
2. Nginx is forwarding WebSocket upgrades. `proxy_set_header Upgrade $http_upgrade;` and `proxy_set_header Connection 'upgrade';` must be present in the `supabase-api` server block.
|
||||
|
||||
### `pnpm build` works locally but fails in Docker
|
||||
|
||||
Check that the `NEXT_PUBLIC_*` build args were passed (section 10.3). Next.js inlines those at build time; if missing, the client bundle ships with `undefined` for them and the app reports "Failed to fetch" on every Supabase call.
|
||||
|
||||
### Postgres uses 100% CPU
|
||||
|
||||
Run `pg_stat_activity` from Studio to find the offender:
|
||||
```sql
|
||||
SELECT pid, state, query_start, wait_event_type, wait_event, left(query, 200)
|
||||
FROM pg_stat_activity
|
||||
WHERE state != 'idle'
|
||||
ORDER BY query_start;
|
||||
```
|
||||
Kill runaway queries with `SELECT pg_terminate_backend(<pid>);`. Add an index. Common culprit: missing index on `comms_messages(channel_id, created_at)` for chat history; the schema already has this, so check that the index exists (`\di idx_comms_messages_*` in psql).
|
||||
|
||||
### Out of memory
|
||||
|
||||
Supabase realtime and Postgres are the heaviest. Bump the Droplet up one tier. Or, if you do not use Storage / Functions / Edge Runtime, comment those services out of `/opt/supabase/docker/docker-compose.yml`. Easy 1–1.5 GB saved.
|
||||
|
||||
### Renewal of SSL certs failed
|
||||
|
||||
```bash
|
||||
sudo systemctl status certbot.timer
|
||||
sudo journalctl -u certbot | tail -50
|
||||
```
|
||||
|
||||
Most common cause: a hostname stopped resolving. Confirm `dig +short <host>` returns the Droplet IP for every hostname Certbot manages.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: keeping the app and Supabase versions in sync
|
||||
|
||||
The application is versioned per its own `package.json`. Supabase is versioned per the `supabase/supabase` repo's tags. They evolve independently.
|
||||
|
||||
- The app pins `@supabase/ssr` and `@supabase/supabase-js` in `package.json`. If you upgrade Supabase server-side (in `/opt/supabase/docker`), check the [Supabase release notes](https://github.com/supabase/supabase/releases) for breaking changes in the JS client.
|
||||
- Run `pnpm update @supabase/ssr @supabase/supabase-js` in a feature branch, run the test suite (`pnpm vitest run`), redeploy.
|
||||
|
||||
For the application-level schema, the source of truth is `scripts/schema.sql` plus the numbered incrementals. The Supabase stack does not manage application schemas; it only provides Postgres and the layers on top.
|
||||
|
||||
---
|
||||
|
||||
This document covers the full self-hosted path. If anything here drifts from `docs/DEPLOYMENT.md` (the managed-Supabase guide), this document is the source of truth for self-hosted deployments.
|
||||
@@ -0,0 +1,418 @@
|
||||
# Governance feature proposals
|
||||
|
||||
A diagnostic-to-feature mapping from twelve recurring institutional pain patterns documented in real hackerspace operations. Filtered through three questions:
|
||||
|
||||
1. **Does this generalize?** Will it serve any hackerspace, not just one with HeatSync Labs's specific bylaws, room layout, or political history?
|
||||
2. **Does it solve a recurring pain?** Not an interesting capability; a documented every-few-months problem.
|
||||
3. **Does it fit the existing model?** Multi-tenant (`space_id` on every row), RLS-enforced, role-gated (`admin`, `board`, `treasurer`, `member`, `associate`), built on Supabase. Avoid features that require new infrastructure (door hardware, SMTP, IdP, federation protocols) unless the operator has already deployed them.
|
||||
|
||||
What follows is what I'd actually add. Things I'd reject or defer are at the end.
|
||||
|
||||
---
|
||||
|
||||
## Architectural principles for any new feature
|
||||
|
||||
Three rules govern every proposal below.
|
||||
|
||||
1. **Privacy default for individuals; transparency default for institutions.** A member's payment notes are private. The vote a board member cast on a proposal is public. Schema choices reflect this.
|
||||
2. **Audit trails for governance, not for member behavior.** Who voted yes on proposal X is logged forever. Whether a member opened the chat at 2am Tuesday is not logged at all.
|
||||
3. **Configuration per space; no hardcoded politics.** Bylaws section numbers, quorum percentages, voting windows, role names, "Hack Your Heart Out" night, anonymity policy — all of it lives in `spaces` settings or per-proposal config. The app ships sensible defaults; each space overrides.
|
||||
|
||||
---
|
||||
|
||||
## Tier 1: the governance kernel (Phase 1) — SHIPPED 2026-05-14
|
||||
|
||||
Status: implemented end-to-end (schema, server actions, validations, tests, UI). See `docs/HANDOFF.md` pass 5 for the build summary.
|
||||
|
||||
Migration: `scripts/016_governance_kernel.sql` (also folded into `scripts/schema.sql` as Section 9). Routes: `/proposals`, `/proposals/[id]`, `/proposals/new`, `/incidents`, `/incidents/[id]`, `/incidents/new`, `/policies`, `/policies/[slug]`, `/policies/new`. Tests: 40 schema-validation tests in `__tests__/governance.test.ts`. Sidebar nav has a new "Governance" group.
|
||||
|
||||
The three top-frequency patterns (CoC complaints, information asymmetry, async voting) collapse into one tightly integrated module: **proposals + incidents + policies**. Built together; each references the others.
|
||||
|
||||
### 1.1 `proposals` and `proposal_votes`
|
||||
|
||||
The single most valuable feature. Generalises patterns 3, 4, 5, 10, 12.
|
||||
|
||||
```
|
||||
proposals
|
||||
id uuid PK
|
||||
space_id uuid FK → spaces
|
||||
title text
|
||||
body text (markdown)
|
||||
proposer_id uuid FK → space_members
|
||||
proposer_name text (denormalized for history)
|
||||
proposal_type enum: 'bylaw_change' | 'board_action' | 'membership_vote'
|
||||
| 'advisory_poll' | 'recall' | 'budget'
|
||||
status enum: 'draft' | 'open' | 'decided' | 'withdrawn' | 'expired'
|
||||
quorum_required integer (absolute count, computed when proposal opens)
|
||||
quorum_percent integer (the percentage from space settings at time of open)
|
||||
threshold enum: 'simple_majority' | 'two_thirds' | 'three_fourths' | 'unanimous'
|
||||
voting_opens_at timestamptz
|
||||
voting_closes_at timestamptz
|
||||
policy_ref_id uuid FK → policies (nullable; for bylaw-change proposals)
|
||||
parent_incident_id uuid FK → incidents (nullable; for appeals)
|
||||
outcome_yes integer (frozen at close)
|
||||
outcome_no integer (frozen at close)
|
||||
outcome_abstain integer (frozen at close)
|
||||
passed boolean (frozen at close)
|
||||
created_at, updated_at, decided_at
|
||||
|
||||
proposal_votes
|
||||
id uuid PK
|
||||
proposal_id uuid FK
|
||||
member_id uuid FK → space_members
|
||||
position enum: 'yes' | 'no' | 'abstain' | 'recused'
|
||||
recusal_reason text (required iff position = 'recused')
|
||||
comment text (optional public comment on vote)
|
||||
voted_at timestamptz
|
||||
UNIQUE (proposal_id, member_id)
|
||||
```
|
||||
|
||||
**RLS**:
|
||||
- SELECT: members of the space.
|
||||
- INSERT proposal: any active member (proposing is universal).
|
||||
- UPDATE proposal: proposer while `status = 'draft'`; admin/board for status transitions.
|
||||
- INSERT/UPDATE vote: voter on their own row only, only while `voting_opens_at <= now() < voting_closes_at`.
|
||||
- DELETE vote: never.
|
||||
|
||||
**Why this generalises**: every cooperative has decisions. The differences (quorum percent, threshold rule, voting window length) are configuration, not code. The hardcoded thing HSL did — "Member-Called Special Elections" — becomes one of several `proposal_type` values; the threshold and quorum are read from space settings at proposal-open time.
|
||||
|
||||
**What it solves**:
|
||||
- Async voting (Pattern 3): voting_opens_at and voting_closes_at do the work.
|
||||
- Vote-of-confidence as a first-class workflow (Pattern 3): proposal_type='recall'.
|
||||
- Decision archive (Pattern 4): closed proposals are immutable and full-text searchable.
|
||||
- Recusal log (Pattern 5): `position='recused'` with required reason.
|
||||
- Vote-record visibility (Pattern 5): a member's voting history is a query, not a leak.
|
||||
- Quorum/threshold transparency (Pattern 12): both are stored on the proposal at open time, displayed on the proposal page, no procedural disputes possible.
|
||||
|
||||
**Defaults a space should set** (new fields on `spaces`):
|
||||
- `default_quorum_percent` (e.g. 10)
|
||||
- `default_quorum_floor` (e.g. 8 — "10% or 8, whichever is greater")
|
||||
- `default_voting_window_hours` (e.g. 216 for 9 days)
|
||||
- `default_threshold` (e.g. 'simple_majority')
|
||||
|
||||
The proposal page shows live counts as votes come in, computes whether quorum is met, shows the outstanding required count if not. No spreadsheet, no manual tally.
|
||||
|
||||
### 1.2 `incidents` (CoC complaints, safety reports, conflicts)
|
||||
|
||||
Generalises Pattern 1 (the CoC complaint black hole) plus the chain-of-custody concern that recurs in every cooperative.
|
||||
|
||||
```
|
||||
incidents
|
||||
id uuid PK
|
||||
space_id uuid FK → spaces
|
||||
reporter_id uuid FK → space_members (nullable; null when anonymous)
|
||||
reporter_token text (server-generated, opaque; lets anonymous reporter check status)
|
||||
is_anonymous boolean
|
||||
subjects uuid[] (optional FKs to space_members; the people the report is about)
|
||||
category text (e.g. 'code_of_conduct', 'safety', 'theft', 'harassment')
|
||||
— free-form so each space defines its own taxonomy
|
||||
severity enum: 'low' | 'medium' | 'high' | 'critical'
|
||||
title text
|
||||
body text
|
||||
status enum: 'received' | 'under_review' | 'decided' | 'appealed' | 'closed'
|
||||
disposition text (markdown; what the board/admin decided)
|
||||
decision_makers uuid[] (FKs to space_members)
|
||||
appeal_proposal_id uuid FK → proposals (set when an appeal is filed)
|
||||
sla_response_by timestamptz (computed: created_at + space.incident_sla_days)
|
||||
created_at
|
||||
acknowledged_at timestamptz (when board first marked under_review)
|
||||
decided_at timestamptz
|
||||
closed_at timestamptz
|
||||
|
||||
incident_updates
|
||||
id uuid PK
|
||||
incident_id uuid FK
|
||||
author_id uuid FK → space_members
|
||||
body text
|
||||
visibility enum: 'reporter_only' | 'all_parties' | 'board_only'
|
||||
created_at
|
||||
```
|
||||
|
||||
**RLS**:
|
||||
- SELECT: reporter + admins/board + named subjects (configurable per space). Anonymous reporters use the `reporter_token` to fetch their own report via a public route; the token never appears in lists.
|
||||
- INSERT: any active member, or anonymous via a rate-limited public endpoint that requires a turnstile/captcha.
|
||||
- UPDATE: admin/board (status, disposition); reporter can add `incident_updates` only.
|
||||
- DELETE: never. Incident records are retained.
|
||||
|
||||
**Status notifications**: every status change inserts a row into the existing `activity_log` AND triggers a notification to the reporter (in-app comms message; email if configured). Silence becomes structurally impossible because the schema *requires* a status transition for action, and every transition notifies the filer.
|
||||
|
||||
**Appeal pathway**: closing an incident in `disposition='dismissed'` exposes a "request membership review" button visible to the reporter. Clicking it creates a `proposals` row with `parent_incident_id` set, pre-populated body, and `proposal_type='recall'` or `'membership_vote'`. The appeal is then a normal proposal with normal voting mechanics.
|
||||
|
||||
**Pattern surfacing**: an admin/board view groups incidents by overlapping subjects. Three independent incidents naming the same member surface as a single card, even if each individual incident was dismissed. No machine-learning needed; just `SELECT subjects, count(*) FROM incidents WHERE subjects @> ARRAY[<member_id>]`.
|
||||
|
||||
**On anonymity**: I would not promise cryptographic anonymity. The honest implementation is "the database admin can read the report; the rest of the board cannot tie it to a member". A `reporter_token` provides status-tracking without UI-level identity. If a space wants stronger anonymity it can disable `reporter_id` storage entirely via a space setting; the cost is that the reporter cannot prove authorship if disputed later.
|
||||
|
||||
### 1.3 `policies` (versioned bylaws, code of conduct, station rules)
|
||||
|
||||
Generalises Patterns 4 and 12.
|
||||
|
||||
```
|
||||
policies
|
||||
id uuid PK
|
||||
space_id uuid FK → spaces
|
||||
slug text (e.g. 'bylaws', 'code-of-conduct', 'laser-station-rules')
|
||||
section_ref text (free-form; each space defines its own structure, e.g. '8.4' or 'Article III §2')
|
||||
parent_policy_id uuid FK → policies (nullable; nests policies, e.g. CoC under Bylaws)
|
||||
title text
|
||||
body_formal text (markdown; legalese version)
|
||||
body_plain text (markdown; plain-language summary — Pattern 12, Kirk's "explain to a five-year-old")
|
||||
version integer
|
||||
prior_version_id uuid FK → policies (null for v1, points back for v2+)
|
||||
status enum: 'draft' | 'active' | 'deprecated' | 'superseded'
|
||||
effective_at timestamptz
|
||||
adopted_by_proposal_id uuid FK → proposals (nullable; null for migrated-in initial versions)
|
||||
created_at, updated_at
|
||||
UNIQUE (space_id, slug, version)
|
||||
```
|
||||
|
||||
**RLS**:
|
||||
- SELECT: members.
|
||||
- INSERT: admin/board.
|
||||
- UPDATE: never (immutability). Edits insert a new row with incremented `version` and `prior_version_id` pointing back.
|
||||
- DELETE: never. Deprecation flips `status='deprecated'`.
|
||||
|
||||
**Diff view**: a UI route renders any two versions side-by-side. Implemented client-side from the two rows.
|
||||
|
||||
**Citation linking**: any `proposals.body` markdown supports a `[policy:bylaws#8.4]` syntax that renders as a hover-card with the clause's current text. Same for `incidents.body`. Members vote knowing what they're voting on.
|
||||
|
||||
**Why versioning matters**: Pattern 4 is "Why is this rule here?" The chain `policy v3 → adopted_by_proposal → proposal.created_at → proposal_votes` is the answer. A query reconstructs the institutional history of any clause.
|
||||
|
||||
---
|
||||
|
||||
## Tier 2: information-equity extensions (Phase 2) — SHIPPED 2026-05-14
|
||||
|
||||
Status: schema fully in place (migration 018). `/financials` route shipped end-to-end (gated by `space.financial_visibility`). Member directory visibility column + COI disclosure + skills/interests/willing_to columns ready; UI for those follows.
|
||||
|
||||
These are smaller and mostly extend existing tables. Generalise Pattern 2 (information asymmetry).
|
||||
|
||||
### 2.1 Member directory transparency
|
||||
|
||||
The pain: an incumbent withholding the cardholder count. Fix in three lines.
|
||||
|
||||
- Add a space setting: `member_directory_visibility` enum (`public_members_visible`, `member_count_visible`, `board_only`). Default: `member_count_visible`.
|
||||
- The members page renders an aggregate count box visible to whatever the setting allows. The page already exists; this is one query.
|
||||
- A space setting `member_directory_public` exposes the directory to non-members at `<host>/<slug>/members`. Off by default.
|
||||
|
||||
No new tables. Just settings + queries.
|
||||
|
||||
### 2.2 Financial dashboard
|
||||
|
||||
The pain: members not knowing about rent increases until "the numbers were posted".
|
||||
|
||||
- A new server-component route `/financials` that renders aggregates from `payments`: income by month, by platform, by member tier; counts of linked vs unlinked.
|
||||
- A space setting `financial_visibility` enum (`treasurer_only`, `board_visible`, `all_members_visible`). Default: `board_visible`.
|
||||
- Optional: an `expenses` table for non-Stripe outflows (rent, insurance, supplies). One row per expense with category, amount, paid_on, payee, note. RLS gates by `financial_visibility`.
|
||||
- Optional: a `recurring_obligations` table (rent, insurance, ISP) with monthly amount and renewal_at. The dashboard sums these into a "monthly burn" figure.
|
||||
|
||||
The "runway" metric is `(cash_on_hand - 3*monthly_burn) / monthly_burn`. Display it. Members making decisions about expansion need this.
|
||||
|
||||
### 2.3 External-account control registry
|
||||
|
||||
The pain: Flores controlling Facebook with no transfer mechanism.
|
||||
|
||||
- Extend `integrations` (already exists) to also model non-OAuth platforms: Facebook page, Twitter, mailing list, domain registrar. Add fields: `account_holder_member_id`, `recovery_email`, `transfer_documented_at`.
|
||||
- A board-visible page lists every external account with the responsible member and last-verified date.
|
||||
- An annual `transfer_documented_at` check: if a board member rotates, the page surfaces "these accounts have not been re-verified since the rotation."
|
||||
|
||||
No new table; columns on `integrations`.
|
||||
|
||||
### 2.4 Board meeting minutes auto-distribution
|
||||
|
||||
The pain: "we decided X privately for privacy reasons."
|
||||
|
||||
- New `kb_visibility` enum value: `board_meeting`. Any KB entry tagged board_meeting is visible to all members and pushed as a comms notification when published.
|
||||
- Optionally: a space setting `board_minutes_sla_hours` (default 48). A scheduled job alerts admins if a board meeting (logged via a separate `meetings` table or a calendar feature) ended more than that many hours ago without published minutes.
|
||||
|
||||
Light-touch. Mostly UI work on top of `knowledge_base`.
|
||||
|
||||
---
|
||||
|
||||
## Tier 3: member-state extensions (Phase 2 continued) — SHIPPED 2026-05-14
|
||||
|
||||
Status: all schema in migration 018. Server actions for self-profile and COI disclosure built. Card-access mid-tenure review trigger live. `inactive_members` view live. UI for self-editing profile / disclosing affiliations follows.
|
||||
|
||||
Each one is a small column-set extension to `space_members`. None requires new tables.
|
||||
|
||||
### 3.1 Skill / interest / willing-to fields
|
||||
|
||||
Generalises Pattern 10 (succession infrastructure).
|
||||
|
||||
Add to `space_members`:
|
||||
- `skills text[]`
|
||||
- `interests text[]`
|
||||
- `willing_to text[]` (e.g. `'board_candidate'`, `'treasurer'`, `'host_volunteer'`, `'area_lead:laser'`)
|
||||
|
||||
A new server-component route `/recruitment` (board-visible) queries `willing_to`. Recruitment becomes "show me members who marked `'board_candidate'`" instead of one person's manual outreach.
|
||||
|
||||
Strictly opt-in. Members write their own values. No automatic skill inference, no behavioural scoring.
|
||||
|
||||
### 3.2 Conflict-of-interest disclosure
|
||||
|
||||
Generalises Pattern 5.
|
||||
|
||||
Add to `space_members`:
|
||||
- `affiliations text[]` (free-form: organizations the member is associated with)
|
||||
- `coi_last_disclosed_at timestamptz`
|
||||
|
||||
A space setting `coi_disclosure_required_for_roles text[]` lists roles that must keep COI current (default: `['admin', 'board', 'treasurer']`). When a member in one of those roles has `coi_last_disclosed_at < now() - 365 days`, the dashboard surfaces a "disclose your affiliations" reminder. Affiliations are visible to all members of the space; the role-vs-affiliation comparison is the institutional check.
|
||||
|
||||
### 3.3 Mid-tenure card review
|
||||
|
||||
Generalises Pattern 7.
|
||||
|
||||
- When `has_card_access` flips from false → true, the schema fires a trigger that inserts a `tasks` row: `task_type='admin'`, `title='Card-access review: <display_name>'`, `due_date=now() + 180 days`, `assigned_to_role='board'`.
|
||||
- The existing tasks page surfaces it like any other due task. Resolution is a normal task completion, with an optional note that appears in the member's profile.
|
||||
|
||||
Pure schema work; uses the `tasks` table that already exists.
|
||||
|
||||
### 3.4 Re-engagement detection
|
||||
|
||||
Generalises Pattern 7's "faded-out members are still members."
|
||||
|
||||
- A view `inactive_members` that computes membership rows with no entries in `activity_log` for the past 180 days.
|
||||
- The members page renders a filter chip "Inactive (N)" that toggles the view.
|
||||
- An optional "send re-engagement message" action that drops a templated message into the member's DM channel (when DMs ship — not in Phase 1).
|
||||
|
||||
No new table; one view.
|
||||
|
||||
---
|
||||
|
||||
## Tier 4: operational additions (Phase 3)
|
||||
|
||||
Lower frequency but real. Build only after Tier 1 and 2 land.
|
||||
|
||||
### 4.1 Hosting calendar
|
||||
|
||||
Generalises Pattern 9.
|
||||
|
||||
```
|
||||
host_shifts
|
||||
id uuid PK
|
||||
space_id uuid FK
|
||||
host_id uuid FK → space_members
|
||||
starts_at timestamptz
|
||||
ends_at timestamptz
|
||||
swap_requested boolean
|
||||
swap_note text
|
||||
created_at, updated_at
|
||||
```
|
||||
|
||||
Members claim, swap, cancel. The space page renders the next 30 days. Notifications when a shift is unclaimed within 24 hours of starting.
|
||||
|
||||
### 4.2 Sponsor / partner registry
|
||||
|
||||
Generalises Pattern 8.
|
||||
|
||||
Two options. Pick one.
|
||||
|
||||
Option A (cheaper): extend `contacts` with `status` enum (`active, paused, withdrawn, prospective`) and `liaison_member_id`. Contacts already supports a `contact_type='partner'` value.
|
||||
|
||||
Option B (cleaner): a new `organizations` table separate from `contacts` (which is people-shaped). Add `engagement_log` rows for status changes with notes.
|
||||
|
||||
I'd start with Option A. Migrate to Option B if it outgrows.
|
||||
|
||||
### 4.3 Community survey
|
||||
|
||||
Generalises Pattern 11.
|
||||
|
||||
```
|
||||
surveys
|
||||
id uuid PK
|
||||
space_id uuid FK
|
||||
title text
|
||||
body text
|
||||
opens_at, closes_at
|
||||
status enum: 'draft' | 'open' | 'closed'
|
||||
is_anonymous boolean
|
||||
visibility_of_results enum: 'admin_only' | 'board_visible' | 'all_members'
|
||||
|
||||
survey_questions
|
||||
id, survey_id, prompt, response_type (likert | text | multi_choice), options jsonb, required
|
||||
|
||||
survey_responses
|
||||
id, survey_id, member_id (nullable iff anonymous), submitted_at
|
||||
|
||||
survey_answers
|
||||
id, response_id, question_id, value text / value_int
|
||||
```
|
||||
|
||||
A space sets an annual baseline survey (trust in board, welcoming to marginalized groups, project completion vs frustration, mission alignment). Results render as a time series across years. No scoring, no leaderboard. Read-only analytics for the membership.
|
||||
|
||||
### 4.4 Mission statement display
|
||||
|
||||
Generalises Pattern 11.
|
||||
|
||||
Add `mission_statement text` to `spaces`. Each space writes its own. Renders in the page footer everywhere. No template; pure freeform.
|
||||
|
||||
---
|
||||
|
||||
## What I would not build
|
||||
|
||||
These are real concerns in the source analysis but they fail one of the three filters (don't generalise, infrastructure-too-heavy, or solve a workflow problem better solved by workflow).
|
||||
|
||||
| Item from the source | Why I'd skip |
|
||||
|----------------------|--------------|
|
||||
| Cryptographic anonymity for complaints | Over-promises. The DB admin can always read. Honest "anonymous-reported, opaque token for status" covers 95% of cases; a hackerspace that needs stronger anonymity should use Signal. |
|
||||
| Discourse / Loomio integration | We already ship comms + KB. Adding a third governance forum on top fragments more than it consolidates. If a space wants Discourse they can self-host it; we don't need to be it. |
|
||||
| Door-system access logging | Hardware integration. Out of scope for a CRUD app. Operators can wire whatever access controller they have to write to `activity_log` via the existing service-role API if they want to. |
|
||||
| Cross-platform identity unification (SAML/OIDC across Slack/Discord/wiki) | Each space's other tools are different. We provide a stable internal identity. Federation is out of scope. |
|
||||
| External-perception dashboard (social media monitoring) | Belongs in a separate marketing tool. Not core to running the space. |
|
||||
| Pattern-detection ML on incidents | Just surface raw co-occurrence. Humans are better at "is this a pattern?" than a classifier on 12 incidents/year. |
|
||||
| Term-limits enforcement | Configurable bylaws can vary too widely. Display "term ends on YYYY-MM-DD" if a member has one. Don't auto-kick. |
|
||||
| Onboarding curriculum modules per station | Too space-specific. Each space defines its own stations. A station might be a laser, a forge, or a bee hive. Generic "modules" become useless abstraction. The existing KB plus a `prerequisite_kb_ids` field on tasks covers the 80% case. |
|
||||
| Public-private framing surveillance (tracking DM contradictions) | Even the source analysis flagged this as wrong. Make the institutional record so visible that drift is self-evident. Don't watch private conversations. |
|
||||
| Email digest summarisation with sentiment scoring | Building a "drama detector" is a tar pit. Provide structured decision logs and let members read those instead of chat backlog. |
|
||||
|
||||
---
|
||||
|
||||
## Schema and code impact summary
|
||||
|
||||
If Tier 1 ships, the changes are:
|
||||
|
||||
**New tables**: `proposals`, `proposal_votes`, `incidents`, `incident_updates`, `policies`.
|
||||
|
||||
**Modified tables**: `spaces` gains ~6 settings columns (defaults for quorum, voting window, visibility flags, SLA, mission statement).
|
||||
|
||||
**New enums**: `proposal_type`, `proposal_status`, `vote_position`, `threshold_rule`, `incident_status`, `incident_severity`, `incident_visibility`, `policy_status`.
|
||||
|
||||
**New RLS policies**: ~20 (4 per new table × 5 tables, roughly).
|
||||
|
||||
**New routes**: `/proposals`, `/proposals/[id]`, `/proposals/new`, `/incidents`, `/incidents/[id]`, `/incidents/new`, `/incidents/track/[token]` (public, for anonymous status), `/policies`, `/policies/[slug]`, `/policies/[slug]/[version]`.
|
||||
|
||||
**New server actions in `lib/actions/`**: a new `governance.ts` (or split into `proposals.ts`, `incidents.ts`, `policies.ts`). Each uses the existing `requireMember` / `requireMemberWithRole` / `parseInput` helpers. Each emits an `activity_log` row via the existing `logActivity` helper.
|
||||
|
||||
**New triggers in `scripts/schema.sql`**:
|
||||
- One to insert a card-review task when `has_card_access` flips true.
|
||||
- One to freeze `outcome_*` on `proposals` when status transitions to `decided`.
|
||||
- One to update `policies.prior_version_id` and increment `version` when a new row replaces an active one.
|
||||
|
||||
All within the architecture already in place. No new dependencies, no new auth providers, no new background workers (the proposal-close action can be a cron-triggered DO endpoint that runs once an hour).
|
||||
|
||||
---
|
||||
|
||||
## Sequencing recommendation
|
||||
|
||||
Build Tier 1 first, as one cohesive thing, because the three modules reference each other and shipping them piecemeal leaves gaps:
|
||||
|
||||
1. `proposals` and `proposal_votes` (basic CRUD + voting)
|
||||
2. `policies` with versioning
|
||||
3. `incidents` with reporter notification and appeal-as-proposal linking
|
||||
4. Cross-references between the three working (citation linking, appeal pathway)
|
||||
|
||||
This is roughly 3 months of focused work for one engineer including UI. The schema work is a week; the rest is forms, lists, detail pages, RLS testing, and writing the in-product help that explains what each thing does to a non-political member.
|
||||
|
||||
Then Tier 2: financials and member-state extensions. Mostly UI on top of existing tables; 1 month.
|
||||
|
||||
Then Tier 3 as appetite allows.
|
||||
|
||||
---
|
||||
|
||||
## A meta-observation
|
||||
|
||||
The source analysis warns: "Cooperatives die more often from bureaucratic creep than from under-tooling." This is correct. The features above only help if they make institutional work *easier*, not if they make it more elaborate. The test for any new screen: would a tired board member at 11pm on a Tuesday actually use this, or would they slack-message the chair instead? If the answer is "slack-message the chair," the feature is wrong.
|
||||
|
||||
The simplest test for the proposals module: can a member, in two minutes, file a vote-of-confidence proposal that hits all the required procedural elements (quorum stated, threshold stated, voting window defined, bylaws clause cited)? If yes, it's good. If they have to read three help pages first, simplify the form.
|
||||
|
||||
The simplest test for the incidents module: when a complaint is filed and the board dismisses it, does the reporter receive an unambiguous in-app notification within 24 hours that names the decision-makers and provides a one-click path to a membership appeal? If yes, the black hole is closed. If they have to ask, the feature failed.
|
||||
|
||||
Build for what hurts repeatedly. Defer what hurts occasionally. Reject what hurts hypothetically.
|
||||
+752
@@ -0,0 +1,752 @@
|
||||
# Handoff Log
|
||||
|
||||
Append-only. Newest entries on top. Keep each entry to one screen.
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
|
||||
1. **"Invalid datetime" error when creating a task with a due_date.** HTML `<input type="date">` sends `"2026-05-22"` (date-only) but `z.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`).
|
||||
2. **Areas were hardcoded constants** in `tasks-client.tsx` (9 strings) and `projects-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_areas` table: `(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.sql` as Section 12.
|
||||
|
||||
Library surface:
|
||||
|
||||
- `lib/types.ts`: `SpaceArea` type.
|
||||
- `lib/validations.ts`: `createAreaSchema`, `updateAreaSchema` (slug regex, length caps).
|
||||
- `lib/actions/areas.ts`: `createArea`, `updateArea`, `deleteArea`. Use `requireMemberWithRole(ADMIN_ROLES)`. Re-exported via the barrel.
|
||||
|
||||
UI wiring:
|
||||
|
||||
- `app/(app)/tasks/page.tsx` and `app/(app)/projects/page.tsx` fetch `space_areas` in parallel and pass `areas: string[]` to the client.
|
||||
- `tasks-client.tsx` and `projects-client.tsx` accept the new prop; old hardcoded arrays renamed `DEFAULT_AREAS` as a fallback.
|
||||
- `app/(app)/settings/page.tsx` fetches the full area list and passes to `SettingsClient`.
|
||||
- `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_areas` via REST with their JWT; got the 10 defaults sorted by `sort_order`. RLS clean, no cross-tenant leak.
|
||||
- **266 tests across 8 files** (was 259; +7 for flexibleDateTime).
|
||||
- `pnpm build` clean, 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."
|
||||
|
||||
1. 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.
|
||||
2. Applied `scripts/schema.sql` from scratch against the empty local DB. **18 tables created**.
|
||||
3. Wrote `scripts/seed.sql` and 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.
|
||||
4. **Migration 019** (`scripts/019_proposal_expiry.sql`): `public.expire_proposals()` flips open proposals past their deadline to `decided` (if quorum met) or `expired`. Schedules itself hourly via `pg_cron` if the extension is enabled. Folded into `schema.sql` as Section 11.
|
||||
5. Wrote `docs/LOCAL_DEV.md`: 8-step quickstart, wipe + re-seed recipe, troubleshooting section.
|
||||
6. Regenerated `types/database.ts` from the live local DB. 1696 lines, 18 tables, 1 view, 3 RPC functions.
|
||||
7. Wrote `.env.local` with the local Supabase URL and keys.
|
||||
8. Started `pnpm dev`. Health endpoint returns 200, public routes return 200, `/dashboard` correctly 307s to `/login`.
|
||||
|
||||
### Verification done with real data
|
||||
|
||||
- Anon-key RLS gate confirmed (empty array on no-session).
|
||||
- Service-role bypass confirmed.
|
||||
- Created `tester@demo.local` 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: `/login` title correct, `/signup` contains 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.
|
||||
|
||||
1. **Renamed `middleware.ts` → `proxy.ts`** for Next.js 16. Renamed the exported `middleware` function to `proxy`. The deprecation warning that the build had been emitting since pass 4 is now gone.
|
||||
2. **Markdown rendering**: installed `react-markdown` + `remark-gfm`. Created `components/markdown.tsx` (`MarkdownBody`) with safe defaults — HTML escaped, no `rehypeRaw`, every link gets `rel="noopener noreferrer"` and `target="_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.
|
||||
3. **`/profile` route** (Tier 3.1 + 3.2 UI closure):
|
||||
- Server page reads the caller's `space_members` row.
|
||||
- `ProfileForm` (client) lets a member edit display_name, handle, phone, skills, interests, willing_to. Uses a tiny custom `ChipInput` component for tag-style multi-value fields with `Enter`/`,` to commit and `Backspace` to delete.
|
||||
- `AffiliationsForm` (client) lets the member disclose COI affiliations. Submit stamps `coi_last_disclosed_at` via `discloseAffiliations`. Surfaces an orange callout for privileged-role members who haven't disclosed yet.
|
||||
- The `willing_to` field ships with suggestion chips: `board_candidate`, `treasurer_candidate`, `host_volunteer`, `area_lead_candidate`, `event_organizer`, `safety_committee`, `docs_steward`. Members can also free-form.
|
||||
4. **`/recruitment` route** (Tier 3 succession view):
|
||||
- Board / admin only (server-side redirect for others).
|
||||
- Queries `space_members` for the current space; groups by each `willing_to` tag; 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.
|
||||
5. **Settings extensions** (Tier 2.1 + Tier 2.2 + mission statement):
|
||||
- Added `mission_statement` field to `updateSpaceSettingsSchema` (max 5000 chars, nullable).
|
||||
- The Space tab on `/settings` now 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) and `member_directory_visibility` (4 options). Saves via the existing `updateSpaceVisibility` action from pass 6.
|
||||
6. **Mission statement display in the app shell**: when `space.mission_statement` is set, the app layout renders it in a small italic footer on every protected page — the "stated-mission visibility on every page" principle from `docs/GOVERNANCE_FEATURES.md` section 1.
|
||||
7. **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_votes` to 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 `received` and `under_review` incidents, 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.
|
||||
8. **Sidebar**: added "My profile" (`UserCircle` icon) and admin-gated "Recruitment" (`UserSearch` icon) under the People group.
|
||||
9. **4 new tests** in `__tests__/governance.test.ts` for the mission_statement extension to `updateSpaceSettingsSchema`. 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
|
||||
|
||||
1. **Apply pending migrations** on production (015, 017, 018) if not already done. Fresh deploys get them all via `scripts/schema.sql`.
|
||||
2. **Regenerate `types/database.ts`** with the Supabase CLI to clear the ~217 TS errors that `typescript.ignoreBuildErrors` masks. Once clean, remove `ignoreBuildErrors` so future drift surfaces in CI.
|
||||
3. **Configure OAuth providers** in Supabase if you want the GitHub / Google buttons to work.
|
||||
4. **Auto-expire open proposals**: when `voting_closes_at < now()`, status should auto-flip from `open` to `expired`. Currently manual via the admin's "Mark decided" button. A cron-triggered server action would close this.
|
||||
5. **Mark KB entry as meeting minutes**: schema (column added in 018) and validation schema in place, but no UI control yet.
|
||||
6. **Notifications on incident status change**: activity_log row written but no in-app comms message or email triggered. Add when notification infra is ready.
|
||||
7. **Hosting calendar, sponsor / partner registry, community surveys**: Tier 4, deferred indefinitely until adoption signals demand.
|
||||
8. **Secrets encryption at rest**: `secrets.value` and `integrations.config` still 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
|
||||
|
||||
```bash
|
||||
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 `/recruitment` as admin/board — see members grouped by willing_to.
|
||||
- Visit `/settings`, scroll to "Visibility", change financial_visibility to `treasurer_only`, save, then visit `/financials` as a plain member — should be blocked. Switch back to `all_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):**
|
||||
1. `proposal_votes.votes_insert` accepted a vote where `member_id` was a `space_members` row from space A but the proposal was in space B, because the policy never joined them. Fixed: insert WITH CHECK now requires `m.space_id = p.space_id` for the joined member.
|
||||
2. `votes_update` had the same bug. Same fix.
|
||||
3. `incidents.incidents_insert` let the reporter set `reporter_id` to any `space_members.id` they owned — across spaces. Fixed: WITH CHECK requires the row's `space_id` match the incident's.
|
||||
4. `incident_updates.incident_updates_insert` did not validate `author_id` at 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.
|
||||
- `appealIncident` does insert-then-update without a transaction; mid-flight failure leaves an orphaned proposal. Acceptable risk.
|
||||
- `supersedePolicy` race between two admins computing `version + 1` simultaneously is caught by the existing `UNIQUE (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.
|
||||
- `/proposals` and `/incidents` lists 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)
|
||||
|
||||
1. **Migration 017** `scripts/017_governance_rls_hardening.sql`: the four RLS fixes + the partial unique index. Mirrored into `scripts/schema.sql`.
|
||||
2. **Migration 018** `scripts/018_member_state_and_visibility.sql`:
|
||||
- `space_members` gains `skills`, `interests`, `willing_to`, `affiliations` (all `text[]`) and `coi_last_disclosed_at`. GIN indexes on `skills` and `willing_to`.
|
||||
- `spaces` gains `financial_visibility` and `member_directory_visibility` enums.
|
||||
- `knowledge_base` gains `is_meeting_minutes` and `meeting_date` (partial index).
|
||||
- `schedule_card_review` trigger inserts a 180-day task whenever `has_card_access` flips on.
|
||||
- `inactive_members` view: members in good standing with no `activity_log` entry in 180 days.
|
||||
3. **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 })`: stamps `coi_last_disclosed_at` and logs to activity_log.
|
||||
- `settings.ts → updateSpaceVisibility({...})`: admin-only.
|
||||
4. **Validation schemas**: `updateMyProfileSchema`, `discloseAffiliationsSchema`, `updateSpaceVisibilitySchema`, `meetingMinutesSchema`, plus exported tuples (`financialVisibilities`, `directoryVisibilities`).
|
||||
5. **13 new tests** in `__tests__/governance.test.ts`. Total now **255 across 8 files**.
|
||||
6. **`/financials` route**: server-component aggregations from `payments` (this month, this year, lifetime, linked vs unlinked, by platform with sparkline-style bars, last 12 months). Gated by `space.financial_visibility` against caller's role.
|
||||
7. **Sidebar**: added "Financials" under People with the `LineChart` icon.
|
||||
|
||||
### 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:
|
||||
|
||||
```sql
|
||||
\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
|
||||
|
||||
1. **Apply 017 on production immediately** if any chance of multi-space membership (admin client could have set it up).
|
||||
2. **Apply 018** for the Tier 2/3 columns and the card-review trigger.
|
||||
3. **Profile / COI / settings-visibility UI**: server actions are in place, no UI yet. A `/profile` route + extending Settings would close Tier 3.1, 3.2 and the financial-visibility toggle.
|
||||
4. **Recruitment view**: query `space_members` by `willing_to` for board-only succession planning.
|
||||
5. **Mark KB entry as meeting minutes from UI**: schema ready, no UI control.
|
||||
6. Earlier-pass items still open: regenerate `types/database.ts`, run migration 015 if not already, rename `middleware.ts` → `proxy.ts` for 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.
|
||||
|
||||
1. **Schema**: `scripts/016_governance_kernel.sql` (and mirrored into `scripts/schema.sql` as Section 9). 5 new tables (`proposals`, `proposal_votes`, `incidents`, `incident_updates`, `policies`), 8 new enums, 6 new columns on `spaces` for 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`).
|
||||
2. **Types** in `lib/types.ts`: hand-typed `Proposal`, `ProposalVote`, `Incident`, `IncidentUpdateRow`, `Policy` and all 8 governance enums. `types/database.ts` should be regenerated via Supabase CLI to fold these in.
|
||||
3. **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).
|
||||
4. **Server actions**: `lib/actions/proposals.ts`, `lib/actions/incidents.ts`, `lib/actions/policies.ts`. Use the existing `requireMember` / `requireMemberWithRole` / `parseInput` / `logActivity` helpers. Re-exported via the barrel in `lib/actions/index.ts`. Notable: `fileIncident` generates an opaque `reporter_token` when anonymous; `appealIncident` atomically spawns a draft `membership_vote` proposal and flips the incident to `appealed`; `supersedePolicy` inserts a new version with `prior_version_id` set rather than mutating the prior row; `updatePolicyStatus(... , 'active')` atomically supersedes any prior active version with the same slug.
|
||||
5. **Tests**: `__tests__/governance.test.ts` adds 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.
|
||||
6. **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.
|
||||
7. **Sidebar**: new "Governance" section with three links (Proposals, Incidents, Policies). Lucide icons: `Vote`, `ShieldAlert`, `ScrollText`.
|
||||
8. **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. Regenerating `types/database.ts` with the Supabase CLI clears them all.
|
||||
|
||||
### Open items, priority order
|
||||
|
||||
1. **Apply `scripts/016_governance_kernel.sql`** to any existing production database. Fresh deploys get it via `scripts/schema.sql` automatically.
|
||||
2. **Regenerate `types/database.ts`** with `supabase gen types typescript --project-id <ref>`. Clears the 60 + 157 = 217 TS errors. Then remove `typescript.ignoreBuildErrors` from `next.config.mjs`.
|
||||
3. **Pass 4 priority 1 still applies**: run `scripts/015_prevent_member_self_role_change.sql` before external users sign in.
|
||||
4. **Auto-expiry of open proposals**: when `voting_closes_at < now()`, the status should flip from `open` to `expired` (or `decided` if board acts). Currently manual. A cron-triggered server action or DB job can do this.
|
||||
5. **Markdown rendering**: proposals' body is currently rendered as `<pre>` whitespace-preserved text. Pulling in `react-markdown` would give links, lists, and the `[policy:slug#section]` citation syntax from the roadmap doc. Defer until adoption is real.
|
||||
6. **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.
|
||||
7. **Rename `middleware.ts` → `proxy.ts`** for 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:
|
||||
|
||||
```sql
|
||||
-- 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
|
||||
|
||||
1. **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 by `typescript.ignoreBuildErrors: true` in `next.config.mjs`. No new errors introduced.
|
||||
2. **Fixed one self-inflicted test bug**: `__tests__/auth-helpers.test.ts` was written in pass 3 using the old `r.error` access pattern, which does not narrow now that `Result<T>` is a tagged union (`ok: true | false`). Rewrote each test to gate on `if (r.ok)` / `if (!r.ok)` before reading `data` or `error`.
|
||||
3. **Surfaced two non-blocking build warnings** that the user should know about:
|
||||
- Next.js 16 deprecates the `middleware` file convention in favour of `proxy`. Build still works with `middleware.ts`; rename to `proxy.ts` whenever convenient.
|
||||
- Next.js detected a stray `/Users/obsidian/Projects/package-lock.json` outside the repo (workspace root inference). Local-only nuisance, does not affect CI or DO builds.
|
||||
4. **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`, generating `JWT_SECRET` / `ANON_KEY` / `SERVICE_ROLE_KEY` via a Node one-liner, full `.env` template, port-remap override (Studio defaults to 3000 and collides with the Next.js app; doc rebinds it to 3001 and forces all internal ports to `127.0.0.1` to keep them off the public internet).
|
||||
- Applying `scripts/schema.sql`, `014_*.sql`, `015_*.sql` via `docker compose exec db psql`.
|
||||
- Building the app image with `NEXT_PUBLIC_*` build args (otherwise the client bundle ships with `undefined` for 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_dump` backups with 14-day local rotation and an off-site path to DO Spaces.
|
||||
- Operations: app upgrades, incremental schema migrations (with `docker compose restart rest` to refresh PostgREST's schema cache), Supabase version upgrades.
|
||||
- Hardening checklist (14 items) and a troubleshooting section covering the eight problems people actually hit.
|
||||
5. **Linked the new doc** from `README.md` (deployment table + body section) and from the top of `docs/DEPLOYMENT.md`. Made it explicit that `DEPLOYMENT.md` covers managed-Supabase paths and `DEPLOY_DO_SELFHOSTED.md` covers 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:
|
||||
1. `scripts/schema.sql` (canonical, idempotent)
|
||||
2. `scripts/014_member_user_id_nullable.sql`
|
||||
3. `scripts/015_prevent_member_self_role_change.sql` (security; do not skip)
|
||||
|
||||
### Open items, priority order
|
||||
|
||||
1. **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.
|
||||
2. **Rename `middleware.ts` to `proxy.ts`** to clear the Next.js 16 deprecation. Single-file rename; the export and matcher contract is unchanged.
|
||||
3. **Regenerate `types/database.ts`** via `supabase gen types typescript --project-id <ref>` (managed) or against the self-hosted Postgres. Clears the 157 TS errors. Then drop `typescript.ignoreBuildErrors: true` from `next.config.mjs` so future type drift surfaces in CI.
|
||||
4. **Wire OAuth providers** in Supabase dashboard (GitHub, Google). The client code already calls `supabase.auth.signInWithOAuth`; only the provider configuration is missing.
|
||||
5. **Encrypt `secrets.value` and `integrations.config`** before storing real production credentials.
|
||||
6. **Stale docs**: `DB_SCHEMA_MAP.md` and `docs/ARCHITECTURE.md` still claim 4 default channels. Schema creates 3 (`general`, `announcements`, `ops`).
|
||||
7. **`signUp` trigger metadata mismatch** in `scripts/schema.sql` (`handle_space_signup` reads `space_id` but 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.ts` now or wait for a future Next.js major version that removes the alias.
|
||||
- Whether to regenerate `types/database.ts` against managed Supabase (`supabase gen types` requires 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
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
1. **Split `lib/actions.ts` (914 lines, 29 functions) into 10 domain files** under `lib/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.ts`
|
||||
- `lib/actions/index.ts` re-exports everything via `export * from './foo'`
|
||||
- Every client import `from '@/lib/actions'` still resolves correctly.
|
||||
2. **Created `lib/permissions.ts`**: role constants (`ROLES`, `ADMIN_ROLES`, `TREASURER_ROLES`, `ACTIVE_STATUSES`), `Role` type alias, `hasRole(role, allowed)` helper. Removes magic-string role chains.
|
||||
3. **Created `lib/auth-helpers.ts`**: `getAuthMember`, `requireMember`, `requireMemberWithRole`, `parseInput`, `logActivity`. Result types use the canonical `ok: boolean` discriminator so TypeScript narrows on `if (!r.ok)` reliably.
|
||||
4. **Wired validation into every server action** that has a schema (was previously only `createTask` and `claimTask`). 18 more actions now reject bad input at the boundary instead of hoping the database rejects it.
|
||||
5. **Expanded `lib/types.ts`** with re-exports for `Role`, `Member`, `MemberSummary`, `Result`, `MemberResult`, `ActionResult`, and the role/permission constants. One-stop import for typed work.
|
||||
6. **Deleted dead code**: `app/auth/login/page.tsx` (duplicate login linking to nowhere), `components/app-shell.tsx` (unused, hardcoded badge counts).
|
||||
7. **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 and `hasRole`.
|
||||
- `__tests__/security.test.ts`: 25 tests for `sanitize*`, `escapeHtml`, `checkRateLimit`, `isValidUuid`.
|
||||
- `__tests__/auth-helpers.test.ts`: 4 tests for `parseInput`.
|
||||
- Full suite: **202 tests, 7 files, all passing**.
|
||||
8. **Fixed vitest config**:
|
||||
- `vitest.setup.ts` had inline JSX in a `.ts` file (esbuild refuses). Replaced with `React.createElement`.
|
||||
- `vitest.config.ts` was picking up Playwright e2e specs. Added `include: ['__tests__/**/*.{test,spec}.{ts,tsx}']` and `exclude: ['e2e/**']`.
|
||||
9. **Added `scripts/README.md`** documenting the migration convention (canonical `schema.sql` + numbered incremental files, idempotency rules, security baseline, verification queries).
|
||||
|
||||
### Open items, priority order
|
||||
|
||||
1. **TypeScript drift between `@supabase/supabase-js` generics and our `Database` type**: 38 errors in `lib/actions/*` of the form `'X' is not assignable to parameter of type 'never'`. These existed in the original `lib/actions.ts` too (the build masks them via `next.config.mjs: typescript.ignoreBuildErrors: true`). Fix is to regenerate `types/database.ts` with the latest supabase CLI so the generated `Database` shape matches what `@supabase/ssr` expects.
|
||||
2. **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.
|
||||
3. **`getCurrentMember` was removed** from `lib/actions.ts` when I split it; no caller referenced it. If anything starts to need it, define a server action in `lib/actions/index.ts` or use `getAuthMember` from `lib/auth-helpers.ts`.
|
||||
4. **Pass 2 priority 1 (run `scripts/015_*.sql` on production) is still outstanding** until the user runs it.
|
||||
5. **`signUp` trigger metadata mismatch** remains documented but unfixed.
|
||||
6. **Stale docs**: `DB_SCHEMA_MAP.md` and `docs/ARCHITECTURE.md` say 4 default channels. Schema creates 3.
|
||||
|
||||
### Decisions pending
|
||||
|
||||
- Whether to regenerate `types/database.ts` via the Supabase CLI to clear the remaining 141 TS errors.
|
||||
- Whether to also run `next.config.mjs` without `typescript.ignoreBuildErrors: true` once types are clean.
|
||||
- Whether to add CI to run `pnpm vitest` and `pnpm exec tsc --noEmit` on 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
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
1. **Fixed dashboard "Quick Chores" bug** (`app/(app)/dashboard/page.tsx:49`). The filter was `.neq('status','done')` but `completeTask()` writes `'completed'`. Completed tasks were leaking into the open list. Replaced with a positive filter on the open statuses.
|
||||
2. **Fixed signup copy**: said default channels were "general, announcements, random". Schema creates "general, announcements, ops". Aligned.
|
||||
3. **Fixed `lib/validations.ts` enum 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.
|
||||
4. **Wired validation into high-risk server actions**: `createProject`, `updateProjectStatus`, `addMember`, `logCashPayment`, `linkPaymentToMember`. Other actions still pass raw `formData`; documented in `docs/AUDIT.md` section 4.4.
|
||||
5. **Patched privilege escalation in `space_members`**: added a `BEFORE UPDATE` trigger (`prevent_member_self_role_change`) that rejects self-updates of `role`, `tier`, `status`, `approved`, `has_card_access`, `space_id` unless the user is already admin/board/treasurer in that space. Applied to `schema.sql`. Shipped as incremental migration `scripts/015_prevent_member_self_role_change.sql` for existing databases. Run this on any production database immediately.
|
||||
6. **Removed duplicate auth functions** in `lib/actions.ts` (`signIn`, `signOut`, `getUser`). Canonical versions live in `lib/auth-actions.ts`. Verified no caller imports them from `actions.ts`.
|
||||
7. **Deleted dead scripts**: `fix-actions.js`, `fix-sidebar.js`, `fix-sidebar.mjs`, `patch-dashboard.mjs`. All had hardcoded `/vercel/share/v0-project` paths.
|
||||
8. **Synced `types/database.ts`** for `space_members.user_id` to `string | null` matching the schema fix from pass 1.
|
||||
|
||||
### Open items, priority order
|
||||
|
||||
1. **Run `scripts/015_prevent_member_self_role_change.sql` on production** before any external user can authenticate. Until then, any member can self-promote to admin via direct PostgREST.
|
||||
2. **`app/auth/login/page.tsx`** is a dead duplicate login page linking to non-existent `/auth/onboarding`. Delete the directory.
|
||||
3. **`components/app-shell.tsx`** is dead (no importer). Delete.
|
||||
4. **Wire validation into remaining actions** listed in `docs/AUDIT.md` 4.4 (updateMember, contacts, KB, secrets, area lead, settings, integrations, imports).
|
||||
5. **Secrets at rest**: still plain text.
|
||||
6. **`signUp` trigger metadata mismatch** is benign (trigger short-circuits) but should be removed or re-aligned. See `docs/AUDIT.md` 4.5.
|
||||
7. **Stale docs**: `DB_SCHEMA_MAP.md` and `docs/ARCHITECTURE.md` still 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/` and `components/app-shell.tsx` now.
|
||||
- Whether to regenerate `types/database.ts` properly via `supabase gen types` instead of the manual edit applied here.
|
||||
|
||||
### How to verify the security fix
|
||||
|
||||
```sql
|
||||
-- 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
|
||||
|
||||
1. Wrote `CLAUDE.md` at 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.
|
||||
2. Audited every source file. Findings in `docs/AUDIT.md`.
|
||||
3. Fixed three idempotency / fresh-deploy issues in `scripts/schema.sql`:
|
||||
- `space_members.user_id` is now nullable (offline members work).
|
||||
- Realtime `ALTER PUBLICATION` wrapped in `DO ... EXCEPTION` blocks.
|
||||
- All RLS policies preceded by `DROP POLICY IF EXISTS`.
|
||||
4. Added `scripts/014_member_user_id_nullable.sql` for upgrading existing databases.
|
||||
5. Added every missing deployment artifact: `Dockerfile`, `.dockerignore`, `docker-compose.yml`, `.do/app.yaml`, `.env.example`, `app/api/health/route.ts`.
|
||||
6. Updated `middleware.ts` to let `/api/health` through without auth.
|
||||
7. Updated `docs/DEPLOYMENT.md` to 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
|
||||
|
||||
1. **Privilege escalation on `space_members.role`** (audit 4.11). A self-update via the `members_update` RLS policy lets a member rewrite their own row, including `role`. Fix before production.
|
||||
2. **Validation schema enum mismatches** (audit 4.1). Six schemas in `lib/validations.ts` reject valid database values. Wire schemas into actions and align the enums.
|
||||
3. **Dead code**: four `scripts/fix-*.{js,mjs}` files with hardcoded `/vercel/share` paths, and `components/app-shell.tsx` which no page imports. Delete on next cleanup.
|
||||
4. **Duplicate auth functions**: `lib/actions.ts` lines 30 to 47 redeclare `signIn`, `signOut`, `getUser` that already live in `lib/auth-actions.ts`. Consolidate.
|
||||
5. **Secrets at rest**: `secrets.value` and `integrations.config` are plain text. Encrypt before production.
|
||||
6. **UI-only features**: OAuth buttons, CSV import processor, webhook endpoint, payment platform syncs (except PayPal), email notifications. See audit 4.7.
|
||||
7. **`package.json` name is `"my-project"`**. Cosmetic.
|
||||
8. **Older docs reference 4 default chat channels**; schema creates 3 (`general`, `announcements`, `ops`). `DB_SCHEMA_MAP.md` and `docs/ARCHITECTURE.md` are 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.sql` against the live database (production may already have user_id nullable per `DB_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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# 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.
|
||||
@@ -0,0 +1,203 @@
|
||||
# Local development quickstart
|
||||
|
||||
Fresh-clone to running app in under five minutes. Uses the Supabase CLI to spin up a complete local stack (Postgres + Auth + PostgREST + Realtime + Storage + Studio) in Docker. No Supabase account needed.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- macOS, Linux, or WSL2 on Windows.
|
||||
- **Docker** (running). `docker info` should print a server section.
|
||||
- **Homebrew** on macOS, or follow the [Supabase CLI install docs](https://supabase.com/docs/guides/cli/getting-started) for your OS.
|
||||
- **Node 20+** and **pnpm 9+**. nvm works fine.
|
||||
|
||||
## 1. Install the Supabase CLI
|
||||
|
||||
```bash
|
||||
brew install supabase/tap/supabase
|
||||
supabase --version # 1.x or 2.x
|
||||
```
|
||||
|
||||
## 2. Clone and install
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd hackerspace-management
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## 3. Initialize and start the local Supabase stack
|
||||
|
||||
```bash
|
||||
supabase init # writes supabase/config.toml; safe if it already exists
|
||||
supabase start
|
||||
```
|
||||
|
||||
First run pulls a few GB of images and takes 1–3 minutes. Subsequent starts are seconds.
|
||||
|
||||
When it finishes, the CLI prints something like:
|
||||
|
||||
```
|
||||
API URL: http://127.0.0.1:54321
|
||||
GraphQL URL: http://127.0.0.1:54321/graphql/v1
|
||||
S3 Storage URL: http://127.0.0.1:54321/storage/v1/s3
|
||||
DB URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
||||
Studio URL: http://127.0.0.1:54323
|
||||
Inbucket URL: http://127.0.0.1:54324
|
||||
JWT secret: super-secret-jwt-token-with-at-least-32-characters-long
|
||||
anon key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
service_role key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
Save the **anon key** and **service_role key**. You can re-print them anytime with `supabase status`.
|
||||
|
||||
The DB is at `postgresql://postgres:postgres@127.0.0.1:54322/postgres`. Studio (Supabase dashboard, locally) is at `http://127.0.0.1:54323`. The Inbucket email catcher is at `http://127.0.0.1:54324` — every email the app would send shows up there.
|
||||
|
||||
## 4. Apply the schema and the seed
|
||||
|
||||
```bash
|
||||
# Schema (canonical, covers every migration through 019)
|
||||
psql "postgresql://postgres:postgres@127.0.0.1:54322/postgres" -f scripts/schema.sql
|
||||
|
||||
# Optional but recommended for local: load a demo space with members, tasks,
|
||||
# proposals, an incident, a policy, and activity-log entries.
|
||||
psql "postgresql://postgres:postgres@127.0.0.1:54322/postgres" -f scripts/seed.sql
|
||||
```
|
||||
|
||||
You can verify with Studio: open `http://127.0.0.1:54323`, switch to the `public` schema, and see 18 tables including `proposals`, `incidents`, `policies`.
|
||||
|
||||
## 5. Generate TypeScript types from the live local schema
|
||||
|
||||
```bash
|
||||
supabase gen types typescript --local > types/database.ts
|
||||
```
|
||||
|
||||
This clears the masked TS errors that `next.config.mjs: typescript.ignoreBuildErrors: true` was hiding. After this you can drop that ignoreBuildErrors flag and any future schema drift surfaces in CI.
|
||||
|
||||
## 6. Write `.env.local`
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Edit `.env.local`:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<paste anon key from step 3>
|
||||
SUPABASE_SERVICE_ROLE_KEY=<paste service_role key from step 3>
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
NODE_ENV=development
|
||||
```
|
||||
|
||||
## 7. Run the dev server
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open `http://localhost:3000`. Health check: `curl http://localhost:3000/api/health` returns `{"status":"ok"}`.
|
||||
|
||||
## 8. Sign up as a real user and join the demo space
|
||||
|
||||
1. Visit `/signup`.
|
||||
2. Pick **Join a Space** and enter invite code: `DEMO-2026-TEST`.
|
||||
3. Fill in your email / password.
|
||||
4. Local Supabase is configured to skip email confirmation by default, so signup takes you straight to `/dashboard`.
|
||||
5. The dashboard now shows the seeded activity. Visit `/proposals`, `/incidents`, `/policies`, `/financials` to see the full feature surface.
|
||||
|
||||
If you want admin permissions for the demo: connect to the DB (or via Studio) and run:
|
||||
|
||||
```sql
|
||||
UPDATE public.space_members
|
||||
SET role = 'admin'
|
||||
WHERE email = 'your-email@example.com';
|
||||
```
|
||||
|
||||
## Common dev tasks
|
||||
|
||||
### Wipe and re-seed
|
||||
|
||||
```bash
|
||||
supabase db reset # drops & re-runs the database
|
||||
psql "postgresql://postgres:postgres@127.0.0.1:54322/postgres" -f scripts/schema.sql
|
||||
psql "postgresql://postgres:postgres@127.0.0.1:54322/postgres" -f scripts/seed.sql
|
||||
```
|
||||
|
||||
### Apply a new incremental migration
|
||||
|
||||
```bash
|
||||
psql "postgresql://postgres:postgres@127.0.0.1:54322/postgres" -f scripts/0NN_my_change.sql
|
||||
```
|
||||
|
||||
PostgREST auto-refreshes on schema changes locally; no restart needed.
|
||||
|
||||
### Run tests
|
||||
|
||||
```bash
|
||||
pnpm vitest run # 259 tests across 8 files; no DB needed
|
||||
pnpm exec playwright test # E2E (Playwright will spin up the dev server)
|
||||
```
|
||||
|
||||
### Type check
|
||||
|
||||
```bash
|
||||
pnpm exec tsc --noEmit
|
||||
```
|
||||
|
||||
After step 5, this should be clean. If it isn't, regenerate types.
|
||||
|
||||
### Stop the local stack
|
||||
|
||||
```bash
|
||||
supabase stop
|
||||
```
|
||||
|
||||
Containers are stopped but volumes persist. To nuke them entirely: `supabase stop --no-backup` then `supabase start` for a fresh slate.
|
||||
|
||||
### Tail logs
|
||||
|
||||
```bash
|
||||
supabase logs # all services
|
||||
supabase logs --type api
|
||||
supabase logs --type db
|
||||
```
|
||||
|
||||
### Run the proposal-expiry job manually
|
||||
|
||||
```sql
|
||||
SELECT public.expire_proposals();
|
||||
-- returns the number of proposals flipped from 'open' to 'decided' or 'expired'
|
||||
```
|
||||
|
||||
In production you'd schedule this via pg_cron (the schema's migration 019 sets this up automatically when pg_cron is available) or via a Next.js cron endpoint guarded by a `CRON_SECRET`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `supabase start` hangs or fails
|
||||
|
||||
Make sure Docker Desktop is open and `docker info` works. Some corporate VPNs interfere with the local images; turn the VPN off, run `supabase start`, then turn it back on.
|
||||
|
||||
### "relation does not exist" when the app runs
|
||||
|
||||
You skipped step 4. Apply `scripts/schema.sql`.
|
||||
|
||||
### "Invalid API key" toasts in the app
|
||||
|
||||
The anon key in `.env.local` doesn't match `supabase status`. `supabase stop --no-backup && supabase start` will regenerate the keys cleanly; paste the new ones into `.env.local`.
|
||||
|
||||
### Emails aren't arriving
|
||||
|
||||
Local Supabase routes outbound mail to Inbucket. Open `http://127.0.0.1:54324`.
|
||||
|
||||
### Signup says "Database error saving new user"
|
||||
|
||||
The `handle_space_signup` trigger fired but couldn't insert the space_members row. Almost always because `space_id` metadata was missing. The app's signup flow handles this correctly via the admin client; if you're testing direct `auth.users` inserts, that path doesn't write a space_members row by design.
|
||||
|
||||
### Realtime chat doesn't refresh
|
||||
|
||||
Confirm `comms_messages` and `comms_channels` are in `supabase_realtime`:
|
||||
|
||||
```sql
|
||||
SELECT tablename FROM pg_publication_tables WHERE pubname = 'supabase_realtime';
|
||||
```
|
||||
|
||||
If they aren't, re-run `scripts/schema.sql` (Section 8 handles it idempotently).
|
||||
Reference in New Issue
Block a user