Files
collective-lists/plan/fase-13-server-admin.md
Oier Bravo Urtasun 51e62ecba3 docs(plan): add Fase 16 — loading spinner (MVP2 reopened to 8 fases)
Adds a tiny planned-phase doc for visual feedback during async operations.

- Single SVG-circle Spinner.svelte component, three sizes (sm/md/lg = 16
  / 24 / 40 px), color via currentColor (theme-aware), motion-reduce-aware,
  role="status" + sr-only m.loading() for a11y.
- Integration at 5 existing sites that already render m.loading() text or
  own a `loading` flag: auth splash, /search, /notes/archive,
  CreateCollectiveModal "creating" state, /collective/manage sub-sections.
- 5 unit + 3 e2e tests planned. No DB migration. No new Paraglide strings.

Rebrand: MVP2 reopens from 7/7  to in-progress 7/8 . Headers in fases
9–15 bump "·N/7" + "fases 9 → 15" to "·N/8" + "fases 9 → 16". README +
CLAUDE.md updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:34:58 +02:00

11 KiB

Fase 13 — Server administration

MVP2 · 5/8. Quinta fase del ciclo MVP2 (rama mvp2, fases 9 → 16).

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_admin puede no pertenecer a ningún colectivo. Tabla server_admins (user_id pk). No flag en users para 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_EMAIL está 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_actions append-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.sh que lee SERVER_ADMIN_EMAIL y, si la tabla está vacía, inserta la fila correspondiente vía join a auth.users. Idempotente.
  • 13.1.3 Añadir SERVER_ADMIN_EMAIL a .env.erosi.example con comentario explicativo.
  • 13.1.4 Tests pgTAP supabase/tests/013_server_admin.sql: is_server_admin() true/false; non-admin no puede SELECT server_admins ajeno; non-admin no puede ver admin_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_admin falla si el target es el único en server_admins.
  • 13.2.4 Integrar server_settings.default_sections en public.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: load redirige a / si is_server_admin() (vía RPC) devuelve false. Sin SSR — ssr: false.
  • 13.3.2 (admin)/+layout.svelte: banner rojo bg-red-600 text-white con 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" filtrando admin_actions por este target_id.
  • 13.3.5 /admin/admins — lista de server_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 de admin_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 — usar select pg_postmaster_start_time(), version() vía RPC).

13.4 Acceso al menú

  • 13.4.1 Si $isServerAdmin (store derivado de un is_server_admin() cacheado al login), añadir entrada "Admin" en el menú de usuario del sidebar/drawer (separador + ícono Shield rojo).
  • 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 helper loginAsAdmin):
    • 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.

13.Z Verificación + cierre

  • 13.Z.1 just test-all verde. 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 prosecdef debe 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" en docs/deployment.md (cómo promover el primer admin via SERVER_ADMIN_EMAIL).

Riesgos / notas

  • Hard delete cascade. Borrar un colectivo borra shopping_listsshopping_itemsshopping_item_tags → (Fase 11) item_tags. Todas las FK ya tienen on delete cascade; verificar en EXPLAIN. Confirmar también que users no 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.usersraw_app_meta_data.
  • Audit append-only sin policy DELETE funciona en runtime, pero postgres superuser 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 con SERVER_ADMIN_EMAIL distinto no toca filas previas.

Scope explícito fuera

  • 2FA obligatorio para admins. Bonito, no MVP.
  • Roles intermedios (read-only admin). Solo server_admin binario.
  • 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.users directamente. Solo a nivel public (colectivos, miembros, settings).