dec-0125

Vista generada de dec-0125: Cuentas, hogares, perfiles, invitaciones y acceso de dispositivos

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0125-cuentas-hogares-perfiles-invitaciones-acceso.md
track/docsdec-0124track/docs:DC10

Vista generada desde docs/decisions/dec-0125-cuentas-hogares-perfiles-invitaciones-acceso.md. No se edita a mano: bun run docs:gen la regenera y bun run docs:check falla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.

CampoValor
EstadoLOCKED
Fecha2026-10-01
Ficherodocs/decisions/dec-0125-cuentas-hogares-perfiles-invitaciones-acceso.md

Por qué importa (del frontmatter del ADR):

Fija el modelo de cuentas de Styx (cuenta, identidades, hogar opcional, perfiles opcionales con PIN y clasificación, concesiones de biblioteca), las invitaciones estilo Wizarr ligadas al state OIDC, la autenticación passwordless por defecto (OIDC con passkeys, Pocket ID recomendado, IdP-agnóstico) con password local sólo por flag, las API keys y cuentas de servicio intercambiadas por JWT en el borde, y el device authorization grant (RFC 8628) de identity-svc para el login del CLI y el quick connect de TV. Sin este ADR cada superficie (web, TV, CLI, agentes) inventaría su propia forma de entrar y de repartir permisos, y los huecos de seguridad de dec-0118 volverían a abrirse por la puerta lateral de cada flujo nuevo.

Nodos del roadmap que lo citan en refs: track/identity/invitations, track/identity/cli-auth, track/identity/api-keys, track/identity/quick-connect, track/identity/accounts-households-profiles, track/identity/local-password

Páginas de la documentación que lo citan: Dispositivos, mando y handoff (especificado), Servidores conectados y Jellyfin (especificado), Modelo de datos (especificado), Modo público y Styx como plataforma (especificado), API para agentes y MCP (especificado), Comandos del CLI (especificado), Salida JSON y códigos de salida (especificado), API keys (especificado), Conectar la TV y el CLI (especificado), Hogares y perfiles (especificado), Iniciar sesión (especificado), Invitaciones (implementado), Entrar con el CLI (implementado), Visión (especificado), Primer arranque (especificado)

Texto del ADR

Leído de docs/decisions/dec-0125-cuentas-hogares-perfiles-invitaciones-acceso.md, el fichero canónico.

dec-0125 — Cuentas, hogares, perfiles, invitaciones y acceso de dispositivos

  • Fecha: 2026-10-01
  • Estado: LOCKED (2026-10-02). waxin, vía AskUserQuestion, lockea el ADR con las decisiones que ya había tomado (passwordless con Pocket ID u otro OIDC, password local sólo por flag, hogares y perfiles opcionales, invitaciones estilo Wizarr, API keys con scopes, quick connect y login del CLI con RFC 8628, paridad headless) y con la enmienda que exige el lock de dec-0127 P5: transferProfileState va a playback-svc y a catalog-svc, e identity borra el perfil de origen sólo cuando los dos confirman. P1–P8 de §17 se lockean con la recomendación del ADR como parámetros reversibles; P9 queda diferida. Todo está en Lock (2026-10-02, waxin). Donde §1–§19 y el lock difieren, gana el lock. Las enmiendas de §15.2 entran en vigor hoy.
  • Propone: lane docs (rama w9/docs), ticket track/docs#11. El nodo que lo implementaría es track/identity; los milestones nuevos van como pregunta (§17, P9), no en el model.
  • Dirección de waxin que cumple (2026-10-01, D3): cuentas y gestión muy abiertas y opcionales; usuarios independientes o grupo/hogar; perfiles estilo Netflix opcionales; invitaciones de registro estilo Wizarr; passwordless por defecto vía Pocket ID u otro OIDC con passkeys; password sólo si se habilita por env/config; API keys para scriptear; login en el CLI; quick connect para TV con QR o código numérico. Y D2: todo lo que ofrece la UI existe por API y por el CLI, también para agentes.
  • Cita:
    • r16: frontier salvo blocker (passkeys vía OIDC, device grant estándar, token exchange).
    • r17: identity-svc es la authority de credenciales; nada de esto vive en otro servicio.
    • r20 §5: OIDC-first con ports ISessionStore/ITokenIssuer.
    • r28: cada pieza nueva termina en un consumidor real (§16).
    • dec-0087: identity authoritative sobre credenciales.
    • dec-0113 (PROPOSED): JWT EdDSA propio + refresh opaco rotatorio. Este ADR añade claims (§5.4) y no cambia el formato.
    • dec-0118 (LOCKED): BFF sin tokens en el navegador, cookies __Host-, CSRF de doble barrera, rotación al cambiar privilegios, auth_time reciente para lo sensible, roles owner/admin/member/restricted, Authorized<R, A>, audit. Este ADR lo respeta entero y propone dos enmiendas acotadas (§15.2).
    • dec-0119/dec-0120: los eventos y queries nuevos van por spire (BUS_ROUTES).
    • dec-0124 (PROPOSED): la doc como contrato; §6.1 (registro de operaciones, CLI, MCP) y §6.2 (este diseño) son de donde sale este ADR.
    • dec-0123 (PROPOSED, w7/release-eng): el CLI styx es un único binario. Este ADR sólo fija sus comandos de autenticación y de cuentas.

1. Contexto: qué existe hoy y qué es nuevo

Verificado sobre el árbol de w9/docs (base 922dedc de la integración AppSec).

PiezaEstadoDónde
OIDC code + PKCE S256 + state + nonce contra un OP real (Pocket ID), login pendiente sellado en __Host-styx_loginexisteapps/identity-svc/src/transport/http/routes/auth.ts (/auth/oidc/start, /auth/oidc/callback), core/credentials/login-seal.ts
Sesión de navegador __Host-styx_sess, qry.identity.browserSession, CSRF doble barrera, Clear-Site-Data en logoutexisteroutes/auth.ts, transport/http/cookies.ts, @styx/authz/csrf
Access JWT EdDSA (iss, aud, sub, sid, name, iat, exp, jti), JWKS, refresh opaco rotatorio con reuse detectionexisteservice/tokens/access-token-signer.ts, core/credentials/refresh-token.ts, dec-0113
Revocación por sesión, por actor y por admin, con recentAuth en las rutas de adminexiste/auth/sessions/revoke-all, /auth/admin/sessions/:sessionId/revoke, /auth/admin/actors/:actorId/revoke
Tabla identity.actors ((issuer, subject) → id, role con check de 4 valores, default restricted)existeservice/db/schema.ts, migraciones 0000..0003
Rol sólo desde configuración (IDENTITY_{OWNER,ADMIN,MEMBER,DISABLED}_SUBJECTS), reconciliado en cada login y al arrancarexisteconfig.ts, TS2-P3-05, TS-P2-03
Roles → permisos <recurso>:<acción> con alcance own/shared/any; restricted sin asset:* por tipoexistepackages/authz/src/policy.ts (TS4-P2-02)
Audit append-only audit.events, rate-limit por IP y por sesión, problem+json sin ecoexisteservice/db/security-audit.ts, middleware/rate-limit.ts
Proxy /auth del BFF, authedServerFn general, e2e con Pocket IDfaltatrack/identity/sec SW20 partial
Perfiles, hogares, invitaciones, concesiones de biblioteca, API keys, cuentas de servicio, device grant, password localnuevoeste ADR
CLI styx (sólo existe styx-upload, de ingesta)nuevoapps/cli/README.md (TARGET sin código), dec-0123, ADR headless (track/docs#12)

Dos restricciones del código actual chocan con D3 y este ADR las trata de frente:

  1. "La configuración es la única fuente de rol" (TS2-P3-05). Una invitación que da rol necesita que el rol pueda venir también de la base de datos (§5.3).
  2. "Prohibido: password auth" (apps/identity-svc/README.md). D3 pide password sólo tras un flag. Este ADR lo mantiene prohibido por defecto y define el único camino permitido (§8.3).

2. Referencias externas estudiadas y qué se toma

ReferenciaQué haceQué se toma / qué no
Wizarr (invitaciones para Jellyfin/Plex/Emby)Enlace de invitación con caducidad, usos o un solo uso, bibliotecas permitidas, duración de la cuenta, y un onboarding guiado (crear cuenta, descargar app, conectar al servidor). Limpieza de cuentas caducadas. SSO opcional.Se toma el modelo de invitación (§7) y el onboarding por pasos configurable por el owner. No se toma la contraseña de invitación (el enlace ya es el secreto) ni la creación de cuenta con password.
Jellyfin Quick ConnectEl dispositivo pide un código corto y un secreto, muestra el código, el usuario lo autoriza desde un cliente ya logueado y el dispositivo sondea con el secreto hasta recibir el token.Es exactamente el device grant (RFC 8628) con otro nombre. Se adopta el estándar (§10) y la UX de Jellyfin (código grande en la TV, autorización desde el móvil).
Plex Home / usuarios gestionadosUna cuenta "home" con usuarios gestionados sin login propio, PIN de 4 dígitos por usuario y restricciones por clasificación y bibliotecas. Un usuario gestionado puede convertirse en cuenta propia.Usuario gestionado = perfil (§6). PIN de perfil, restricción por clasificación y por bibliotecas, y la transferencia de perfil a cuenta propia (§7.2, profile-transfer).
Netflix (perfiles)Varios perfiles por cuenta, cada uno con su historial, idioma y clasificación; perfil infantil; bloqueo de perfil con PIN; transferencia de perfil a una cuenta nueva.Perfiles opcionales con un perfil primario siempre presente, selector "¿quién está viendo?" sólo si hay más de uno, PIN, nivel de madurez, transferencia.
Pocket ID 2.x (OP de referencia, el que ya usa track/identity)OIDC/OAuth 2.0 passkey-only. Registro con ALLOW_USER_SIGNUPS=disabled|withToken|open, tokens de registro con caducidad, límite de usos y grupos (desde 2.0). Grupos que restringen clientes OIDC. API de admin con cabecera X-API-KEY y, desde 2.10, APIs OAuth con permisos acotados. SCIM. Login por código de un solo uso por email sólo si se habilita. Soporta device authorization.Adaptador de aprovisionamiento recomendado (§8.2): Styx crea un token de registro de un solo uso por invitación. Styx no delega el device grant en el OP (§10.1). Las rutas exactas de la API se fijan contra la versión fijada en el spike (P3).
RFC 8628 (OAuth 2.0 Device Authorization Grant)device_code + user_code + verification_uri(_complete) + interval + expires_in; sondeo con authorization_pending, slow_down, access_denied, expired_token. §5.4 phishing remoto, §6.1 entropía del user_code.Endpoint propio en identity-svc (§10), código numérico para mandos de TV, QR con verification_uri_complete, mitigaciones de §5.4 en la pantalla de aprobación.
WebAuthn / passkeysCredencial ligada al origen, resistente a phishing, sin secreto compartido.Las passkeys viven en el OP, no en Styx (§8.1). Styx sólo ve el resultado OIDC (amr, auth_time).
Tokens con prefijo verificable (patrón de GitHub ghp_)Prefijo fijo + secreto aleatorio + checksum CRC32: el secret scanning lo detecta sin falsos positivos y el servidor descarta basura sin tocar la base.Formato de API key de §9.1.
NIST SP 800-63B-4 + OWASP Password StorageLongitud mínima 15 si la password es factor único (8 con MFA), sin reglas de composición, lista de bloqueo; argon2id con memoria ≥ 19 MiB.Requisitos del password opt-in (§8.3).

3. Decisión (resumen)

  1. Modelo: servidor → cuentas (humanas o de servicio) con 1..n identidades (issuer, subject) → hogar opcional con roles propios → perfiles por cuenta, siempre al menos uno (el primario), opcionales en la UI. Las bibliotecas se conceden a cuentas u hogares y un perfil puede estrecharlas (§4).
  2. Permiso efectivo = rol de servidor ∩ restricciones del perfil ∩ alcance de la credencial ∩ bibliotecas visibles. Un único can() en @styx/authz lo calcula (§5).
  3. Invitaciones estilo Wizarr con token de un solo secreto, ligadas al state OIDC dentro del login sellado y canjeadas de forma atómica en el callback (§7).
  4. Passwordless por defecto: OIDC contra un OP con passkeys; Pocket ID recomendado, IdP- agnóstico mediante un port de aprovisionamiento con capacidades (§8). Password local apagado por defecto, sólo con IDENTITY_PASSWORD_LOGIN=enabled, argon2id y TOTP (§8.3).
  5. API keys personales con prefijo verificable, hash, scopes ⊆ permisos, caducidad obligatoria, último uso y revocación; cuentas de servicio para automatización. Nunca llegan a los servicios: el borde las intercambia por un JWT corto (§9).
  6. Device authorization grant (RFC 8628) servido por identity-svc para el login del CLI y el quick connect de TV y dispositivos: código numérico + QR, aprobación desde una sesión ya iniciada con elección de perfil (§10).
  7. Paridad headless: cada operación de este dominio tiene ruta, permiso, método de SDK, comando de CLI y marca agentSafe en el registro de operaciones de dec-0124 §6.1 (§11).

4. Modelo

4.1 Entidades

Servidor (instalación)
├── Cuenta (actor)                      kind: human | service · rol de servidor · estado · caducidad
│   ├── Identidad 1..n                  (issuer, subject) OIDC, o local si el password está habilitado
│   ├── Perfil 1..n                     siempre ≥1 (primario) · PIN · madurez · bibliotecas · idioma
│   ├── API key 0..n                    sólo para kind human y service
│   └── Sesiones 0..n                   browser | native | device, cada una con su perfil
├── Hogar 0..n (opcional)
│   └── Miembro                         cuenta + rol de hogar (owner | manager | member) + supervisada
├── Concesión de biblioteca             biblioteca → cuenta | hogar
└── Invitación                          crea cuenta, une a hogar o transfiere un perfil

Reglas:

  • Cuenta = quien inicia sesión. Es el actor de hoy (id estable de Styx, sub del JWT). Una cuenta humana tiene al menos una identidad. Una de servicio no tiene identidades: sólo API keys (§9.3).
  • Identidades separadas de la cuenta. Hoy (issuer, subject) vive en actors. Pasa a una tabla identities (1..n por cuenta) para poder cambiar de OP sin perder la cuenta, enlazar una cuenta local con su OIDC, o tener dos OP a la vez. La migración es mecánica: una fila por actor.
  • Perfil = quien está viendo. Toda cuenta tiene exactamente un perfil primario, creado con la cuenta. Si la cuenta tiene un solo perfil la UI no lo muestra: los perfiles son opcionales en la experiencia, no en el modelo. Así el estado por persona (progreso, "seguir viendo", idioma, lista) se indexa siempre por (actorId, profileId) y no hay dos caminos.
  • Hogar = grupo opcional de cuentas. Sirve para tres cosas: compartir bibliotecas con todos sus miembros de una vez, invitar a gente al grupo y supervisar (los managers fijan restricciones a los miembros marcados como supervisados). Una cuenta pertenece como mucho a un hogar (P5). Un hogar no es un tenant: el servidor sigue siendo uno, y el owner del servidor ve todo.
  • Concesión de biblioteca: una biblioteca (de catalog-svc) se concede a una cuenta o a un hogar. Las bibliotecas visibles de una cuenta = las suyas ∪ las de su hogar. Un perfil puede restringirse a un subconjunto, nunca ampliarlo.

4.2 Las tres formas de usarlo (ninguna se impone)

FormaCómo se montaEjemplo
Cuentas independientesCada persona tiene su cuenta (su passkey) y sus bibliotecas concedidas. Sin hogar. Un perfil por cuenta, invisible.Amigos a los que waxin invita a una biblioteca de películas.
Cuenta familiar con perfilesUna cuenta (una passkey, la de quien la gestiona) con un perfil por persona; perfiles infantiles con madurez y PIN en los adultos.Una TV del salón con "Papá", "Mamá", "Peques".
Hogar de cuentasCada adulto su cuenta, todas en un hogar que comparte bibliotecas; los menores, cuentas supervisadas o perfiles de una cuenta adulta.Familia donde cada uno usa su móvil y su passkey.

Las tres conviven en el mismo servidor. Pasar de una a otra no pierde datos: un perfil se convierte en cuenta propia con profile-transfer (§7.2) y una cuenta entra o sale de un hogar sin tocar sus perfiles.

4.3 Tablas (schema identity, Postgres)

TablaColumnas principalesNotas
actors (existe)+ kind (human/service), status (active/disabled/expired), expires_at, role_source (config/db), created_via_invitation_id, access_versionissuer/subject se mueven a identities. access_version sube con cada cambio de acceso (§5.4).
identitiesactor_id, issuer, subject, created_at, last_login_at; único (issuer, subject)issuer = urn:styx:local para cuentas con password (§8.3).
householdsid, name, created_by, created_at
household_membershousehold_id, actor_id (único), household_role (owner/manager/member), supervisedTodo hogar tiene ≥1 owner (check diferido en la transacción).
profilesid, actor_id, name, avatar_ref, is_primary, can_manage, kind (standard/kids), maturity_ceiling, allowed_library_ids (null = todas las de la cuenta), ui_language, audio_language, subtitle_language, pin_hash, pin_failed, pin_locked_until, deleted_atExactamente un is_primary por cuenta (índice parcial único). El primario tiene can_manage = true y no se borra.
library_grantslibrary_id, grantee_kind (actor/household), grantee_id, granted_by, created_atlibrary_id es de catalog-svc; identity no valida su existencia en caliente (evento de borrado, §12.2).
invitationsver §7.1token_hash único.
invitation_redemptionsinvitation_id, actor_id, redeemed_at, ip_prefixAppend-only. Es el registro de usos que muestra la UI.
api_keysver §9.1token_hash único.
local_credentialsactor_id, password_hash, totp_secret_enc, totp_enabled_at, failed, locked_until, updated_atSólo existe si el password está habilitado (§8.3); la migración la crea, pero ningún código la lee con el flag apagado.

En Valkey (prefijo identity:), además de lo que hay: la sesión gana profileId, kind (browser/native/device), deviceName, profileLocked, y los pendientes del device grant (§10.3). El estado por perfil (progreso, listas) no vive en identity: lo posee su servicio y se indexa por (actorId, profileId); identity emite evt.identity.profileDeleted para que lo borre (§12.2).

5. Roles y permiso efectivo

5.1 Tres ejes, un solo can()

  1. Rol de servidor (existe): owner, admin, member, restricted, con ROLE_GRANTS de @styx/authz. Es de la cuenta.
  2. Rol de hogar (nuevo): owner, manager, member. Sólo da permisos sobre el hogar (invitar a él, gestionar sus miembros, fijar restricciones a los supervisados, conceder al hogar las bibliotecas que el propio manager ya ve). No da permisos de servidor.
  3. Restricciones del perfil (nuevo): madurez, subconjunto de bibliotecas y can_manage. Un perfil con can_manage = false (todo perfil kids, y los que el owner de la cuenta marque) no ejerce permisos de gestión aunque la cuenta sea admin u owner: desde el perfil infantil de la TV del salón no se borran bibliotecas.
permitido(principal, permiso, recurso) =
      ROLE_GRANTS[cuenta.rol][permiso] cubre recurso          (own | shared | any)
  ∧   (permiso es de gestión ⇒ perfil.can_manage)
  ∧   (recurso tiene biblioteca ⇒ biblioteca ∈ visibles(cuenta) ∩ perfil.allowed_library_ids)
  ∧   (recurso tiene clasificación ⇒ clasificación ≤ perfil.maturity_ceiling)
  ∧   (credencial con scopes ⇒ permiso ∈ scopes)              (API key, device con scope, §9-§10)
  ∧   cuenta.status = active

Todo es intersección: ningún eje puede ampliar lo que da otro. Es el mismo principio que TS4-P2-02 (alcances explícitos) llevado a perfiles y credenciales.

5.2 Qué significa "compartido" a partir de ahora

Hoy shared cubre "lo suyo y lo que no tiene dueño" (ownerActorId null). Con concesiones, shared pasa a significar "lo suyo y lo de las bibliotecas que se le han concedido": un recurso sin dueño en una biblioteca no concedida queda fuera. Es un estrechamiento: ninguna cuenta ve más que hoy. Para no romper la instalación de una sola persona, la primera arrancada tras la migración concede todas las bibliotecas existentes al hogar o a las cuentas member+ existentes (decisión de migración, P6).

restricted sigue sin asset:* por tipo (TS4-P2-02 no cambia). Una concesión a un restricted no le da reproducción: le da visibilidad para el día en que pase a member. El rol con el que entra un invitado es member por defecto (§7.1).

5.3 De dónde sale el rol (enmienda a TS2-P3-05)

  • Las listas IDENTITY_{OWNER,ADMIN,MEMBER,DISABLED}_SUBJECTS siguen mandando sobre las identidades que nombran, en las dos direcciones, como hoy (role_source = config). Una cuenta listada no puede cambiar de rol por la API ni por una invitación.
  • Las identidades no listadas toman el rol de la base (role_source = db): el que les dio su invitación o el que les ponga un owner/admin por la API. Por defecto, restricted (deny-by-default, TS-P2-03, sin cambios).
  • Nueva variable IDENTITY_ROLE_SOURCE = config | config+db. config es el comportamiento de hoy (las invitaciones crean cuentas restricted con concesiones, inútiles para reproducir hasta que la configuración las liste). config+db habilita el rol por invitación y por API. Qué valor es el default tras el lock lo decide waxin (P4; recomendado config+db).
  • Un cambio de rol, de hogar, de concesiones o de restricciones de perfil sube access_version y rota o revoca las sesiones afectadas (dec-0118 §2.3: rotación al cambiar privilegios), igual que hoy un cambio de rol por configuración.
  • Nadie concede lo que no tiene: un admin no crea owners; un manager de hogar no concede bibliotecas que no ve ni roles de servidor. owner de servidor sólo por configuración o por el arranque inicial (§8.4), nunca por invitación ni por API.

5.4 Qué lleva el principal (claims nuevos sobre dec-0113)

Aditivos al access JWT de dec-0113 §1 (que no fija la lista como cerrada mientras sea PROPOSED):

ClaimValorPara qué
pidid del perfil activoestado por perfil y restricciones
acvaccess_version de la cuenta al emitirlos servicios cachean el snapshot de acceso por (sub, pid, acv) y lo invalidan solos
credbrowser | native | device | pat | serviceaudit y policies (p. ej. pat no puede crear API keys)
scplista de scopes (sólo si la credencial está acotada: API key, device con scope)intersección de §5.1
amrmétodos del login (hwk/swk de passkey vía OP, pwd+otp local)exigir passkey para lo sensible si waxin lo quiere (P7)

Las bibliotecas visibles y la madurez no van en el JWT (la lista puede ser larga y cambia): un servicio que las necesita pide qry.identity.access { actorId, profileId } y cachea la respuesta por acv. Mismo patrón que qry.identity.session: revocation-aware y con presupuesto por actor (TS2-P2-01).

6. Perfiles

  • Crear y editar: cualquier perfil con can_manage de la cuenta (el primario siempre). Campos: nombre, avatar, tipo (standard/kids), madurez, bibliotecas, idiomas, PIN. Tope de perfiles por cuenta configurable (IDENTITY_MAX_PROFILES, sin número fijado aquí).
  • Madurez: escala ordinal propia de Styx (maturity_ceiling), a la que track/domain-media mapea los sistemas de clasificación de cada país (como el ParentalRating numérico de Jellyfin). El contenido sin clasificar se oculta a los perfiles kids por defecto (configurable por perfil). Dónde vive la tabla de mapeo es de domain-media, no de este ADR.
  • PIN (opcional, 4–6 dígitos): protege entrar en el perfil y salir de un perfil bloqueado. No es una credencial: nunca sirve para iniciar sesión ni para nada fuera de una sesión ya autenticada. Se guarda con argon2id (es de baja entropía; el hash sólo protege ante una fuga de la base). La defensa real es el límite: 5 fallos → bloqueo creciente desde 15 min, evento evt.security.profilePinLockout y aviso al primario. El PIN se cambia con auth_time reciente de la cuenta (dec-0118 §2.3).
  • Cambiar de perfil es una operación de la sesión: POST /auth/profile/select { profileId, pin? }. Rota la credencial (dec-0118 §2.3 ya lo pide para "perfil adulto/infantil") y el siguiente access JWT lleva el pid nuevo. El perfil activo vive en la sesión del servidor, no en una cookie legible ni en el cliente.
  • Dispositivo con perfil bloqueado (profileLocked): la TV de los peques aprobada con "bloquear en este perfil" (§10.4) sólo cambia de perfil con el PIN de un perfil can_manage.
  • Selector "¿quién está viendo?": aparece si la cuenta tiene más de un perfil, al abrir sesión y al volver tras inactividad (configurable por cuenta). Con un solo perfil no existe.
  • Borrar un perfil (nunca el primario): borrado lógico, evt.identity.profileDeleted, y los dos dueños del estado por perfil (playback-svc y catalog-svc, dec-0127 P5) purgan su parte.

7. Invitaciones

7.1 Qué es una invitación

CampoValor
kindaccount (cuenta nueva independiente) · household (cuenta nueva o existente que entra en un hogar) · profile-transfer (§7.2)
token_hashSHA-256 del token (256 bits aleatorios, base64url). El token se muestra una vez
short_code (opcional)código tecleable XXXX-XXXX (Crockford base32, 40 bits) para dictarlo por teléfono; con límite de intentos propio
expires_atobligatorio; tope IDENTITY_INVITE_MAX_TTL (sin valor fijado aquí)
max_uses / uses1 por defecto; ilimitado sólo lo crea un owner
rolerestricted | member (default). admin sólo lo crea el owner con auth_time reciente. owner nunca
library_idsbibliotecas concedidas a la cuenta nueva; ⊆ las que el creador puede conceder
household_id, household_role, supervisedhogar de destino y rol en él (member por defecto; manager sólo si lo crea el owner del hogar)
account_ttlduración de la cuenta creada (la "account duration" de Wizarr): al vencer pasa a expired, sin sesiones
profile_defaultsmadurez, idiomas y tipo del perfil primario de la cuenta nueva
idp_groupsgrupos del OP que el adaptador asigna al aprovisionar (§8.2)
note, created_by, created_at, revoked_atpara la lista de la UI y el audit

Quién crea: owner/admin cualquier invitación dentro de su alcance; manager de hogar sólo household a su hogar, con role ≤ member y bibliotecas ⊆ las del hogar. Crear exige auth_time reciente (es gestionar usuarios, dec-0118 §2.3) y un perfil con can_manage.

7.2 Transferir un perfil a una cuenta propia

profile-transfer lleva el profileId de origen. Al canjearla se crea una cuenta nueva cuyo perfil primario hereda el estado del perfil de origen, y el perfil de origen se borra.

Enmendado en el lock (por dec-0127 P5): el estado por perfil tiene dos dueños. identity-svc manda transferProfileState a playback-svc (sesiones, progreso, preferencias de pista) y a catalog-svc (visto, favoritos, listas, proyección de progreso), y borra el perfil de origen sólo cuando los dos confirman. Es una saga de dos pasos idempotentes: si uno falla o no responde, se reintenta y el perfil de origen no se borra. El estado intermedio y la política de reintentos los fija el ticket con su consumidor. La crea el dueño de la cuenta de origen; es el caso "el peque ya tiene su móvil" y el "transferir perfil" de Netflix.

7.3 Flujo de canje (ligado al state OIDC)

Invitado                      web (BFF)                       identity-svc                         OP (Pocket ID)
   │ GET /join/<token> ─────────▶│  invite.preview(token) ─────────▶│ hash, valida, NO consume
   │                             │◀─ {servidor, quién invita, qué da, caduca} (sin datos personales)
   │◀── Set-Cookie __Host-styx_invite (sellada, 15 min) + 303 /join   (el token sale de la barra)
   │ "Aceptar con passkey" ─────▶│  proxy /auth ───────────────────▶│ /auth/oidc/start?invite=1
   │                             │                                  │ abre __Host-styx_invite, mete
   │                             │                                  │ invitationId+tokenHash en el
   │                             │                                  │ login sellado junto a state/nonce
   │                             │                                  │ ¿adaptador con createSignupToken?
   │                             │                                  │   sí → token de registro del OP
   │                             │                                  │        (1 uso, TTL del login, grupos)
   │◀──────────────── 302 al registro/autorización del OP ──────────│
   │ registra passkey / inicia sesión en el OP ──────────────────────────────────────────────────────▶│
   │◀──────────────────────────────── 302 /auth/oidc/callback?code&state ────────────────────────────│
   │ ───────────────────────────▶│ ───────────────────────────────▶│ abre el sello, compara state,
   │                             │                                  │ canjea code, y EN UNA TRANSACCIÓN:
   │                             │                                  │  uses+1 si uses<max ∧ vigente ∧ no revocada
   │                             │                                  │  crea cuenta+identidad+perfil primario
   │                             │                                  │  (o une la existente al hogar)
   │                             │                                  │  concesiones, rol (salvo config), audit
   │◀──────── sesión __Host-styx_sess + 302 /onboarding ────────────│

Reglas del canje:

  • El token nunca llega al callback. Viaja dentro del login sellado (AES-256-GCM, el mismo de TS4-P2-01) ligado al state. Un atacante no puede hacer que la víctima canjee su invitación (invite-swap) ni reutilizar un state ajeno: el sello está atado a la cookie del navegador que empezó el login.
  • Atómico: el UPDATE … SET uses = uses + 1 WHERE uses < max_uses AND expires_at > now() AND revoked_at IS NULL RETURNING y la creación de la cuenta van en la misma transacción. Dos canjes concurrentes del último uso: uno gana, el otro recibe IDENTITY_INVITE_EXHAUSTED.
  • Identidad ya registrada: con kind = account se rechaza (IDENTITY_ACCOUNT_EXISTS) y la UI ofrece entrar con la cuenta existente; una invitación nunca cambia el rol de una cuenta que ya existe. Con kind = household, la cuenta existente entra en el hogar.
  • Sin oráculo: un token inexistente da el mismo error que uno mal formado. Quien tiene un token válido puede ver que ha caducado o se ha agotado (ya conoce el secreto).
  • /join lleva Referrer-Policy: no-referrer, el token no se registra en logs (redacción en la capa de logging) y el GET /join/<token> cambia el token por la cookie sellada y redirige.
  • Límites: invite.preview y /join son públicos: rate-limit por IP (los mismos buckets de /auth/*) y el código corto con su propio contador de fallos.
  • Revocar una invitación no afecta a las cuentas ya creadas con ella. Borrar las cuentas creadas con una invitación es una operación aparte, explícita (Wizarr la ofrece como "limpieza").

7.4 Onboarding

Tras el canje, /onboarding con pasos que el owner configura (texto de bienvenida en Markdown sanitizado, W5 de dec-0118 §6.3), elegir nombre y avatar del perfil, crear más perfiles si la cuenta es familiar, descargar apps, conectar la TV (lleva directo al quick connect, §10) y "primer script con API key" si la cuenta es member+. Los pasos son datos (onboarding.steps) y se gestionan por API como todo lo demás.

8. Autenticación

8.1 Passwordless por defecto: OIDC con passkeys

  • Styx es relying party OIDC (lo que ya hace identity-svc) y no implementa WebAuthn: las passkeys, su recuperación y su gestión son del OP. Styx exige del OP: discovery, code + PKCE S256, nonce, auth_time (para recentAuth) y, si lo hay, amr.
  • Pocket ID es el OP recomendado y el de referencia en deploy/ y en los e2e (SW20). Es passkey-only, ligero y self-hosted. Cualquier OP OIDC conforme sirve (Authelia, Authentik, Kanidm, Keycloak, Zitadel…): sin adaptador de aprovisionamiento funciona en modo manual (§8.2).
  • Varios OP a la vez: posibles por la tabla identities; la pantalla de login lista los configurados. Uno solo por defecto.
  • Re-autenticación para lo sensible: prompt=login + max_age=0 al OP y comprobación de auth_time en el callback (dec-0118 §2.3).

8.2 Aprovisionamiento en el OP: port con capacidades

interface IIdentityProviderAdmin {
  readonly capabilities: ReadonlySet<'signupToken' | 'createUser' | 'disableUser' | 'groups'>;
  createSignupToken(input: {
    ttlS: number;
    groups: readonly string[];
  }): Promise<Result<{ url: string }>>;
  disableUser(subject: string): Promise<Result<void>>;
}
AdaptadorCapacidadesCredencial
pocket-idsignupToken (requiere ALLOW_USER_SIGNUPS=withToken en el OP), groups, disableUserAPI acotada del OP (APIs OAuth con permisos desde 2.10) en vez de STATIC_API_KEY; secreto por fichero montado, como la clave CSRF
manual (default)ninguna—
  • Con signupToken: cada invitación genera, al empezar su login, un token de registro del OP de un solo uso con el TTL del login pendiente y los idp_groups. Nunca se crea por adelantado (un token del OP sin consumir es un registro abierto).
  • En modo manual: el OP tiene que permitir que el invitado se registre (registro abierto, o el admin lo crea a mano). La pantalla de la invitación lo explica. La invitación sigue siendo la que da acceso a Styx: un usuario que se registre en el OP sin invitación entra como restricted sin bibliotecas (deny-by-default de hoy).
  • Deshabilitar o borrar una cuenta en Styx revoca sus sesiones y API keys; deshabilitarla en el OP es opcional (capacidad disableUser).
  • Cómo encadena Pocket ID el registro con token y la autorización OIDC posterior, y las rutas exactas de su API, se verifican en un spike contra la versión fijada (P3) antes de implementar. Si el OP no puede devolver al flujo OIDC tras el registro, el invitado vuelve a /join y pulsa "continuar": el sello sigue vigente.

8.3 Password local: sólo con flag, apagado por defecto

IDENTITY_PASSWORD_LOGIN = disabled | enabled, disabled por defecto. Con disabled ninguna ruta de password existe (no responden 403: no están montadas), y el guard de rutas lo comprueba. Con enabled:

  • Cuentas locales con identidad issuer = urn:styx:local. Se crean sólo por invitación o por un admin; nunca por registro abierto.
  • Hash: argon2id (Bun.password), parámetros configurables con suelo OWASP (≥ 19 MiB, t ≥ 2) y rehash transparente al subir parámetros.
  • Política (NIST SP 800-63B-4): mínimo 15 caracteres si es factor único, 8 si hay TOTP; máximo ≥ 64; sin reglas de composición; lista de bloqueo local (sin llamadas externas por defecto; HIBP k-anonymity sólo opt-in).
  • MFA: TOTP (RFC 6238) con IDENTITY_PASSWORD_MFA = required | optional, required por defecto. Secreto TOTP cifrado en reposo.
  • Superficie: sólo el formulario de la web a través del BFF, con la doble barrera CSRF. No hay grant de password en la API (nada de ROPC): el CLI y los dispositivos usan el device grant y los scripts usan API keys. Rate-limit por cuenta y por IP, bloqueo progresivo, audit de cada fallo.
  • Recuperación: enlace de un solo uso emitido por un admin (no se asume SMTP); por email sólo si hay SMTP configurado.
  • Consecuencias de seguridad que el owner acepta al habilitarlo: identity-svc pasa a ser un almacén de credenciales (activo nuevo en el threat model de dec-0118 §1); el login es phishable (una passkey no); aparecen el credential stuffing y la recuperación como superficie. La página de operación lo dice antes de enseñar la variable. Una cuenta local puede enlazar después su identidad OIDC y pasar a passkey.

8.4 Arranque inicial (el primer owner)

  • Hoy: IDENTITY_OWNER_SUBJECTS. Se mantiene y es lo recomendado para despliegues declarativos.
  • Nuevo: si no hay ningún owner ni en la configuración ni en la base, identity-svc genera un código de configuración de un solo uso con caducidad, lo escribe en su log de arranque y lo sirve por styx server setup (CLI local, que lo lee del log o del socket de administración que decida el ADR headless). La primera identidad que inicia sesión presentando ese código pasa a owner (role_source = db), y el código se destruye. Es el asistente de primer arranque de Jellyfin sin la ventana en la que cualquiera que llegue primero se queda el servidor.

9. API keys y cuentas de servicio

9.1 API key personal (PAT)

AspectoDecisión
Formatostyx_pat_ + 32 caracteres base62 (≥ 190 bits aleatorios) + 6 base62 de CRC32 del cuerpo. Las de servicio, styx_sat_. Regex publicable para secret scanning: styx_(pat|sat)_[0-9A-Za-z]{38}
ChecksumEl servidor descarta una key con CRC incorrecto antes de tocar la base (barato ante basura y fuerza bruta) y los escáneres no dan falsos positivos
AlmacenamientoSHA-256 del token completo, índice único (alta entropía: no hace falta un hash lento; mismo criterio que el refresh de dec-0113). Para identificarla en la UI se guarda display_prefix (styx_pat_Ab12…)
MostrarUna sola vez, al crearla. No se puede volver a leer
Camposid, actor_id, name, scopes[], profile_id (opcional; si no, el primario), expires_at (obligatorio), created_at, created_from_session_id, last_used_at, last_used_ip_prefix, revoked_at
Caducidadobligatoria; default y máximo configurables (IDENTITY_PAT_DEFAULT_TTL, IDENTITY_PAT_MAX_TTL, sin valores fijados aquí). Sin "nunca caduca" para cuentas humanas. Aviso en la UI y en styx key list antes de caducar
ScopesUn scope es un permiso de @styx/authz (asset:play, asset:scan…) o un alias definido allí (styx:read, styx:library-admin…). Al crearla: scopes ⊆ permisos actuales de la cuenta, o IDENTITY_SCOPE_EXCEEDS_GRANTS. Al usarla: §5.1 interseca con el rol de ese momento (degradar a la cuenta degrada sus keys)
Crearsesión interactiva con auth_time reciente y perfil can_manage. Una API key no puede crear API keys (cred = pat lo impide en la policy)
Último usose actualiza con rebote (≤ 1 escritura por minuto y key) para no meter Postgres en el hot path
Revocarpor la propia cuenta, por un admin, y en bloque al deshabilitar la cuenta
Topenúmero máximo de keys por cuenta configurable

9.2 Cómo se usa: intercambio por JWT en el borde

  • Los servicios no aprenden un formato nuevo: siguen verificando sólo el access JWT de dec-0113. El cliente manda Authorization: Bearer styx_pat_… al gateway de API (frontera W2 de dec-0118 §1, sin home hoy; su forma la cierra el ADR headless) y el gateway la cambia por un JWT corto con POST /auth/token en forma de token exchange (RFC 8693: grant_type=urn:ietf:params:oauth:grant-type:token-exchange, subject_token_type=urn:styx:token-type:api-key). El JWT lleva cred = pat, scp y pid. El gateway lo cachea en memoria hasta su exp (≤ 5 min).
  • Nunca desde el navegador (dec-0118 §2.1): /auth/token rechaza con IDENTITY_KEY_IN_BROWSER_CONTEXT cualquier intercambio que traiga Cookie, Origin o cabeceras Sec-Fetch-* de navegador; el BFF elimina Authorization de toda petición del navegador; y los servicios no tienen CORS (SW12). Una API key pegada en una página no sirve.
  • Revocar una key invalida los intercambios siguientes de inmediato y los JWT ya emitidos caducan en ≤ 5 min (la misma ventana que dec-0118 §2.3 acepta para el BFF); las mutaciones comprueban revocation-aware como hoy.

9.3 Cuentas de servicio

  • actors.kind = service: sin identidades OIDC, sin login interactivo, con un perfil técnico primario y can_manage según su rol. Rol ≤ admin. Las crea un owner/admin.
  • Su única credencial son API keys styx_sat_, cuya caducidad máxima puede ser mayor que la de las personales (configurable) y que se rotan con styx key rotate (emite la nueva, la vieja sigue viva un periodo de solape configurable y luego se revoca).
  • Casos: el hook de Radarr/Sonarr que pide escanear al importar, la monitorización, un servidor MCP (§11.3), el runner de los e2e.
  • Toda acción queda en el audit con actorKind = service.

10. Acceso de dispositivos: device authorization grant (RFC 8628)

10.1 Por qué en identity-svc y no en el OP

Pocket ID soporta el device grant, pero el resultado que necesita Styx es una sesión de Styx con un perfil elegido y, si se quiere, bloqueada en ese perfil. Eso no lo sabe el OP. identity- svc ya es el emisor de los tokens de Styx (dec-0113), así que es el servidor de autorización del grant. Funciona igual con cualquier OP, soporte o no el device grant.

10.2 Endpoints

RutaPolicyQué
POST /auth/device/codepublic, rate-limitRFC 8628 §3.1–3.2. Entrada: client_id (cliente público registrado: styx-cli, styx-tv, styx-apple…), device_name, device_kind, scope?. Salida: device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval
POST /auth/device/tokenpublic (la autoriza el device_code)RFC 8628 §3.4–3.5: authorization_pending, slow_down, access_denied, expired_token. Éxito: access JWT + refresh opaco de dec-0113 (sesión kind = device). Errores en formato OAuth (RFC 6749 §5.2), no problem+json, por interoperabilidad
GET /auth/device/lookup?user_codeauthenticated (vía BFF)datos para la pantalla de confirmación; cuenta como intento
POST /auth/device/approveauthenticated + CSRF (vía BFF){ userCode, profileId, profileLocked, scope? }
POST /auth/device/denyauthenticated + CSRFrechaza; el dispositivo recibe access_denied

verification_uri = https://<origen de la web>/link; verification_uri_complete = https://<web>/link?code=48210937, que el dispositivo pinta como QR.

10.3 El código y sus límites

  • user_code: 8 dígitos, mostrados 4821 0937 (los mandos de TV teclean números; RFC 8628 §6.1 contempla códigos numéricos para dispositivos de entrada limitada). Son 26,6 bits.
  • device_code: 256 bits aleatorios; Valkey guarda su SHA-256 (mismo criterio que el refresh).
  • TTL 10 min por defecto, un solo uso, interval 5 s con slow_down si el dispositivo sondea más rápido.
  • Intentos: lookup/approve con un código que no existe cuentan contra la sesión (5 por minuto, 20 por hora) y contra la cuenta. Con P pendientes a la vez, cada intento acierta con probabilidad P/10⁸: con P = 1 000 y 20 intentos por hora y cuenta, adivinar un código ajeno es del orden de 2·10⁻⁴ por cuenta y hora, y las cuentas son por invitación. Si se quiere más margen: 9 dígitos (P8).
  • Pendientes acotados: /auth/device/code es anónimo y escribe estado, así que tiene tope por IP y tope global (la lección de TS3-P2-02/TS4-P2-01: un endpoint anónimo que llena estado compartido es un DoS). Al llegar al tope global, 503 con Retry-After; los pendientes viven en Valkey con TTL bajo el maxmemory ya configurado.

10.4 Aprobación, perfil y anti-phishing (RFC 8628 §5.4)

La pantalla de /link (o la del móvil que escanea el QR) muestra, antes de aprobar:

  • nombre y tipo del dispositivo y del cliente que lo pide (Styx para TV, styx CLI);
  • hace cuánto se pidió el código y su IP aproximada; si la red del dispositivo no es la del móvil que aprueba, un aviso visible ("este dispositivo no está en tu red");
  • lo que va a recibir: perfil (selector, con PIN si el perfil lo tiene), "bloquear en este perfil", y los scopes si el cliente pidió menos que todo;
  • el texto "si no estás delante de este dispositivo, no lo apruebes".

El QR rellena el código pero nunca aprueba solo: siempre hay un clic explícito sobre esa pantalla. Aprobar no exige auth_time reciente salvo que se pida un perfil can_manage o scopes de gestión (P7). Cada aprobación y cada rechazo van al audit (evt.security.deviceApproved/deviceDenied) y el dispositivo aparece en "Dispositivos" con su perfil, último uso y botón de revocar.

10.5 Login del CLI

  • styx login [--server URL]: device grant con client_id = styx-cli. Imprime el código, la URL y un QR en la terminal, y abre el navegador si hay entorno gráfico (--no-browser lo evita). El refresh token va al almacén seguro del sistema (Keychain, Secret Service, Credential Manager); sin almacén, a un fichero 0600 con un aviso explícito.
  • styx login --with-token: lee una API key de stdin (nunca de argv, que queda en el historial y en ps). STYX_TOKEN en el entorno para CI y agentes.
  • styx auth status [--json], styx logout (revoca la sesión en el servidor, no sólo borra el fichero), styx auth switch-profile.

11. Paridad headless y superficie para agentes

11.1 Operaciones del dominio

Entradas del registro de operaciones de dec-0124 §6.1 (la forma general del registro, del SDK y del CLI la cierra el ADR headless, track/docs#12; aquí se fija qué operaciones hay). Todas por identity-svc; la web las consume por el SDK desde sus server functions. agentSafe = no significa que el CLI exige --yes y que no se exponen por MCP.

OperaciónHTTP (identity-svc)Permiso / policyCLIagentSafe
auth.methods.getGET /auth/methodspublicstyx auth methodssí
auth.session.get (existe)GET /auth/sessionauthenticatedstyx auth statussí
auth.sessions.revokeAll (existe)POST /auth/sessions/revoke-allactor:revoke-sessions ownstyx auth logout --allno
auth.profile.selectPOST /auth/profile/selectauthenticated (+PIN)styx auth switch-profilesí
auth.token.exchangePOST /auth/tokenpublic (la autoriza la key)— (lo hace el SDK)—
auth.device.code / .tokenPOST /auth/device/{code,token}publicstyx loginsí
device.approve / .deny / .lookup/auth/device/{approve,deny,lookup}authenticated + CSRFstyx device approve <código> --profile <p>no
device.list / .revokeGET /devices, DELETE /devices/:ididentity-session:revoke own/anystyx device list|revokelist sí, revoke no
account.me.get / .updateGET/PATCH /accounts/meauthenticatedstyx account show|updatesí
account.list / .getGET /accounts, GET /accounts/:idaccount:read any (admin)styx account list|show <id>sí
account.update (rol, estado, caducidad)PATCH /accounts/:idaccount:manage any + recentAuthstyx account set <id> --role … --expires …no
account.deleteDELETE /accounts/:idaccount:manage any + recentAuthstyx account delete <id>no
profile.list / .create / .update/accounts/me/profiles[/:id]profile:manage own (perfil can_manage)styx profile list|create|updatesí
profile.deleteDELETE /accounts/me/profiles/:idprofile:manage own + recentAuthstyx profile deleteno
profile.pin.set / .clearPUT/DELETE /accounts/me/profiles/:id/pinprofile:manage own + recentAuthstyx profile pin set|clearno
household.create / .get / .update/households[/:id]household:manage (rol de hogar)styx household create|show|updatesí
household.member.update / .remove/households/:id/members/:actorIdhousehold:manage + recentAuthstyx household member set|removeno
household.leavePOST /households/:id/leaveauthenticatedstyx household leaveno
household.deleteDELETE /households/:idowner del hogar + recentAuthstyx household deleteno
grant.library.list / .set/accounts/:id/libraries, /households/:id/librarieslibrary:grant (admin, o manager ⊆ lo suyo) + recentAuth en setstyx library grant|revoke|grantslist sí, set no
invite.createPOST /invitationsinvitation:create + recentAuthstyx invite create --libraries … --role … --uses … --expires … --household …no
invite.list / .getGET /invitations[/:id]invitation:read own/anystyx invite list|showsí
invite.revokeDELETE /invitations/:idinvitation:create own/anystyx invite revokeno
invite.previewPOST /invitations/previewpublic, rate-limitstyx invite inspect <enlace>sí
key.createPOST /accounts/me/keysapi-key:create own + recentAuth, cred ≠ patstyx key create --scope … --expires …no
key.list / .revokeGET/DELETE /accounts/me/keys[/:id]api-key:read|revoke own (admin: any)styx key list|revokelist sí, revoke no
serviceAccount.create / .list / .delete/service-accounts[/:id]service-account:manage (admin) + recentAuthstyx service-account …no
serviceAccount.key.create / .rotate/service-accounts/:id/keysservice-account:manage + recentAuthstyx key create --service-account <id> / styx key rotateno
onboarding.get / .updateGET/PUT /onboardingpublic (get) / server:manage (update)styx server onboarding show|setget sí, update no
server.setup.claimPOST /setup/claimpublic con código de configuraciónstyx server setupno
auth.password.* (sólo con flag)/auth/password/{login,change}, /auth/mfa/totp/*public (login, vía BFF) / authenticated— (sólo web; el CLI usa device grant)—

Permisos nuevos en @styx/authz (RESOURCE_ACTIONS): account:{read,manage}, profile:manage, household:manage, library:grant, invitation:{create,read}, api-key:{create,read,revoke}, service-account:manage, server:manage. Sus alcances por rol salen de §5 y los fija el ticket de implementación.

11.2 CLI para agentes

Las reglas generales (--json con esquema por comando, exit codes estables, --yes, --dry-run, sin prompts sin TTY) son de dec-0124 §6.1 y del ADR headless. Específico de este dominio:

  • Un agente entra con una API key de scope mínimo (STYX_TOKEN), nunca con la sesión de una persona. El camino recomendado es una cuenta de servicio con scopes de lectura y de la tarea.
  • key.create, invite.create, account.update y todo lo que concede o escala es agentSafe = no: un agente no se fabrica credenciales ni invita a nadie sin que una persona lo confirme.
  • La salida --json nunca incluye secretos salvo en la respuesta de key create / invite create, que es la única vez que existen en claro, y esas dos marcan el campo secret para que el agente sepa que no debe registrarlo.

11.3 MCP (propuesta, no lock)

Un servidor MCP generado del registro (P5 de dec-0124) expondría como herramientas sólo las operaciones agentSafe = sí de la tabla, autenticado con una API key de cuenta de servicio. Este ADR sólo fija qué operaciones de cuentas serían elegibles; adoptarlo lo decide waxin en el ADR headless.

12. Contratos a añadir (sin implementar)

12.1 @styx/api-contracts

Ficheros nuevos bajo packages/api-contracts/src/identity/ (o ampliación de identity.ts; lo decide el ticket), TypeBox 1.x (dec-0116), cada esquema con description para el OpenAPI (dec-0124 §7.1):

  • Cuentas: AccountSchema, AccountKindSchema, AccountStatusSchema, AccountUpdateBodySchema, AccountListQuerySchema/ReplySchema, IdentityLinkSchema.
  • Perfiles: ProfileSchema, ProfileCreateBodySchema, ProfileUpdateBodySchema, ProfilePinBodySchema, ProfileSelectBodySchema, MaturityCeilingSchema.
  • Hogares: HouseholdSchema, HouseholdMemberSchema, HouseholdRoleSchema, HouseholdMemberUpdateBodySchema.
  • Concesiones: LibraryGrantSchema, LibraryGrantSetBodySchema.
  • Invitaciones: InvitationSchema, InvitationKindSchema, InvitationCreateBodySchema, InvitationCreatedReplySchema (con el token, una vez), InvitationPreviewBodySchema/ReplySchema.
  • API keys: ApiKeySchema, ApiKeyCreateBodySchema, ApiKeyCreatedReplySchema (con el secreto, una vez), ScopeSchema, TokenExchangeBodySchema/ReplySchema.
  • Cuentas de servicio: ServiceAccountSchema, ServiceAccountCreateBodySchema.
  • Device grant: DeviceCodeBodySchema/ReplySchema, DeviceTokenBodySchema, DeviceTokenErrorSchema (OAuth), DeviceLookupReplySchema, DeviceApproveBodySchema, DeviceSchema.
  • Métodos y onboarding: AuthMethodsReplySchema, OnboardingStepsSchema, SetupClaimBodySchema.
  • Principal: SessionPrincipalSchema gana profileId, credentialKind, scopes?, accessVersion; AccessSnapshotSchema nuevo.

12.2 Bus (BUS_ROUTES, por spire)

SubjectTipoProductor → consumidoresPara qué
qry.identity.accessqryidentity ← catalog, playback, realtimesnapshot de acceso (bibliotecas visibles, madurez, can_manage) por acv
evt.identity.accessChangedevtidentity → todos los que cacheaninvalida cachés por (actorId, acv)
evt.identity.accountDisabledevtidentity → playback (cierra sesiones de reproducción), workerscorte inmediato
evt.identity.profileDeletedevtidentity → playback-svc y catalog-svc (dec-0127 P5)purga del estado
cmd.playback.transferProfileState y cmd.catalog.transferProfileStatecmdidentity → playback-svc y catalog-svc (dec-0127 P5)profile-transfer (§7.2): el origen se borra sólo cuando los dos confirman
evt.catalog.libraryDeletedevtcatalog → identityborra concesiones huérfanas
evt.security.* nuevosevtidentity → sink de auditinviteRedeemed, inviteRejected, deviceApproved, deviceDenied, deviceCodeGuessing, apiKeyCreated, apiKeyRevoked, apiKeyRejected, profilePinLockout, passwordLoginFailed, setupClaimed

12.3 Códigos de error (IdentityErrorCode)

IDENTITY_INVITE_INVALID, IDENTITY_INVITE_EXPIRED, IDENTITY_INVITE_EXHAUSTED, IDENTITY_ACCOUNT_EXISTS, IDENTITY_ACCOUNT_DISABLED, IDENTITY_PROFILE_PIN_REQUIRED, IDENTITY_PROFILE_PIN_LOCKED, IDENTITY_PROFILE_LIMIT, IDENTITY_SCOPE_EXCEEDS_GRANTS, IDENTITY_KEY_IN_BROWSER_CONTEXT, IDENTITY_KEY_LIMIT, IDENTITY_PASSWORD_LOGIN_DISABLED (sólo para el registro de operaciones: con el flag apagado las rutas no existen), IDENTITY_DEVICE_CODE_CAPACITY, IDENTITY_SETUP_CLOSED. Los del device grant en el endpoint de token usan los códigos OAuth de RFC 8628 §3.5.

13. Mapa de UX

13.1 Web (Chrome / Chrome Canary primero)

PantallaQuiénQué hace
/loginanónimobotones por OP configurado ("Continuar con passkey"), formulario sólo si el password está habilitado, "¿tienes una invitación?"
/join/<token> → /joininvitadoservidor, quién invita, qué da (bibliotecas, hogar), caducidad; "Aceptar con passkey"; estados caducada/agotada/ya tienes cuenta
/onboardingrecién llegadopasos configurables: bienvenida, perfil, más perfiles, apps, conectar la TV, API key
/whocuenta con >1 perfilselector de perfiles a pantalla completa, avatar grande, PIN pad, "gestionar perfiles"
/linkcualquiera logueadoteclear el código de la TV (o llegar por QR), pantalla de confirmación de §10.4, éxito
/settings/accountcuentaidentidades enlazadas, sesiones y dispositivos (revocar), API keys (crear con scopes y caducidad, ver una vez, revocar), password/TOTP si aplica
/settings/profilesperfil can_managecrear/editar perfiles: nombre, avatar, infantil, madurez, bibliotecas, idiomas, PIN; transferir perfil a cuenta propia
/settings/householdmiembro de hogarmiembros, roles, supervisados, invitar al hogar, bibliotecas del hogar, salir
/admin/usersadminlista con rol, origen del rol (config/db), estado, caducidad, último acceso; cambiar rol, deshabilitar, bibliotecas
/admin/invitationsadmin / managerasistente de creación (tipo → bibliotecas → rol → hogar → usos → caducidad → duración de cuenta) y resultado con enlace, código corto, QR y "compartir"; lista con usos, estado y canjes
/admin/service-accountsadmincrear, keys, rotar, borrar
/admin/onboardingownereditor de pasos
/admin/authownersólo lectura: OP(s), adaptador y sus capacidades, flag de password, origen de roles; cada valor dice qué variable lo cambia

13.2 TV y dispositivos (Pi 3, Apple TV, apps)

  1. Primer arranque: elegir servidor (URL; descubrimiento local es otro ADR).
  2. "Conecta esta TV": código grande 4821 0937, QR, nombre del servidor, "abre styx.example/link en tu móvil", cuenta atrás, botón "nuevo código". Navegable con el mando (r58: Pi 3 no es un cliente degradado).
  3. Aprobado: selector de perfiles (o directo al perfil si se bloqueó en él) → inicio.
  4. Cambiar de perfil con PIN pad numérico; salir de un perfil bloqueado pide el PIN de un perfil gestor.
  5. Ajustes → "Desconectar esta TV" (revoca la sesión en el servidor).

13.3 CLI

styx login (código + QR en la terminal), styx auth status --json, styx invite create … --json (devuelve enlace, código corto y caducidad), styx key create … --json (devuelve el secreto una vez), styx device approve 48210937 --profile peques (aprobar la TV desde la terminal, con la misma confirmación de §10.4 en texto).

14. Amenazas y mitigaciones (complementa dec-0118 §1)

AmenazaMitigación
Invitación filtrada (reenviada, en un chat)caducidad obligatoria, usos = 1 por defecto, revocación, lista de canjes visible; el canje pide además registrar una passkey
Invite-swap / CSRF de login con invitación ajenael token viaja en el login sellado ligado al state y a la cookie del navegador que lo empezó (§7.3)
Escalada por invitación o por APIconcesiones ⊆ las del creador, owner nunca por invitación, admin sólo por owner con auth_time reciente, roles de config fijos
Fuerza bruta del user_code8 dígitos + límites por sesión y cuenta + tope de pendientes + audit deviceCodeGuessing (§10.3)
Phishing remoto del device grant (RFC 8628 §5.4)pantalla de confirmación con dispositivo, red y hora; QR que nunca aprueba solo; aviso explícito (§10.4)
DoS por endpoints anónimos (/device/code, /join)topes por IP y global, estado en Valkey con TTL bajo maxmemory (lecciones TS3/TS4)
API key robadaprefijo para secret scanning, caducidad obligatoria, scopes mínimos, último uso visible, revocación inmediata en el intercambio
API key usada desde una página (XSS, extensión)/auth/token rechaza contexto de navegador; el BFF quita Authorization; servicios sin CORS
Niño que sale de su perfilprofileLocked + PIN de gestor, bloqueo por fallos, perfiles kids sin can_manage
Fuga de la base de identitysólo hashes (SHA-256 de tokens de alta entropía, argon2id de PIN y password), TOTP cifrado, audit append-only
Password habilitadoapagado por defecto, rutas no montadas sin flag, NIST 800-63B-4, TOTP requerido por defecto, sin ROPC (§8.3)
Toma del servidor recién instaladoowner por configuración, o código de configuración de un solo uso fuera de banda (§8.4)

15. Encaje con lo que ya está decidido

15.1 Sin cambios

  • dec-0118 §2.1 (navegador sin tokens): todas las pantallas nuevas van por server functions del BFF (authedServerFn, SW20) y por el proxy /auth. Ningún flujo nuevo entrega un token al navegador. Las API keys no se aceptan desde él (§9.2).
  • dec-0118 §2.2–§2.5: cookies, rotación (ahora también al cambiar de perfil, que ya estaba escrito), auth_time para lo sensible, CSRF en toda mutación nueva.
  • dec-0118 §3–§4: cada ruta nueva declara policy; cada id que llega del cliente (profileId, householdId, invitationId, keyId, deviceId, actorId) se resuelve a Authorized<R, A> antes del handler, con 404 para lo ajeno.
  • dec-0113: formato del JWT y del refresh intactos; las sesiones device son sesiones nativas de §2.4 de dec-0118.

15.2 Enmiendas propuestas (cada una entra con el lock de este ADR)

  1. TS2-P3-05 / README de identity-svc ("la configuración es la única fuente de rol"): pasa a "la configuración manda sobre las identidades que lista; el resto toma el rol de la base", con IDENTITY_ROLE_SOURCE (§5.3).
  2. README de identity-svc ("Prohibido: password auth"): pasa a "prohibido salvo IDENTITY_PASSWORD_LOGIN=enabled, con los requisitos de dec-0125 §8.3".
  3. @styx/authz: shared pasa de "sin dueño" a "concedido" (§5.2), y los permisos nuevos de §11.1.
  4. dec-0113 §1: claims aditivos pid, acv, cred, scp, amr (§5.4).

15.3 Orden respecto a track/identity y track/identity/sec

  • Antes de cualquier flujo nuevo: SW20 (proxy /auth del BFF, authedServerFn general, e2e con Pocket ID). Todo lo de este ADR cuelga de ahí.
  • I1..I6 de track/identity siguen como están (pendientes de ratificar); este ADR no los toca.
  • Las rutas nuevas entran en el test de policy deny-by-default (SW10) y en la allowlist de rutas public de identity-svc (IDENTITY_PUBLIC_ROUTES) sólo las que §10.2/§11.1 marcan public.

16. Consumidor real de cada pieza (r28)

PiezaConsumidor que la justifica
Perfilesla web (selector y estado por perfil) y la TV del salón de waxin
Hogarinvitar a la familia con bibliotecas compartidas
Invitaciones + adaptadorel alta de cualquier persona que no sea el owner
Device grantstyx login y la app de TV (Pi 3, F-APPLIANCE-PI3)
API keysscripts de waxin y agentes (D2); el hook de escaneo al importar
Cuentas de servicioel runner de e2e y la automatización
Password localninguno hoy: por eso sólo existe tras un flag y su implementación es la última (P9)

17. Preguntas para waxin (bloquean el lock)

Contestadas o diferidas en el lock. Se conservan como registro.

  • P1 — Perfiles siempre en el modelo: un perfil primario por cuenta aunque la UI lo oculte (recomendado: un solo camino para el estado por persona), o perfiles sólo cuando se crean.
  • P2 — Hogar: hogares de cuentas como primera clase (recomendado) o sólo cuenta familiar con perfiles (más simple, pierde "cada uno su passkey" con bibliotecas compartidas).
  • P3 — Pocket ID: adaptador pocket-id con tokens de registro (recomendado, tras un spike contra la versión que se fije) o sólo modo manual al principio.
  • P4 — Origen del rol: default IDENTITY_ROLE_SOURCE=config+db (recomendado) o config.
  • P5 — Una cuenta, un hogar: como mucho un hogar por cuenta (recomendado) o varios.
  • P6 — Migración de "compartido": al migrar, conceder todas las bibliotecas existentes a las cuentas member+ (recomendado, nadie pierde acceso) o empezar sin concesiones.
  • P7 — Exigir passkey (amr) y auth_time reciente para aprobar dispositivos con perfil gestor y para crear API keys (recomendado) o sólo auth_time.
  • P8 — Código del quick connect: 8 dígitos (recomendado, mando de TV) o 9 dígitos / 8 letras base-20.
  • P9 — Milestones en track/identity (propuesta; no se añaden al model hasta tu OK). Nombres y orden; los gate items los redacta el ticket, con author ≠ verifier y mutantes:
    • track/identity/accounts — identidades separadas, hogares, perfiles, concesiones, qry.identity.access, can() efectivo de §5.1, migración de shared y del rol (§5.3).
    • track/identity/invites — invitaciones, canje atómico ligado al state, adaptador pocket-id y modo manual, onboarding, código de configuración inicial.
    • track/identity/devices — device grant, /link, quick connect de TV, login del CLI, dispositivos y revocación.
    • track/identity/keys — API keys, cuentas de servicio, /auth/token y el gateway de W2 (con el ADR headless).
    • track/identity/password — password local opt-in con TOTP. Último, sin consumidor hoy.
    • Cada uno nace con sus páginas especificado en guias/cuentas/ (dec-0124 §9.5).

18. Qué fija el lock y qué queda reversible

  • Fija el lock: el modelo (§4.1) y que el perfil primario existe siempre (si P1 lo confirma); la regla de intersección del permiso efectivo (§5.1) y que nadie concede lo que no tiene (§5.3); que la invitación viaja en el login sellado y se canjea de forma atómica (§7.3); que passwordless es el default y el password vive tras un flag apagado sin ROPC (§8.3); el formato y el intercambio de las API keys y su rechazo en el navegador (§9); que el device grant es de identity-svc con aprobación explícita y elección de perfil (§10).
  • Reversible sin lock (configuración o ticket): TTLs, topes, número de perfiles, parámetros de argon2id, longitud de PIN, textos y pasos del onboarding, la lista de adaptadores de OP, los nombres exactos de rutas y de esquemas.

19. Consecuencias

  • identity-svc crece de "relying party + sesiones" a la authority completa de cuentas. Sigue siendo un solo servicio (r17); nada de esto se reparte.
  • Los servicios que filtran por biblioteca pasan a pedir qry.identity.access y a cachearlo por acv: un coste de bus acotado que ya tiene patrón (TS2-P2-01).
  • El estado por perfil necesita dueño (progreso, listas). Resuelto por dec-0127 P5 (lock del 2026-10-02): se reparte entre playback-svc y catalog-svc con la clave (actorId, profileId) de este ADR, y los eventos de borrado y transferencia van a los dos (lock, punto 2).
  • El gateway de API de W2 deja de ser una frontera teórica: las API keys y los clientes nativos lo necesitan. Su forma la cierra el ADR headless.
  • Lo que no decide: el diseño general del CLI, del SDK y del MCP (ADR headless); el descubrimiento de servidores en la LAN; la clasificación por país (domain-media); quién posee el estado por perfil (lo decide dec-0127 P5); la app de TV (F-APPLIANCE-PI3 / r59).

Lock (2026-10-02, waxin)

Decisión de waxin vía AskUserQuestion (2026-10-02): lockear este ADR con las decisiones que ya había tomado y con la enmienda que pide el lock de dec-0127 (LOCKED el mismo día, rama w10/adr-datos).

  1. Lo que waxin decidió y queda lockeado (§3 tal cual):
    • passwordless por defecto con Pocket ID u otro OIDC con passkeys, IdP-agnóstico (§8.1, §8.2);
    • password local sólo por flag de configuración (IDENTITY_PASSWORD_LOGIN=enabled), apagado por defecto, con argon2id y TOTP, sin ROPC (§8.3);
    • hogares y perfiles opcionales (§4.2): nadie está obligado a crear un hogar ni a ver un selector de perfiles;
    • invitaciones estilo Wizarr, ligadas al state OIDC y canjeadas de forma atómica (§7);
    • API keys con scopes ⊆ permisos, caducidad obligatoria y revocación, intercambiadas por un JWT corto en el borde y rechazadas en el navegador (§9);
    • quick connect y login del CLI con RFC 8628, servidos por identity-svc, con aprobación explícita y elección de perfil (§10);
    • paridad headless: cada operación con ruta, permiso, método de SDK, comando de CLI y marca agentSafe (§11).
  2. Enmienda que exige dec-0127 P5 (aplicada en §6, §7.2, §12.2 y §19). El estado por perfil tiene dos dueños: playback-svc (sesiones, progreso, preferencias de pista) y catalog-svc (visto, favoritos, listas, proyección de progreso), con la clave (actorId, profileId) de este ADR (accountId ≡ actorId).
    • transferProfileState se manda a playback-svc y a catalog-svc. identity-svc borra el perfil de origen sólo cuando los dos confirman (saga de dos pasos idempotentes).
    • evt.identity.profileDeleted lo consumen los dos y cada uno purga su parte.
    • profile.state.export compone las dos exportaciones en el BFF o en el CLI, no en un servicio (dec-0127 P5).
  3. §17, sin respuesta separada: recomendación del ADR como parámetro reversible. Se cambian sin ADR nuevo:
    • P1: un perfil primario siempre en el modelo, aunque la UI lo oculte con un solo perfil;
    • P2: hogares de cuentas como primera clase (cada miembro con su passkey y bibliotecas compartidas), opcionales;
    • P3: adaptador pocket-id con tokens de registro, tras un spike contra la versión de Pocket ID que se fije; el modo manual sigue disponible;
    • P4: IDENTITY_ROLE_SOURCE=config+db por defecto;
    • P5: como mucho un hogar por cuenta;
    • P6: al migrar "compartido", conceder todas las bibliotecas existentes a las cuentas member+ (nadie pierde acceso);
    • P7: passkey (amr) y auth_time reciente para aprobar dispositivos con perfil gestor y para crear API keys;
    • P8: código del quick connect de 8 dígitos.
  4. Diferida de forma explícita: P9 — milestones de track/identity. El lock no los añade a styx.model.yml. La lista de §17 (accounts, invites, devices, keys, password) queda como propuesta para la wave que implemente este ADR, que la lleva a waxin con check:roadmap.
  5. Enmiendas que entran en vigor hoy (§15.2): el rol también desde la base (IDENTITY_ROLE_SOURCE, TS2-P3-05), el password local tras el flag en el README de identity-svc, shared = concedido en @styx/authz y los claims aditivos pid, acv, cred, scp y amr sobre dec-0113 (que sigue PROPOSED: los claims entran cuando se lockee su formato, sin cambiarlo).

El lock desbloquea el código de producción de este ADR, en el orden de §15.3: primero SW20 (proxy /auth del BFF y authedServerFn), después el resto. Las páginas guias/cuentas/** siguen en estado especificado hasta que haya código.

Texto del ADR
dec-0125 — Cuentas, hogares, perfiles, invitaciones y acceso de dispositivos
1. Contexto: qué existe hoy y qué es nuevo
2. Referencias externas estudiadas y qué se toma
3. Decisión (resumen)
4. Modelo
4.1 Entidades
4.2 Las tres formas de usarlo (ninguna se impone)
4.3 Tablas (schema identity, Postgres)
5. Roles y permiso efectivo
5.1 Tres ejes, un solo can()
5.2 Qué significa "compartido" a partir de ahora
5.3 De dónde sale el rol (enmienda a TS2-P3-05)
5.4 Qué lleva el principal (claims nuevos sobre dec-0113)
6. Perfiles
7. Invitaciones
7.1 Qué es una invitación
7.2 Transferir un perfil a una cuenta propia
7.3 Flujo de canje (ligado al state OIDC)
7.4 Onboarding
8. Autenticación
8.1 Passwordless por defecto: OIDC con passkeys
8.2 Aprovisionamiento en el OP: port con capacidades
8.3 Password local: sólo con flag, apagado por defecto
8.4 Arranque inicial (el primer owner)
9. API keys y cuentas de servicio
9.1 API key personal (PAT)
9.2 Cómo se usa: intercambio por JWT en el borde
9.3 Cuentas de servicio
10. Acceso de dispositivos: device authorization grant (RFC 8628)
10.1 Por qué en identity-svc y no en el OP
10.2 Endpoints
10.3 El código y sus límites
10.4 Aprobación, perfil y anti-phishing (RFC 8628 §5.4)
10.5 Login del CLI
11. Paridad headless y superficie para agentes
11.1 Operaciones del dominio
11.2 CLI para agentes
11.3 MCP (propuesta, no lock)
12. Contratos a añadir (sin implementar)
12.1 @styx/api-contracts
12.2 Bus (BUS_ROUTES, por spire)
12.3 Códigos de error (IdentityErrorCode)
13. Mapa de UX
13.1 Web (Chrome / Chrome Canary primero)
13.2 TV y dispositivos (Pi 3, Apple TV, apps)
13.3 CLI
14. Amenazas y mitigaciones (complementa dec-0118 §1)
15. Encaje con lo que ya está decidido
15.1 Sin cambios
15.2 Enmiendas propuestas (cada una entra con el lock de este ADR)
15.3 Orden respecto a track/identity y track/identity/sec
16. Consumidor real de cada pieza (r28)
17. Preguntas para waxin (bloquean el lock)
18. Qué fija el lock y qué queda reversible
19. Consecuencias
Lock (2026-10-02, waxin)