# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Status **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 (6/7 ✅).** Branch `mvp2`. Original cycle (fases 9–14) shipped 2026-05-18; ciclo reabierto con **Fase 15** añadida después. **Fase 9: dark mode + polish** (migrations 015–016 + pgTAP 009–010 + dark-mode plumbing + max-w-2xl reading column + sync_conflicts table/queue logging + WebKit Playwright project; see `docs/history/fase-9-polish-dark-mode.md`). **Fase 10: spec completion (H06/H07/H08 + trash restore + account deletion + sidebar-create + reset-from-detail + Accept-Language)** — migrations 017–021 + 4 new pgTAP suites + 6 new Playwright specs; see `docs/history/fase-10-spec-completion.md`. **Fase 11: 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: section visibility (feature flags)** — migration 023 + pgTAP 015 + JSONB `feature_flags` on `users`+`collectives` + `public.section_enabled()` SQL helper with COALESCE precedence; see `docs/history/fase-12-section-visibility.md`. **Fase 13: server administration** — migrations 024+025 (server_admins + admin_actions + server_settings + 9 SECURITY DEFINER admin RPCs) + pgTAP 016+017 + `/admin` route group + last-admin guard + audit-append-only via missing-policies pattern + `section_enabled()` extended with server layer + `SERVER_ADMIN_EMAIL` bootstrap (prod needs manual re-run after the operator's first Keycloak login); see `docs/history/fase-13-server-admin.md`. **Fase 14: PWA hardening** — `useRegisterSW` + `UpdateToast` (auto-update toast) + offline chip + queued-op badge + `/settings/sync-conflicts` review route + `__APP_VERSION__`/`__APP_COMMIT__`/`__BUILD_TIME__` defines baked at build via Dockerfile `GIT_SHA` build-arg (host-side `git rev-parse --short HEAD` in `deploy-erosi.sh`) + "Acerca de" version chip in `/settings`; no migration; see `docs/history/fase-14-pwa-hardening.md`. **Final test totals (`just test-all`): 166 pgTAP + 173 Vitest integration + 47 Vitest unit + 89 Playwright + 1 gated rate-limit = 476 tests green; 3 skipped (2 Realtime presence — upstream `handle_out/3` bug `supabase/realtime#1617`; 1 rate-limit — gated). Delta vs MVP close: +121 pgTAP + +33 integration + +32 unit + +30 e2e = +217 tests across MVP2 fases 9–14.** Fase 7+8 already shipped as MVP cleanup, not MVP2. **Pending in MVP2 (📋): Fase 15 — Common items management** (`plan/fase-15-common-items.md`). Extends `item_frequency` (migration 026) with a `weight integer` column written via admin-only SECURITY DEFINER RPCs (`set_item_frequency_weight` + `purge_item_frequency`). Suggestion query reorders by `weight desc, use_count desc, last_used_at desc`. UI 3-way segmented control (Hide / Normal / Boost → -50 / 0 / 50) lives in `/collective/manage` (admin write, member read-only, guest hidden). `fetchSuggestions` gains `excludeNames` arg to drop items already on the active list from the dropdown. ## Documentation Map - `README.md` — full development plan, confirmed tech stack, Justfile reference, technical warnings - `docs/development.md` — local dev stack, repo layout, Justfile + test commands, endpoints, credentials, secrets - `docs/deployment.md` — production deploy to ambrosio (erosi.limonia.net) + prod-specific fixes - `docs/history/fase-{1,2a,2b,3,4,5,6,7}.md` — per-phase record of what was built - `docs/history/fase-8-auth-logout.md` — post-fase-7 prod auth hardening (migration 014 + RP-initiated logout) - `docs/history/fase-9-polish-dark-mode.md` — MVP2 dark mode + reading width + sync-conflicts debug panel - `docs/history/fase-10-spec-completion.md` — MVP2 CU-H06/H07/H08 + trash RPCs + account delete + sidebar create + reset from detail + auto language - `docs/history/fase-11-item-tags-importance.md` — MVP2 item tags + drag-reorder - `docs/history/fase-12-section-visibility.md` — MVP2 section visibility / feature flags (this fase) - `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules) - `plan/fase-*.md` — phase-by-phase implementation plans (Fase 0 through Fase 7) - `design/slate_collective/DESIGN.md` + `design/*/screen.png` — "Monolith Editorial" direction + per-screen Stitch mockups. Consult before changing UI. ## Code Language **All code must be written in English.** This applies to variable names, function names, type names, database column names, SQL identifiers, comments, and any other code artifact. The UI may display text in any language, but the code layer is always English. ## Local Dev: Required /etc/hosts Entry GoTrue (Supabase Auth) and the browser both must resolve `keycloak` to reach the Keycloak container. Add this line to `/etc/hosts` on the development machine: ``` 127.0.0.1 keycloak ``` Without this, the OAuth redirect URL (`http://keycloak:8080/...`) built by Keycloak's discovery document is unreachable from the browser. ## Auth Architecture (Keycloak → GoTrue → Supabase) **Strategy: Option B — GoTrue as OIDC proxy.** The app authenticates with Keycloak (PKCE), then exchanges the Keycloak token with GoTrue for a Supabase JWT. Supabase RLS uses `auth.uid()` which resolves to the GoTrue user UUID (mapped from Keycloak `sub`). GoTrue maintains `auth.users`. Key decisions: - **Self-registration enabled** in Keycloak — users can create accounts on the Keycloak login screen. - **Invitations are link-only** — no email sent. Admin generates a link and shares it manually. - **Multiple collectives per user** — a user can belong to several collectives and switch between them in the sidebar. Logout is RP-initiated: `logout()` in `$lib/auth` calls `supabase.auth.signOut()` to clear the GoTrue session, then redirects to Keycloak's `/protocol/openid-connect/logout` endpoint with `client_id` + `post_logout_redirect_uri=/logged-out`. Keycloak clears its SSO cookie and bounces the browser back to the public `/logged-out` page (outside the `(app)` group so the auth-gate `$effect` never runs). Keycloak shows a one-click "Do you want to log out?" confirmation because GoTrue's proxy flow does not surface the Keycloak `id_token` to the client — without `id_token_hint`, Keycloak always asks; this is the accepted UX. An `isLoggingOut` module flag suppresses the `(app)` layout `$effect` between `signOut()` and the end-session redirect, which otherwise races the navigation and can produce "PKCE code verifier not found in storage" errors. ## Sync Strategy | Content | Mechanism | Target latency | |---|---|---| | Items in active shopping session | WebSocket (Supabase Realtime) | < 1 second | | Lists, tasks (outside session) | SSE / polling | < 5 seconds | | Notes | Polling | < 30 seconds | **Offline-first:** all operations execute locally first (IndexedDB), then sync in background. Conflict resolution is last-write-wins. Conflicts are logged to a `sync_conflicts` table for debugging — no CRDT in MVP. ## Domain Model The central organizing unit is the **Collective** (household group), not the individual user. All content belongs to the Collective. **Roles:** `admin` (full CRUD + member management) | `member` (full CRUD on content) | `guest` (read-only) **Core entities:** `Collective` → `CollectiveMember`, `ShoppingList` → `ShoppingItem`, `TaskList` → `Task`, `Note`, `CollectiveInvitation` **Key field names (English):** `collective_id`, `list_id`, `is_checked`, `checked_by`, `checked_at`, `created_by`, `created_at`, `completed_at`, `sort_order`, `display_name`, `avatar_type`, `avatar_emoji`, `avatar_url`, `language` **Key business rules:** - Only one active shopping session per list at a time. - Completing a list keeps items as history; it can be "reset" (uncheck all → back to active). - Trash retains deleted items/notes for 7 days before permanent deletion. - If the sole admin deletes their account, the system auto-promotes the oldest member before allowing deletion. **Section visibility (feature flags) — required steps for any new top-level section.** A "section" here is one of the four current navigable areas (lists, tasks, notes, search) — adding a fifth means EVERY one of: 1. SQL migration that updates `public.known_sections()` to include the new key + (if it adds a new RPC) maps onto the planned Fase 13 server-layer extension of `public.section_enabled()`. 2. `SectionKey` union + `SECTION_KEYS` array entry in `packages/types/src/domain.ts`. 3. Default-checked toggle row in `/settings` (per-user) and `/collective/manage` (admin) — both already iterate `SECTION_KEYS`, so this is automatic as long as you add the `section_label_` Paraglide message + the route prefix in `(app)/+layout.svelte`'s `SECTION_BY_PREFIX` array (drives the redirect guard). 4. pgTAP assertion in `supabase/tests/015_section_visibility.sql` that `section_enabled('', user, collective)` defaults to true. Without all four, the section either shows up but can't be hidden, or can be hidden but isn't toggled from the UI — both are confusing failure modes. ## Internationalisation Library: **Paraglide JS** (`@inlang/paraglide-sveltekit`). Compile-time, zero runtime overhead, tree-shaken per language. - Message files: `messages/en.json` (default) and `messages/es.json` - Language detection order: `users.language` (if logged in) → `Accept-Language` header → `en` - Language switching: update `users.language` in Supabase + call `setLanguageTag()` — no page reload - **Never hardcode visible user-facing strings in components.** All UI text goes through Paraglide messages. Supported languages: `en`, `es`. ## TypeScript Types `packages/types/src/database.ts` is **generated** by `just db-types` (`supabase gen types typescript`). Never edit it manually. Domain-level types go in `packages/types/src/domain.ts`. ## Dev Test Users Defined in `keycloak/realm-export.json` and `supabase/seed.sql`. All use password `test1234`. | User | Email | Role in test collective | |---|---|---| | `ana` | ana@dev.local | Admin | | `borja` | borja@dev.local | Member | | `carmen` | carmen@dev.local | Member | | `david` | david@dev.local | Guest | | `eva` | eva@dev.local | (no collective — for onboarding testing) | ## Gotchas ### Auth / OIDC integration - **`colectivo-web` is a confidential client** — GoTrue needs a client secret to exchange codes with Keycloak. `GOTRUE_EXTERNAL_KEYCLOAK_SECRET` must match Keycloak's client secret (`gotrue-dev-secret` in dev). - **Custom `openid` client scope in Keycloak** — GoTrue v2.158.1 sends `scope=profile email` (no `openid`) to Keycloak, ignoring `GOTRUE_EXTERNAL_KEYCLOAK_SCOPES`. Without `openid` in the token scope, Keycloak's userinfo endpoint returns 401. Fix: a custom client scope named `openid` with `include.in.token.scope=true` is set as a default scope on `colectivo-web`, so Keycloak injects `openid` even when not requested. - **`GOTRUE_DISABLE_SIGNUP: "false"`** — GoTrue's signup flag blocks OIDC-based user creation too. Must be `false`; Keycloak is the gatekeeper for who can register. - **`auth.users` pre-seeded with Keycloak UUIDs** — GoTrue generates its own UUIDs on first login. `seed.sql` pre-inserts both `auth.users` (with `instance_id='00000000-0000-0000-0000-000000000000'` and empty-string token fields) and `auth.identities` (provider=keycloak, provider_id=Keycloak sub). This ensures `auth.uid()` = seed UUID = FK in all public tables. - **Kong env var substitution via `kong-start.sh`** — Kong 2.8 does not interpolate `${VAR}` in declarative config files. `infra/kong-start.sh` runs `perl -pe 's/\$\{(\w+)\}/$ENV{$1}/ge'` on `kong.yml` before starting Kong. Without this, Kong loads the literal string `${SUPABASE_ANON_KEY}` as the API key, rejecting all requests. - **GoTrue v2.158.1 inserts OIDC-provisioned users with empty-string `role` and `aud`, AND re-clears role via a follow-up UPDATE.** For users created via the external provider path (Keycloak → GoTrue), `auth.users.role` and `auth.users.aud` are left as `''` instead of the expected `'authenticated'`. The JWT minted from that row carries the empty role, and PostgREST's per-request `SET LOCAL role = $role_claim` then fails with `role "" does not exist` — every REST call errors out before policies or RPCs even run. Dev never surfaced this because `seed.sql` hardcodes both columns. Two migrations are required, not one: **013** backfills existing rows and installs a BEFORE INSERT trigger on `auth.users`; **014** installs a matching BEFORE UPDATE trigger because GoTrue immediately UPDATEs the same row (pop ORM resends the whole struct including the zero-value `role=''`), clobbering the insert-time backfill. Both triggers call the same `auth.ensure_user_role_default()` function. Symptom on prod (ambrosio) was two rows with `created_at`/`updated_at` ~150–250ms apart, empty `role`, `aud='authenticated'` (the UPDATE kept aud because GoTrue sends `'authenticated'` for it). Users in existing browser sessions must log out + back in once for a fresh JWT after the migrations are applied. Covered by `supabase/tests/008_auth_user_role_default.sql` (11 pgTAP assertions including UPDATE-clobber behaviour). ### SvelteKit / frontend - **Do not call `getSession()` in a SvelteKit `load` function to gate auth.** Supabase JS v2 initialises its session from localStorage asynchronously (`_recoverAndRefresh`). In a `load` function that runs immediately on mount, `getSession()` can return `null` for a valid session before initialisation completes — causing `login()` to fire and redirect to Keycloak unnecessarily. The correct pattern: `(app)/+layout.ts` only sets `ssr: false`, and `(app)/+layout.svelte` uses a `$effect` that redirects when `!$authLoading && !$isAuthenticated`. This waits for `onAuthStateChange` to fire (which is authoritative). - **`onAuthStateChange` fires `INITIAL_SESSION` on page load, not `SIGNED_IN`.** `SIGNED_IN` only fires immediately after a login flow. Load collective data whenever the session is present, regardless of event type — otherwise collectives are never loaded on page refresh. Pattern in root `+layout.svelte`: `if (session) { await loadUserCollectives(...) }` covering `INITIAL_SESSION`, `SIGNED_IN`, and `TOKEN_REFRESHED`. - **CSS custom properties use RGB triplets — always use `rgb()`, never `hsl()`.** All design tokens in `app.css` store values as space-separated RGB triplets (e.g. `51 65 85`). Using `hsl(var(--token))` interprets the first value as a hue degree — `hsl(51 65% 85%)` renders as light yellow, not slate-700. The Tailwind config and any `background-color`/`color` declarations in `app.css` must use `rgb(var(--token) / )`. - **Supabase JS v2 + SvelteKit: `loadUserCollectives` (or any PostgREST query) inside `onAuthStateChange` must be deferred with `setTimeout(..., 0)`.** GoTrue's default `navigator.locks`-based auth lock is held while the `onAuthStateChange` callback runs. Any `supabase.from(...).select(...)` called inside that callback calls `getAccessToken() → getSession() → initializePromise → _acquireLock`, which is the same lock that's already held by the emit logic — the promise never resolves, the query hangs forever. Fix: schedule the query off the callback's microtask chain with `setTimeout(async () => { await loadUserCollectives(session.user.id); … }, 0)`. As a defensive extra, `$lib/supabase` passes a pass-through `auth.lock` option so the whole lock path is a no-op. Both are needed: without the defer, some browsers still deadlock; without the pass-through, others do. Removing either one breaks the Playwright E2E suite. - **Logout must be RP-initiated AND guarded against the auth-gate `$effect`.** A naïve logout (`supabase.auth.signOut()` only) has two problems at once: (a) Keycloak's SSO cookie is untouched, so the `(app)` layout's `$effect` immediately re-fires `login()` and Keycloak silently re-authenticates from the SSO cookie — logout looks broken; (b) the `signOut` → `onAuthStateChange` → `$effect` → `signInWithOAuth` chain races `signOut`'s async storage clear, and can clobber the PKCE verifier just written by `signInWithOAuth`, producing "PKCE code verifier not found in storage" on prod (dev doesn't hit it because the loopback round-trip is sub-ms). Fix, in `$lib/auth`: set a module-level `isLoggingOut = true`, call `signOut()`, then `window.location.assign(keycloakEndSessionUrl)` with `client_id` + `post_logout_redirect_uri=${origin}/logged-out`. The `(app)` layout `$effect` reads `isLoggingOut` and skips its auto-login branch. `/logged-out` is a public route outside the `(app)` group so the effect never runs there at all. Keycloak shows a "Do you want to log out?" confirmation because we cannot pass `id_token_hint` (GoTrue's proxy flow doesn't expose the Keycloak id_token in the Supabase session — only `provider_token` and `provider_refresh_token`); clicking once is accepted UX. Covered by A-05 + A-07 in `apps/web/tests/e2e/auth.test.ts`. - **Never call `crypto.randomUUID()` directly — use `generateId()` from `$lib/utils/id`.** `crypto.randomUUID` is gated behind secure contexts (HTTPS / localhost / 127.0.0.1 / file://). When the app is served over a bare LAN IP for on-device phone previews (`http://192.168.1.167:5173`), `window.crypto.randomUUID` is `undefined` and every optimistic-id site crashes `handleAdd` with `TypeError: crypto.randomUUID is not a function`. `generateId()` uses `crypto.randomUUID()` when available and falls back to an RFC 4122 v4 assembly via `crypto.getRandomValues()` (which IS exposed on insecure contexts). Covered by `src/lib/utils/id.test.ts` (U-01..U-04) + `tests/e2e/insecure-context.test.ts` (INS-01, stubs `randomUUID = undefined` via `addInitScript` and drives the add-item flow). ### PWA / build - **PWA service worker: do not use `injectManifest` with a file named `service-worker.ts`.** SvelteKit intercepts any file at `src/service-worker.[jt]s` and enforces a hard restriction: only `$service-worker` and `$env/static/public` may be imported — Workbox modules are blocked. With `injectManifest`, `@vite-pwa/sveltekit` tries to find the compiled SW output at `.svelte-kit/output/client/service-worker.js`, but SvelteKit never produces it (compilation fails on the blocked imports). Fix for Fase 1–2a: use `generateSW` strategy (plugin auto-generates the SW, no custom source needed). Fix for Fase 2b when a custom SW is needed: name the file `src/sw.ts` — SvelteKit does not recognise it as a service worker — and set `strategies: 'injectManifest'`, `filename: 'sw.ts'` in `vite.config.ts`. - **`@vite-pwa/sveltekit` does not auto-register the service worker.** The plugin provides a virtual module (`virtual:pwa-register`, `virtual:pwa-register/svelte`) but you have to call `registerSW()` (or `useRegisterSW()` for Svelte bindings) yourself. Without that call, the SW file is generated + served but never registered, and `navigator.serviceWorker.getRegistration()` returns `undefined`. The canonical place is `apps/web/src/routes/+layout.svelte`'s `onMount()`. - **`@vite-pwa/sveltekit` 0.6.8 `injectManifest` hardcodes the source SW filename.** The plugin looks for the compiled SW at `.svelte-kit/output/client/service-worker.js` regardless of `filename: 'sw.ts'`. SvelteKit blocks Workbox imports from `src/service-worker.[jt]s` (see the SW-filename gotcha above), so there's no way to actually feed the plugin. **Workaround:** use `strategies: 'generateSW'` instead — the plugin auto-generates a precache-only SW equivalent to our minimal `src/sw.ts`. `vite.config.ts` currently uses this. ### Testing - **Playwright auth: do not cache Supabase sessions via `storageState`. Do a live Keycloak login per test.** `browser.newContext({ storageState })` restores localStorage + cookies, but Supabase's `_recoverAndRefresh` does not reliably re-fire `INITIAL_SESSION` from a restored context — the page hydrates without triggering `onAuthStateChange`, so `currentUser` / `currentCollective` stay empty. The working pattern is `await loginAs(page, USERS.ana)` at the top of each test (see `tests/fixtures/login.ts`), which exercises the real OAuth flow and waits for the session token to land in localStorage before returning. Adds ~1s per test but is deterministic. - **Playwright `baseURL` is hardcoded to `http://localhost:5173`, ignoring `PUBLIC_APP_URL`.** The LAN-IP variant in root `.env` is for on-device phone previews and introduces timing races in the OAuth round-trip that produce flaky failures in the suite. Keep the loopback for deterministic E2E; phone previews are separate. - **Realtime `waitFor` timeout is 10s, not 5s.** Under full `just test-all` load the Phoenix socket occasionally takes several seconds to propagate a fresh subscription's filter before the test mutation fires. Set in `packages/test-utils/src/realtime-helpers.ts`. Don't drop below 10s — passing runs resolve on event arrival, only the flake window widened. - **WebKit (Mobile Safari) Playwright project is gated on `RUN_WEBKIT=1`.** Fase 9.4 added a `webkit` project (`devices['iPhone 13']`) for touch-gesture specs (the swipe-to-check/uncheck handlers use unified pointer events, but Chromium's touch emulation does not fire them in the right order). The project is conditionally included in `playwright.config.ts` based on `process.env.RUN_WEBKIT` so `just test-all` stays green on hosts that lack the GTK/WebKit system deps (libicu74, libxml2, libmanette-0.2-0, libwoff1 — install via `sudo pnpm exec playwright install-deps`). `just test-webkit` runs the gated suite. Specs named `*.webkit.test.ts` are also excluded from the chromium project via `testIgnore`, so a file never runs in both browsers. ### Backend / RLS / Kong - **`strip_path: true` on a full-table Kong route strips too much.** With `paths: [/rest/v1/collective_invitations]` and `strip_path: true`, Kong forwards `/` upstream — PostgREST returns `PGRST117: Unsupported HTTP method: POST` because it tries to route root, not the table. Fix: add a `request-transformer` plugin that `replace.uri: /collective_invitations`. Applies identically to any route where the match path equals the full upstream resource path (as opposed to a prefix match like `/rest/v1/`). - **Kong `KONG_PLUGINS` must explicitly list every plugin used in `kong.yml`.** Without `rate-limiting` (or any other non-default plugin) in the comma list, Kong boots with `plugin 'xxx' not enabled; add it to the 'plugins' configuration property`. `infra/docker-compose.dev.yml` currently lists `request-transformer,cors,key-auth,acl,basic-auth,rate-limiting`. - **`.select()` after `.insert()` evaluates the SELECT RLS policy on the returned row, not just the INSERT policy.** PostgREST's chained `.insert(...).select().single()` adds `RETURNING *` to the INSERT, and Postgres then evaluates the table's SELECT policy against the fresh row. For policies that gate on sibling-table state (e.g., `is_member(id)` requiring a row in `collective_members`), this causes INSERTs to spuriously fail with "new row violates row-level security policy" even though the INSERT policy itself passed. Fix: drop the `.select()` if you don't need the row back, or (better) wrap the insert + any sibling-table sets in a `SECURITY DEFINER` RPC that atomically builds the membership state. ### Deploy - **supabase/postgres v15.1.1.78 does NOT create the Supabase internal roles on a fresh volume.** The image ships with custom extensions + configs but no role-bootstrap script. Dev "works" because its volume was initialised long ago, grandfathering the roles (`authenticator`, `anon`, `authenticated`, `service_role`, `supabase_admin`, `supabase_auth_admin`, `supabase_storage_admin`, `supabase_replication_admin`, `supabase_realtime_admin`, `pgbouncer`, `dashboard_user`). On a fresh volume only `postgres` exists. `infra/db-init/00-role-passwords.sh` now bootstraps them idempotently (create-if-missing) + enables `pg_cron` + creates the empty `supabase_realtime` publication + sets `supabase_auth_admin` search_path to `auth` (without this GoTrue migrations create `factor_type` in `public` and later migrations explode). - **`docker compose exec -T` without explicit stdin drains the surrounding heredoc.** When a script is delivered over SSH as `ssh host bash -s << 'REMOTE' ... REMOTE`, the outer bash reads its script from stdin. Any `docker compose exec -T ` inside that script — especially inside a command substitution like `x=$(docker compose exec -T db psql -c '...')` — inherits and consumes that same stdin, swallowing the rest of the heredoc. Symptom: the first iteration of a loop runs, every following iteration is silently skipped. Caused `infra/scripts/deploy-erosi.sh` to silently skip migrations 012 and 013 on prod until fixed (had to apply manually after every deploy). **Fix:** redirect stdin per call with `