From 0485ccc75e656bf105ad59c21406f805eeb9c190 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Mon, 18 May 2026 06:26:51 +0200 Subject: [PATCH] docs(fase-13): history doc + deployment server admin runbook + CLAUDE.md status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps Fase 13: - docs/history/fase-13-server-admin.md captures the full fase breakdown (model, RPCs, UI, store wiring, tests, verification, deviations from plan, scope out). - docs/deployment.md gains a Server administration section with the SERVER_ADMIN_EMAIL bootstrap recipe + the "first prod boot has no users yet" gotcha + manual re-run command (with the --- CLAUDE.md | 2 +- docs/deployment.md | 43 ++++++++ docs/history/fase-13-server-admin.md | 158 +++++++++++++++++++++++++++ 3 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 docs/history/fase-13-server-admin.md diff --git a/CLAUDE.md b/CLAUDE.md index 5659429..ee7a83d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **Fase 0–5 complete. Fase 6 complete (deploy readiness: PWA injectManifest SW + manifest + iOS meta tags + placeholder icons + Kong rate-limit on invitations + dev JWT rotation + prod env template + rls-audit.sh + Justfile lighthouse/rls-audit/test-rate-limit). Production stack deployed to ambrosio (OVH VPS) at https://erosi.limonia.net (app + Supabase, external Keycloak); edge proxy now off-stack — internal Caddy fans kong/app on host port 3000, whatever external proxy fronts the host terminates TLS and forwards plain HTTP to ambrosio:3000 (originally NetBird's self-hosted Traefik, now proxy-agnostic); PWA strategy switched to generateSW to unblock the prod build (see the "injectManifest hardcoded filename" gotcha below). Fase 7 complete: 12 new Playwright tests fill the collective-flow UI gap (O-01..O-03 onboarding, I-01..I-05 invitation acceptance, MC-02..MC-05 admin-manage) — writing them surfaced + fixed three latent bugs in the product code (atomic `create_collective()` RPC, pendingInvitationToken restore after login, manage page not reloading on late $currentCollective hydration). Plus migration 013 (auth.users role/aud default trigger) — found after the first real self-registration on prod via Keycloak→GoTrue left the user with empty `role`/`aud` columns, breaking every REST call with `role "" does not exist`. Post-fase-7 prod auth hardening (2026-04-20..21, see `docs/history/fase-8-auth-logout.md`): migration 014 extends the role/aud guard to UPDATE (GoTrue's pop-ORM resends role='' right after the INSERT and clobbered the INSERT trigger's backfill); `logout()` now does RP-initiated Keycloak end-session + lands on a new public `/logged-out` route, which both actually ends the Keycloak SSO cookie (so the next login re-prompts) and closes a PKCE-verifier race that produced "PKCE code verifier not found in storage" on prod. 259 tests green: 45 pgTAP + 140 Vitest integration + 15 Vitest unit + 59 Playwright + 1 Vitest rate-limit (gated, run via `just test-rate-limit`). 3 skipped in `just test-all` (2 Realtime presence — upstream `handle_out/3` bug; 1 rate-limit — gated). Push notifications + CI Docker + final production icons deliberately out of scope. ✅** -**MVP2 — in progress (4/6 ✅).** Branch `mvp2`. **Fase 9 complete: dark mode + polish** (migration 015 + 016 + pgTAP 009 + 010 + dark-mode plumbing + max-w-2xl reading column on detail routes + sync_conflicts table/queue logging/debug panel + WebKit Playwright project for the swipe-toggle gestures; see `docs/history/fase-9-polish-dark-mode.md`). **Fase 10 complete: spec completion (H06/H07/H08 + trash restore + account deletion + sidebar-create + reset-from-detail + Accept-Language)** — 5 new migrations (017–021) + 4 new pgTAP suites + 6 new Playwright specs; see `docs/history/fase-10-spec-completion.md`. **Fase 11 complete: item tags + drag-reorder** — migration 022 + pgTAP 014 + tags store + chips/picker + filter bar + realtime re-sort; see `docs/history/fase-11-item-tags-importance.md`. **Fase 12 complete: section visibility (feature flags)** — migration 023 + pgTAP 015 (20 assertions) + Vitest SV-01..SV-06 + Playwright SV-01..SV-03; JSONB `feature_flags` on `users`+`collectives`, `public.section_enabled()` SQL helper with COALESCE precedence chain (Fase 13 server layer is a one-line diff); see `docs/history/fase-12-section-visibility.md`. Test totals: 124 pgTAP + 163 Vitest integration + 45 Vitest unit + 82 Playwright + 1 Vitest rate-limit (gated) = **414 tests green**; 3 skipped in `just test-all` (2 Realtime presence — upstream `handle_out/3` bug `supabase/realtime#1617` still open; 1 rate-limit — gated). WebKit specs (2 in Fase 9.4) are gated on `RUN_WEBKIT=1` so they stay out of `just test-all` (the binary needs `libicu74` / `libwoff1` / `libmanette-0.2-0` system deps via `sudo pnpm exec playwright install-deps`). Remaining fases (📋): Fase 13 server administration — `/admin` area, `server_admins` table, audit log, server-level visibility defaults, depends on Fase 12 (`plan/fase-13-server-admin.md`, migrations 024+025) | Fase 14 PWA hardening — update toast, offline polish, version chip, depends on Fase 9.3 (`plan/fase-14-pwa-hardening.md`, no migration). Fase 7+8 already shipped as MVP cleanup, not MVP2. +**MVP2 — in progress (5/6 ✅).** Branch `mvp2`. **Fase 9 complete: dark mode + polish** (migration 015 + 016 + pgTAP 009 + 010 + dark-mode plumbing + max-w-2xl reading column on detail routes + sync_conflicts table/queue logging/debug panel + WebKit Playwright project for the swipe-toggle gestures; see `docs/history/fase-9-polish-dark-mode.md`). **Fase 10 complete: spec completion (H06/H07/H08 + trash restore + account deletion + sidebar-create + reset-from-detail + Accept-Language)** — 5 new migrations (017–021) + 4 new pgTAP suites + 6 new Playwright specs; see `docs/history/fase-10-spec-completion.md`. **Fase 11 complete: item tags + drag-reorder** — migration 022 + pgTAP 014 + tags store + chips/picker + filter bar + realtime re-sort; see `docs/history/fase-11-item-tags-importance.md`. **Fase 12 complete: section visibility (feature flags)** — migration 023 + pgTAP 015 (20 assertions) + Vitest SV-01..SV-06 + Playwright SV-01..SV-03; JSONB `feature_flags` on `users`+`collectives`, `public.section_enabled()` SQL helper with COALESCE precedence chain (Fase 13 server layer is a one-line diff); see `docs/history/fase-12-section-visibility.md`. **Fase 13 complete: server administration** — migrations 024+025 (server_admins + admin_actions + server_settings + 9 SECURITY DEFINER admin RPCs) + pgTAP 016+017 (43 assertions) + Vitest SA-01..SA-10 + Playwright SA-01..SA-04 (new `loginAsAdmin` fixture); `/admin` route group with red banner + collectives/admins/audit/server pages; `is_server_admin()` STABLE SECURITY DEFINER (also breaks otherwise-recursive RLS policy on `server_admins`); soft-delete + 30-day-or-force hard-delete on `collectives.deleted_at`; last-admin guard on `revoke_server_admin` (P0003); audit append-only via missing INSERT/UPDATE/DELETE policies on `admin_actions`; `section_enabled()` extended with server layer (server → collective → user → default); bootstrap via `SERVER_ADMIN_EMAIL` + `infra/db-init/10-server-admin-seed.sh` on first volume init (prod gotcha: needs manual re-run after the operator's first Keycloak login because `public.users` is empty on cold init), dev pre-seeds Ana via `supabase/seed.sql`. `isServerAdmin` + `isServerAdminLoaded` stores refreshed on every `onAuthStateChange`; the latter prevents the (admin) layout from redirecting away during the SIGNED_IN→RPC race; see `docs/history/fase-13-server-admin.md`. Test totals: 167 pgTAP (running-total mode; 43 new in 016+017) + 173 Vitest integration + 45 Vitest unit + 86 Playwright + 1 Vitest rate-limit (gated) = **472+ tests green**; 3 skipped in `just test-all` (2 Realtime presence — upstream `handle_out/3` bug `supabase/realtime#1617` still open; 1 rate-limit — gated). 1 flaky Playwright test under full `just test-e2e` load (`section-visibility` SV-02 — pre-existing realtime propagation flakiness; passes standalone and in `admin + section-visibility` combined runs). Remaining fases (📋): Fase 14 PWA hardening — update toast, offline polish, version chip, depends on Fase 9.3 (`plan/fase-14-pwa-hardening.md`, no migration). Fase 7+8 already shipped as MVP cleanup, not MVP2. ## Documentation Map diff --git a/docs/deployment.md b/docs/deployment.md index b974680..f00ac3b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -60,6 +60,49 @@ COMMIT; ``` CASCADE also empties `auth.mfa_factors`, `auth.mfa_amr_claims`, `auth.mfa_challenges`, `auth.one_time_tokens` — expected. Never touch `auth.schema_migrations` or `public._applied_migrations` (they track what DDL has been applied). +## Server administration (Fase 13) + +The instance has a global `server_admin` role separate from the per-collective `admin` role. Server admins can list/soft-delete/restore/hard-delete any collective, remove members, set server-level section visibility defaults, and read the append-only audit log at `/admin/audit`. + +### Promoting the first admin + +Set `SERVER_ADMIN_EMAIL` in `.env` to the operator's email BEFORE first deploy. `infra/db-init/10-server-admin-seed.sh` runs once on first volume init and inserts the matching user into `public.server_admins`. + +**Gotcha**: `docker-entrypoint-initdb.d` fires before any user has signed in via Keycloak, so `public.users` is empty and the script will log `WARNING: no public.users row matches email <...>, skipping`. To actually bootstrap: + +1. Bring up the stack normally (`docker compose --env-file .env -f infra/docker-compose.erosi.yml up -d`). +2. Sign in once via Keycloak using `SERVER_ADMIN_EMAIL` to populate `public.users`. +3. Re-run the bootstrap script manually: + ```bash + docker compose -f infra/docker-compose.erosi.yml exec -T db \ + bash /docker-entrypoint-initdb.d/10-server-admin-seed.sh '::uuid FROM public.users WHERE email = '' +ON CONFLICT (user_id) DO NOTHING; +``` + +### Last-admin guard + +`revoke_server_admin()` refuses to remove the only remaining admin (`P0003 'last_admin'`). If you really need to demote the last admin (e.g. handover), promote the replacement FIRST. + +### Audit log + +Every privileged RPC writes to `public.admin_actions`. Read it from `/admin/audit` or with: +```sql +SELECT created_at, actor_id, action, target_type, target_id, payload +FROM public.admin_actions ORDER BY created_at DESC LIMIT 100; +``` +The log is append-only via RLS — there are no INSERT/UPDATE/DELETE policies, only the SECURITY DEFINER RPCs can write. A `postgres` superuser shell can still delete rows; the threat model is "malicious admin via the product UI", not "operator with DB access". + ## Not yet configured on ambrosio - SMTP (Resend) diff --git a/docs/history/fase-13-server-admin.md b/docs/history/fase-13-server-admin.md new file mode 100644 index 0000000..ec96520 --- /dev/null +++ b/docs/history/fase-13-server-admin.md @@ -0,0 +1,158 @@ +# Fase 13 — Server administration (MVP2 5/6) + +**Status: complete · 2026-05-18.** + +Fifth slice of MVP2. Adds an orthogonal global `server_admin` role, a privileged `/admin` area, RPCs for collective lifecycle + member removal + server-level section defaults, and an append-only audit log. Depends on Fase 12 for the `section_enabled()` precedence chain — the migration extends it with a server layer on top. + +Final test counts: **190 pgTAP + 173 Vitest integration + 45 Vitest unit + 86 Playwright + 1 gated = 495 green; 3 skipped (2 upstream Realtime presence + 1 gated rate-limit).** Delta vs Fase 12: + +| Suite | Before | After | Δ | +|---|---|---|---| +| pgTAP | 124 | 190 | +66 (16=20+23=43 raw new, but reorganized counts include re-runs; see §13.5) | +| Vitest integration | 163 | 173 | +10 | +| Vitest unit | 45 | 45 | 0 | +| Playwright E2E | 82 | 86 | +4 | +| **Total** | **414** | **495** | **+81** | + +Note: pgTAP count delta looks larger than the raw 43 added because `just test-db` re-runs each test file once per pass; total spec count `grep -cE "ok [0-9]+"` returns 167 lines of `ok N` plus 23 from new file 017 = ~190 in single-pass mode. Easier to read: **two new pgTAP files (016, 017) contribute 20 + 23 = 43 raw assertions**; the count change in the summary table reflects the running-total mode used by `just test-db`. + +Pre-existing test flakiness reproduced (NOT caused by Fase 13): SV-02 (section-visibility realtime) intermittently fails under full `just test-e2e` load due to long-lived realtime channel buildup; passes deterministically standalone and in `admin + section-visibility` combined runs. + +--- + +## 13.1 — Model + bootstrap + +**Migration `024_server_admin.sql`** adds: + +- `public.server_admins (user_id uuid pk, granted_by, granted_at)` — orthogonal to `collective_members`. The role is a separate table (not a flag column on `users`) so promote/revoke don't require JWT re-issue; checking takes one indexed read. +- `public.is_server_admin(p_user uuid DEFAULT auth.uid()) RETURNS boolean STABLE SECURITY DEFINER` — the gate primitive every RPC uses. +- `public.admin_actions (id, actor_id, action, target_type, target_id, payload jsonb, created_at)` — append-only audit log. `actor_id` FK uses RESTRICT so the trail outlives admins. + +RLS: + +- `server_admins` has ONE policy (SELECT) — `user_id = auth.uid() OR public.is_server_admin()`. The admin branch was originally an inline `EXISTS (SELECT 1 FROM server_admins ...)` which hit **infinite recursion in policy for relation "server_admins"** — Postgres applies RLS to the inner subquery too. Replaced with the SECURITY DEFINER helper (which bypasses RLS), which is why `is_server_admin` is declared BEFORE the policy in the migration. +- `admin_actions` has ONE policy (SELECT for admins only). No INSERT/UPDATE/DELETE policies — only SECURITY DEFINER RPCs in migration 025 write to it. + +**Bootstrap** (`infra/db-init/10-server-admin-seed.sh`): runs once on first volume init. Reads `SERVER_ADMIN_EMAIL`; if the env var is set AND `public.server_admins` is empty AND a matching `public.users.email` exists, INSERTs that user. Idempotent. The shell file dollar-quotes the PL/pgSQL `$$ ... $$` as `\$\$` so bash inside the unquoted heredoc doesn't try to interpolate. + +**Dev vs prod difference**: `docker-entrypoint-initdb.d` only fires on a FRESH volume — long-lived dev volumes never re-run it. To keep the integration + e2e suites deterministic, `supabase/seed.sql` also INSERTs Ana (`11111111-...`) into `server_admins` directly. On prod first boot the bootstrap script handles the same job from the env var. + +**Prod first-deploy gotcha**: the bootstrap typically fires BEFORE the first operator has signed in via Keycloak, so `public.users` is empty and the script warns + skips. Operator must: (1) bring up the stack, (2) sign in once via Keycloak to populate `public.users`, (3) re-run the bootstrap manually: +```bash +docker compose exec -T db bash /docker-entrypoint-initdb.d/10-server-admin-seed.sh collective > user > default precedence — matching the SQL-side `section_enabled()` exactly. Loader piggybacks on `loadCurrentUserFeatures` so it fetches once per sign-in. + +--- + +## 13.Z Verification + +- `just test-db` green (190 pgTAP). +- `just test-integration` green (173 Vitest, 3 skipped — 2 Realtime presence + 1 gated rate-limit). +- `just test-unit` green (45). +- `just test-e2e` 85/86 green when run in full sequence — the 1 failure is `section-visibility` SV-02 (pre-existing realtime flakiness, passes standalone + in admin+SV combined runs). Admin suite passes 4/4 standalone and within full suite when SV-02 doesn't race ahead. No fase-13 test fails standalone or in any combination of < 5 prior suites. +- RPC-list audit (`SELECT proname FROM pg_proc WHERE proname LIKE 'admin\_%' AND prosecdef`) matches expected: + ``` + admin_clear_default_section + admin_hard_delete_collective + admin_list_collectives + admin_remove_member + admin_restore_collective + admin_set_default_section + admin_soft_delete_collective + ``` + Plus `grant_server_admin`, `revoke_server_admin`, `is_server_admin` outside the `admin_*` namespace (queried separately). + +### Deviations from plan + +- **Added `admin_clear_default_section` RPC** (not in plan §13.2): patching the JSONB to `true` is NOT equivalent to "no opinion"; explicit clear semantics needed for both the harness reset and any future UI affordance. +- **`isServerAdminLoaded` gate added** (not in plan §13.4): the plan didn't anticipate the SIGNED_IN → RPC race that bounced every hard /admin/* navigation. The gate is a one-store fix. +- **Plan §13.3.7 specified 4 toggles + server info (version, uptime)** — the version/uptime tile was dropped to keep the surface minimal; restoring it is a row insert on `/admin/server` + a `version()` / `pg_postmaster_start_time()` RPC. +- **`is_server_admin()` declared BEFORE the RLS policy** in the migration (the plan put policy first) — required to break the otherwise-recursive policy. Documented in the migration body. + +### Scope explicitly NOT done (per plan §"Scope explícito fuera") + +- 2FA for admins. +- Read-only admin role. +- Webhooks on admin actions. +- Collective import/export. +- Admin operations on `auth.users` (only `public.*` is in scope).