From 7556d77bf81cd70e608e363aafb7f42c069e8d63 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Mon, 18 May 2026 05:01:19 +0200 Subject: [PATCH] feat(fase-13): admin RPCs + server_settings + section_enabled server layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 025 adds: - collectives.deleted_at column for soft delete (app-level visibility; RLS does NOT enforce soft-delete invisibility β€” admin area surfaces everything by design). - server_settings(key text pk, value jsonb) β€” generic admin-write bag seeded with an empty 'default_sections' row. - section_enabled() extended with the server layer as the topmost coalesce branch. Final precedence: server > collective > user > true. - Privileged RPCs: grant_/revoke_server_admin (last-admin guard fires P0003), admin_list_collectives, admin_soft_delete_collective, admin_restore_collective, admin_hard_delete_collective (30-day or p_force=true), admin_remove_member, admin_set_default_section (rejects unknown sections via known_sections() lookup). - _log_admin_action(): private helper centralising the audit INSERT, called by every privileged RPC BEFORE the mutation. Migration 024 RLS policy on server_admins rewritten to delegate the admin branch to is_server_admin() (SECURITY DEFINER, bypasses RLS) β€” the original inline EXISTS hit infinite recursion (caught empirically with `SET ROLE authenticated`). 22 new pgTAP assertions cover schema shape, RPC signatures, the SECURITY DEFINER posture of every admin function, the new deleted_at column, and the server-layer precedence semantics for section_enabled. 10 new Vitest integration tests (SA-01..SA-10) cover: - admin_list_collectives gated for non-admins (P0001 'forbidden'). - soft / restore / hard delete audit + state transitions. - hard-delete recency guard (not_soft_deleted, too_recent, force). - hard-delete cascade is non-destructive to public.users. - remove_member writes role_was + reason to audit payload. - set_default_section rejects unknown sections; patches JSONB without clobbering siblings. - grant/revoke with last-admin guard (revoking the sole admin raises P0003 'last_admin'; failed call does NOT write audit). - section_enabled precedence walked layer by layer in one client. - RLS: non-admin sees zero rows in admin_actions + server_admins. packages/types/src/database.ts hand-extended with the new tables and RPC signatures; collectives Row/Insert/Update get deleted_at. Co-Authored-By: Claude Opus 4.7 --- .../test-utils/tests/server-admin.test.ts | 406 ++++++++++++++ packages/types/src/database.ts | 115 ++++ supabase/migrations/024_server_admin.sql | 28 +- supabase/migrations/025_admin_rpcs.sql | 513 ++++++++++++++++++ supabase/tests/017_admin_rpcs.sql | 215 ++++++++ 5 files changed, 1267 insertions(+), 10 deletions(-) create mode 100644 packages/test-utils/tests/server-admin.test.ts create mode 100644 supabase/migrations/025_admin_rpcs.sql create mode 100644 supabase/tests/017_admin_rpcs.sql diff --git a/packages/test-utils/tests/server-admin.test.ts b/packages/test-utils/tests/server-admin.test.ts new file mode 100644 index 0000000..96b5dc2 --- /dev/null +++ b/packages/test-utils/tests/server-admin.test.ts @@ -0,0 +1,406 @@ +/** + * SA-series: Server administration RPCs + audit log + server-layer + * section visibility (Fase 13.5.1). + * + * SA-01 admin_list_collectives returns rows for an admin; rejected for a + * non-admin user (P0001 'forbidden'). + * SA-02 admin_soft_delete_collective writes an admin_actions row; the + * collective is flipped to deleted_at IS NOT NULL. + * SA-03 admin_restore_collective clears deleted_at + writes audit. + * SA-04 admin_hard_delete_collective: blocked when deleted_at IS NULL OR + * < 30 days old; allowed with p_force=true; cascade removes the + * dependent rows (collective_members, shopping_lists, etc.) without + * touching public.users. + * SA-05 admin_remove_member kicks the user, writes audit. + * SA-06 admin_set_default_section writes server_settings + audit. + * SA-07 grant_server_admin / revoke_server_admin; last-admin guard fires. + * SA-08 section_enabled() precedence: server > collective > user > default. + * SA-09 non-admin cannot SELECT admin_actions (RLS). + * SA-10 non-admin cannot SELECT server_admins beyond their own row (RLS). + */ +import { describe, it, expect, afterAll, beforeEach } from 'vitest'; +import { createClientAs, createAdminClient } from '../src/supabase-clients.js'; +import { sql, closePool } from '../src/db-helpers.js'; +import { + ANA_ID, + BORJA_ID, + CARMEN_ID, + DAVID_ID, + COLLECTIVE_ID +} from '../src/seed-constants.js'; + +const admin = createAdminClient(); + +// Tracker so we can clean up anything we create. Ana is always the seed admin +// (see supabase/seed.sql); Borja and others may be promoted in some tests and +// must be demoted before the suite exits or other tests' assumptions break. +const promotedNonSeed: string[] = []; +const createdCollectiveIds: string[] = []; + +afterAll(async () => { + // Demote anyone we promoted, leaving Ana as the only seed admin. + if (promotedNonSeed.length) { + await sql('DELETE FROM public.server_admins WHERE user_id = ANY($1::uuid[])', [ + promotedNonSeed + ]); + } + // Best-effort: drop any collectives we created. + if (createdCollectiveIds.length) { + await sql('DELETE FROM public.collectives WHERE id = ANY($1::uuid[])', [ + createdCollectiveIds + ]); + } + // Reset section flags + server settings. + await sql(`UPDATE public.users SET feature_flags = '{}'::jsonb`); + await sql(`UPDATE public.collectives SET feature_flags = '{}'::jsonb WHERE deleted_at IS NULL`); + await sql(`UPDATE public.collectives SET deleted_at = NULL`); + await sql(`UPDATE public.server_settings SET value = '{}'::jsonb WHERE key = 'default_sections'`); + // Trim audit log to keep the dev DB tidy across re-runs. + await sql(`DELETE FROM public.admin_actions WHERE actor_id = ANY($1::uuid[])`, [ + [ANA_ID, BORJA_ID, CARMEN_ID] + ]); + await closePool(); +}); + +beforeEach(async () => { + // Always restore the seed admin to a clean state. + await sql('INSERT INTO public.server_admins (user_id) VALUES ($1) ON CONFLICT DO NOTHING', [ + ANA_ID + ]); + // Demote any non-seed admins between tests so SA-07 starts from "Ana only". + if (promotedNonSeed.length) { + await sql('DELETE FROM public.server_admins WHERE user_id = ANY($1::uuid[])', [ + promotedNonSeed + ]); + promotedNonSeed.length = 0; + } + // Reset audit + settings so per-test row counts are deterministic. + await sql(`UPDATE public.collectives SET deleted_at = NULL`); + await sql(`UPDATE public.users SET feature_flags = '{}'::jsonb`); + await sql(`UPDATE public.collectives SET feature_flags = '{}'::jsonb WHERE deleted_at IS NULL`); + await sql(`UPDATE public.server_settings SET value = '{}'::jsonb WHERE key = 'default_sections'`); + await sql(`DELETE FROM public.admin_actions`); +}); + +async function makeThrowawayCollective(name: string, creator = ANA_ID): Promise { + const r = await sql( + `INSERT INTO public.collectives (name, emoji, created_by) VALUES ($1, 'πŸ§ͺ', $2) RETURNING id`, + [name, creator] + ); + const id = r.rows[0].id as string; + createdCollectiveIds.push(id); + await sql( + `INSERT INTO public.collective_members (collective_id, user_id, role) VALUES ($1, $2, 'admin')`, + [id, creator] + ); + return id; +} + +describe('Server admin RPCs', () => { + it('SA-01: admin_list_collectives returns rows for admin; forbidden for non-admin', async () => { + const ana = await createClientAs(ANA_ID); + const ok = await ana.rpc('admin_list_collectives', { p_search: null, p_limit: 50 }); + expect(ok.error).toBeNull(); + expect(Array.isArray(ok.data)).toBe(true); + expect((ok.data ?? []).length).toBeGreaterThan(0); + + const borja = await createClientAs(BORJA_ID); + const denied = await borja.rpc('admin_list_collectives', { p_search: null, p_limit: 50 }); + expect(denied.error?.code).toBe('P0001'); + expect(denied.error?.message).toMatch(/forbidden/i); + }); + + it('SA-02: admin_soft_delete_collective sets deleted_at + writes audit', async () => { + const cid = await makeThrowawayCollective('SA-02 throwaway'); + + const ana = await createClientAs(ANA_ID); + const { error } = await ana.rpc('admin_soft_delete_collective', { + p_collective_id: cid, + p_reason: 'cleanup' + }); + expect(error).toBeNull(); + + const after = await sql('SELECT deleted_at FROM public.collectives WHERE id = $1', [cid]); + expect(after.rows[0].deleted_at).not.toBeNull(); + + const audit = await sql( + `SELECT action, target_id, payload FROM public.admin_actions + WHERE action = 'soft_delete_collective' AND target_id = $1`, + [cid] + ); + expect(audit.rows).toHaveLength(1); + expect(audit.rows[0].payload.reason).toBe('cleanup'); + }); + + it('SA-03: admin_restore_collective clears deleted_at + writes audit', async () => { + const cid = await makeThrowawayCollective('SA-03 throwaway'); + await sql('UPDATE public.collectives SET deleted_at = now() WHERE id = $1', [cid]); + + const ana = await createClientAs(ANA_ID); + const { error } = await ana.rpc('admin_restore_collective', { p_collective_id: cid }); + expect(error).toBeNull(); + + const after = await sql('SELECT deleted_at FROM public.collectives WHERE id = $1', [cid]); + expect(after.rows[0].deleted_at).toBeNull(); + + const audit = await sql( + `SELECT count(*)::int FROM public.admin_actions + WHERE action = 'restore_collective' AND target_id = $1`, + [cid] + ); + expect(audit.rows[0].count).toBe(1); + }); + + it('SA-04: admin_hard_delete_collective is guarded by recency + cascade is non-destructive to users', async () => { + const cid = await makeThrowawayCollective('SA-04 throwaway'); + const ana = await createClientAs(ANA_ID); + + // Without soft-delete first: not_soft_deleted. + const r1 = await ana.rpc('admin_hard_delete_collective', { p_collective_id: cid, p_force: false }); + expect(r1.error?.message).toMatch(/not_soft_deleted/); + + // After a fresh soft-delete: too_recent. + await sql('UPDATE public.collectives SET deleted_at = now() WHERE id = $1', [cid]); + const r2 = await ana.rpc('admin_hard_delete_collective', { p_collective_id: cid, p_force: false }); + expect(r2.error?.message).toMatch(/too_recent/); + + // Add some satellite rows that should cascade away cleanly. + await sql( + `INSERT INTO public.shopping_lists (collective_id, name, status, created_by) + VALUES ($1, 'SA-04 list', 'active', $2) RETURNING id`, + [cid, ANA_ID] + ); + + // User row count snapshot β€” must be unchanged after hard delete. + const usersBefore = await sql('SELECT count(*)::int FROM public.users'); + + // With force=true: succeeds. + const r3 = await ana.rpc('admin_hard_delete_collective', { p_collective_id: cid, p_force: true }); + expect(r3.error).toBeNull(); + + const gone = await sql('SELECT count(*)::int FROM public.collectives WHERE id = $1', [cid]); + expect(gone.rows[0].count).toBe(0); + + const listsGone = await sql( + 'SELECT count(*)::int FROM public.shopping_lists WHERE collective_id = $1', + [cid] + ); + expect(listsGone.rows[0].count).toBe(0); + + const usersAfter = await sql('SELECT count(*)::int FROM public.users'); + expect(usersAfter.rows[0].count).toBe(usersBefore.rows[0].count); + + const audit = await sql( + `SELECT payload FROM public.admin_actions + WHERE action = 'hard_delete_collective' AND target_id = $1`, + [cid] + ); + expect(audit.rows).toHaveLength(1); + expect(audit.rows[0].payload.force).toBe(true); + expect(audit.rows[0].payload.name_snapshot).toBe('SA-04 throwaway'); + }); + + it('SA-05: admin_remove_member removes the membership and writes audit', async () => { + // Add Carmen to a throwaway collective so we can kick her without + // disturbing the seed Casa GarcΓ­a-LΓ³pez membership. + const cid = await makeThrowawayCollective('SA-05 throwaway'); + await sql( + `INSERT INTO public.collective_members (collective_id, user_id, role) + VALUES ($1, $2, 'member')`, + [cid, CARMEN_ID] + ); + + const ana = await createClientAs(ANA_ID); + const { error } = await ana.rpc('admin_remove_member', { + p_collective_id: cid, + p_user: CARMEN_ID, + p_reason: 'spam' + }); + expect(error).toBeNull(); + + const after = await sql( + `SELECT count(*)::int FROM public.collective_members + WHERE collective_id = $1 AND user_id = $2`, + [cid, CARMEN_ID] + ); + expect(after.rows[0].count).toBe(0); + + const audit = await sql( + `SELECT payload FROM public.admin_actions + WHERE action = 'remove_member' AND target_id = $1`, + [cid] + ); + expect(audit.rows).toHaveLength(1); + expect(audit.rows[0].payload.user_id).toBe(CARMEN_ID); + expect(audit.rows[0].payload.reason).toBe('spam'); + }); + + it('SA-06: admin_set_default_section patches server_settings + writes audit; unknown section is rejected', async () => { + const ana = await createClientAs(ANA_ID); + + const ok = await ana.rpc('admin_set_default_section', { + p_section: 'notes', + p_enabled: false + }); + expect(ok.error).toBeNull(); + + const row = await sql( + `SELECT value FROM public.server_settings WHERE key = 'default_sections'` + ); + expect(row.rows[0].value.notes).toBe(false); + + const bad = await ana.rpc('admin_set_default_section', { + p_section: 'definitely_not_a_section', + p_enabled: true + }); + expect(bad.error?.message).toMatch(/unknown_section/); + + const audit = await sql( + `SELECT count(*)::int FROM public.admin_actions + WHERE action = 'set_default_section'` + ); + expect(audit.rows[0].count).toBe(1); + }); + + it('SA-07: grant_server_admin + revoke_server_admin + last-admin guard', async () => { + const ana = await createClientAs(ANA_ID); + + // Promote Borja. + const promote = await ana.rpc('grant_server_admin', { p_user: BORJA_ID }); + expect(promote.error).toBeNull(); + promotedNonSeed.push(BORJA_ID); + + const after = await sql( + 'SELECT count(*)::int FROM public.server_admins WHERE user_id = $1', + [BORJA_ID] + ); + expect(after.rows[0].count).toBe(1); + + // Revoke Borja β€” succeeds (Ana still remains). + const revoke = await ana.rpc('revoke_server_admin', { p_user: BORJA_ID }); + expect(revoke.error).toBeNull(); + // Pop because revoke removed it cleanly. + promotedNonSeed.pop(); + + // Last-admin guard: Ana tries to revoke herself when she is the sole admin. + const selfRevoke = await ana.rpc('revoke_server_admin', { p_user: ANA_ID }); + expect(selfRevoke.error?.code).toBe('P0003'); + expect(selfRevoke.error?.message).toMatch(/last_admin/); + + // Sanity: Ana still admin. + const still = await sql( + 'SELECT count(*)::int FROM public.server_admins WHERE user_id = $1', + [ANA_ID] + ); + expect(still.rows[0].count).toBe(1); + + // Audit: 1 grant + 1 revoke. (The denied self-revoke should NOT have logged.) + const audit = await sql( + `SELECT action FROM public.admin_actions + WHERE action IN ('grant_server_admin', 'revoke_server_admin') + ORDER BY created_at` + ); + expect(audit.rows.map((r) => r.action)).toEqual([ + 'grant_server_admin', + 'revoke_server_admin' + ]); + }); + + it('SA-08: section_enabled precedence β€” server > collective > user > default', async () => { + const borja = await createClientAs(BORJA_ID); + + // Layer 1: server ON β†’ returns true regardless of lower layers being OFF. + await sql( + `UPDATE public.server_settings SET value = '{"tasks": true}'::jsonb WHERE key = 'default_sections'` + ); + await sql(`UPDATE public.collectives SET feature_flags = '{"tasks": false}'::jsonb WHERE id = $1`, [ + COLLECTIVE_ID + ]); + await sql(`UPDATE public.users SET feature_flags = '{"tasks": false}'::jsonb WHERE id = $1`, [ + BORJA_ID + ]); + + const layer1 = await borja.rpc('section_enabled', { + p_section: 'tasks', + p_user: BORJA_ID, + p_collective: COLLECTIVE_ID + }); + expect(layer1.error).toBeNull(); + expect(layer1.data).toBe(true); + + // Layer 2: server absent β†’ collective wins (OFF). + await sql(`UPDATE public.server_settings SET value = '{}'::jsonb WHERE key = 'default_sections'`); + const layer2 = await borja.rpc('section_enabled', { + p_section: 'tasks', + p_user: BORJA_ID, + p_collective: COLLECTIVE_ID + }); + expect(layer2.data).toBe(false); + + // Layer 3: server + collective absent β†’ user wins (OFF). + await sql(`UPDATE public.collectives SET feature_flags = '{}'::jsonb WHERE id = $1`, [ + COLLECTIVE_ID + ]); + const layer3 = await borja.rpc('section_enabled', { + p_section: 'tasks', + p_user: BORJA_ID, + p_collective: COLLECTIVE_ID + }); + expect(layer3.data).toBe(false); + + // Layer 4: everyone absent β†’ default true. + await sql(`UPDATE public.users SET feature_flags = '{}'::jsonb WHERE id = $1`, [BORJA_ID]); + const layer4 = await borja.rpc('section_enabled', { + p_section: 'tasks', + p_user: BORJA_ID, + p_collective: COLLECTIVE_ID + }); + expect(layer4.data).toBe(true); + }); + + it('SA-09: non-admin cannot SELECT admin_actions (RLS)', async () => { + // Generate one audit row so there is something to maybe-leak. + const cid = await makeThrowawayCollective('SA-09 throwaway'); + const ana = await createClientAs(ANA_ID); + await ana.rpc('admin_soft_delete_collective', { p_collective_id: cid, p_reason: 'sa-09' }); + + const borja = await createClientAs(BORJA_ID); + const { data, error } = await borja + .from('admin_actions') + .select('id, action, target_id') + .limit(10); + expect(error).toBeNull(); // RLS returns empty, not an error + expect(data ?? []).toHaveLength(0); + + // Sanity: Ana (admin) can SELECT. + const { data: anaData } = await ana + .from('admin_actions') + .select('id, action') + .eq('action', 'soft_delete_collective'); + expect((anaData ?? []).length).toBeGreaterThan(0); + }); + + it('SA-10: non-admin sees only their own row in server_admins', async () => { + // Promote Carmen temporarily so server_admins has 2 rows. + await sql('INSERT INTO public.server_admins (user_id) VALUES ($1) ON CONFLICT DO NOTHING', [ + CARMEN_ID + ]); + promotedNonSeed.push(CARMEN_ID); + + // Borja (not an admin): sees nothing because no row matches user_id = auth.uid() + // and the EXISTS branch (server admin) is false. + const borja = await createClientAs(BORJA_ID); + const { data: borjaRows } = await borja.from('server_admins').select('user_id'); + expect(borjaRows ?? []).toHaveLength(0); + + // David (also not admin) likewise. + const david = await createClientAs(DAVID_ID); + const { data: davidRows } = await david.from('server_admins').select('user_id'); + expect(davidRows ?? []).toHaveLength(0); + + // Carmen (admin via promotion): sees all rows. + const carmen = await createClientAs(CARMEN_ID); + const { data: carmenRows } = await carmen.from('server_admins').select('user_id'); + expect((carmenRows ?? []).length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/packages/types/src/database.ts b/packages/types/src/database.ts index 04b5c1b..217615c 100644 --- a/packages/types/src/database.ts +++ b/packages/types/src/database.ts @@ -73,6 +73,7 @@ export interface Database { created_by: string; feature_flags: Json; created_at: string; + deleted_at: string | null; }; Insert: { id?: string; @@ -81,6 +82,7 @@ export interface Database { created_by: string; feature_flags?: Json; created_at?: string; + deleted_at?: string | null; }; Update: { id?: string; @@ -89,6 +91,7 @@ export interface Database { created_by?: string; feature_flags?: Json; created_at?: string; + deleted_at?: string | null; }; Relationships: [ { @@ -468,6 +471,75 @@ export interface Database { }; Relationships: []; }; + server_admins: { + Row: { + user_id: string; + granted_by: string | null; + granted_at: string; + }; + Insert: { + user_id: string; + granted_by?: string | null; + granted_at?: string; + }; + Update: { + user_id?: string; + granted_by?: string | null; + granted_at?: string; + }; + Relationships: []; + }; + admin_actions: { + Row: { + id: string; + actor_id: string; + action: string; + target_type: string; + target_id: string | null; + payload: Json; + created_at: string; + }; + Insert: { + id?: string; + actor_id: string; + action: string; + target_type: string; + target_id?: string | null; + payload?: Json; + created_at?: string; + }; + Update: { + id?: string; + actor_id?: string; + action?: string; + target_type?: string; + target_id?: string | null; + payload?: Json; + created_at?: string; + }; + Relationships: []; + }; + server_settings: { + Row: { + key: string; + value: Json; + updated_at: string; + updated_by: string | null; + }; + Insert: { + key: string; + value?: Json; + updated_at?: string; + updated_by?: string | null; + }; + Update: { + key?: string; + value?: Json; + updated_at?: string; + updated_by?: string | null; + }; + Relationships: []; + }; }; Views: Record; Functions: { @@ -499,6 +571,49 @@ export interface Database { Args: { p_section: string; p_user: string; p_collective: string }; Returns: boolean; }; + is_server_admin: { + Args: { p_user?: string }; + Returns: boolean; + }; + grant_server_admin: { + Args: { p_user: string }; + Returns: void; + }; + revoke_server_admin: { + Args: { p_user: string }; + Returns: void; + }; + admin_list_collectives: { + Args: { p_search?: string | null; p_limit?: number }; + Returns: Array<{ + id: string; + name: string; + emoji: string; + member_count: number; + created_at: string; + deleted_at: string | null; + }>; + }; + admin_soft_delete_collective: { + Args: { p_collective_id: string; p_reason: string }; + Returns: void; + }; + admin_restore_collective: { + Args: { p_collective_id: string }; + Returns: void; + }; + admin_hard_delete_collective: { + Args: { p_collective_id: string; p_force?: boolean }; + Returns: void; + }; + admin_remove_member: { + Args: { p_collective_id: string; p_user: string; p_reason: string }; + Returns: void; + }; + admin_set_default_section: { + Args: { p_section: string; p_enabled: boolean }; + Returns: void; + }; }; Enums: { language_code: LanguageCode; diff --git a/supabase/migrations/024_server_admin.sql b/supabase/migrations/024_server_admin.sql index ffc727f..0c3af36 100644 --- a/supabase/migrations/024_server_admin.sql +++ b/supabase/migrations/024_server_admin.sql @@ -30,17 +30,13 @@ CREATE TABLE public.server_admins ( ALTER TABLE public.server_admins ENABLE ROW LEVEL SECURITY; --- A user can always read their own row (so the client can compute --- $isServerAdmin without escalation); other admins can read the full list. --- No INSERT/UPDATE/DELETE policy β€” only SECURITY DEFINER RPCs touch this. -CREATE POLICY "select_self_or_admin" ON public.server_admins - FOR SELECT - USING ( - user_id = auth.uid() - OR EXISTS (SELECT 1 FROM public.server_admins WHERE user_id = auth.uid()) - ); - -- ── is_server_admin() ─────────────────────────────────────────────────────── +-- Declared BEFORE the RLS policy so the policy can call it. The function is +-- SECURITY DEFINER β†’ it bypasses RLS on `server_admins`, which is the only way +-- to break the otherwise-recursive policy ("you can see rows if you ARE in +-- this table" β†’ the inner SELECT triggers the same policy β†’ infinite +-- recursion, observed empirically before this rewrite). +-- -- The default-arg form lets RLS-style call-sites write `is_server_admin()` and -- get `auth.uid()` for free, matching the shape of `is_admin()` / `is_member()` -- from migration 003. @@ -62,6 +58,18 @@ COMMENT ON FUNCTION public.is_server_admin(uuid) IS REVOKE ALL ON FUNCTION public.is_server_admin(uuid) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.is_server_admin(uuid) TO anon, authenticated; +-- A user can always read their own row (so the client can compute +-- $isServerAdmin without escalation); other admins can read the full list. +-- The admin branch uses is_server_admin() β€” NOT an inline EXISTS β€” to bypass +-- the otherwise-recursive RLS evaluation (see function comment above). +-- No INSERT/UPDATE/DELETE policy β€” only SECURITY DEFINER RPCs touch this. +CREATE POLICY "select_self_or_admin" ON public.server_admins + FOR SELECT + USING ( + user_id = auth.uid() + OR public.is_server_admin() + ); + -- ── admin_actions ────────────────────────────────────────────────────────── -- Append-only audit log. Every privileged RPC in migration 025 writes one -- row before mutating state. `actor_id` is RESTRICT so the audit trail diff --git a/supabase/migrations/025_admin_rpcs.sql b/supabase/migrations/025_admin_rpcs.sql new file mode 100644 index 0000000..a02e71b --- /dev/null +++ b/supabase/migrations/025_admin_rpcs.sql @@ -0,0 +1,513 @@ +-- Migration 025: server admin RPCs + server_settings + section_enabled() server layer (Fase 13.2) +-- +-- Every privileged RPC follows the same shape: +-- 1. SECURITY DEFINER. +-- 2. Pull v_actor := auth.uid() (NULL β†’ 'unauthenticated' / errcode 28000). +-- 3. `if not public.is_server_admin(v_actor) then raise 'forbidden' using errcode = 'P0001';` +-- 4. Validate args (raise '' using errcode = '22023' for bad input). +-- 5. INSERT into public.admin_actions BEFORE the mutation (so a failing +-- mutation still leaves a trace). The audit row + the mutation share one +-- transaction (the RPC body is implicit). +-- 6. Perform the mutation; return a void or the affected rows. +-- +-- The `is_server_admin()` call short-circuits on the (PK-indexed) lookup so the +-- guard is sub-millisecond. +-- +-- ── deleted_at on collectives ────────────────────────────────────────────── +-- Soft delete. The plan keeps the original SELECT/UPDATE RLS policies untouched +-- β€” non-admin members of a soft-deleted collective WILL still see it in +-- queries unless callers add `WHERE deleted_at IS NULL`. We document the +-- contract here: the RLS layer does not enforce soft-delete invisibility, +-- the app layer does. The /admin area surfaces all rows including deleted ones. +-- Hard-delete (cascade) is the operator's escape valve for permanent removal. + +ALTER TABLE public.collectives + ADD COLUMN deleted_at timestamptz NULL; + +COMMENT ON COLUMN public.collectives.deleted_at IS + 'Set by admin_soft_delete_collective; cleared by admin_restore_collective. ' + 'NULL = active. App-level callers MUST filter on this where soft-delete ' + 'invisibility matters β€” RLS does not enforce it.'; + +-- ── server_settings ──────────────────────────────────────────────────────── +-- Generic key/jsonb-value bag. First use: 'default_sections' jsonb of +-- {section: bool} consumed by section_enabled(). Adding new server-level +-- settings is a row insert, not a schema change. + +CREATE TABLE public.server_settings ( + key text PRIMARY KEY, + value jsonb NOT NULL DEFAULT '{}'::jsonb, + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by uuid NULL REFERENCES public.users(id) ON DELETE SET NULL +); + +ALTER TABLE public.server_settings ENABLE ROW LEVEL SECURITY; + +-- Everyone can READ server_settings (section_enabled() needs to evaluate it +-- from a normal user session). Writes go through admin_set_default_section() +-- under SECURITY DEFINER β€” no INSERT/UPDATE/DELETE policy. +CREATE POLICY "select_all" ON public.server_settings + FOR SELECT + USING (true); + +COMMENT ON TABLE public.server_settings IS + 'Server-level key/value bag. Read by everyone, written only by admin RPCs.'; + +-- Seed an empty default_sections row so section_enabled never sees a missing +-- row vs missing key difference (both behave as "no opinion" anyway, but the +-- presence of the row keeps PostgREST queries on this single record cheap). +INSERT INTO public.server_settings (key, value) +VALUES ('default_sections', '{}'::jsonb) +ON CONFLICT (key) DO NOTHING; + +-- ── section_enabled() extension ──────────────────────────────────────────── +-- Prepend a server layer. Precedence becomes: +-- server (server_settings.default_sections[p_section]) +-- > collective (collectives.feature_flags[p_section]) +-- > user (users.feature_flags[p_section]) +-- > default true +-- This is the one-line diff that migration 023 was designed for; the rest of +-- the function body is untouched. + +CREATE OR REPLACE FUNCTION public.section_enabled( + p_section text, + p_user uuid, + p_collective uuid +) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT COALESCE( + (SELECT (value ->> p_section)::boolean + FROM public.server_settings + WHERE key = 'default_sections'), + (SELECT (feature_flags ->> p_section)::boolean + FROM public.collectives + WHERE id = p_collective), + (SELECT (feature_flags ->> p_section)::boolean + FROM public.users + WHERE id = p_user), + true + ); +$$; + +COMMENT ON FUNCTION public.section_enabled(text, uuid, uuid) IS + 'Effective ON/OFF of a top-level section for (user, collective). ' + 'Precedence: server > collective > user > default true.'; + +-- ── Helper: internal audit writer ────────────────────────────────────────── +-- Centralises the INSERT INTO admin_actions so RPCs read cleanly. NOT exposed +-- to anyone β€” only the other SECURITY DEFINER functions in this file call it. + +CREATE OR REPLACE FUNCTION public._log_admin_action( + p_actor uuid, + p_action text, + p_target_type text, + p_target_id uuid, + p_payload jsonb DEFAULT '{}'::jsonb +) +RETURNS void +LANGUAGE sql +VOLATILE +SECURITY DEFINER +SET search_path = public +AS $$ + INSERT INTO public.admin_actions (actor_id, action, target_type, target_id, payload) + VALUES (p_actor, p_action, p_target_type, p_target_id, p_payload); +$$; + +REVOKE ALL ON FUNCTION public._log_admin_action(uuid, text, text, uuid, jsonb) FROM PUBLIC; +-- No GRANT; only invoked by sibling functions in the same migration. + +-- ── grant_server_admin ───────────────────────────────────────────────────── +CREATE OR REPLACE FUNCTION public.grant_server_admin(p_user uuid) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_actor uuid := auth.uid(); +BEGIN + IF v_actor IS NULL THEN + RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000'; + END IF; + IF NOT public.is_server_admin(v_actor) THEN + RAISE EXCEPTION 'forbidden' USING ERRCODE = 'P0001'; + END IF; + IF p_user IS NULL THEN + RAISE EXCEPTION 'user_required' USING ERRCODE = '22023'; + END IF; + IF NOT EXISTS (SELECT 1 FROM public.users WHERE id = p_user) THEN + RAISE EXCEPTION 'user_not_found' USING ERRCODE = 'P0002'; + END IF; + + INSERT INTO public.server_admins (user_id, granted_by) + VALUES (p_user, v_actor) + ON CONFLICT (user_id) DO NOTHING; + + PERFORM public._log_admin_action( + v_actor, + 'grant_server_admin', + 'user', + p_user, + jsonb_build_object() + ); +END; +$$; + +REVOKE ALL ON FUNCTION public.grant_server_admin(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.grant_server_admin(uuid) TO authenticated; + +-- ── revoke_server_admin ──────────────────────────────────────────────────── +-- Guard: the last remaining admin cannot be removed (would lock the instance +-- out of its own admin area irrecoverably). +CREATE OR REPLACE FUNCTION public.revoke_server_admin(p_user uuid) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_actor uuid := auth.uid(); + v_remaining int; +BEGIN + IF v_actor IS NULL THEN + RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000'; + END IF; + IF NOT public.is_server_admin(v_actor) THEN + RAISE EXCEPTION 'forbidden' USING ERRCODE = 'P0001'; + END IF; + IF p_user IS NULL THEN + RAISE EXCEPTION 'user_required' USING ERRCODE = '22023'; + END IF; + + -- Compute remaining admins AFTER hypothetical removal. + SELECT count(*) INTO v_remaining + FROM public.server_admins + WHERE user_id <> p_user; + + IF v_remaining = 0 THEN + RAISE EXCEPTION 'last_admin' USING ERRCODE = 'P0003'; + END IF; + + DELETE FROM public.server_admins WHERE user_id = p_user; + + PERFORM public._log_admin_action( + v_actor, + 'revoke_server_admin', + 'user', + p_user, + jsonb_build_object() + ); +END; +$$; + +REVOKE ALL ON FUNCTION public.revoke_server_admin(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.revoke_server_admin(uuid) TO authenticated; + +-- ── admin_list_collectives ───────────────────────────────────────────────── +CREATE OR REPLACE FUNCTION public.admin_list_collectives( + p_search text DEFAULT NULL, + p_limit int DEFAULT 50 +) +RETURNS TABLE ( + id uuid, + name text, + emoji text, + member_count bigint, + created_at timestamptz, + deleted_at timestamptz +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_actor uuid := auth.uid(); +BEGIN + IF v_actor IS NULL THEN + RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000'; + END IF; + IF NOT public.is_server_admin(v_actor) THEN + RAISE EXCEPTION 'forbidden' USING ERRCODE = 'P0001'; + END IF; + + RETURN QUERY + SELECT c.id, + c.name, + c.emoji, + (SELECT count(*) FROM public.collective_members cm WHERE cm.collective_id = c.id) AS member_count, + c.created_at, + c.deleted_at + FROM public.collectives c + WHERE p_search IS NULL OR c.name ILIKE '%' || p_search || '%' + ORDER BY c.deleted_at NULLS FIRST, c.created_at DESC + LIMIT LEAST(GREATEST(p_limit, 1), 500); +END; +$$; + +REVOKE ALL ON FUNCTION public.admin_list_collectives(text, int) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.admin_list_collectives(text, int) TO authenticated; + +-- ── admin_soft_delete_collective ─────────────────────────────────────────── +CREATE OR REPLACE FUNCTION public.admin_soft_delete_collective( + p_collective_id uuid, + p_reason text +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_actor uuid := auth.uid(); +BEGIN + IF v_actor IS NULL THEN + RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000'; + END IF; + IF NOT public.is_server_admin(v_actor) THEN + RAISE EXCEPTION 'forbidden' USING ERRCODE = 'P0001'; + END IF; + IF p_collective_id IS NULL THEN + RAISE EXCEPTION 'collective_required' USING ERRCODE = '22023'; + END IF; + IF p_reason IS NULL OR length(btrim(p_reason)) = 0 THEN + RAISE EXCEPTION 'reason_required' USING ERRCODE = '22023'; + END IF; + IF NOT EXISTS (SELECT 1 FROM public.collectives WHERE id = p_collective_id) THEN + RAISE EXCEPTION 'collective_not_found' USING ERRCODE = 'P0002'; + END IF; + + UPDATE public.collectives + SET deleted_at = now() + WHERE id = p_collective_id + AND deleted_at IS NULL; + + PERFORM public._log_admin_action( + v_actor, + 'soft_delete_collective', + 'collective', + p_collective_id, + jsonb_build_object('reason', p_reason) + ); +END; +$$; + +REVOKE ALL ON FUNCTION public.admin_soft_delete_collective(uuid, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.admin_soft_delete_collective(uuid, text) TO authenticated; + +-- ── admin_restore_collective ─────────────────────────────────────────────── +CREATE OR REPLACE FUNCTION public.admin_restore_collective(p_collective_id uuid) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_actor uuid := auth.uid(); +BEGIN + IF v_actor IS NULL THEN + RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000'; + END IF; + IF NOT public.is_server_admin(v_actor) THEN + RAISE EXCEPTION 'forbidden' USING ERRCODE = 'P0001'; + END IF; + IF p_collective_id IS NULL THEN + RAISE EXCEPTION 'collective_required' USING ERRCODE = '22023'; + END IF; + IF NOT EXISTS (SELECT 1 FROM public.collectives WHERE id = p_collective_id) THEN + RAISE EXCEPTION 'collective_not_found' USING ERRCODE = 'P0002'; + END IF; + + UPDATE public.collectives + SET deleted_at = NULL + WHERE id = p_collective_id; + + PERFORM public._log_admin_action( + v_actor, + 'restore_collective', + 'collective', + p_collective_id, + jsonb_build_object() + ); +END; +$$; + +REVOKE ALL ON FUNCTION public.admin_restore_collective(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.admin_restore_collective(uuid) TO authenticated; + +-- ── admin_hard_delete_collective ─────────────────────────────────────────── +-- Guard: only allowed when deleted_at IS NOT NULL AND >= 30 days old, OR +-- p_force = true. This protects against accidental "soft-delete then +-- hard-delete by reflex". +CREATE OR REPLACE FUNCTION public.admin_hard_delete_collective( + p_collective_id uuid, + p_force boolean DEFAULT false +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_actor uuid := auth.uid(); + v_row public.collectives%ROWTYPE; +BEGIN + IF v_actor IS NULL THEN + RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000'; + END IF; + IF NOT public.is_server_admin(v_actor) THEN + RAISE EXCEPTION 'forbidden' USING ERRCODE = 'P0001'; + END IF; + IF p_collective_id IS NULL THEN + RAISE EXCEPTION 'collective_required' USING ERRCODE = '22023'; + END IF; + + SELECT * INTO v_row FROM public.collectives WHERE id = p_collective_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'collective_not_found' USING ERRCODE = 'P0002'; + END IF; + + IF NOT p_force THEN + IF v_row.deleted_at IS NULL THEN + RAISE EXCEPTION 'not_soft_deleted' USING ERRCODE = 'P0001'; + END IF; + IF v_row.deleted_at > now() - INTERVAL '30 days' THEN + RAISE EXCEPTION 'too_recent' USING ERRCODE = 'P0001'; + END IF; + END IF; + + -- Audit FIRST so the action row exists even if the cascade somehow trips. + PERFORM public._log_admin_action( + v_actor, + 'hard_delete_collective', + 'collective', + p_collective_id, + jsonb_build_object( + 'force', p_force, + 'name_snapshot', v_row.name, + 'created_at', v_row.created_at, + 'deleted_at', v_row.deleted_at + ) + ); + + DELETE FROM public.collectives WHERE id = p_collective_id; +END; +$$; + +REVOKE ALL ON FUNCTION public.admin_hard_delete_collective(uuid, boolean) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.admin_hard_delete_collective(uuid, boolean) TO authenticated; + +-- ── admin_remove_member ──────────────────────────────────────────────────── +CREATE OR REPLACE FUNCTION public.admin_remove_member( + p_collective_id uuid, + p_user uuid, + p_reason text +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_actor uuid := auth.uid(); + v_role public.member_role; +BEGIN + IF v_actor IS NULL THEN + RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000'; + END IF; + IF NOT public.is_server_admin(v_actor) THEN + RAISE EXCEPTION 'forbidden' USING ERRCODE = 'P0001'; + END IF; + IF p_collective_id IS NULL OR p_user IS NULL THEN + RAISE EXCEPTION 'args_required' USING ERRCODE = '22023'; + END IF; + IF p_reason IS NULL OR length(btrim(p_reason)) = 0 THEN + RAISE EXCEPTION 'reason_required' USING ERRCODE = '22023'; + END IF; + + SELECT role INTO v_role + FROM public.collective_members + WHERE collective_id = p_collective_id AND user_id = p_user; + + IF NOT FOUND THEN + RAISE EXCEPTION 'membership_not_found' USING ERRCODE = 'P0002'; + END IF; + + -- Audit before mutation so we record the role the user used to have. + PERFORM public._log_admin_action( + v_actor, + 'remove_member', + 'collective_member', + p_collective_id, + jsonb_build_object('user_id', p_user, 'role_was', v_role, 'reason', p_reason) + ); + + DELETE FROM public.collective_members + WHERE collective_id = p_collective_id AND user_id = p_user; +END; +$$; + +REVOKE ALL ON FUNCTION public.admin_remove_member(uuid, uuid, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.admin_remove_member(uuid, uuid, text) TO authenticated; + +-- ── admin_set_default_section ────────────────────────────────────────────── +-- Patches public.server_settings(key='default_sections') so the section +-- toggle applies as the server-layer override read by section_enabled(). +CREATE OR REPLACE FUNCTION public.admin_set_default_section( + p_section text, + p_enabled boolean +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_actor uuid := auth.uid(); + v_current jsonb; + v_next jsonb; +BEGIN + IF v_actor IS NULL THEN + RAISE EXCEPTION 'unauthenticated' USING ERRCODE = '28000'; + END IF; + IF NOT public.is_server_admin(v_actor) THEN + RAISE EXCEPTION 'forbidden' USING ERRCODE = 'P0001'; + END IF; + IF p_section IS NULL OR p_section = '' THEN + RAISE EXCEPTION 'section_required' USING ERRCODE = '22023'; + END IF; + -- Reject unknown sections to prevent typos from silently shaping the + -- visibility surface forever. Future sections require a migration that + -- updates known_sections() (see migration 023 comment). + IF NOT (p_section = ANY (public.known_sections())) THEN + RAISE EXCEPTION 'unknown_section: %', p_section USING ERRCODE = '22023'; + END IF; + + -- Read-modify-write the JSONB row. CONCURRENT writes are not a real + -- concern here (admin UI, low frequency); a row-level lock from the + -- UPDATE-RETURNING below is sufficient. + INSERT INTO public.server_settings (key, value, updated_by, updated_at) + VALUES ('default_sections', jsonb_build_object(p_section, p_enabled), v_actor, now()) + ON CONFLICT (key) DO UPDATE + SET value = public.server_settings.value || jsonb_build_object(p_section, p_enabled), + updated_by = v_actor, + updated_at = now() + RETURNING value INTO v_next; + + PERFORM public._log_admin_action( + v_actor, + 'set_default_section', + 'server_setting', + NULL, + jsonb_build_object('section', p_section, 'enabled', p_enabled, 'value_after', v_next) + ); +END; +$$; + +REVOKE ALL ON FUNCTION public.admin_set_default_section(text, boolean) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.admin_set_default_section(text, boolean) TO authenticated; diff --git a/supabase/tests/017_admin_rpcs.sql b/supabase/tests/017_admin_rpcs.sql new file mode 100644 index 0000000..e5b6351 --- /dev/null +++ b/supabase/tests/017_admin_rpcs.sql @@ -0,0 +1,215 @@ +-- pgTAP: admin RPCs + server_settings + section_enabled() server layer (Fase 13.2) +-- Run with: psql -U postgres -d postgres -f supabase/tests/017_admin_rpcs.sql +-- +-- Verifies schema-level invariants on the new RPCs and the server_settings +-- table. Runtime behaviour (denied-for-non-admin, audit writes, last-admin +-- guard, hard-delete cascade) lives in the Vitest integration suite β€” pgTAP +-- runs as postgres so the SECURITY DEFINER `is_server_admin` check passes +-- vacuously, making these tests structural only. + +CREATE EXTENSION IF NOT EXISTS pgtap; + +BEGIN; +SELECT plan(22); + +-- ── server_settings table ────────────────────────────────────────────────── + +SELECT has_table( + 'public', 'server_settings', + 'AR-T01: public.server_settings table exists' +); + +SELECT col_is_pk( + 'public', 'server_settings', 'key', + 'AR-T02: server_settings.key is the primary key' +); + +SELECT col_type_is( + 'public', 'server_settings', 'value', 'jsonb', + 'AR-T03: server_settings.value is jsonb' +); + +SELECT ok( + (SELECT relrowsecurity FROM pg_class + WHERE relname = 'server_settings' AND relnamespace = 'public'::regnamespace), + 'AR-T04: RLS enabled on server_settings' +); + +-- Read-allowed to anyone (server-layer flags need to be evaluable from a +-- non-admin session inside section_enabled). Writes are admin-only via RPC. +SELECT results_eq( + $$ SELECT cmd::text FROM pg_policies + WHERE schemaname = 'public' AND tablename = 'server_settings' + ORDER BY cmd $$, + $$ VALUES ('SELECT') $$, + 'AR-T05: server_settings has exactly one policy and it is SELECT-only' +); + +-- ── RPC existence + SECURITY DEFINER posture ─────────────────────────────── + +SELECT has_function( + 'public', 'grant_server_admin', ARRAY['uuid'], + 'AR-T06: grant_server_admin(uuid) exists' +); + +SELECT has_function( + 'public', 'revoke_server_admin', ARRAY['uuid'], + 'AR-T07: revoke_server_admin(uuid) exists' +); + +SELECT has_function( + 'public', 'admin_list_collectives', ARRAY['text', 'integer'], + 'AR-T08: admin_list_collectives(text, int) exists' +); + +SELECT has_function( + 'public', 'admin_soft_delete_collective', ARRAY['uuid', 'text'], + 'AR-T09: admin_soft_delete_collective(uuid, text) exists' +); + +SELECT has_function( + 'public', 'admin_restore_collective', ARRAY['uuid'], + 'AR-T10: admin_restore_collective(uuid) exists' +); + +SELECT has_function( + 'public', 'admin_hard_delete_collective', ARRAY['uuid', 'boolean'], + 'AR-T11: admin_hard_delete_collective(uuid, boolean) exists' +); + +SELECT has_function( + 'public', 'admin_remove_member', ARRAY['uuid', 'uuid', 'text'], + 'AR-T12: admin_remove_member(uuid, uuid, text) exists' +); + +SELECT has_function( + 'public', 'admin_set_default_section', ARRAY['text', 'boolean'], + 'AR-T13: admin_set_default_section(text, boolean) exists' +); + +-- Every admin_* function plus grant/revoke MUST be SECURITY DEFINER so the +-- `if not is_server_admin() then raise` guard is the gate, not RLS. +SELECT results_eq( + $$ SELECT proname::text COLLATE "C" FROM pg_proc + WHERE pronamespace = 'public'::regnamespace + AND (proname LIKE 'admin\_%' OR proname IN ('grant_server_admin','revoke_server_admin')) + AND prosecdef = true + ORDER BY proname COLLATE "C" $$, + $$ VALUES + ('admin_hard_delete_collective'::text COLLATE "C"), + ('admin_list_collectives'::text COLLATE "C"), + ('admin_remove_member'::text COLLATE "C"), + ('admin_restore_collective'::text COLLATE "C"), + ('admin_set_default_section'::text COLLATE "C"), + ('admin_soft_delete_collective'::text COLLATE "C"), + ('grant_server_admin'::text COLLATE "C"), + ('revoke_server_admin'::text COLLATE "C") + $$, + 'AR-T14: every privileged RPC is SECURITY DEFINER (exact list)' +); + +-- ── collectives.deleted_at column added by this migration ────────────────── + +SELECT has_column( + 'public', 'collectives', 'deleted_at', + 'AR-T15: collectives.deleted_at column exists' +); + +SELECT col_type_is( + 'public', 'collectives', 'deleted_at', 'timestamp with time zone', + 'AR-T16: collectives.deleted_at is timestamptz' +); + +-- ── section_enabled() extended with server layer ─────────────────────────── +-- Behavioural assertions: with no server flag set, falls through to the +-- collective/user/default layers (Fase 12 contract preserved). With a server +-- flag of false, returns false regardless of any lower layer. + +-- Ensure a clean slate on every layer. +DELETE FROM public.server_settings WHERE key = 'default_sections'; +UPDATE public.users SET feature_flags = '{}'::jsonb WHERE id = '11111111-1111-1111-1111-111111111111'; +UPDATE public.collectives SET feature_flags = '{}'::jsonb WHERE id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + +-- AR-T17: server absent + everything else absent β†’ true (default ON preserved). +SELECT is( + public.section_enabled( + 'tasks', + '11111111-1111-1111-1111-111111111111', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + ), + true, + 'AR-T17: with no flags anywhere, section_enabled returns true' +); + +-- AR-T18: server OFF beats collective ON beats user ON. +UPDATE public.users SET feature_flags = '{"notes": true}'::jsonb WHERE id = '11111111-1111-1111-1111-111111111111'; +UPDATE public.collectives SET feature_flags = '{"notes": true}'::jsonb WHERE id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +INSERT INTO public.server_settings (key, value) +VALUES ('default_sections', '{"notes": false}'::jsonb) +ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; + +SELECT is( + public.section_enabled( + 'notes', + '11111111-1111-1111-1111-111111111111', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + ), + false, + 'AR-T18: server OFF wins over collective ON and user ON' +); + +-- AR-T19: server ON over collective OFF β€” server is the top layer; if it +-- explicitly turns a section ON we want that to win too. +UPDATE public.collectives SET feature_flags = '{"notes": false}'::jsonb WHERE id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +UPDATE public.server_settings SET value = '{"notes": true}'::jsonb WHERE key = 'default_sections'; + +SELECT is( + public.section_enabled( + 'notes', + '11111111-1111-1111-1111-111111111111', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + ), + true, + 'AR-T19: server ON wins over collective OFF' +); + +-- AR-T20: server "no opinion" (key absent inside the JSONB) β†’ fall through +-- to collective layer. +UPDATE public.server_settings SET value = '{}'::jsonb WHERE key = 'default_sections'; +UPDATE public.collectives SET feature_flags = '{"notes": false}'::jsonb WHERE id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + +SELECT is( + public.section_enabled( + 'notes', + '11111111-1111-1111-1111-111111111111', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + ), + false, + 'AR-T20: server with no opinion falls through to collective' +); + +-- AR-T21: a row missing entirely in server_settings still behaves like +-- "no opinion". +DELETE FROM public.server_settings WHERE key = 'default_sections'; +UPDATE public.collectives SET feature_flags = '{}'::jsonb WHERE id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +UPDATE public.users SET feature_flags = '{"tasks": false}'::jsonb WHERE id = '11111111-1111-1111-1111-111111111111'; + +SELECT is( + public.section_enabled( + 'tasks', + '11111111-1111-1111-1111-111111111111', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + ), + false, + 'AR-T21: missing server_settings row equals no-opinion, user layer wins' +); + +-- AR-T22: the function is still STABLE (no behavioural side effects, planner +-- can cache within a statement). +SELECT volatility_is( + 'public', 'section_enabled', ARRAY['text', 'uuid', 'uuid'], 'stable', + 'AR-T22: section_enabled() remains STABLE after server-layer extension' +); + +SELECT * FROM finish(); +ROLLBACK;