### Fase 13 — Server administration

**MVP2 · 5/6.** Quinta fase del ciclo MVP2 (rama `mvp2`, fases 9 → 14).

**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`.

```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'`):

```sql
-- 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_lists` → `shopping_items` → `shopping_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.users` → `raw_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).
