diff --git a/CLAUDE.md b/CLAUDE.md index f418495..21a5b9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 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.oier.ovh (app + Supabase) + https://auth.oier.ovh (Keycloak); Let's Encrypt certs via host Caddy; PWA strategy switched to generateSW to unblock the prod build (see gotcha #16). 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). 248 tests green: 34 pgTAP + 140 Vitest integration + 15 Vitest unit + 58 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. ✅** +**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.oier.ovh (app + Supabase) + https://auth.oier.ovh (Keycloak); Let's Encrypt certs via host Caddy; PWA strategy switched to generateSW to unblock the prod build (see gotcha #16). 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`. 255 tests green: 41 pgTAP + 140 Vitest integration + 15 Vitest unit + 58 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. ✅** - `README.md` — full development plan, confirmed tech stack, Justfile reference, and technical warnings - `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules) @@ -108,6 +108,8 @@ Both are flagged in `plan/fase-7-collective-flow-tests.md` as future UI follow-u 18. **`.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. F-08 in `rls-isolation.test.ts` has been silently masking this since Fase 1 because it only asserts the _spoof_ case; never verified the happy-path insert actually succeeded. +19. **GoTrue v2.158.1 inserts OIDC-provisioned users with empty-string `role` and `aud`.** 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. Migration `013_auth_users_role_default.sql` backfills existing rows and installs a BEFORE INSERT trigger on `auth.users` that fills empty/NULL role + aud with `'authenticated'`. Users in existing browser sessions must log out + back in once for a fresh JWT after the migration is applied. + ### Fase 6+ — production deploy to ambrosio (2026-04-14) Live URLs: **https://erosi.oier.ovh** (app + Supabase API, single-domain) + **https://auth.oier.ovh** (Keycloak). OVH VPS, Ubuntu 24, Docker 29. diff --git a/supabase/migrations/013_auth_users_role_default.sql b/supabase/migrations/013_auth_users_role_default.sql new file mode 100644 index 0000000..444de2f --- /dev/null +++ b/supabase/migrations/013_auth_users_role_default.sql @@ -0,0 +1,46 @@ +-- Migration 013: default `role` and `aud` to 'authenticated' on auth.users. +-- +-- Background: GoTrue v2.158.1, when creating a user via the OIDC path +-- (Keycloak external provider), persists the user with empty-string `role` +-- and `aud` columns. The JWT it subsequently issues carries the empty role, +-- and PostgREST's per-request `SET LOCAL role = $role_claim` then fails with +-- `role "" does not exist` on every REST call — including RPCs. +-- +-- The seed users in `supabase/seed.sql` hardcode role='authenticated' and +-- aud='authenticated', which is why dev never surfaced this. Prod (ambrosio) +-- hit it on the first real self-registration. +-- +-- Two parts: +-- 1. Backfill any already-persisted rows missing role/aud. +-- 2. BEFORE INSERT trigger that fills empty/NULL role + aud with +-- 'authenticated' so future self-registrations are safe without +-- requiring a GoTrue upgrade. + +-- ── 1. Backfill ────────────────────────────────────────────────────────── +UPDATE auth.users +SET + role = COALESCE(NULLIF(role, ''), 'authenticated'), + aud = COALESCE(NULLIF(aud, ''), 'authenticated') +WHERE role IS NULL OR role = '' OR aud IS NULL OR aud = ''; + +-- ── 2. Trigger function + trigger ──────────────────────────────────────── +CREATE OR REPLACE FUNCTION auth.ensure_user_role_default() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.role IS NULL OR NEW.role = '' THEN + NEW.role := 'authenticated'; + END IF; + IF NEW.aud IS NULL OR NEW.aud = '' THEN + NEW.aud := 'authenticated'; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS ensure_role_default ON auth.users; +CREATE TRIGGER ensure_role_default +BEFORE INSERT ON auth.users +FOR EACH ROW +EXECUTE FUNCTION auth.ensure_user_role_default(); diff --git a/supabase/tests/008_auth_user_role_default.sql b/supabase/tests/008_auth_user_role_default.sql new file mode 100644 index 0000000..91d70cd --- /dev/null +++ b/supabase/tests/008_auth_user_role_default.sql @@ -0,0 +1,89 @@ +-- pgTAP: auth.users role/aud defaulting (migration 013) +-- Run with: psql -U postgres -d postgres -f supabase/tests/008_auth_user_role_default.sql +-- +-- Covers the bug found on ambrosio (2026-04-15): GoTrue v2.158.1 inserts +-- OIDC-created users into auth.users with empty-string `role` and `aud`, +-- which later makes PostgREST's `SET LOCAL role = ''` fail with +-- `role "" does not exist`. Migration 013 adds a BEFORE INSERT trigger + +-- backfills the existing rows. + +CREATE EXTENSION IF NOT EXISTS pgtap; + +BEGIN; +SELECT plan(7); + +-- ── Existence assertions ──────────────────────────────────────────────── +SELECT has_function( + 'auth', 'ensure_user_role_default', ARRAY[]::text[], + 'ensure_user_role_default trigger function exists in the auth schema' +); + +SELECT has_trigger( + 'auth', 'users', 'ensure_role_default', + 'BEFORE INSERT trigger ensure_role_default exists on auth.users' +); + +-- ── Backfill assertion ────────────────────────────────────────────────── +SELECT ok( + (SELECT count(*) FROM auth.users WHERE role IS NULL OR role = '' OR aud IS NULL OR aud = '') = 0, + 'No pre-existing auth.users row has an empty role or aud' +); + +-- ── Trigger behavior ──────────────────────────────────────────────────── +-- Insert a synthetic user with empty role/aud; the trigger should fill both. +-- Drop the AFTER trigger locally so we don't exercise handle_new_user's +-- side-effects (tested elsewhere, not the focus here). +ALTER TABLE auth.users DISABLE TRIGGER on_auth_user_created; + +-- NEW row with empty strings +INSERT INTO auth.users (instance_id, id, email, role, aud) +VALUES ( + '00000000-0000-0000-0000-000000000000', + 'f0000000-0000-0000-0000-000000000001', + 'empty-role@test.local', + '', + '' +); +SELECT is( + (SELECT role FROM auth.users WHERE id = 'f0000000-0000-0000-0000-000000000001'), + 'authenticated', + 'Empty role on INSERT is defaulted to authenticated' +); +SELECT is( + (SELECT aud FROM auth.users WHERE id = 'f0000000-0000-0000-0000-000000000001'), + 'authenticated', + 'Empty aud on INSERT is defaulted to authenticated' +); + +-- NEW row with NULL values +INSERT INTO auth.users (instance_id, id, email, role, aud) +VALUES ( + '00000000-0000-0000-0000-000000000000', + 'f0000000-0000-0000-0000-000000000002', + 'null-role@test.local', + NULL, + NULL +); +SELECT is( + (SELECT role FROM auth.users WHERE id = 'f0000000-0000-0000-0000-000000000002'), + 'authenticated', + 'NULL role on INSERT is defaulted to authenticated' +); + +-- Explicit non-empty role is preserved (trigger should only fill when empty). +INSERT INTO auth.users (instance_id, id, email, role, aud) +VALUES ( + '00000000-0000-0000-0000-000000000000', + 'f0000000-0000-0000-0000-000000000003', + 'custom-role@test.local', + 'service_role', + 'authenticated' +); +SELECT is( + (SELECT role FROM auth.users WHERE id = 'f0000000-0000-0000-0000-000000000003'), + 'service_role', + 'Explicit role is preserved by the trigger' +); + +SELECT * FROM finish(); +ROLLBACK;