Adds four planned-phase docs covering the next post-MVP roadmap: - Fase 11 — item tags (collective-scoped) + drag-reorder UI for the existing `shopping_items.sort_order` column. Migration 021. - Fase 12 — section visibility via JSONB `feature_flags` on `users` and `collectives`, with a `section_enabled()` SQL helper that resolves precedence (collective > user > default ON). Migration 022. - Fase 13 — `/admin` area with a global `server_admins` role, append-only `admin_actions` audit log, RPCs for collective CRUD + member removal, and server-level section-visibility defaults that sit above Fase 12. Migrations 023+024. Depends on Fase 12. - Fase 14 — PWA hardening: `onNeedRefresh` update toast, explicit offline indicator + sync queue badge + conflict surface, `__APP_VERSION__` and `__APP_COMMIT__` baked at build with a chip in `/settings`. No migration. Depends on Fase 9.3 (`sync_conflicts` wiring). Also bumps CLAUDE.md project status with the planned-fases pointer, and brings README.md status + index tables in line with Fase 7, 8, 9, 10 (previously missing) plus the four new ones. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
10 KiB
Fase 13 — Server administration
Estado: 📋 Planificada. Mayor salto de scope hasta ahora — introduce un rol global ortogonal a los roles de colectivo, un área /admin nueva, RPCs con privilegios elevados, y un audit log. Requiere Fase 12 para los toggles de visibilidad a nivel servidor.
Objetivo: dar al operador del servidor herramientas para gestionar la salud de la instancia sin tocar SQL en producción — listar y borrar colectivos huérfanos, expulsar miembros problemáticos, fijar visibilidad por defecto de secciones a nivel servidor, y dejar trazabilidad de cada acción.
Decisiones clave:
- Rol global aparte del modelo de colectivo. Un
server_adminpuede no pertenecer a ningún colectivo. Tablaserver_admins (user_id pk). No flag enuserspara evitar tener que regrabar JWT al promote/demote — la verificación es una query a esta tabla. - Bootstrap por seed o ENV. En arranque, si la tabla está vacía y
SERVER_ADMIN_EMAILestá seteado, promover ese email a admin (idempotente). - RPCs SECURITY DEFINER con check explícito
if not is_server_admin(auth.uid()) then raise exception 'forbidden'. No se exponen tablas con RLS open para admins. - Audit log inmutable. Tabla
admin_actionsappend-only (no UPDATE/DELETE policy). Cada RPC admin escribe una fila. - UI separada en
/admin/*. Layout propio. No se cuela con el shell del usuario normal (cambio visual evidente — banner rojo).
13.1 Modelo: rol + audit log
Fichero: supabase/migrations/023_server_admin.sql.
create table public.server_admins (
user_id uuid primary key references public.users(id) on delete cascade,
granted_by uuid null references public.users(id) on delete set null,
granted_at timestamptz not null default now()
);
alter table public.server_admins enable row level security;
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())
);
-- Sin INSERT/UPDATE/DELETE policy: solo SECURITY DEFINER RPCs pueden tocarla.
create or replace function public.is_server_admin(p_user uuid default auth.uid())
returns boolean language sql stable security definer
set search_path = public, auth
as $$ select exists (select 1 from public.server_admins where user_id = p_user) $$;
create table public.admin_actions (
id uuid primary key default gen_random_uuid(),
actor_id uuid not null references public.users(id) on delete restrict,
action text not null,
target_type text not null,
target_id uuid null,
payload jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
alter table public.admin_actions enable row level security;
create policy "select_admin_only" on public.admin_actions
for select using (public.is_server_admin());
-- Append-only: ningún UPDATE/DELETE policy. Solo INSERT desde RPCs SECURITY DEFINER.
create index admin_actions_actor_idx on public.admin_actions (actor_id, created_at desc);
create index admin_actions_target_idx on public.admin_actions (target_type, target_id);
Tareas:
- 13.1.1 Migración 023.
- 13.1.2 Bootstrap: script
infra/db-init/10-server-admin-seed.shque leeSERVER_ADMIN_EMAILy, si la tabla está vacía, inserta la fila correspondiente vía join aauth.users. Idempotente. - 13.1.3 Añadir
SERVER_ADMIN_EMAILa.env.erosi.examplecon comentario explicativo. - 13.1.4 Tests pgTAP
supabase/tests/013_server_admin.sql:is_server_admin()true/false; non-admin no puede SELECTserver_adminsajeno; non-admin no puede veradmin_actions.
13.2 RPCs admin
Fichero: supabase/migrations/024_admin_rpcs.sql.
Funciones (todas SECURITY DEFINER con check if not is_server_admin() then raise exception 'forbidden' using errcode = 'P0001'):
-- Gestión de admins (solo otro admin puede promote/demote; el último admin no puede demoteárse).
create function public.grant_server_admin(p_user uuid) returns void ...
create function public.revoke_server_admin(p_user uuid) returns void ...
-- Colectivos
create function public.admin_list_collectives(p_search text default null, p_limit int default 50)
returns table (id uuid, name text, member_count int, created_at timestamptz, deleted_at timestamptz) ...
create function public.admin_soft_delete_collective(p_collective_id uuid, p_reason text) returns void ...
create function public.admin_restore_collective(p_collective_id uuid) returns void ...
create function public.admin_hard_delete_collective(p_collective_id uuid) returns void ...
-- Solo si deleted_at not null y han pasado >= 30 días, o force flag explícito.
-- Miembros
create function public.admin_remove_member(p_collective_id uuid, p_user uuid, p_reason text) returns void ...
-- Section visibility por defecto a nivel servidor (Fase 12 dependency)
create function public.admin_set_default_section(p_section text, p_enabled boolean) returns void ...
-- Escribe en una tabla nueva server_settings (key, value jsonb) — se crea aquí.
Tareas:
- 13.2.1 Migración 024 con todas las RPCs + tabla
server_settings (key text primary key, value jsonb). - 13.2.2 Cada RPC: chequeo de admin → inserción en
admin_actions→ ejecución. Wrap en transacción. - 13.2.3 Guard "último admin":
revoke_server_adminfalla si el target es el único enserver_admins. - 13.2.4 Integrar
server_settings.default_sectionsenpublic.section_enabled(...)de Fase 12 — añade una capa por encima del colectivo: precedencia final server → collective → user → default true. - 13.2.5 Tests pgTAP
supabase/tests/014_admin_rpcs.sql: cada RPC denegada para non-admin; soft-delete escribe audit; hard-delete bloqueado antes de los 30 días sin force; promote/demote audit; último admin no puede demoteárse.
13.3 UI /admin/*
Ficheros: nueva ruta apps/web/src/routes/(admin)/. Layout propio.
- 13.3.1
(admin)/+layout.ts:loadredirige a/siis_server_admin()(vía RPC) devuelve false. Sin SSR —ssr: false. - 13.3.2
(admin)/+layout.svelte: banner rojobg-red-600 text-whitecon texto "Admin mode — actions are logged". Sidebar simple: Colectivos, Admins, Audit log, Servidor. - 13.3.3
/admin/collectives— tabla paginada con search por nombre, columnas: nombre, miembros, creado, estado (activo / soft-deleted). Acciones por fila: ver, soft-delete (modal con reason obligatorio), restore, hard-delete (modal de confirmación + checkbox "Entiendo que es irreversible"). - 13.3.4
/admin/collectives/[id]— detalle: miembros con rol, botón "Expulsar" por miembro (modal con reason). Sección "Acciones recientes" filtrandoadmin_actionspor estetarget_id. - 13.3.5
/admin/admins— lista deserver_admins. Botón "Promote user" abre modal con email lookup (auth.users where email = ...). Botón "Revoke" por fila (deshabilitado si es uno mismo o si es el único). - 13.3.6
/admin/audit— feed reverse-chronological deadmin_actions, filtros por actor, acción, fecha. Paginación a 50. - 13.3.7
/admin/server— defaults de sección (Fase 12): 4 toggles + servicio info (versión, uptime — usarselect pg_postmaster_start_time(), version()vía RPC).
13.4 Acceso al menú
- 13.4.1 Si
$isServerAdmin(store derivado de unis_server_admin()cacheado al login), añadir entrada "Admin" en el menú de usuario del sidebar/drawer (separador + íconoShieldrojo). - 13.4.2 Refrescar el flag al
INITIAL_SESSION/SIGNED_IN/TOKEN_REFRESHED.
13.5 Tests
- 13.5.1 Vitest integración
tests/integration/server-admin.test.ts: precedencia de section-visibility con la capa server activa; RPCs denegadas para no-admin; audit log se escribe; hard-delete cascade no rompe FK fuera del colectivo. - 13.5.2 Playwright
tests/e2e/admin.test.ts(nuevo helperloginAsAdmin):- SA-01 admin promovido vía seed accede a
/admin; usuario normal redirigido a/. - SA-02 admin soft-deletea un colectivo → miembros no pueden entrar (redirect a
/onboarding); admin restore lo recupera. - SA-03 admin apaga "Notes" a nivel servidor → todos los colectivos lo ven OFF aunque el admin de colectivo lo ponga ON localmente.
- SA-04 admin promueve a otro usuario; el segundo accede a
/admin; el primero intenta revocarse a sí mismo siendo el único restante → bloqueado.
- SA-01 admin promovido vía seed accede a
13.Z Verificación + cierre
- 13.Z.1
just test-allverde. Suma esperada: +6 pgTAP + ~4 vitest + 4 playwright. - 13.Z.2 Revisión manual de seguridad:
select * from pg_proc where proname like 'admin\_%' and prosecdefdebe coincidir con la lista esperada de RPCs SECURITY DEFINER (todas y solo las nuestras). - 13.Z.3 Smoke en prod: crear un server_admin vía bootstrap, listar colectivos, ver audit log. No tocar datos reales.
- 13.Z.4 Documentar en
docs/history/fase-13-server-admin.md+ sección "Server administration" endocs/deployment.md(cómo promover el primer admin viaSERVER_ADMIN_EMAIL).
Riesgos / notas
- Hard delete cascade. Borrar un colectivo borra
shopping_lists→shopping_items→shopping_item_tags→ (Fase 11)item_tags. Todas las FK ya tienenon delete cascade; verificar enEXPLAIN. Confirmar también queusersno se ve afectado. - JWT no contiene el flag admin (el rol se chequea por query, no por claim). Compromiso: una query extra al login + cada vez que el store se hidrata. Beneficio: promote/revoke instantáneo sin re-login. Si esto se vuelve caliente, mover a custom claim vía hook
auth.users→raw_app_meta_data. - Audit append-only sin policy DELETE funciona en runtime, pero
postgressuperuser puede borrar. Aceptable: el modelo de amenaza es "admin malicioso del producto", no "operador con shell en el servidor". - Bootstrap idempotente importante — si ya hay admins, el script no debe degradar nada. Test con
pgTAP: segunda invocación conSERVER_ADMIN_EMAILdistinto no toca filas previas.
Scope explícito fuera
- 2FA obligatorio para admins. Bonito, no MVP.
- Roles intermedios (read-only admin). Solo
server_adminbinario. - Webhooks o notificaciones de acciones admin. Solo audit table.
- Importar/exportar colectivos como JSON. Solo CRUD destructivo + creación de usuario normal.
- Admin sobre
auth.usersdirectamente. Solo a nivelpublic(colectivos, miembros, settings).