El servidor central
Protocolo de sincronización de openplate
Protocolo de claves y de comunicación, versión 2
Esta página es una traducción automática de la documentación en inglés.
Versión del protocolo: 2 · Versión del sobre: 1 · Estado: anterior a 1.0, nada publicado
Esta es la especificación normativa del protocolo de comunicación entre un cliente de openplate y un servidor central. Se ha redactado para que un tercero pueda implementar cualquiera de las dos partes sin necesidad de leer nuestro código: un cliente alternativo que sincronice contra nuestro servicio alojado, o un servidor alternativo al que se pueda apuntar un cliente de openplate mediante CORE_URL.
La versión legible por máquina se encuentra en dos archivos duplicados que se mantienen a mano entre sí:
| Repositorio | Archivo |
|---|---|
openplate-core | src/protocol.ts |
openplate | app/lib/sync/engine/protocol.ts |
Cada repositorio tiene una prueba unitaria que valida sus constantes frente a literales transcritos (tests/unit/protocol.test.ts y tests/unit/sync-engine/protocol.test.ts). No hay una CI compartida entre los repositorios, de modo que esas pruebas son lo único que evita una divergencia silenciosa del protocolo. Este documento es normativo; el TypeScript es su transcripción.
1. Resumen en un párrafo
El cliente conserva todas las claves. Serializa todo su almacén local, lo comprime con gzip, lo cifra con AES-256-GCM usando una clave que el servidor nunca ha visto y envía el resultado como un único blob opaco. El servidor almacena los bytes, gestiona sus versiones y rechaza las escrituras que sobreescribirían las de otro dispositivo. También almacena dos pequeños registros de clave (la misma clave de cifrado de datos envuelta bajo dos claves de cifrado de claves distintas, una derivada de la frase de contraseña del usuario y otra de un código de recuperación) para que un segundo dispositivo pueda inicializarse. Ninguna ruta de código en el servidor descifra nada de esto. El servidor sí conserva el código de recuperación de cada cuenta, sellado bajo un secreto propio (§3.1), por lo que el operador de una instancia administrada tiene lo necesario para abrir un diario. La §9 detalla exactamente lo que el servidor sabe.
La imagen de abajo muestra una sesión completa. El protocolo de enlace de versiones se ejecuta primero y no es consultivo: ante una discrepancia, o si no puede comunicarse con un servicio, el cliente se detiene ahí en lugar de enviar un sobre que el otro extremo podría interpretar de forma distinta. La §6 establece esa regla, las §5.7 a §5.9 describen el inicio de sesión que protege y la §5.1 detalla el envío, incluida la pérdida de compare-and-swap de la que el cliente debe recuperarse.
Código fuente del diagrama
sequenceDiagram
participant C as Client
participant S as Core server
C->>S: GET /health
S-->>C: protocolVersion, envelopeVersion
alt versions differ, or unreachable
C->>C: refuse to sync
Note over C: no push, no pull, no retry
else versions equal
C->>S: POST /v1/auth/kdf
S-->>C: salt, Argon2id params
C->>C: derive authHash, derive KEK
C->>S: POST /v1/auth/login
S-->>C: access token, refresh token
C->>C: encrypt snapshot under DEK
C->>S: POST /blob, baseVersion 3
alt baseVersion matches
S-->>C: 200, newVersion 4
else another device wrote first
S-->>C: 409, currentVersion 5
C->>S: GET /blob
C->>C: decrypt, merge, re-encrypt
C->>S: POST /blob, baseVersion 5
S-->>C: 200, newVersion 6
end
end2. Terminología
| Término | Significado |
|---|---|
| DEK | Clave de cifrado de datos. 32 bytes aleatorios. Cifra el blob. Nunca sale del cliente sin envolver. |
| KEK | Clave de cifrado de claves. Envuelve la DEK. Existen dos: la derivada de la frase de contraseña y la derivada del código de recuperación. |
| Sobre | El formato de transmisión del blob cifrado: iv ‖ AES-256-GCM(gzip(JSON(payload))). |
| Registro de clave | Una DEK envuelta, más (solo en el caso de la frase de contraseña) los parámetros KDF necesarios para derivar de nuevo su KEK. |
blobVersion | Contador monotónico por cuenta. El token de compare-and-swap. |
| Cuenta | La unidad de aislamiento. Una cuenta tiene a lo sumo un blob actual y a lo sumo dos registros de clave. |
| Correo electrónico | El identificador de la cuenta: una dirección canónica (NFKC, sin espacios laterales, en minúsculas). Único por servidor. |
| Invitación | Una capacidad de un solo uso DIRIGIDA a un correo electrónico, generada por un operador. La única vía de entrada. |
| Custodia | El código de recuperación de la cuenta, sellado en el servidor bajo una subclave de SERVER_SECRET. |
| Rol | admin o member. El propio token de acceso de un administrador autentica /v1/admin. |
El protocolo 2 sustituyó el identificador por un correo electrónico (ADR-0005). El Handle de la versión 1, un identificador opaco por servidor que no podía contener un @, ya no existe: se eliminaron la columna, el analizador sintáctico y la regla. Un cliente que hable la versión 1 debe negarse a comunicarse con un servicio de la versión 2 en lugar de funcionar a medias; consulta la §6.
3. Criptografía (en el cliente; el servidor no implementa nada de esto)
Un servidor conforme no necesita nada de esta sección; está aquí para que un cliente alternativo pueda interoperar y para que un revisor pueda comprobar las afirmaciones.
3.1 Derivación de claves
┌─HKDF-SHA-256(salt, info=PASSPHRASE_KEK)──► KEK_p (never sent)
passphrase ─Argon2id(salt, m, t, p)─► hash ─┤
└─HKDF-SHA-256(salt, info=AUTH)───────────► authHash (sent to the server)
┌─HKDF-SHA-256(salt="", info=RECOVERY_KEK)──► KEK_r (never sent)
recovery code ───────────────────────┤
└─HKDF-SHA-256(salt="", info=RECOVERY_AUTH)─► recoveryAuthHash (sent)- Parámetros de Argon2id (registrados por cuenta en el
kdfDescriptordel registro de clave de la frase de contraseña y en el propio descriptor KDF de la cuenta, de modo que puedan aumentarse más adelante sin romper las cuentas existentes):memorySizeKib: 65536(64 MiB),iterations: 3,parallelism: 1,hashLength: 32. Sal: 16 bytes aleatorios. - Las Etiquetas
infode HKDF son cadenas de bytes congeladas, codificadas en UTF-8. Proporcionan separación de dominios para que los valores derivados sean criptográficamente independientes: -openplate-sync:passphrase-kek:v1-openplate-sync:recovery-kek:v1-openplate-sync:auth:v1-openplate-sync:recovery-auth:v1 - La rama
authes lo que el cliente envía como su contraseña. Es un hermano deKEK_p, no un padre ni un hijo: ambos son salidas HKDF sobre el mismo hash de Argon2id bajo diferentes etiquetasinfo, por lo que poseer uno no da información sobre el otro. Esta es la razón completa por la que el servidor puede autenticar a un usuario para el que no puede descifrar datos.authHashtiene 32 bytes, en base64 en la transmisión. - La rama
recovery-authes lo que el cliente envía para demostrar la posesión del código de recuperación (§5.14). Es un hermano deKEK_rexactamente en el mismo sentido en queauthHashes un hermano deKEK_p, y tiene 32 bytes, en base64 en la transmisión. - La etiqueta
recovery-authnunca es la etiquetarecovery-kek. Esa separación de dominios es estructural, no mera prolijidad. La rama KEK deriva la clave que abre el diario; si la misma salida también se enviara al servidor, este servicio almacenaría un HMAC del material que descifra una DEK, y la afirmación de que «el operador no puede leer tus datos» (una afirmación que solo se sostiene mientras el operador carezca del código de recuperación custodiado, §9.1) descansaría en que SHA-256 sea unidireccional y no en que el operador nunca haya tenido ese valor. Ambas etiquetas están fijadas, ninguna se deriva de la otra y cualquier cambio futuro en cualquiera de ellas será una nueva etiqueta:v2y no una redefinición (ADR-0004). - El servidor tampoco almacena nunca
authHashnirecoveryAuthHash. AlmacenaHMAC-SHA-256(serverPepper, ...)de cada uno, manteniendo la sal secreta fuera de la base de datos. Consulta el §5.8. - La ruta de recuperación omite deliberadamente Argon2id y utiliza una sal de HKDF vacía. Esto es correcto, no un descuido: el RFC 5869 §3.1 lo permite cuando el material de clave de entrada ya tiene una entropía alta, como ocurre por diseño con un código aleatorio de 160 bits. Solo las frases de contraseña humanas de baja entropía necesitan un estiramiento intensivo en memoria y una sal real.
- Código de recuperación: 20 bytes aleatorios (160 bits), representados en un alfabeto base32 estilo Crockford (
0123456789ABCDEFGHJKMNPQRSTVWXYZ, sinO,I,Lpara sobrevivir a la transcripción) en grupos de 5. De forma canónica, son 32 caracteres sin las agrupaciones y en mayúsculas; esa es la forma que sella el servidor. - El código de recuperación se deposita en custodia en el servidor (protocolo 2, ADR-0005). El cliente ya no se lo muestra a la persona: envía el código sin procesar una vez en el cuerpo del registro, y el servidor almacena
iv(12) ‖ AES-256-GCM(escrowKey, code) ‖ tag(16)enaccounts.recovery_code_escrow, dondeescrowKeyes una tercera subclave HMAC congelada deSERVER_SECRET(openplate-sync:escrow-key:v1, junto a la sal secreta del verificador y la clave del descriptor ficticio). Un restablecimiento por correo (§5.12) devuelve el código al titular de la cuenta, quien luego ejecuta la rotación ordinaria del §5.14 con él. Por lo tanto, el operador de una instancia administrada posee lo necesario para abrir un diario. Este es un cambio real en lo que este servicio es, se declara aquí en lugar de ocultarse, y se argumenta por completo endocs/adr/0005-organization-accounts-and-escrowed-recovery.md. - El depósito en custodia es sobre el CÓDIGO, no sobre
KEK_ry no sobre la DEK. Nada en el servidor deriva una KEK, desempaqueta una DEK ni conserva una; el código se convierte en clave solo después de que un cliente ejecuta HKDF sobre él. Eso no compra ningún secreto frente al operador, que también puede ejecutar HKDF; compra un servidor cuya ruta de código no contiene ningún descifrado de datos de usuario, que es lo que hace que la afirmación sea verificable en lugar de una promesa. - Las KEK son claves AES-GCM de 256 bits, importadas como no extraíbles.
3.2 El sobre
build: payload ─► JSON ─► UTF-8 ─► gzip ─► AES-256-GCM(key=DEK, iv=random 12B, aad=AAD) ─► iv ‖ ciphertext‖tag
parse: split(iv, rest) ─► AES-256-GCM decrypt ─► gunzip ─► UTF-8 ─► JSON ─► payload- IV: 12 bytes aleatorios, nuevos por cada cifrado, empaquetados como los bytes iniciales de
ciphertext. No hay ningún campo IV independiente en ninguna parte de este protocolo. - Etiqueta: la etiqueta de autenticación GCM de 16 bytes se añade al texto cifrado (convención de WebCrypto).
- AAD es la codificación UTF-8 de un objeto JSON canónico y con orden de claves fijo:json
{"accountId":<int>,"blobVersion":<int>,"payloadSchemaVersion":<int>}Vincular estos datos evita el corta y pega (reproducir un blob en una cuenta diferente) y la reversión (reproducir una versión más antigua, o una carga útil de un esquema de almacenamiento local incompatible). Un cliente debe presentar la tríada idéntica al descifrar o la comprobación de la etiqueta fallará, lo cual es el comportamiento previsto, no un error que deba eludirse.
- Compresión (
gzip, RFC 1952) se aplica al texto en claro antes del cifrado. El texto cifrado no se puede comprimir, así que o se comprime antes o no se comprime en absoluto. Consulta el §8 para entender por qué esto importa y el §9.2 para la descripción honesta de lo que filtra.
- Estructura de Payload (todo lo que está dentro de
snapshotes opaco para este protocolo):json{ "snapshot": { "...": "the client's local-store snapshot, protocol-opaque" }, "syncMeta": { "perEntity": { "<entityId>": { "lamport": 3, "deviceId": "abc" } }, "tombstones": [{ "entityId": "x", "entityType": "foodLog", "lamport": 4, "deviceId": "abc" }] } }
- DEK empaquetada:
iv ‖ AES-256-GCM(key=KEK, plaintext=DEK), sin AAD: una DEK empaquetada no está vinculada a ninguna versión concreta del blob. La longitud es siempre de12 + 32 + 16 = 60bytes.
3.3 Semántica de combinación (lado del cliente)
Los conflictos se resuelven por entidad mediante (lamport, deviceId): el contador de Lamport más alto gana; los empates se resuelven por orden lexicográfico de deviceId. El reloj de pared del dispositivo no es explícitamente una autoridad de ordenación; sufre desviaciones y difiere fácilmente entre dispositivos. Un tombstone participa en la misma comparación que un valor activo. Compromiso de diseño aceptado en la v1: el último escritor gana a nivel de registro completo, por lo que una edición simultánea sin conexión de la mismo entidad en dos dispositivos descarta la escritura más antigua de forma silenciosa. No hay combinación por campos ni interfaz para conflictos.
3.4 El empaquetado para compartir (ADR-0002)
Un share es un tercer empaquetado de la misma DEK, dirigido a la clave pública de otra cuenta. El servidor lo almacena, se lo entrega a la única cuenta a la que va dirigido y no guarda ninguna clave para él; el §9.1 no cambia con esta función.
sender (grantor, holding recipientPub):
(ephPriv, ephPub) ← ECDH P-256, fresh per wrap, discarded after
Z ← ECDH(ephPriv, recipientPub)
KEK_share ← HKDF-SHA-256(salt = empty, IKM = Z,
info = "openplate-sync:share-kek:p256:v1")
AAD ← UTF-8 of canonical fixed-key-order JSON:
{"grantorAccountId":<int>,"recipientKeyFingerprint":"<base64>"}
wrap ← ephPub(65, uncompressed SEC1) ‖ iv(12) ‖ AES-256-GCM(KEK_share, DEK, aad=AAD)- La longitud es de 125 bytes, siempre. Observa que es un invariante diferente respecto a la DEK empaquetada de 60 bytes del §3.2: 60 para un registro de clave, 125 para un share. Se guardan en tablas distintas y ninguna ruta de validación compartida bifurca por longitud.
- P-256, y la curva se especifica en la etiqueta en lugar de solo en la versión, de modo que una construcción futura será una etiqueta nueva en vez de una ambigüedad sobre
:v1. - La sal HKDF vacía es correcta, por los mismos motivos del RFC 5869 §3.1 que el §3.1 ya recoge para el código de recuperación: el IKM es una salida ECDH nueva y de alta entropía, no un secreto humano que requiera una función con coste de memoria.
- Este empaquetado lleva AAD; las DEK empaquetadas del §3.2 no lo llevan. El empaquetado de un registro de clave está delimitado por una fila exclusiva del propietario y no se puede confundir con el de nadie más. El empaquetado de un share reside en una tabla de asociación que controla el servidor, donde sí podría confundirse: vincularlo hace que una fila insertada falle la comprobación de su etiqueta en lugar de descifrarse dentro del diario equivocado.
- Los AAD vinculan la huella de la clave del destinatario, no el id de cuenta de quien recibe la concesión. La sustitución ataca la clave, así que la clave es lo que nombra la vinculación, y quien recibe la concesión reconstruye los AAD a partir de una huella calculada localmente, de modo que ningún valor suministrado por el servidor entra en la cadena de confianza.
recipientKeyFingerprintesSHA-256de la clave pública sin comprimir en bruto. El servidor la almacena como metadatos de fijación y nunca avala, entrega ni genera una clave pública; la clave fijada fidedigna reside dentro de la propia instantánea cifrada de quien otorga la concesión.
Quien recibe la concesión debe probar a descifrar. Los AAD del blob del §3.2 vinculan payloadSchemaVersion, que el §7 define como un entero opaco que nunca viaja por la red. Un propietario conoce el suyo; quien recibe la concesión no conoce el de quien la otorga. Así que quien recibe la concesión intenta descifrar probando las versiones de esquema que admite su compilación y se queda con aquella cuya etiqueta GCM valide. Esto es ligero y es el comportamiento previsto; no añadas un campo de versión de esquema en texto en claro para resolverlo.
3.5 El sobre de contribución a investigación (ADR-0003)
Un aportación es un fragmento reducido y acotado por fechas del diario, cifrado con la clave pública de un estudio. Es un artefacto distinto a un share, no uno más limitado: diferente payload, diferente clave, diferente ciclo de vida y no interviene ninguna DEK; el empaquetado se aplica directamente sobre el payload.
El seudónimo. En el compartimento privado del propietario reside una raíz aleatoria de 256 bits por cuenta, de modo que sobrevive a una restauración de recuperación y llega a un segundo dispositivo.
pid = HMAC-SHA-256(root, "openplate-sync:study-pseudonym:v1" ‖ uint64be(studyAccountId))
truncated to the leading 128 bits, Crockford base32, 26 charactersLos bytes son fijos, porque una concatenación con especificación insuficiente equivale a dos implementaciones que discrepan en un mismo despliegue. La etiqueta son sus bytes UTF-8 sin terminador; studyAccountId es 8 bytes, sin signo, big-endian, siempre ocho, nunca su texto decimal ni una codificación de longitud mínima. La salida son los 16 bytes iniciales del MAC en el alfabeto base32 de Crockford 0123456789ABCDEFGHJKMNPQRSTVWXYZ (sin símbolo de control, sin guiones), lo que equivale exactamente a 26 caracteres en mayúsculas. Un cliente que derive sobre los dígitos ASCII del id genera un seudónimo bien formado que no enlaza con nada.
Estable entre los envíos de un mismo colaborador, no vinculable entre estudios (las salidas HMAC con mensajes distintos son independientes) e inderivable para cualquiera que tenga tanto la tabla de cuentas como una cohorte. H(accountId ‖ studyId) no tendría esa última propiedad: con entradas públicas se revierte mediante enumeración.
El seudónimo protege frente al investigador, no frente al servidor. El servidor autentica el push mediante un token de portador y, por tanto, conoce la cuenta detrás de cada fila en cualquier caso; consulta el §9.2.
El sobre.
(ephPriv, ephPub) ← ECDH P-256, fresh per contribution
Z ← ECDH(ephPriv, studyPub)
KEK ← HKDF-SHA-256(salt = empty, IKM = Z,
info = "openplate-sync:research-kek:p256:v1")
AAD ← UTF-8 of canonical fixed-key-order JSON:
{"studyAccountId":<int>,"pseudonym":"<string>",
"contributionVersion":<int>,"schemaTier":"<string>",
"studyKeyFingerprint":"<base64>"}
body ← ephPub(65) ‖ iv(12) ‖ AES-256-GCM(KEK, payload, aad = AAD)Una etiqueta fija nueva en lugar de una versión de la etiqueta de compartición: finalidad distinta, la misma lógica que incluyó la curva en el nombre.
Los AAD no llevan ningún id de cuenta, y tampoco ninguna respuesta del lado del estudio. Esta es la inversión deliberada del §5.16, donde grantorAccountId es obligatorio porque los AAD del §3.2 lo vinculan. El investigador puede reconstruir aquí cada campo de los AAD antes del descifrado: cuatro van en la respuesta, y la huella digital la calcula él localmente a partir de su propia clave.
La carga útil es un nivel fijo, seleccionado por nombre. Un estudio elige un nivel y un intervalo temporal; nunca proporciona una lista de campos. La v1 define uno:
daily-intake:v1: una fila por día natural dentro del intervalo, con date (granularidad de día, sin marcas de tiempo), energyKcal, proteinG, carbsG, fatG, fiberG, loggedEntryCount. El recuento existe porque, de otro modo, un investigador no puede distinguir entre "no comió nada" y "no registró nada"; es un recuento, nunca las entradas.
Un campo nuevo implica una revisión del protocolo, nunca una configuración. Consulta ADR-0003.
4. Convenciones de transporte
- Todos los cuerpos de solicitud y respuesta son
application/json. - Los campos binarios (
ciphertext,wrappedDek) son cadenas en base64 (alfabeto estándar, con relleno). No se envían con un tipo de contenido binario, y es algo deliberado: cualquier campo de cada solicitud debe poder leerlo un autoalojador que depure su propia instancia. - Las marcas de tiempo son cadenas ISO-8601 UTC, p. ej.
2026-08-04T10:11:12.000Z. - Un código de recuperación en tránsito es TEXTO en base32 de Crockford, dondequiera que aparezca (
signup.recoveryCode,recover-rotate.recoveryCode,rotate-dek.recoveryCodey la respuesta dereset/open). Un servidor DEBE aceptarlo agrupado o sin agrupar y, en cualquier caso, canonizarlo a 32 caracteres en mayúsculas eliminando espacios y guiones, sellar ESE valor y devolver esa misma forma canónica desdereset/open. Por tanto, un código tiene una única forma sellada, de modo que un re-escrow tras una rotación sea comparable con lo que había antes, y un cliente que muestre el código en grupos de cinco pueda enviar de vuelta lo que mostró. Un cliente conforme también acepta ambas formas. - Todo cuerpo de respuesta que no sea 2xx es
{"error": "<human-readable text>"}. El texto es solo para diagnóstico; los clientes deben bifurcar según el código de estado, nunca según el mensaje. - Las solicitudes que exceden el límite del cuerpo se rechazan con
413. Cada grupo de rutas bajo/v1/synctiene su propio límite, y ningún grupo hereda el de otro: los registros de blob y clave admiten el tope del blob en base64 más 4 KiB,rotate-dekel tope del blob en base64 más 64 KiB, el grupo de compartición 8 KiB y el grupo de investigación 512 KiB. - Una ruta autenticada comprueba el token de tipo bearer antes de leer el cuerpo. Quien realiza la llamada sin un token válido recibe
401, nunca413, sin importar el tamaño del cuerpo. - Una dirección de origen es una dirección IPv4 o una IPv6 /64. Cada limitador que este documento denomina por IP o por dirección de origen cuenta a quien llama por IPv6 por los primeros 64 bits de su dirección, ya que una conexión doméstica dispone de un /64 completo. Una dirección IPv6 mapeada a IPv4 (
::ffff:a.b.c.d) cuenta como la dirección IPv4 que contiene. Una dirección IPv4 cuenta como sí misma. Una petición cuya dirección no pueda determinar el servidor comparte un único depósito con cualquier otra petición similar.
4.1 Autenticación
Un token de portador en una cabecera Authorization: Bearer <token>. Sin cookies, en ninguna dirección.
Access-Control-Allow-Origin: *, y nunca se envíaAccess-Control-Allow-Credentials. Por lo tanto, cualquier cliente de openplate (el nuestro, el de alguien que autoaloja en su propio dominio o una implementación de terceros) puede comunicarse con cualquier instancia de este servicio sin importar el origen.- Esa combinación es segura precisamente porque no hay credenciales implícitas en el entorno. Una página hostil puede emitir una petición de origen cruzado y recibirá un
401, porque el navegador no tiene nada que adjuntar de forma automática. Esta es la propiedad contra CSRF de la que carecen las cookies, y es la razón por la que permitir cualquier origen es una decisión meditada y no un atajo. - Las llamadas no autenticadas reciben
401. Las llamadas autenticadas pero no autorizadas reciben403. Un servidor conforme no debe confundirlas. - Dos
403llevan un código de máquina fijo sobre el que un cliente bifurca:account-suspendeden cualquier ruta con portador, yhealth-consent-requireden cualquier ruta de datos que el §5.15.1 no liste como abierta. Son la excepción a «bifurca según el estado, nunca según el mensaje», porque403por sí solo no permite distinguirlos entre sí ni de un rechazo ordinario:| Estado | Cuerpo | Significado | Qué hace el cliente | | ------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | |
401| cualquiera | Sin token de acceso válido | Renueva una vez y luego pide iniciar sesión | |403|{"error":"account-suspended"}| Un operador suspendió la cuenta (§5) | Avisa de ello; iniciar sesión no servirá | |403|{"error":"health-consent-required"}| La instancia pide un consentimiento de datos de salud y la cuenta no tiene la versión actual (§5.15.1) | Pide el consentimiento y luego reintenta | |403| cualquier otra cosa | Autenticado, no permitido para esta petición concreta | Lee la tabla del propio endpoint |
- Cada cabecera de petición personalizada que lee una ruta figura en
Access-Control-Allow-Headers:Authorization,Content-Type,Idempotency-Key(§5.23) yX-Intake-Id(§5.19). Cada cabecera de respuesta personalizada que lee un cliente figura enAccess-Control-Expose-Headers:Retry-After,X-Trial-Scans-Left,X-Quota-UsedyX-Quota-Limit. Un navegador se niega a enviar una cabecera omitida en la primera lista, y oculta la omitida en la segunda lista, sin dejar rastro en ningún registro.
Esto sustituyó a una cookie de sesión de mismo origen que existía mientras los núcleos de los manejadores estaban montados dentro de la aplicación openplate. Ese cambio, y el traslado de las rutas de sincronización de /api/sync a /v1/sync, son previos a la 1.0 y no incrementan PROTOCOL_VERSION: no existe ningún blob en producción, no hay implementaciones de terceros y ningún cliente desplegado puede romperse por ellos. Una vez publicado este documento junto a una versión pública, ese margen se termina; consulta el §7.
4.2 Ciclo de vida de los tokens
Dos tipos de token, ambos cadenas aleatorias opacas, ambos almacenados solo como resúmenes SHA-256. Un volcado de la tabla de tokens no produce nada reproducible, y usar SHA-256 sin estirar es correcto aquí porque la preimagen contiene 256 bits de aleatoriedad; no hay diccionario que probar.
| Token | Vida útil | Propósito |
|---|---|---|
access | 15 min | Se envía en cada petición. Corta, porque si se filtra, es útil mientras siga viva. |
refresh | 30 días | Se intercambia por un par nuevo. Rotativo: cada uso lo consume. |
Por qué un par opaco y no un JWT. La revocación es estructural en este protocolo: un cambio de frase de contraseña y una rotación del código de recuperación deben invalidar cualquier sesión activa de inmediato, y quien cambia su frase de contraseña bajo sospecha espera exactamente eso. A un token sin estado solo se le puede hacer expirar, nunca dejar de funcionar, a menos que se añada la misma lista de denegación en el servidor que un token opaco en base de datos ya supone.
Por qué usar un par. El cliente nunca debe persistir la frase de contraseña, por lo que no puede volver a derivar silenciosamente un hash de autenticación para iniciar sesión otra vez. Un token de actualización rotativo de larga duración es lo único que hace posible la reautenticación silenciosa en un diseño donde el servidor nunca ve la frase de contraseña.
Rotación y detección de reutilización. Cada par lleva un identificador family que sobrevive a la rotación.
POST /v1/auth/refreshcon un token de actualización válido lo revoca y devuelve un par nuevo en la misma familia.- Presentar un token de actualización que ya está revocado es la señal de reutilización: el cliente legítimo lo rotó, así que quien lo presenta ahora tiene una copia que no debería tener. Se revoca toda la familia. Esto cierra la sesión del atacante y la del usuario real, que es el resultado correcto; la alternativa dejaría a un atacante con una sesión operativa.
- La escritura decide, no la lectura. Dos solicitudes con el mismo token de actualización pueden encontrarlo activo a la vez. Solo una de ellas puede consumirlo (mediante una actualización condicional de "activo" a "revocado"), y la otra recibe una respuesta de reutilización:
401, revocando toda la familia. Así, exactamente una de las dos solicitudes simultáneas con un mismo token obtiene200, y ese par no sobrevive a la respuesta de reutilización de la otra. El cliente debe secuenciar sus propias actualizaciones (§11); la concurrencia entre dos instancias con el mismo token es justamente el caso para el que existe la detección de reutilización. - Los tokens de acceso generados en rotaciones anteriores se dejan intactos deliberadamente; caducan por sí solos en pocos minutos y revocarlos al momento de la rotación interrumpiría una petición legítima en curso.
Desencadenantes de la revocación. Cada una de estas acciones revoca todos los tokens access y refresh pendientes de la cuenta:
POST /v1/auth/change-passphrasePOST /v1/auth/recover-rotatePOST /v1/sync/rotate-dek, salvo la propia familia de quien llama (§5.17)- suspensión por parte de un operador
- eliminación de la cuenta (por cascada de filas)
POST /v1/auth/logout revoca una familia (ese dispositivo) y no modifica las demás sesiones de la cuenta.
Los tokens de sesión son el único tipo presente en account_tokens. Hasta la versión 0.5.0 esa tabla también contenía dos tipos de LINK de un solo uso, generados para enviarse en un mensaje: uno confirmaba una dirección y el otro canjeaba un enlace de recuperación enviado por correo. Ambos desaparecieron junto con el servicio de correo y ninguno volvió. El protocolo 2 no incluye ninguna confirmación de dirección (la propia invitación sirve de verificación, §5.8) y su enlace de restablecimiento no sustituye ninguna credencial (§5.12).
Dos tokens de capacidad residen fuera de esa tabla, y ambos llevan un prefijo para que no pueda enviarse uno donde corresponde el otro:
| Token | Prefijo | Vida útil | Almacenado en | Qué permite obtener |
|---|---|---|---|---|
| Invitación de registro | si_ | 7 d | signup_invites | Crea UNA cuenta, en la dirección indicada en la invitación. |
| Restablecimiento de contraseña | sr_ | 60 min | password_resets | Devuelve el código de recuperación custodiado de la cuenta, una sola vez. |
Ambos constan de 256 bits de aleatoriedad, ambos se almacenan únicamente como un resumen SHA-256 y ambos son de un solo uso. Ninguno se acepta jamás como credencial Authorization: Bearer, y nunca se admite un token de sesión en su lugar: el prefijo actúa como un filtro de formato antes de cualquier búsqueda, y su rechazo genera el mismo fallo genérico que recibe un token incorrecto, por lo que no añade ningún oráculo.
Al definirse La suspensión también revoca. accounts.suspended_at, se revocan todos los tokens access y refresh pendientes en la misma transacción, de modo que la suspensión surte efecto de inmediato en lugar de esperar a que expire el token de acceso actual.
5. Endpoints
Dos familias, bajo un único espacio de nombres con versión:
| Familia | Prefijo | Autenticación |
|---|---|---|
| Sincronización (§5.1 a §5.5) | /v1/sync (SYNC_API_PREFIX) | Bearer, siempre |
| Handshake (§5.6) | /health | Ninguno |
| Cuenta (§5.7 a §5.15) | /v1/auth | Mixto: especificado por endpoint |
Una cuenta suspendida se rechaza en todas partes. POST /login, POST /refresh, POST /recover, POST /recover-rotate, cada ruta protegida por bearer y el árbol de administración responden 403 {"error":"account-suspended"}, esa cadena exacta, para que un cliente pueda reconocerla e informar de lo ocurrido. En login y en las rutas de recuperación, la comprobación se ejecuta DESPUÉS de verificar la credencial, de modo que una dirección desconocida sigue recibiendo el 401 habitual e indistinguible.
A una cuenta sin el consentimiento de la instancia se le rechaza cada ruta de datos. Si instance.healthConsent no es null (§5.6), una cuenta que no tenga exactamente esa versión recibe 403 {"error":"health-consent-required"} en cada ruta que guarde, envíe o consuma algo para ella, y conserva las rutas necesarias para aceptar, marcharse y releer su propia copia. El §5.15.1 lista ambas. La suspensión se comprueba primero, de modo que una cuenta suspendida recibe account-suspended.
Las rutas en §5.1 a §5.5 se indican de forma relativa a SYNC_API_PREFIX; el resto son absolutas.
5.1 POST /blob: push (compare-and-swap)
Petición:
{ "baseVersion": 3, "envelopeVersion": 1, "ciphertext": "<base64>", "shrinkAcknowledged": false }baseVersion: lablobVersionque el cliente cree que está almacenada actualmente.0afirma "esta cuenta aún no tiene ningún blob".- La escritura se acepta solo si
baseVersioncoincide con la versión actual de la cuenta. Este es todo el modelo de concurrencia. No existe el force-push ni escrituras sinIf-Match. shrinkAcknowledged: OPCIONAL, y su ausencia significafalse. Consulta la protección contra reducción más abajo.
Respuestas:
| Estado | Cuerpo | Significado |
|---|---|---|
200 | {"newVersion": 4} | Aceptada. El blob está ahora en newVersion. |
409 | {"currentVersion": 5} | Carrera perdida. Otro dispositivo escribió primero. |
400 | {"error": "..."} | baseVersion no es un entero no negativo, envelopeVersion no es un entero positivo, ciphertext no está presente o no es base64, o está vacío, o shrinkAcknowledged está presente y no es un booleano. |
400 | {"error": "...", "currentSizeBytes": 5310, "nextSizeBytes": 1588} | Una reducción importante no confirmada. No se escribió nada. Consulta más abajo. |
413 | {"error": "..."} | El blob supera MAX_BLOB_BYTES. |
401/403 | {"error": "..."} | No autenticado / no permitido. |
La protección contra reducción (M224). Un push cuyo ciphertext decodificado sea estrictamente menos de la mitad del size_bytes de la versión almacenada (BLOB_SHRINK_ACK_RATIO) se RECHAZA con 400 a menos que la petición incluya "shrinkAcknowledged": true. Una cuenta que aún no tenga ningún blob nunca se rechaza; un primer push no es un borrado.
Es una CONFIRMACIÓN, no un veredicto. Un cliente la establece en true exactamente cuando emite borrados a partir de un estado en el que confía plenamente, y un cliente que no pueda garantizar eso omite el campo y asume el rechazo. El servicio almacena texto cifrado y no puede distinguir entre un borrado deliberado y un cliente que perdió su almacenamiento local y cree que todo se eliminó; son los mismos bytes. Por tanto, pregunta, y un cliente que no responda nada recibe un rechazo en lugar de un borrado total.
Cuando SÍ se acepta una reducción confirmada, la versión inmediatamente anterior se preserva de la purga durante BLOB_PRE_SHRINK_PIN_DAYS (§8).
El CAS se comprueba PRIMERO: un push a partir de un baseVersion desactualizado da el 409 habitual, sea cual sea su tamaño, ya que la tarea de ese cliente es hacer pull y fusionar, y normalmente no reduce el tamaño una vez hecho. La comprobación solo afecta a un push que, de otro modo, habría sido aceptado.
El rechazo es 400 y deliberadamente NO 409: un 409 en esta ruta significa "otro dispositivo escribió antes" y obliga a ejecutar el bucle de recuperación descrito abajo, el cual enviaría de nuevo los mismos bytes. Tampoco se utilizó 413: la petición no es demasiado grande.
shrinkAcknowledged es un CAMPO DEL CUERPO y nunca debe convertirse en una cabecera. Una cabecera de petición personalizada debe incluirse en el Access-Control-Allow-Headers de CORS del servicio, o de lo contrario el navegador leerá la comprobación previa (preflight), verá una cabecera no permitida y nunca enviará la petición, sin registrar ninguna línea en los logs y sin dejar rastro observable en pruebas fuera del navegador. Motivo: docs/adr/0009-a-shrinking-blob-is-acknowledged-or-refused.md.
El bucle de recuperación tras un 409 es un comportamiento obligatorio del cliente, no una optimización: haz pull de currentVersion, descífralo, fusiónalo con el estado local (§3.3), vuelve a cifrar con los AAD vinculados al nuevo blobVersion y haz push de nuevo con baseVersion: currentVersion. Un cliente que trate 409 como un error irrecuperable dejará el dispositivo del usuario permanentemente desincronizado.
5.2 GET /blob: pull
| Estado | Cuerpo |
|---|---|
200 | {"blobVersion": 4, "envelopeVersion": 1, "ciphertext": "<base64>", "createdAt": "<iso>"} |
404 | {"error": "..."}: esta cuenta nunca ha hecho push de un blob. No es una condición de error; es el estado normal de una cuenta nueva. |
5.3 GET /key-records: list
{
"records": [
{
"kind": "passphrase",
"kdfDescriptor": { "salt": "<base64>", "params": { "memorySizeKib": 65536, "iterations": 3, "parallelism": 1 } },
"wrappedDek": "<base64>",
"updatedAt": "<iso>"
},
{ "kind": "recovery", "kdfDescriptor": null, "wrappedDek": "<base64>", "updatedAt": "<iso>" }
]
}Devuelve {"records": []} si la cuenta no ha completado la configuración inicial. Como máximo un registro por kind.
5.4 PUT /key-records/:kind: create or rotate (compare-and-swap)
:kind es passphrase o recovery; cualquier otro valor es 400.
Petición:
{
"kdfDescriptor": { "...": "..." } | null,
"wrappedDek": "<base64>",
"expectedUpdatedAt": "<iso>" | null,
"currentAuthHash": "<base64, 32 bytes>"
}expectedUpdatedAt: nullafirma "aún no existe ningún registro de este tipo" (configuración inicial).- Cualquier otro valor afirma "el último registro que leí tenía exactamente este
updatedAt" (rotación). - La clave debe estar presente. La ausencia de
expectedUpdatedAtdevuelve un400, deliberadamente: quien llama no debe poder eludir la comprobación de concurrencia olvidando un campo. - Una sobrescritura exige demostrar la frase de contraseña. Cuando
expectedUpdatedAtno esnull,currentAuthHash(la rama de autenticación de la frase de contraseña actual, §3.1) es OBLIGATORIO: si falta o está mal formado devuelve un400que lo nombra, y si no coincide con la cuenta devuelve401 {"error":"current passphrase is incorrect"}, el cuerpo que envíachange-passphrase, sin escribir nada. Una creación (null) mantiene solo bearer e ignora el campo: ocupa un espacio vacío durante la configuración, y el CAS la rechaza en cuanto existe un registro. Sustituir un envoltorio sustituye la llave de acceso a la cuenta, y un token de tipo bearer por sí solo no debe poder hacer eso. - Los intentos de acceso se limitan por cuenta, en un mismo contador junto con
change-passphrase,deleteyrotate-dek: una cuenta bloqueada recibe429conRetry-Afteren los cuatro casos, desde cualquier dirección. Una coincidencia reinicia el contador.
Validación, todo 400:
wrappedDekvacíokind: "recovery"con unkdfDescriptorno nulo (la ruta de recuperación solo usa HKDF; no hay parámetros que registrar)kind: "passphrase"con unkdfDescriptornulo
Respuestas:
| Estado | Cuerpo | |
|---|---|---|
200 | El registro almacenado, con la misma estructura que una entrada GET /key-records. | |
400 | {"error": "..."}: error de validación anterior, o sobrescritura sin un currentAuthHash bien formado. | |
401 | {"error": "current passphrase is incorrect"}: sobrescritura cuyo currentAuthHash no coincidió. | |
409 | `{"currentUpdatedAt": "<iso>" \ | null}`: la aserción de CAS no se cumplió. |
429 | {"error": "..."} con Retry-After: los intentos de frase de contraseña para esta cuenta están bloqueados. |
5.5 DELETE /key-records/:kind: eliminado, sin restaurar
Eliminado en 2026-09. La ruta responde ahora como cualquier ruta desconocida bajo el prefijo: 401 si no hay token, 403 si la cuenta no cuenta con el consentimiento de la instancia, y el 404 habitual en los demás casos. Ningún cliente la utilizaba, y borrar el único registro de clave restante provocaba que todos los blobs almacenados dejaran de ser descifrables de forma permanente usando solo un token de tipo bearer.
Un registro de clave se sustituye mediante §5.4, demostrando la frase de contraseña, o mediante rotación (§5.14, §5.17), y solo se elimina al borrar la cuenta (§5.15).
Un recurso compartido (§5.16) no cuenta como registro de clave. Criptográficamente es un tercer envoltorio de la misma DEK, pero constituye una capacidad de otra persona, revocable por ella, inverificable por ti y dependiente de su continua cooperación y honestidad. Ningún cliente debe ofrecer jamás «recupera tus datos a través de tu dietista» como vía de recuperación.
5.6 GET /health: negociación de versión
Sin autenticación, de forma deliberada: un cliente debe poder detectar que es incompatible antes de tener credenciales, y una comprobación de estado que necesitase un token estaría informando sobre el token.
{
"protocolVersion": 2,
"envelopeVersion": 1,
"serviceVersion": "0.20.0",
"instance": {
"name": "openplate",
"language": "de",
"mail": true,
"memberInvites": true,
"openSignup": true,
"signupCaptcha": { "provider": "turnstile", "siteKey": "0x4AAAAAAAexample" },
"trial": { "scans": 10, "days": 14 },
"plans": true,
"push": false,
"healthConsent": { "version": "2026-09-28" },
"nutrientReferenceBasis": "dge",
"ai": { "model": "vendor/model-name" },
"defaultCapabilities": null
}
}instance describe qué es este despliegue y qué puede hacer, y es opcional: un servicio anterior al campo lo omite, y un cliente que lo exija se negaría a hablar con cada una de esas instancias. name es la etiqueta del operador para la instancia, language es uno de en, de, fr, it, es, tr (los seis idiomas en los que redacta su correo; un cliente lo muestra y nunca toma decisiones a partir de él, así que añadir un séptimo no cambia el protocolo), mail indica si puede enviar correos en absoluto, memberInvites indica si un miembro normal puede invitar a otras personas aquí (§5.21), openSignup indica si una persona puede solicitar una cuenta aquí (§5.8.3), signupCaptcha indica qué necesita esa solicitud, trial promete los escaneos gratuitos que recibe una cuenta nueva (§5.19), plans indica si hay un sistema de facturación detrás de esta instancia para que exista /v1/plans/* (§5.22), push indica si esta instancia puede enviar notificaciones push web para que exista /v1/push/* (§5.24), healthConsent indica el consentimiento sobre datos de salud que pide a cada cuenta (§5.15.1), nutrientReferenceBasis indica los valores de referencia de micronutrientes de quién muestra, y ai es null cuando no hay configurada ninguna clave de subida. ai.model es el modelo del nivel predeterminado de la instancia (§5.19): el modelo al que el proxy envía una solicitud a menos que el operador haya enrutado el esquema de esa solicitud a otro nivel. Es null cuando el operador no especificó ninguno y se envía el modelo del propio emisor.
defaultCapabilities es la lista de capacidades (§5.19, «Capacidades») que tiene una cuenta cuando no tiene un registro propio, por ejemplo ["scan", "recipes"]. Es siempre presente: null significa que esta instancia no comprueba ninguna capacidad en absoluto, por lo que todas las funciones están abiertas, que es lo que tiene una instancia con Autoalojamiento que no configura nada, y [] significa que una cuenta no tiene ninguna hasta que un registro indique lo contrario. Un cliente que no encuentra ninguna clave (cualquier servicio anterior a la existencia del campo) lo interpreta como null. Es descriptivo, nunca una concesión: el proxy decide por cada solicitud a partir del propio registro de la cuenta y de este valor por defecto.
healthConsent es el consentimiento explícito para datos de salud que esta instancia pide a cada cuenta, {"version": "<v>"}, o null cuando no pide ninguno, que es el valor predeterminado en Autoalojamiento. Es null en lugar de estar ausente, como ai, y un cliente que no encuentra ninguna clave (cualquier servicio anterior al campo) lo interpreta como null. A diferencia del resto de este bloque, el servicio aplica esto: mientras no sea null, crear una cuenta requiere el consentimiento coincidente (§5.8), y cada ruta de datos rechaza una cuenta que no tenga exactamente esta versión con 403 {"error":"health-consent-required"} hasta que acepte en §5.15.1. Un cliente que encuentra una versión pregunta antes de sincronizar, y trata ese 403 como la misma pregunta formulada a destiempo. Un cliente que encuentra null no muestra ninguna casilla de consentimiento, y el servicio no le rechaza nada por ello.
push sigue a plans con exactitud: un booleano que solo indica si existe una puerta de acceso. false significa que todo el subárbol /v1/push responde con el habitual 404 de ruta desconocida, por lo que el cliente no dibuja ningún ajuste de notificaciones. No dice nada sobre el contenido de un push, porque un push contiene un tipo y nada más (§5.24).
plans es un booleano y no una promesa opcional, que es a propósito lo contrario de la elección que toma instance.feedback más abajo. Ese campo es una promesa sobre lo que ocurre con una fotografía, y una instancia que no tiene nada que prometer lo omite. Este no promete nada: solo indica si existe una puerta de acceso, que es el mismo tipo de afirmación que hacen mail y memberInvites, de modo que false es la respuesta honesta tanto para una instancia sin facturación como para un servicio compilado antes de que existiera el campo.
openSignup es un booleano, como memberInvites y plans: solo indica si existe una puerta. true significa que POST /v1/auth/signup-request acepta una dirección (§5.8.3); false, así como un servicio compilado antes del campo, significa que la ruta responde con el 404 habitual de ruta desconocida, y un cliente muestra el texto de invitación en lugar de un formulario de registro. Es descriptivo, nunca una concesión: los límites de tasa, el captcha, los dominios rechazados y el límite de un correo por buzón al día se mantienen en el servicio.
signupCaptcha solo está presente mientras openSignup sea true y el operador ejecute un captcha. provider es turnstile hoy; siteKey es la clave pública de sitio de Cloudflare Turnstile, con la que un cliente renderiza el widget y que no otorga nada. El token que genera el widget viaja como captchaToken en la solicitud de registro. Que esté ausente significa que la solicitud no necesita ningún token.
trial es un promesa, como feedback abajo, por lo que está ausente en lugar de null en una instancia que no ejecuta periodo de prueba de escaneos. scans es el número de escaneos de IA gratuitos que recibe allí una cuenta nueva (§5.19, "The scan trial"). days es el número de días tras el día del registro al final de los cuales concluye esa prueba incluso si quedan escaneos, lo que ocurra antes (§5.8 indica cuándo cae ese final); es ausente, nunca null, en una instancia cuya prueba no tiene fecha de finalización, y un cliente que no encuentra ningún days indica los escaneos exactamente como antes y NO DEBE indicar un número de días. Un cliente que no encuentra ningún trial NO DEBE indicar un número de escaneos gratuitos. Ambos números son los que escribe cada punto de entrada de la prueba, publicados a partir de los mismos ajustes (TRIAL_SCANS, TRIAL_DAYS), de modo que la frase que una persona lee antes de registrarse y los límites que mantiene el proxy no puedan divergir.
memberInvites es descriptivo, nunca una concesión, como todo lo demás en este bloque. Un cliente lo lee para decidir si dibuja una tarjeta de invitación o no; nunca lo lee para decidir si puede acuñar. false significa que POST /v1/auth/invites responde con el habitual 404 de ruta desconocida, y true sigue dejando el límite total, la regla de reinvitación y la limitación de tasa en manos del servicio.
Es descriptivo, nunca vinculante. mail: true no promete que llegue una carta, y ai informa de lo que configuró el operador en lugar de conceder nada; una cuenta con dailyAiLimit: 0 recibe un 403 sin importar lo que diga esto.
nutrientReferenceBasis es dge, efsa o us: qué valores de referencia de micronutrientes muestra cada cliente en esta instancia, si los del DGE alemán, los de la EFSA de la UE o las cifras del NASEM de EE. UU. Es opcional, de modo que un servicio compilado antes de la existencia de este campo lo omite y un cliente que no lo reconozca lo ignora. Es un criterio por instancia, nunca por idioma y nunca por persona: el idioma y el organismo de referencia son ortogonales, y un valor predeterminado ligado a la configuración regional sería, en la práctica, un criterio por persona.
También es el el único campo de este bloque que un administrador puede modificar mientras el servicio se ejecuta. Todo lo demás aquí pertenece al entorno del operador y permanece fijo hasta un nuevo despliegue; este valor sí se almacena, y PATCH /v1/admin/settings (§5.20) lo escribe. Por tanto, un cliente lo lee en cada conexión en lugar de guardarlo en caché durante toda la vida de la instalación. Un servicio DEBE servir esta ruta desde una copia local en memoria del proceso y NO DEBE leer su almacenamiento para responder a /health: esta es la ruta de healthcheck del contenedor, consultada continuamente, y una lectura del almacenamiento en ese punto convierte un fallo puntual de la base de datos en un reinicio.
instance.feedback es el único campo aquí que es una promesa en lugar de una descripción, y es la excepción al párrafo anterior. Una instancia que acepta estimaciones reportadas conserva una fotografía de la comida de alguien, y publica durante cuánto tiempo:
{
"protocolVersion": 2,
"envelopeVersion": 1,
"serviceVersion": "0.6.0",
"instance": { "name": "openplate", "language": "en", "mail": true, "ai": null, "feedback": { "retentionDays": 30 } }
}retentionDays es el número a partir del cual borra el propio barrido de retención del servicio, publicado desde la misma vinculación, para que la frase que un cliente muestra a una persona antes de que entregue una fotografía y el borrado posterior no diverjan.
El campo está ausente, nunca null, en una instancia que no acepta reportes. ai: null es una afirmación que hace toda instancia; esto es una promesa, y una instancia con la función desactivada no tiene ninguna que hacer, por lo que no añade clave alguna y se mantiene indistinguible de una compilada antes de que existiera el campo, exactamente igual que su árbol /v1/feedback se mantiene indistinguible de uno donde nunca se programó la función.
Un cliente que no encuentre una ventana anunciada NO DEBE indicar ninguno. No ofrece reporte alguno, o muestra un texto que no nombra ningún periodo; mostrar un número a partir de un valor predeterminado local publica una promesa que el servicio nunca hizo, ante una persona que decide si enviar una fotografía.
signupMode está desaparecido en el protocolo 2, junto con el ajuste que describía: una cuenta solo se crea al canjear una invitación, y openSignup indica si una persona puede solicitar una (§5.8). Un servicio que todavía publica signupMode está hablando la versión 1.
notice es el mensaje del operador para todos los clientes, y es opcional exactamente en el mismo sentido que instance: una instancia que no tiene nada que decir omite el campo, y un cliente que nunca ha oído hablar de él lo ignora.
{
"protocolVersion": 2,
"envelopeVersion": 1,
"serviceVersion": "0.6.0",
"notice": { "text": "This instance moves to a new address on 1 March.", "url": "https://example.org/moving" }
}text es obligatorio cuando el campo está presente; url es opcional y, cuando está presente, es una URL https:/http: absoluta. El servicio limita text a 280 caracteres y se niega a arrancar con uno más largo, porque /health también es la ruta de HEALTHCHECK del contenedor y se sondea de forma continua.
Este es un canal de tipo pull y nada más. No puede saber quién ha leído un aviso: una persona que abre la aplicación lo ve, y una que no, no. No es un mecanismo de notificación y no debes depender de él como tal. El protocolo 2 sí otorga al servicio dos cartas que puede enviar (una invitación y un restablecimiento de contraseña, §5.8 y §5.12), y ninguna de las dos es un canal para nada más: un operador que necesite anunciar algo a sus usuarios mantiene esa lista de contactos por su cuenta, fuera de este servicio.
Un cliente DEBE tratar text y url como entrada hostil. Proceden de cualquier servidor al que haya apuntado el usuario. Renderiza text como texto y nunca como marcado, y sigue url solo tras comprobar su esquema de forma explícita.
5.7 POST /v1/auth/kdf: descriptor KDF previo al inicio de sesión
Sin autenticación, con límite de tasa por IP. Devuelve la sal y los parámetros de Argon2id que un dispositivo necesita para derivar authHash antes de poder iniciar sesión.
POST en lugar de GET, para lo que es una lectura: un GET coloca la dirección en la línea de petición, y de ahí pasa a los registros de acceso, registros de proxy, cabeceras Referer y el historial del navegador. Un endpoint cuyo único propósito es no revelar quién tiene una cuenta no debería dispersar el identificador por el que se le preguntó. Ese argumento ya era válido para un nombre de usuario; al volver a transmitir una dirección por la red, es la diferencia entre una filtración y una lista de correo.
Petición: {"email": "anna@example.org"} · Respuesta 200:
{
"kdfDescriptor": {
"salt": "<base64, 16 bytes>",
"params": { "memorySizeKib": 65536, "iterations": 3, "parallelism": 1 }
}
}Una dirección desconocida también recibe un descriptor. Se deriva de forma determinista como HMAC(serverSecret, email) sobre la dirección canónica (§5.8), por lo que es estable entre peticiones, idéntico en estructura y generado por la misma ruta de código. Solo se devuelve un 400 ante una entrada que no pueda ser una dirección en absoluto. Ni la migración M181 a identificadores ni la M192 de vuelta a direcciones cambiaron una sola línea de la derivación: opera sobre una cadena opaca, y ambas cosas lo son.
Esto importa más de lo que parece. Un inicio de sesión en el que el servidor nunca ve la frase de contraseña requiere un endpoint no autenticado y basado en identificadores que responde antes de la autenticación; implementado de forma ingenua, es una lista libre, silenciosa y no limitable de qué direcciones tienen cuenta. La estabilidad es tan estructural como la forma: un valor ficticio aleatorio sería distinguible si se pregunta dos veces.
Un servidor conforme NO DEBE devolver 404, un cuerpo vacío ni una estructura distinta para una dirección desconocida. También debe:
- Haz el mismo trabajo en ambas ramas. Derivar el valor simulado de forma incondicional, incluso para cuentas que existen y nunca lo usarán, de modo que un acierto y un fallo cuesten la misma búsqueda y el mismo HMAC. Derivarlo de forma perezosa deja una diferencia de tiempos: la respuesta no dice nada, pero el tiempo que tardó en generarse sí.
- Derívalo sobre la dirección canónica, de modo que no se puedan distinguir dos formas de escribir una misma dirección desconocida mediante sus descriptores.
- Limita la tasa por dirección de origen, devolviendo
429conRetry-After. Esta es la otra mitad de la misma defensa: la señal temporal residual es estadística y solo surge tras muchas muestras por dirección. Impedir esas muestras es lo que cierra la brecha. Limitar la tasa según la dirección enviada sería peor que no hacer nada, porque probar muchas direcciones es el ataque, de modo que un contador por dirección otorga una cuota nueva para cada dirección que el atacante quiera probar.
5.8 POST /v1/auth/signup
Sin autenticación, con límite de tasa por IP. Una invitación sigue siendo lo único que crea una cuenta, en cada instancia. En una instancia con instance.openSignup: true, una persona puede SOLICITAR una invitación dirigida a sí misma (§5.8.3); lo que recibe es una invitación ordinaria, canjeada aquí exactamente como una generada por un operador. SIGNUP_MODE es un fallo de arranque, porque no hay ningún modo que configurar: el único interruptor es si existe la puerta de solicitud del §5.8.3.
{
"inviteToken": "si_…",
"authHash": "<base64, 32 bytes>",
"kdfDescriptor": { "...": "..." },
"displayName": "optional or null",
"recoveryAuthHash": "<base64, 32 bytes>",
"recoveryCode": "ABCDE-FGHJK-MNPQR-STVWX-YZ012-3456",
"keyRecords": [
{ "kind": "passphrase", "kdfDescriptor": { "...": "..." }, "wrappedDek": "<base64>" },
{ "kind": "recovery", "kdfDescriptor": null, "wrappedDek": "<base64>" }
],
"healthConsent": { "version": "2026-09-28" }
}healthConsent es obligatorio donde instance.healthConsent no es null y se ignora en cualquier otro lugar (§5.15.1). Su version debe coincidir con el de la instancia byte a byte. Sin él la respuesta es 400 {"error":"health-consent-required"} y no se crea ni se consume nada: la invitación sigue siendo canjeable, así que la persona marca la casilla y envía de nuevo. La comprobación se ejecuta después de todos los demás campos, por lo que una invitación mal formada sigue respondiendo primero el 403 de abajo. Una cuenta creada con él mantiene el consentimiento desde su primera petición, de modo que ninguna ruta de datos la rechaza; un cliente que crea cuentas en dicha instancia (una consola de estudio, una herramienta de siembra) envía también el campo, o a su cuenta se le rechaza cada ruta de datos (§5.15.1).
No existe el campo email, y esa es la clave. La dirección proviene de la fila de la invitación, dentro de la transacción. Un cuerpo no puede reclamar un buzón al que el operador no escribió, que es lo que hace que la propia invitación sea la verificación de la dirección: quien recibió la carta es quien la canjea, así que no hay enlace de confirmación ni nada pendiente de confirmar después. role y dailyAiLimit provienen de la invitación por la misma razón: una cuenta nunca solicita su propio estado.
recoveryAuthHash, recoveryCode y AMBOS registros de claves son obligatorios. Cada uno era opcional en el protocolo 1 y ahora ninguno lo es:
- El cliente ya no muestra el código de recuperación a la persona (§3.1), por lo que una cuenta creada sin depósito es una cuenta que ningún restablecimiento podrá recuperar jamás, y a su titular nunca se le avisó.
- Un registro
passphrasees lo que permite que la frase de contraseña descifre algo; sin él, la cuenta inicia sesión pero no lee nada, y el cliente ya habrá descartado la frase de contraseña para cuando lo descubra. - Un registro
recoveryes lo que permite desencapsular el código en depósito; sin él, un restablecimiento por correo entrega una credencial que se autentica pero no abre nada, algo que se descubre el día en que hace falta.
recoveryCode se valida como Crockford base32 de 20 bytes (32 caracteres una vez eliminados espacios y guiones y convertida la cadena a mayúsculas) y se normaliza a esa forma antes de sellarse. Nunca se registra en los logs, bajo ninguna forma ni en ninguna ruta.
| Estado | Significado |
|---|---|
201 | {"account": AccountView, "tokens": {...}} (§5.15). Siempre se emite una sesión; no queda nada por confirmar. |
400 | Un authHash, recoveryAuthHash o recoveryCode con un formato incorrecto; un descriptor sin una sal de 16 bytes y parámetros Argon2id positivos; o keyRecords sin un tipo. {"error":"health-consent-required"}: la instancia solicita un consentimiento y el cuerpo no incluye ninguno, u otra versión; la invitación NO se consume. |
403 | {"error":"invite-invalid"}: la invitación falta, está mal formada, pertenece a otro servicio, es desconocida, ha expirado, está revocada o ya ha sido canjeada. Siete casos, una sola respuesta. |
409 | Ya existe una cuenta para la dirección de la invitación. La invitación NO se consume. |
429 | Peticiones limitadas. Retry-After en segundos. |
El servidor almacena HMAC-SHA-256(serverPepper, authHash), y la misma construcción sobre recoveryAuthHash, no un segundo KDF lento sobre cualquiera de ellos. El cliente ya pagó el coste intensivo en memoria; aplicar otra función hash en el servidor no aportaría resistencia contra fuerza bruta (un atacante con el hash de autenticación ya se saltó Argon2id) y generaría una denegación de servicio por saturación de inicios de sesión en la que cada intento retiene 64 MiB. Añadir pimienta sigue cumpliendo su propósito: al estar fuera de la base de datos, un volcado de la tabla no se puede reproducir contra una instancia activa ni probarse fuera de línea contra intentos de adivinación.
Todo el envío se confirma en una sola transacción: el canje de la invitación, la fila de la cuenta (con el consentimiento, donde la instancia lo solicite), el depósito en custodia sellado y ambos registros de claves. Cada estado intermedio es un desastre distinto que el usuario no puede ver hasta que intenta leer su propio diario.
El 409 es el único oráculo de enumeración en este protocolo, y el protocolo 2 lo redujo a casi nada. Solo puede acceder quien posea una invitación activa DIRIGIDA exactamente a la dirección que se indica como ocupada, por lo que solo confirma lo que el operador escribió en el mensaje. En el protocolo 1, quien tenía una invitación podía sondear identificadores arbitrarios con ella; ahora no puede, porque la dirección no la elige el usuario. No consume la invitación, así que un operador que haya invitado a alguien dos veces por error no destruye la invitación activa. Justificación completa: SECURITY.md.
5.8.1 Invitaciones
Una invitación es una capacidad de un solo uso y con caducidad dirigida a una sola persona. Incluye la dirección con la que se creará la cuenta, el nombre estimado por el operador, el rol y la cuota diaria de IA. Los tokens desconocidos, con formato incorrecto, ausentes, de otro servicio, caducados, revocados o ya canjeados producen el MISMO 403 y el mismo cuerpo, {"error":"invite-invalid"}: distinguirlos permitiría a quien llama sondear qué tokens existen y revelaría que un token fue real en el pasado.
Qué otorga el canje (2026-09-30), determinado por la fila de la invitación, en este orden: una fila con prueba de escaneo otorga dicha prueba (abajo); una fila generada por un miembro otorga la concesión del acceso de miembros (§5.21), la prueba de escaneo o el par de días, incluso si quien invitó eliminó su cuenta desde entonces, y nada de IA si la instancia ha desactivado las invitaciones de miembros tras enviarse la carta; cualquier otra fila, sea una creación del operador sin prueba o un registro abierto en una instancia que no ofrece ninguna, otorga su cuota diaria como concesión gratuita permanente de la cuenta (freeDailyAiLimit, §5.15) con un dailyAiLimit pagado de 0. Antes, ese último caso escribía un dailyAiLimit sin fecha, formato para el cual el §5.19 ya no otorga nada.
Un token de invitación empieza por si_, y el servicio rechaza cualquier valor que no lo haga. El prefijo vincula el token a este servicio y a este endpoint. Una persona recibe una invitación por correo junto a un token de restablecimiento de contraseña que empieza por sr_; sin los prefijos, ambos son cadenas intercambiables y uno podría enviarse al endpoint equivocado. La comprobación es una comprobación de formato antes de la búsqueda, rechazada con el mismo estado y el mismo cuerpo que cualquier otra invitación incorrecta, de modo que la comprobación no añade ningún oráculo. Los tokens de sesión no llevan prefijo y no cambian.
La generación es POST /v1/admin/invites. Una invitación más antigua en estado PENDING para la misma dirección queda revocada por una nueva, de modo que nunca hay más de una capacidad activa por dirección; una dirección que ya tiene una cuenta no puede ser invitada en absoluto (409). La única excepción es la puerta de solicitud del §5.8.3, que no toca una invitación pendiente del operador o de un miembro en lugar de retirarla por la petición de un extraño.
Una invitación puede incluir una prueba de escaneo (trialScans, §5.19): el registro abierto, una creación de administrador con "trial": true y, donde la instancia lo ejecute, una invitación de miembro escriben el número de la instancia en la fila, y el canje lo copia a la cuenta. Donde la instancia también establece un límite de días (instance.trial.days), la fila también incluye eso, y el canje pone en marcha el reloj: el día del canje no cuenta, y el trialEndsAt de la cuenta es la medianoche local al final del days.º día posterior, en la zona horaria de la instancia (TRIAL_TIME_ZONE, UTC a menos que el operador haya definido una). Un canje el 2026-09-29 en Europe/Berlin, a las 10:00 o a las 23:30, con catorce días, finaliza el 2026-10-14 00:00 hora de Berlín, 2026-10-13T22:00:00.000Z. Es una regla de calendario, no una duración: a través de un cambio de hora, el último día sigue terminando a la medianoche local. La zona se lee en el momento del canje y no se escribe en la fila, porque desplaza el límite entre dos días y nunca el número de días. Una fila creada antes de que existiera el límite de días no incluye ninguno, y la cuenta que crea no tiene fecha de finalización.
Una instancia también puede permitir que un miembro ordinario emita una, según las condiciones de la instancia y sin revelar nada de lo que expone el 409 de este párrafo. Eso es POST /v1/auth/invites, §5.21.
5.8.2 POST /v1/auth/invite-lookup
Sin autenticación, con límite de tasa por IP. Solicitud {"inviteToken": "si_…"}.
{ "email": "anna@example.org", "displayName": "Anna", "expiresAt": "2026-09-11T10:00:00.000Z" }El cliente lo llama cuando una persona abre el enlace de su correo, para que el formulario de registro pueda MOSTRAR la dirección a la que se envió el mensaje en lugar de pedir que la escriban. Ese es el propósito de una invitación nominal: no pueden equivocarse al escribir su propia dirección en una cuenta a la que nadie puede acceder.
No muestra nada más. El rol y la cuota que otorga la invitación se omiten deliberadamente: quien no se ha registrado no tiene por qué saber que el operador le asignó el rol de administrador, y quien tenga el enlace de un tercero tiene todavía menos motivos para saberlo.
Los tokens desconocidos, con formato incorrecto, de otro servicio, caducados, revocados o consumidos son UN 404 {"error":"invite-invalid"}, tras un trabajo idéntico: se calcula el hash del token y se consulta la tabla en todas las ramas. Una búsqueda válida no consume nada, por lo que quien abre el enlace dos veces sigue teniendo una invitación.
5.8.3 POST /v1/auth/signup-request: una persona solicita una cuenta
Sin autenticar. Solo está presente cuando instance.openSignup es true; en cualquier otro lugar la ruta responde con el 404 habitual de ruta desconocida. Una instancia necesita tener el correo configurado para abrirla, ya que el mensaje sirve para verificar la dirección.
Petición: {"email": "anna@example.org", "captchaToken": "…", "plan": "yearly", "tier": "tier-a", "locale": "de"}. captchaToken es obligatorio cuando instance.signupCaptcha está presente y se ignora en caso contrario. plan, tier y locale son opcionales e indican lo que la persona eligió en la pantalla de registro antes de solicitarlo; no se lee nada más del cuerpo.
planes"monthly"o"yearly". Cuando es uno de ellos, el enlace enviado por correo incluye&plan=<key>tras la invitación.tieres el identificador del nivel del facturador al que pertenece el plan (§5.22). El servicio no conoce ninguna lista de niveles, por lo que solo evalúa el estructura y nada más: una etiqueta en minúsculas de 1 a 32 caracteres, con una letra al principio seguida de letras, dígitos y guiones (^[a-z][a-z0-9-]{0,31}$), coincidiendo con exactitud, sin recortar espacios ni cambiar mayúsculas o minúsculas. Si encaja, el enlace enviado por correo incluye&tier=<id>después de&plan=(o después de la invitación cuando no hay plan). El servicio no comprueba que el facturador venda el nivel ni que viniera un plan con él; eso lo decide el cliente cuando lee el enlace.localees uno de los seis idiomas de la instancia (en,de,fr,it,es,tr), la misma lista que acepta ellocalede push (§5.24). Cuando es uno de ellos, el enlace enviado por correo incluye&lang=<code>, y la carta, o la nota para el titular de la cuenta, se redacta en ese idioma. Sin unlocaleválido, ambos textos se redactan en el idioma de la instancia (instance.language).- Un valor ausente,
null, un valor de otro tipo y cualquier otra cadena son ignoran en silencio: nunca un400, y la respuesta que figura abajo no cambia. Paratiereso cubreAlpha(mayúsculas o minúsculas),alpha(espacios de relleno), una etiqueta de 33 caracteres,42, un objeto, un array ya&plan=monthly(que no tiene&ni=para ofrecer el fragmento). No se almacena ningún campo; cada uno viaja en el enlace, de modo que un enlace abierto en otro dispositivo sigue conociendo el plan y el nivel. La nota para el titular de la cuenta no incluye ningún enlace, por lo que no lleva ninguno de ellos; solo su idioma sigue alocale.
Un enlace con los tres contiene <client>/join#server=…&invite=si_…&plan=yearly&tier=tier-a&lang=de.
{}→ 202 con ese cuerpo, vacío y fijo.
Para una dirección nueva, el servicio genera una invitación nominativa ordinaria y la envía por correo: rol member, la duración de invitación por defecto, sin remitente de invitación y los términos de la instancia, que corresponden a su prueba de escaneo si tiene una activa (§5.19) o a nada de IA en caso contrario. El enlace enviado por correo lleva a §5.8.2 y §5.8, sin cambios. La respuesta NO DEBE variar según el estado real de la dirección, como en §5.21: una dirección nueva, una dirección que ya tiene una cuenta, una dirección que ya tiene un mensaje pendiente del operador o de un miembro, y un buzón que ya ha recibido un mensaje hoy son un único 202 con un solo cuerpo. Los mensajes son propios del punto de acceso, nunca la invitación ni la nota de §5.21, que indican que alguien invitó al lector: una dirección nueva recibe un mensaje indicando que ella misma, o alguien usándola, pidió crear una cuenta, con el enlace único, su caducidad y la indicación de que ignorar el correo no cambia nada; quien ya tiene una cuenta recibe una nota diciendo lo mismo y que no se ha creado una segunda cuenta, sin enlace. Un mensaje pendiente de otro punto de acceso no se toca, de modo que un desconocido no pueda revocar la invitación de un operador enviando la dirección. Solo los mensajes cambian.
| Estado | Significado |
|---|---|
202 | {}. Aceptada, sea cual sea el estado real de la dirección |
400 | {"error":"email-invalid"}: no es una dirección. {"error":"email-domain-refused"}: una dirección en un servicio conocido de correo desechable, cotejada con el dominio y cada uno de sus dominios superiores. {"error":"captcha-failed"}: falta el token del captcha o fue rechazado; resuélvelo de nuevo |
404 | La instancia no tiene activado el registro abierto |
429 | Más de cinco peticiones desde una misma dirección de origen en una hora. Retry-After en segundos |
503 | {"error":"captcha-unavailable"}: no se pudo consultar al proveedor de captcha. Inténtalo de nuevo más tarde |
Los 400 describen la petición, nunca las cuentas de la instancia: un dominio no dice nada sobre quién tiene una cuenta, por lo que rechazarlo no actúa como oráculo.
Dos límites de frecuencia. Por dirección de origen, cinco peticiones por hora contando cada intento, lo que limita la acción de un script. Por buzón, un mensaje al día: las peticiones adicionales siguen respondiendo 202 y no envían nada, de modo que el límite no puede revelar por qué direcciones preguntó otra persona. La clave del buzón es el clave de prueba: la dirección canónica (§5.8) eliminando cualquier +tag de la parte local, y para gmail.com y googlemail.com eliminando cada punto y escribiendo el dominio como gmail.com. anna+x@gmail.com, a.n.n.a@gmail.com y anna@gmail.com comparten una misma clave; a.nna@example.org y anna@example.org no.
Un buzón, una prueba, para siempre. Un buzón cuya clave ya canjeó una invitación con escaneos gratuitos, bajo cualquier grafía, o cuya cuenta tuvo una prueba y se eliminó, recibe una invitación cuya prueba es 0: la persona sigue obteniendo una cuenta, y el primer escaneo responde 403 trial-scans-spent. El servicio reconoce el buzón mediante un hash con clave de su clave de prueba, nunca mediante una dirección almacenada (§9.2).
No se registra ninguna dirección, en ninguna rama. Un servidor NO DEBE registrar la dirección enviada ni el token de captcha.
5.9 POST /v1/auth/login
Sin autenticación, con dos limitadores. Ambos cuentan un 401 y nada más, y un inicio de sesión correcto limpia ambos.
- Por IP y correo electrónico. Cinco fallos son libres. Esto ralentiza un ataque de fuerza bruta desde un único origen sin permitir que nadie bloquee la cuenta a una víctima desde otra dirección.
- Por correo electrónico, desde cualquier dirección. Se responden veinte fallos; la vigesimoprimera petición se rechaza durante un minuto, y cada fallo adicional duplica el bloqueo hasta llegar a quince minutos. Un depósito sin fallos durante quince minutos se reinicia. Esto acota a quien intente adivinar rotando direcciones. Una dirección sin cuenta se cuenta del mismo modo, por lo que el rechazo no revela si la cuenta existe. La dirección se normaliza según la búsqueda de cuentas (§2), de modo que otra variante ortográfica corresponde al mismo depósito.
Cualquiera de los dos bloqueos es el mismo 429 con Retry-After, la mayor de las dos esperas.
Solicitud {"email": "...", "authHash": "..."} → 200 {"account": AccountView, "tokens": {...}}.
400 cuando email no es una dirección plausible o authHash no son 32 bytes decodificados en base64: la solicitud nunca llega a la comprobación de credenciales, por lo que este estado no aporta información sobre si la cuenta existe. 401 para una cuenta desconocida y para un hash de autenticación incorrecto, con el texto del cuerpo idéntico y tras un trabajo idéntico, porque la comparación del verificador se ejecuta en ambas ramas contra un valor sustitutivo del mismo tamaño. 403 {"error":"account-suspended"} cuando la cuenta está suspendida, comprobado DESPUÉS de la credencial, de modo que solo a quien ha demostrado ser titular de la cuenta se le explica por qué se le deniega el acceso. 429 cuando se supera el límite de tasa.
5.10 POST /v1/auth/refresh
Sin autenticación (el token de actualización es la credencial). Solicitud {"refreshToken": "..."} → 200 {"tokens": {...}}. Consulta §4.2 para la rotación y la detección de reutilización. Cada fallo da 401, salvo si la cuenta está suspendida, que da 403 {"error":"account-suspended"} y NO consume el token presentado: una suspensión puede levantarse, y quemarlo cerraría la sesión en un dispositivo que se va a recuperar. El estado diferenciado es lo que evita que un cliente entre en un bucle infinito en este endpoint.
5.11 POST /v1/auth/logout
Bearer. 204. Revoca la familia de tokens de quien llama: solo en este dispositivo.
5.12 POST /v1/auth/reset/request y POST /v1/auth/reset/open: el restablecimiento enviado por correo
Estos números se retiraron en 0.5.0, cuando verify-email y request-reset se eliminaron junto con el sistema de correo. El protocolo 2 los reutiliza, y reutilizarlos en lugar de tomar dos nuevos es una decisión deliberada: lo que figura aquí ahora es la respuesta a lo que figuraba antes, y quien siga una referencia a §5.12 desde un comentario del código fuente debe llegar a la resolución y no a una referencia obsoleta.
§5.12.1 POST /v1/auth/reset/request: sin autenticación, con límite de tasa por (IP, correo), NUNCA se reinicia tras una operación correcta.
Petición {"email": "anna@example.org"} → 202 {}, siempre.
202 tanto para una dirección conocida como para una desconocida o con formato erróneo. Un servidor conforme DEBE realizar el mismo trabajo en ambas ramas antes de responder: buscar la dirección, emitir el token, generar su resumen criptográfico. La escritura en el almacén y el envío, que solo corresponden a una dirección conocida, NO DEBEN retrasar la respuesta: el servidor de referencia los ejecuta tras enviar el 202 (desde 2026-09), y cualquier fallo en ese punto se registra, nunca se devuelve. Dicha simetría constituye todo el argumento antienumeración, y es el que este documento registraba previamente como FALTRANTE: el antiguo request-reset ejecutaba el trabajo costoso solo para direcciones existentes, revelando con sus tiempos lo que ocultaba en su cuerpo. Antes de 2026-09, este servidor aún esperaba una escritura y un envío únicamente en la rama conocida.
Nunca se devuelve un 400, ni siquiera para un valor que a todas luces no sea una dirección: el código de estado se convertiría en un oráculo gratuito sobre la estructura de las direcciones que alberga esta instancia, y no hay nada útil que quien llama pueda hacer con esa distinción.
El token consiste en 32 bytes aleatorios, base64url, con el prefijo sr_. Solo se almacena su resumen SHA-256 en password_resets, con un TTL de 60 minutos. Un token activo por cuenta: una nueva solicitud marca como consumidas todas las filas anteriores aún sin consumir en la misma transacción, evitando que quien revise mensajes antiguos en su bandeja de entrada pueda canjear la carta de ayer. Dichas transacciones se serializan por cuenta (un bloqueo de fila sobre la cuenta), de modo que solicitudes superpuestas dejan exactamente un token activo.
Cuando el correo no está configurado, el envío no hace nada y el endpoint sigue respondiendo 202. En ese caso, los usuarios de una instancia autohospedada no tienen restablecimiento; el recurso para quien opera el servidor es POST /v1/admin/accounts/:id/reset-mail, que devuelve el enlace.
§5.12.2 POST /v1/auth/reset/open: no autenticado, con límite de tasa por IP.
Petición {"resetToken": "sr_…"} → 200:
{ "email": "anna@example.org", "recoveryCode": "ABCDEFGHJKMNPQRSTVWXYZ0123456789" }El token se consume en la MISMA sentencia que lo lee (UPDATE … WHERE consumed_at IS NULL AND expires_at > now RETURNING), por lo que dos peticiones con el mismo token no pueden recibir respuesta ambas. Tokens desconocidos, usados o caducados devuelven UN 404 {"error":"reset-invalid"} tras un trabajo idéntico.
ESTE ENDPOINT NO ESCRIBE NADA EN LA CUENTA, y esa frase resume toda la diferencia respecto al flujo que documentaba el §5.13. Devuelve el código de recuperación que el servidor ya guarda en custodia (§3.1); luego, el cliente ejecuta con él el proceso ORDINARIO del §5.14 recover-rotate: probar el código, definir una nueva frase de contraseña, reenvolver la DEK, generar un código nuevo y volver a custodiarlo, todo en una sola transacción. Sin los registros de claves, lo que esto devuelve es una cadena. Un cambio futuro que permitiera a esta ruta tocar un verificador o un registro de claves habría reconstruido el flujo de toma de control de cuentas que eliminó ADR-0004, tuviera el nombre que tuviese.
Lo que cuesta, explicado explícitamente en vez de dejarlo implícito. El restablecimiento funciona porque quien opera el servidor custodia el código de recuperación. Lee el §3.1 y docs/adr/0005-organization-accounts-and-escrowed-recovery.md antes de decidir confiar en una instancia alojada; la decisión depende del operador, no de la criptografía.
5.13 POST /v1/auth/verify-email: eliminado en 0.5.0 y no restaurado
Se eliminó con el gestor de correo en 0.5.0, y el protocolo 2 no lo recupera aunque este servicio vuelva a enviar correo.
No queda nada por confirmar: una cuenta se crea al canjear una invitación DIRIGIDA a un buzón de correo (§5.8), por lo que la persona que recibió el mensaje es quien se registró. La invitación constituye la verificación, y un segundo enlace solo exigiría demostrar dos veces lo que ya quedó demostrado una vez.
5.14 POST /v1/auth/recover, POST /v1/auth/recover-rotate y POST /v1/auth/change-passphrase
El autenticador con código de recuperación y las dos rotaciones de credenciales. recover-rotate y change-passphrase admiten la misma estructura de envío porque hacen lo mismo; solo cambia la prueba.
POST /v1/auth/recover: no autenticado, con límite de tasa por IP y correo. Petición {"email": "...", "recoveryAuthHash": "<base64, 32 bytes>"} → 200 {"account": AccountView, "tokens": {...}}.
Lo que se devuelve es una sesión ordinaria, deliberadamente no una inferior: quien posee el código de recuperación es quien posee la cuenta por diseño, y un token restringido de "modo de recuperación" añadiría una segunda superficie de autorización sin aportar ninguna propiedad que el código no ofrezca ya.
// POST /v1/auth/recover-rotate: unauthenticated, proof is the recovery code
{
"email": "...",
"recoveryAuthHash": "<the current recovery proof>",
"newAuthHash": "<new>",
"kdfDescriptor": {...},
"keyRecords": [ ... ],
"newRecoveryAuthHash": "<a new recovery proof>" | null, // optional: rotate the code too
"recoveryCode": "<the new code, in the clear>" // REQUIRED whenever newRecoveryAuthHash is present
}
// POST /v1/auth/change-passphrase: bearer, proof is the current passphrase
{ "currentAuthHash": "...", "newAuthHash": "...", "kdfDescriptor": {...}, "keyRecords": [ ... ] }Las entradas de keyRecords son {"kind": "passphrase" | "recovery", "kdfDescriptor": {...} | null, "wrappedDek": "<base64>"}, como máximo una por tipo, y siguen las mismas reglas del §5.4 (el descriptor de un registro recovery debe ser null; el de un registro passphrase no debe serlo).
change-passphrase devuelve 200 {"tokens": {...}}. recover-rotate devuelve 200 {"account": AccountView, "tokens": {...}}, porque quien llama llegó sin sesión y necesita saber en qué cuenta acaba de reingresar. Ambos devuelven un par nuevo.
Todo el envío se aplica de forma atómica. El nuevo verificador, el nuevo descriptor KDF de la cuenta, un nuevo verificador de recuperación opcional, la custodia resellada, los registros de claves insertados o actualizados, la revocación de cada sesión activa y el nuevo par de quien llama: o se confirman todos o no lo hace ninguno. Esto no es un detalle de implementación. Cada estado intermedio es un desastre particular que el usuario no percibe hasta que intenta leer su propio diario: un verificador sin su registro reenvuelto inicia sesión pero no descifra nada, un registro sin su verificador ni siquiera puede iniciar sesión y un verificador de recuperación rotado sin su registro deja un código que autentica pero luego no desenvuelve nada.
keyRecords debe estar presente, incluso como []. Una clave ausente es un 400, por la misma razón por la que se exige expectedUpdatedAt en el §5.4: el silencio nunca debe interpretarse como consentimiento en una ruta que puede dejar datos aislados.
Los tipos no enviados no se tocan. Un cambio de frase de contraseña reenvuelve la DEK bajo una nueva KEK_p; el registro recovery sigue envolviendo la misma DEK sin cambios y conserva su validez.
Hay cuatro reglas que se aplican únicamente a recover-rotate:
- Se requiere un registro de clave
passphrase, y[]es un400. A diferencia de un cambio de frase de contraseña, esta ruta cambió necesariamenteKEK_p, por lo que aceptar un envío sin el reempaquetado crearía una cuenta que inicia sesión a la perfección pero no descifra nada. - Rotar el código de recuperación es todo o nada, y en el protocolo 2 esa es una regla de TRES partes.
newRecoveryAuthHash, un registro de claverecoveryyrecoveryCodedeben llegar juntos o no llegar; cualquier subconjunto es un400. Cada pieza que falta es su propio desastre: un verificador sin el registro deja un código que autentica pero no desempaqueta nada; un registro sin el verificador deja uno que desempaqueta pero no puede iniciar sesión; y un ESCROW que aún conserva el código antiguo convierte el siguiente restablecimiento enviado por correo (§5.12) en una carta con una credencial que la cuenta ya no acepta, descubierto justo el día en que se necesita. - La escritura es un compare-and-swap sobre el verificador de recuperación con el que coincidió la prueba, reafirmado dentro de la transacción. No es la autenticación, que ya ocurrió; es lo que impide que dos recuperaciones simultáneas sobrescriban una credencial cuando ya se le comunicó al usuario que es suya.
- Un fallo, cuatro causas. Una dirección desconocida, una cuenta que nunca configuró un código de recuperación, un código incorrecto y una rotación que perdió esa carrera de compare-and-swap responden
401con un texto idéntico, tras un trabajo idéntico. Una condición de carrera no debe distinguirse de un intento fallido, y la falta de un segundo autenticador no debe distinguirse de una cuenta inexistente. Una cuenta SUSPENDED es la única excepción: responde403 {"error":"account-suspended"}, y solo después de que la prueba tuvo éxito.
change-passphrase se limita por cuenta, desde cualquier dirección, en el depósito que delete, rotate-dek y la sobrescritura de registros de clave comparten (§5.4): quien la invoca ya posee un token, y currentAuthHash constituye un intento que dicho token no puede validar. Una cuenta bloqueada recibe 429 con Retry-After; una operación correcta vacía el depósito.
Ambos endpoints de recuperación comparten uno depósito de límite por (IP, correo electrónico), y ninguno de los dos lo restablece en caso de éxito. Autentican el mismo secreto, por lo que una cuota independiente para cada uno reduciría a la mitad el coste de adivinarlo, y una recuperación legítima ocurre una sola vez, de modo que ningún cliente legítimo necesita que le devuelvan su cuota. POST /v1/auth/reset/request se limita bajo la misma regla.
Lo que una rotación puede y no puede hacer. Restaura inicio de sesión. No puede restaurar datos, porque el servidor nunca tuvo una clave. Un change-passphrase que envía keyRecords: [] deja una cuenta funcional cuyo blob queda permanentemente indescifrable, que es exactamente la razón por la que recover-rotate rechaza ese envío de inmediato. Un cliente compatible debe advertirlo, en esos mismos términos, antes de que el usuario confirme el proceso.
Si se pierde la frase de contraseña, §5.12 es el camino de retorno, y funciona porque el operador custodia el código en escrow (§3.1). El protocolo 1 decía aquí que perder la frase de contraseña y el código juntos destruía una cuenta para siempre, sin que nadie pudiera abrirla. Esa afirmación ahora solo es cierta para una instancia cuyo SERVER_SECRET también se haya perdido, razón por la cual ese secreto debe respaldarse JUNTO CON la base de datos, y por la que perderlo resulta peor de lo que parece.
La versión honesta de la antigua advertencia apunta al operador, no a las matemáticas. Una instancia administrada puede abrir cualquier cuenta que contenga. Una instancia autohospedada es su propio operador, así que la promesa anterior se mantiene en el caso personal. Un cliente compatible indica con cuál de las dos se está comunicando antes de que una persona guarde un diario en ella.
5.15 GET /v1/auth/account, PATCH /v1/auth/account y POST /v1/auth/delete
Las tres al portador.
AccountView es la ÚNICA estructura de cuenta en este protocolo. Se devuelve desde POST /signup, POST /login, GET /account, PATCH /account, POST /recover, POST /recover-rotate y los endpoints de cuentas de administración, así que un cliente tiene exactamente un descodificador de cuentas:
{
"id": 1,
"email": "anna@example.org",
"displayName": null,
"role": "member",
"dailyAiLimit": 200,
"aiUsedToday": 3,
"allowanceExpiresAt": null,
"freeDailyAiLimit": 0,
"capabilities": null,
"trialScans": { "granted": 10, "left": 7 },
"trialEndsAt": "2026-09-19T00:00:00.000Z",
"suspendedAt": null,
"invitesLeft": 5,
"invitesNeedAPlan": false,
"healthConsent": { "version": "2026-09-28", "at": "2026-09-04T10:11:12.000Z" },
"createdAt": "2026-09-04T10:11:12.000Z"
}No contiene nada secreto ni puede contenerlo: ningún verificador, ningún descriptor KDF, ninguna DEK empaquetada, ningún escrow, ningún token. Cada campo es información propia de la persona o el estado que le concedió un operador. aiUsedToday cuenta para dailyAiLimit en el día UTC actual; suspendedAt es non-null mientras cada llamada autenticada responde 403 account-suspended.
invitesLeft es cuántas invitaciones puede enviar aún esta cuenta mediante POST /v1/auth/invites (§5.21), o null cuando ese límite no le aplica. null, nunca 0, para un administrador: 0 se interpreta como "las has usado todas", y un administrador no ha usado ninguna, porque las crea a través de la API de administración, exenta del límite y de la regla de reinvitación. Una instancia con instance.memberInvites: false envía null por la misma razón: allí no hay límite, porque no hay ruta, y un 0 anunciaría una cuota agotada que nunca existió. Un cliente puede mostrarlo y NO DEBE autorizar basándose en él; el servicio rechaza una sexta creación sin importar lo que el cliente crea.
invitesNeedAPlan es true cuando invitesLeft es 0 solo porque la cuenta es una prueba de escaneo que nadie ha pagado todavía (§5.21), y false en cualquier otro caso, incluidos un administrador y una instancia con instance.memberInvites: false. Una cuenta es dicha prueba cuando tiene trialScans y su allowanceExpiresAt es null o ya pasó; un allowanceExpiresAt futuro, que es lo que escribe el facturador al pagar, vuelve a abrir las invitaciones y mantiene trialScans. El campo es acumulativo: un cliente que lo ignora lee invitesLeft: 0, lo cual sigue siendo cierto, y un cliente que lo lee puede indicar que las invitaciones se abren con un plan en lugar de decir que ya se usaron todas.
allowanceExpiresAt es un instante ISO o null, y null significa que la cuota de IA no tiene fecha de fin, que es lo que mantiene una instancia autohospedada. A partir de ese instante, el proxy de §5.19 responde 403 allowance-expired. Controla la IA y nada más: la sincronización sigue funcionando tras esa fecha, porque el diario pertenece a la cuenta y un dispositivo nuevo debe poder descargarlo. Un cliente puede mostrar la fecha y no debe autorizar basándose en ella; la regla reside en el proxy.
freeDailyAiLimit es la concesión gratuita permanente de la cuenta: unidades de IA por día UTC (§5.19) que se aplican siempre que no haya un periodo de pago activo, sin fecha de finalización y sin límite de escaneos. 0 es ninguna. El orden del proxy (§5.19) es un periodo de pago activo (allowanceExpiresAt en el futuro, en dailyAiLimit), luego esta concesión y después la prueba de escaneo, de modo que una cuenta con una concesión gratuita recurre a ella cuando termina un periodo de pago en lugar de perder la IA. Un cliente que muestra un límite diario muestra este siempre que no haya un periodo de pago activo. El valor en la vista es el que aplica el proxy: el límite propio de la cuenta cuando está por encima de 0; de lo contrario, el valor por defecto de la instancia (§5.19); de lo contrario, 0. Solo un operador lo escribe (§5.20); la credencial del facturador no puede. Un cliente puede mostrarlo y NO DEBE autorizar sobre él. El campo es aditivo: un cliente que lo ignore descodifica la vista sin cambios.
capabilities es la lista de capacidades contra las que el proxy comprueba esta cuenta (§5.19, «Capacidades»): el registro propio de la cuenta; si no, el defaultCapabilities de la instancia (§5.6); si no, null. null significa sin comprobación, no «nada»: todas las funciones están abiertas, que es el caso de cada cuenta en una instancia que no define ningún valor por defecto ni ningún registro. [] significa ninguna función en absoluto. Un cliente que encuentre null DEBE tratar cada función como disponible. Un cliente puede mostrarlo y NO DEBE autorizar sobre él: el proxy responde 403 capability-required. El campo es aditivo: un cliente que lo ignore descodifica la vista sin cambios. Las vistas de administración y del facturador contienen en su lugar el registro propio de la cuenta (§5.20), donde null significa ningún registro.
trialScans es {"granted": n, "left": n} para una cuenta con una prueba de escaneo, y null para una sin ella, que es cualquier cuenta en una instancia que no ejecute ninguna. left es granted menos los escaneos usados, nunca por debajo de 0. Un cliente puede representarlo y NO DEBE autorizar sobre él: el proxy cuenta (§5.19), left es una instantánea tomada al construir esta vista, y cada respuesta intermediada lleva el número actualizado en X-Trial-Scans-Left. Un allowanceExpiresAt futuro levanta la restricción de escaneo, por lo que una cuenta de pago aún puede incluir este campo.
trialEndsAt es un instante ISO, o null para un periodo de prueba de escaneos sin fecha de finalización y para una cuenta sin prueba alguna. Se escribe una vez, cuando la prueba comienza con el canje (§5.8), en una instancia que define un límite de días, y es siempre una medianoche local en la zona horaria de esa instancia; una cuenta creada antes de que su instancia definiera uno conserva null, y su prueba nunca se acorta a posteriori. A partir de ese instante el proxy responde 403 trial-expired (§5.19), a menos que los escaneos gratuitos se hayan agotado antes. Un cliente puede representarlo y NO DEBE autorizar sobre él. Una allowanceExpiresAt futura lo anula exactamente igual que anula el recuento de escaneos. El campo es acumulativo: un cliente que lo ignore descodifica la vista sin cambios.
healthConsent es {"version": "<v>", "at": "<ISO instant>"} para una cuenta que tiene registrado el consentimiento de datos de salud, y null para una que no lo tiene: cada cuenta creada antes de que su instancia lo solicitara, y cada cuenta en una instancia que no solicita ninguno. version es el texto que la persona aceptó y at es el propio reloj del servicio en ese momento. Un cliente compara version con instance.healthConsent.version (§5.6) y pregunta una vez cuando difieren o cuando esto es null en una instancia que lo solicita (§5.15.1). El campo es acumulativo: un cliente que lo ignora descodifica la vista sin cambios.
Los endpoints de cuentas de administración devuelven la misma estructura más dos campos de operador, blob y keyRecordKinds (ADR-0001). Por tanto, un cliente que descodifica un AccountView a partir de una respuesta de administración funciona sin cambios y lee dos campos que no solicitó.
GET /v1/auth/account → 200 {"account": AccountView}.
PATCH /v1/auth/account toma {"displayName": string | null} → 200 {"account": AccountView}. La clave DEBE estar presente, incluso como null: una clave ausente es un 400, la misma regla que siguen keyRecords y expectedUpdatedAt, porque un PATCH que no hizo nada en silencio por un nombre de campo mal escrito es un cambio que el cliente cree haber aplicado.
Ese es el único campo que una cuenta puede cambiar sobre sí misma. email es la identidad y solo se modifica mediante un operador; role y dailyAiLimit son estados que una cuenta no debe poder aumentarse por sí misma; todo lo relativo a la autenticación se gestiona a través de §5.14.
POST /v1/auth/delete toma {"authHash": "..."} y devuelve 204. Se requiere volver a autenticarse aunque quien llama ya tenga un token válido: una sesión olvidada en un dispositivo compartido no debe ser suficiente para destruir irreversiblemente los datos de alguien. Un authHash incorrecto es 401; los intentos se limitan por cuenta en el depósito que describe el §5.4, y una cuenta bloqueada recibe 429 con Retry-After. En una instancia con facturador (§5.22), una vez pasadas ambas comprobaciones, el servicio envía POST <PLANS_UPSTREAM_URL>/erase con X-Plans-Secret y X-Account-Id y sin cuerpo, de modo que el facturador cancela las suscripciones de la cuenta antes de que esta desaparezca. Espera cinco segundos como máximo y elimina responda lo que responda el facturador; DELETE /v1/admin/accounts/:id hace lo mismo. En una instancia cuya API de correo es Pigeon (MAIL_API_URL termina en /v1/emails), una vez eliminada la cuenta el servicio también envía POST <base>/v1/recipients/erase con {"email": "<address>"} y la clave Bearer de la API de correo, para que Pigeon borre cada copia que conserve de la dirección. Esa llamada nunca cambia la respuesta: realiza hasta tres intentos, retrasa el 204 dos segundos como máximo, continúa cualquier intento que quede pendiente tras la respuesta y, en caso de fallo definitivo, registra una línea con un recuento y un código de estado o de error y ninguna dirección. Nada almacena la dirección para reintentarlo más tarde; el límite de retención de Pigeon actúa como salvaguarda. DELETE /v1/admin/accounts/:id hace lo mismo.
La eliminación borra la cuenta y, en cascada, cada blob, registro de clave, token de restablecimiento y fila de uso que le pertenezcan. No hay borrado lógico ni periodo de gracia. Esta es la vía de eliminación en autoservicio, y es completa por diseño y no gracias a una tarea de limpieza que alguien deba acordarse de ejecutar.
La misma transacción también cancela todas las invitaciones enviadas por la cuenta que sigan pendientes (§5.21). Una invitación enviada conserva las condiciones del acceso de quien la emitió; dejar una pendiente tras su marcha permitiría canjearla, y en accesos con prueba por días su cuota empieza únicamente al momento del canje.
En una instancia que ejecuta una prueba de escaneo, la misma transacción también elimina la dirección y el nombre de cada fila de invitación sobre ese buzón y, cuando la cuenta tenía una prueba, conserva un hash unidireccional con clave del buzón para que la regla de una prueba por buzón del §5.8.3 sobreviva a la eliminación. El hash se conserva durante TRIAL_HASH_RETENTION_DAYS (365 por defecto) tras la eliminación y luego se elimina mediante un barrido cada hora, tras lo cual el mismo buzón puede volver a tener una prueba. Una instancia que no concede ninguna prueba de escaneo no conserva ningún hash. No se conserva nada más sobre la persona (§9.2). La base y el periodo se detallan en docs/adr/0010-the-mailbox-hash-has-a-basis-and-an-end.md.
5.15.1 POST /v1/auth/account/health-consent: consentimiento explícito para datos de salud
Bearer. Presente solo donde instance.healthConsent no es null; en cualquier otro lugar, la ruta responde con el 404 habitual de ruta desconocida, para todo el mundo, con sesión iniciada o no.
Por qué una instancia lo solicita. Un diario son datos de salud: comidas, peso, ayuno. En una instancia administrada, el operador tiene en custodia el código de recuperación (§3.1, ADR-0005) y, por tanto, puede abrir el diario, y su aviso de privacidad indica el consentimiento explícito según el art. 9(2)(a) del RGPD como base jurídica. El operador debe poder demostrar que se otorgó el consentimiento, cuándo y con qué texto. Una instancia en Autoalojamiento cuyo operador es la propia persona no pide nada a nadie y deja HEALTH_CONSENT_VERSION sin definir.
Dos formas en que un consentimiento llega a una cuenta, una sola versión. El operador define HEALTH_CONSENT_VERSION, una cadena corta de 1 a 32 letras, dígitos, ., _ o - (una fecha como 2026-09-28), y /health la publica como instance.healthConsent.version.
- Una cuenta nueva da su conformidad en el paso de creación de la cuenta:
POST /v1/auth/signuplleva"healthConsent": {"version": "<v>"}y lo registra en la misma instrucción que la cuenta (§5.8). - Una cuenta existente sin él, o con una versión anterior, recibe la pregunta una vez y da su conformidad aquí.
Obligatorio en cada ruta de datos. Hasta que acepte, a una cuenta que no tenga la versión actual de la instancia se le rechaza 403 {"error":"health-consent-required"}, el mismo cuerpo en cada ruta, tras la comprobación del portador y antes de guardar, contar o enviar nada:
| Rechazado para una cuenta sin el consentimiento | Motivo |
|---|---|
Cada ruta bajo /v1/sync excepto las dos lecturas de abajo: el push de blobs, escrituras y eliminaciones de registros de claves, rotate-dek, elementos compartidos, investigación | Guardan o transmiten el diario |
POST /v1/chat/completions (§5.19), tras la suspensión y antes de la cuota | El cuerpo es una foto del plato |
POST /v1/feedback (§5.25), /v1/pulse/* (§5.23), /v1/push/* (§5.24) | Cada una guarda algo extraído del diario |
/v1/plans/* (§5.22), excepto el GET /v1/plans/prices anónimo | Uso de la cuenta, sin aceptar ni marcharse |
PATCH /v1/auth/account, POST /v1/auth/invites (§5.21) | Uso de la cuenta, sin aceptar ni marcharse |
POST /v1/auth/change-passphrase (§5.14) | Su segunda mitad reescribe el compartimento en el blob, lo cual se rechaza, por lo que el cambio completo espera |
| Abierto para una cuenta sin el consentimiento | Motivo |
|---|---|
POST /v1/auth/login, POST /v1/auth/refresh, POST /v1/auth/logout | Inicio y cierre de sesión |
GET /v1/auth/account | El cliente lo lee para saber que debe preguntar |
POST /v1/auth/account/health-consent (esta ruta) | Donde la cuenta acepta |
POST /v1/auth/delete (§5.15) | La eliminación es la vía para rechazar o retirar un consentimiento |
GET /v1/sync/blob (§5.2), GET /v1/sync/key-records (§5.3) | Su propia copia. Iniciar sesión en un nuevo dispositivo requiere ambos antes de que un cliente pueda solicitarlo, y una exportación en un nuevo dispositivo es la descarga. No se almacena nada |
/health, GET /v1/plans/prices, POST /v1/legal/declarations, las rutas no autenticadas de /v1/auth/* (§5.7 a §5.14) | No hay sesión, por lo que no hay cuenta que consultar |
/v1/admin/* (§5.20) | La propia credencial del operador; las rutas de diario de un administrador se rechazan como las de cualquiera |
El consentimiento a una redacción anterior se rechaza igual que si no hubiera ninguno. El rechazo se levanta en la solicitud inmediatamente posterior después de que esta ruta responda 200, con el mismo token de acceso: el servicio lee la fila de la cuenta en cada solicitud autenticada, igual que hace ante una suspensión, y el consentimiento junto con ella. En una instancia donde instance.healthConsent sea null, nada de esto rechaza nada.
El emisor de notificaciones push lee la tabla de suscripciones en lugar de una ruta, por lo que aplica la misma regla por su cuenta: un dispositivo suscrito antes de que la instancia lo solicitara conserva su fila y no recibe nada hasta que la cuenta dé su consentimiento (§5.24).
Petición: {"version": "2026-09-28"} → 200 {"account": AccountView} (§5.15), con healthConsent establecido.
| Estado | Significado |
|---|---|
200 | {"account": AccountView}. El consentimiento queda registrado, ahora o desde una llamada previa con la misma versión |
400 | {"error":"health-consent-required"}: el cuerpo no contiene la cadena version, o no es la que publica /health; no se registra nada |
401 | No hay un token de acceso válido |
403 | {"error":"account-suspended"} |
404 | La instancia no solicita ningún consentimiento |
Cuatro reglas que un servidor conforme DEBE cumplir:
- La versión almacenada es la de la instancia, nunca la del cliente. La cadena
versiondel cuerpo se compara byte por byte con la de la instancia, sin recortar espacios ni cambiar mayúsculas y minúsculas, y lo que se escribe es la cadena de la instancia. - El instante lo marca el reloj del servidor. El cliente no envía ninguna hora, y no se leería ninguna si la enviara.
- Idempotente, y el primer instante es el que cuenta. Una segunda llamada con la versión ya registrada no cambia nada y responde el mismo
200;atse mantiene en el momento en que la persona dio su consentimiento inicial. Una versión distinta reemplaza a ambas, de modo que una nueva redacción lleva su propio instante. - Retirar el consentimiento equivale a eliminar la cuenta. Ninguna ruta elimina un consentimiento. La persona que lo retira elimina la cuenta (
POST /v1/auth/delete, §5.15), lo que borra el diario y el consentimiento junto con la fila. Quien opera el servidor lee el consentimiento en la vista de administración de la cuenta y ninguna ruta de administración lo escribe: un consentimiento que un operador pudiera fijar en nombre de otra persona no probaría nada.
health-consent-required es el único rechazo en cada comprobación de consentimiento, con dos estados: 400 al crear la cuenta y en esta ruta, donde el cliente vuelve a mostrar su casilla de verificación, y 403 en una ruta de datos, donde el cliente dirige a la persona hacia ella. Cambiar HEALTH_CONSENT_VERSION vuelve a pedir el consentimiento a todas las cuentas, y desde ese momento cualquier ruta de datos rechaza a las cuentas que aceptaron la redacción anterior hasta que acepten la nueva; un operador solo lo cambia cuando cambia la redacción.
5.16 Comparticiones: /v1/sync/shares y /v1/sync/shared (ADR-0002)
Presente solo cuando el despliegue define SYNC_SHARING. Sin esto, cada ruta que aparece abajo responde el 404 habitual de ruta desconocida a cualquiera que llame, tenga credenciales o no; el terminador está montado antes de la autenticación, por lo que una instancia no configurada no se distingue de una donde la funcionalidad nunca se programó.
Ambas partes identifican una compartición mediante el id de cuenta de la contraparte, nunca con un id sintético de compartición: la identidad estable de una compartición es el par (otorgante, receptor), y eso es lo que sobrevive a una rotación de la DEK.
Lado del otorgante.
| Verbo | Ruta | Notas | ||
|---|---|---|---|---|
PUT | /shares/:granteeAccountId | `{"wrappedDek": "<base64>", "recipientKeyFingerprint": "<string>", "expectedUpdatedAt": "<iso>" \ | null}. CAS exactly as §5.4: null asserts no share exists yet, any other value asserts the row last read had this updatedAt, and an **absent** key is a 400. 409 returns {"currentUpdatedAt": "<iso>" \ | null}`. |
GET | /shares | Las propias concesiones del otorgante. Nunca devuelve wrappedDek: un blob dirigido a la clave de otra persona no sirve de nada aquí, así que no viaja adonde nadie lo necesita. | ||
DELETE | /shares/:granteeAccountId | 204, idempotente. Un borrado definitivo; no hay tombstone. |
Lado del receptor.
| Verbo | Ruta | Notas |
|---|---|---|
GET | /shared | Comparticiones dirigidas a quien llama, cada una con su wrappedDek; solo quien llama puede abrirlo. |
GET | /shared/:grantorAccountId/blob | {"grantorAccountId": <int>, "blobVersion": <int>, "envelopeVersion": <int>, "ciphertext": "<base64>", "createdAt": "<iso>"}. grantorAccountId es obligatorio: los AAD de la §3.2 lo vinculan, por lo que un receptor sin él no puede descifrar en absoluto. |
DELETE | /shared/:grantorAccountId | 204, idempotente. Permite al receptor descartar una compartición dirigida a él. |
- La superficie del receptor no tiene verbos de escritura contra el otorgante, y sirve únicamente la fila de compartición propia de quien llama, el blob actual del otorgante y
grantorAccountId. Nunca los registros de clave del otorgante, el descriptor KDF, el verificador, el depósito en custodia, el correo electrónico ni el nombre visible. Un receptor que pudiera obtener la DEK envuelta derecoverydel otorgante estaría a un solo código de recuperación descifrado por fuerza bruta de tener autoridad de rotación sobre esa cuenta. - Solo el blob actual. El anillo de versiones retenidas es un mecanismo de recuperación para el propietario, no una cronología para el receptor.
- La autorización es una lectura de fila en vivo en cada solicitud, nunca se almacena en caché. Eso es lo que hace que un
DELETEsurta efecto en la llamada inmediatamente posterior. - Si es desconocido, ajeno o si nunca se ha enviado un push, la respuesta siempre es mismo
404. La ausencia de un recurso compartido no debe confirmar que una cuenta existe.
5.17 POST /v1/sync/rotate-dek: rotación atómica de DEK (ADR-0002)
Bearer, como propietario de la cuenta. Un envío, una transacción:
{
"blob": { "baseVersion": 3, "envelopeVersion": 1, "ciphertext": "<base64>" },
"keyRecords": [{ "kind": "passphrase", "kdfDescriptor": { "...": "..." }, "wrappedDek": "<base64>" }],
"newRecoveryAuthHash": "<base64, 32 bytes>",
"recoveryCode": "ABCDE-FGHJK-MNPQR-STVWX-YZ012-3456",
"currentAuthHash": "<base64, 32 bytes>",
"shares": [{ "granteeAccountId": 7, "wrappedDek": "<base64>", "recipientKeyFingerprint": "<string>" }]
}El cliente genera una nueva DEK, vuelve a cifrar con ella su snapshot completo, la vuelve a envolver con ambas KEK y la vuelve a envolver para cada recurso compartido que conserva. El servicio almacena el resultado todo o nada.
Presente en cada despliegue, a diferencia del §5.16. La rotación no forma parte de la superficie para compartir: reescribe el propio blob de quien llama y sus dos registros de claves propios, filas que existen en cada cuenta en todas partes, y es la respuesta a cualquier sospecha de que se filtró una DEK (una copia de seguridad restaurada, un dispositivo perdido) en una instancia que nunca ha compartido nada. Condicionar el único mecanismo que puede retirar una DEK comprometida tras un flag no relacionado dejaría a dicho operador sin forma de retirarla.
currentAuthHashes OBLIGATORIO, y un token de portador por sí solo nunca rota. Es la rama de autenticación de la frase de contraseña actual (§3.1), validada como la validachange-passphrase. Su ausencia o formato incorrecto devuelve un400que lo indica; si no coincide, devuelve401 {"error":"current passphrase is incorrect"}y no se escribe nada. Como una rotación escribe el verificador de recuperación quePOST /v1/auth/recoveracepta, antes de este campo un token sustraído podía fijar su propio código e iniciar sesión indefinidamente con él, sin importar lo que el propietario hiciera después con su frase de contraseña. Los intentos se limitan por cuenta en el depósito descrito en el §5.4 (429conRetry-After). La transacción vuelve a comprobar que el verificador de la frase de contraseña de la cuenta siga siendo el comprobado; un cambio confirmado de la frase de contraseña en el intervalo convierte la rotación en un401, y no se escribe nada.- Todas las demás sesiones se revocan en la misma transacción. La familia de tokens de quien realiza la llamada se conserva, de modo que el dispositivo que rota la clave mantiene la sesión abierta; el resto de tokens
accessyrefreshde la cuenta dejan de funcionar. La rotación se ejecuta cuando se sospecha la filtración de una clave, y cualquier sesión superviviente supondría dicha filtración. - Todo o nada, en una sola transacción de base de datos. prohibición 8 de ADR-0002: una rotación es atómica o no existe, y ninguna secuencia de endpoints que confirmen cambios individualmente puede documentarse o utilizarse como tal. Una aplicación parcial es el bloqueo de tipo «inicia sesión sin problemas, no descifra nada» que el §5.14 ya se niega a permitir, con un participante más: un registro de clave vuelto a envolver mientras la escritura del blob perdió su CAS deja aislado al propietario, y un recurso compartido vuelto a envolver mientras la escritura del blob perdió su CAS deja aislado al profesional de la salud.
blobse evalúa mediante compare-and-swap enbaseVersion, exactamente como en el §5.1. Un valor desactualizado da como resultado un409{"currentVersion": n}y no se escribe nada en absoluto.newRecoveryAuthHashYrecoveryCodeson OBLIGATORIOS, y un envío en el que falte cualquiera de los dos es un400que nombra dicho campo. Una rotación siempre genera un nuevo código de recuperación, porque el registro de claverecoveryque vuelve a envolver está sellado con una KEK derivada de ese código; por lo tanto, el servidor reemplazaaccounts.recovery_verifiery el depósito en custodia (§3.1) dentro de la misma transacción junto con el blob, los registros de claves y los recursos compartidos. Una rotación que dejase a esos dos con el código ANTIGUO producía una cuenta cuyo código en custodia se autenticaba y luego no desenvolvía nada, un fallo latente desde el momento en que el código de recuperación se convirtió en el segundo autenticador, y fatal una vez que un restablecimiento enviado por correo (§5.12) empezó a entregar ese código a la gente. El cliente no muestra el nuevo código a la persona; se guarda en el depósito en custodia y permanece allí.- El propio servidor deriva la prueba de recuperación. Ejecuta la rama de autenticación para recuperación descrita en el §3.1 sobre el
recoveryCodecanónico y calcula a partir de AHÍ el nuevo verificador, garantizando que el verificador y el depósito siempre describan el mismo código.newRecoveryAuthHashsigue siendo obligatorio y debe coincidir con la prueba derivada; una discrepancia devuelve un400que lo señala, y no se escribe nada. keyRecordsdebe incluir AMBOS tipos. Si falta un tipo, se devuelve un400, nunca una rotación parcial silenciosa: enviar solo la envolturapassphrasedejaría el registrorecoveryenvolviendo una DEK que ya no abre nada, de modo que el código de recuperación seguiría iniciando sesión en la cuenta y nunca más volvería a descifrarla. Cada entrada obedece las reglas del §5.4 (un descriptorrecoverydebe sernull, un descriptorpassphraseno debe serlo). No existe unexpectedUpdatedAtpor registro: el propio envío es la unidad de concurrencia.shareses la lista de elementos para CONSERVAR, y cada fila de recurso compartido que no figure en ella se elimina en la misma transacción. Esto invierte el §5.14, donde un registro de clave no modificado se conserva, deliberadamente, porque estas filas son la autorización de otra persona sobre el diario de quien llama y el silencio debe ser el valor predeterminado seguro. Por lo tanto,shares: []revoca todo y es válido; una clave ausenteshareses un400, por la razón de que el §5.4 exige queexpectedUpdatedAtse escriba explícitamente. En un despliegue sinSYNC_SHARINGla lista debe estar vacía; una lista que no esté vacía es un400, ya que afirma un estado que esa instancia no puede albergar.- Un recurso compartido especificado que no existe es un
400, revertido por completo, nunca tratado como una concesión. Es posible que el destinatario haya eliminado su parte; vuelve a leerGET /v1/sync/sharesy reenvía. - Las versiones anteriores del blob que se conservan (§8) permanecen selladas bajo la DEK ANTIGUA y se convierten en peso muerto en el instante en que se confirma una rotación, ilegibles para todos, incluido su propietario. No se eliminan aquí: la purga los borra tras cinco pushes adicionales, y descartarlos durante una rotación tiraría por la borda la única defensa del propietario contra una escritura defectuosa del cliente en la misma operación.
| Estado | Cuerpo |
|---|---|
200 | {"newVersion": 4, "keptShares": 1, "revokedShares": 2} |
400 | {"error": "..."}: tipo de registro de clave ausente, campo incorrecto o no proporcionado, un newRecoveryAuthHash que no corresponde a la prueba del código, o una lista de conservación que referencia un recurso compartido inexistente. |
401 | {"error": "current passphrase is incorrect"}: currentAuthHash no coincidió, o la frase de contraseña cambió durante la rotación. No se escribió nada. |
409 | {"currentVersion": 5}: el CAS del blob no se mantuvo. No se ha escrito nada. |
413 | {"error": "..."}: el nuevo blob supera MAX_BLOB_BYTES. |
429 | {"error": "..."} con Retry-After: los intentos de frase de contraseña para esta cuenta están bloqueados. |
La rotación es una revocación de nivel 2, y las reglas de redacción del §5.16 siguen vigentes. Eliminar una fila de un recurso compartido impide que el servidor la sirva; rotar añade que las entradas futuras se sellan con una clave que la parte revocada nunca tuvo. Ninguna de las dos cosas recupera lo que ya se descargó, y ningún cliente puede afirmar lo contrario.
5.18 Contribuciones de investigación: /v1/sync/contributions y /v1/sync/study (ADR-0003)
Presente solo cuando el despliegue define SYNC_RESEARCH. Si no está presente, todas las rutas que figuran abajo responden con el 404 habitual de ruta desconocida a cualquier llamador, tenga credenciales o no, ya que el terminador se monta antes de la autenticación. Es independiente de SYNC_SHARING; ningún flag implica al otro.
Lado del colaborador, con autenticación como colaborador:
| Verbo | Ruta | Notas |
|---|---|---|
PUT | /contributions/:studyAccountId | {"pseudonym","schemaTier","body","contributionVersion"}. CAS sobre un contributionVersion monotónico. La contribución es el conjunto de datos acumulativo de la ventana, recalculado y reenviado entero; el cliente siempre conserva el origen, así que esta fila es una proyección, nunca una copia primaria. |
GET | /contributions | Las inscripciones del propio colaborador. Nunca devuelve body. |
DELETE | /contributions/:studyAccountId | Retirada. Una sola transacción: elimina la fila de forma definitiva e inserta una lápida indexada por seudónimo. 204, idempotente. |
Lado del estudio, con autenticación como la cuenta del estudio:
| Verbo | Ruta | Notas |
|---|---|---|
GET | /study/contributions | {"pseudonym","contributionVersion","schemaTier","body","createdAt"} por fila. Nunca un id de cuenta. |
GET | /study/withdrawals | Seudónimos que se retiraron, con marcas de tiempo. El cliente del estudio debe purgarlos antes de presentar o exportar nada. |
GET /study/contributions repite studyAccountId una vez, en el nivel superior del sobre, no en cada fila: es el propio id del llamador, se autenticó como tal, es idéntico en cada fila y no es un identificador de colaborador. El investigador lo necesita para reconstruir los AAD de la sección 3.5, y fila a fila solo añadiría ruido.
El compare-and-swap de contributionVersion. El valor enviado es la nueva versión, no una base; se vincula a los AAD, por lo que debe ser el valor con el que se selló el texto cifrado. La regla es estrictamente mayor que la almacenada: un cliente que recalcula y reenvía toda la proyección nunca debe quedar bloqueado por una versión que jamás salió del dispositivo. Una escritura descartada es 409 {"currentVersion": <int>}, lo que coincide con la forma de la sección 5.1.
El servidor valida schemaTier frente a los niveles que define este protocolo. El nombre del nivel es metadato, no contenido (viaja en claro y el servidor ya lo almacena) y, sin esta comprobación, la prohibición 1 de ADR-0003 solo tendría efecto en el cliente. Un nivel desconocido es 400.
El servidor no valida la forma del seudónimo, solo que esté presente y delimitado. No puede verificarlo (eso requeriría la raíz del colaborador) y una comprobación estructural implicaría una autoridad que no tiene.
| Estado | Cuándo |
|---|---|
400 | cuerpo mal formado, schemaTier desconocido, contributionVersion ausente |
404 | estudio desconocido, contribución desconocida y cualquier otro caso de no encontrado: una sola ruta de código |
409 | contributionVersion no estrictamente mayor que el almacenado |
413 | la contribución supera MAX_CONTRIBUTION_BYTES (256 KiB) |
Un seudónimo por estudio, garantizado por la base de datos. Si dos colaboradores enviaran el mismo seudónimo se fusionarían de forma silenciosa en una única serie de participante, y un investigador analizaría a dos personas como una sola sin que nada falle. Una colisión accidental ronda 2^-128, por lo que la restricción nunca debería dispararse, y esa es la idea: hace que la corrupción sea imposible en vez de improbable.
La retirada borra de verdad en este lado. Una contribución que el estudio todavía no ha obtenido no llega a nadie. Lo que el estudio ya obtuvo no se puede recuperar: la lápida contiene la instrucción, y respetarla es una obligación ética que este sistema declara pero no puede imponer.
5.19 POST /v1/chat/completions: el proxy de IA
Presente solo cuando el operador configuró una clave de upstream. Sin una, la ruta responde con el 404 habitual de ruta desconocida, para todo el mundo, con credenciales o sin ellas, y instance.ai es null en el handshake (§5.6). Una implementación de este protocolo PUEDE omitir la ruta por completo; un cliente DEBE leer instance.ai antes de ofrecer un escaneo en lugar de sondear la ruta.
Autenticado mediante el token de acceso ordinario de la cuenta (§4.1), verificado antes de leer el cuerpo: cualquier solicitud sin un token válido recibe 401 sin importar su tamaño o forma, y el servicio ni la almacena en búfer ni la analiza. El cuerpo consiste en una solicitud de chat-completion compatible con OpenAI. El servicio comprueba que sea un objeto JSON, reenvía solo los campos de una lista de permitidos (abajo), reescribe los pocos que fijan el coste por solicitud, acota lo que una solicitud puede introducir y no rechaza nada por campos desconocidos: simplemente los descarta. La respuesta es la del proveedor, transmitida junto con su estado.
La instancia decide cuánto puede costar una solicitud. Una única clave de origen puede abastecer a todas las cuentas de una instancia, y el cómputo diario de solicitudes nada dice sobre el coste de cada una. Por tanto, el servicio reescribe estos campos para cada cuenta antes de reenviar, sin rechazar jamás una solicitud a causa de ellos.
Solo se reenvían estos campos de nivel superior: model, messages, stream, stream_options, temperature, top_p, response_format, max_tokens, max_completion_tokens, reasoning y n. Cualquier otro campo se descarta y su nombre (nunca su valor) queda registrado, permitiendo que el cliente que envíe campos desconocidos para el servicio continúe funcionando. Dentro de messages, un mensaje conserva role, content y name; una parte del contenido es text (con text) o image_url (únicamente con url, descartando detail), y todo image_url cuyo url no sea un URI data:image/...;base64, se descarta, ya que una URL remota o un documento tras un URI de datos constituye una entrada no evaluada. Se descarta cualquier otro tipo de parte.
| Campo | Lo que recibe el proveedor |
|---|---|
model | el modelo del nivel de la solicitud, según la elección del operador: el nivel cuya ruta coincide con el response_format.json_schema.name de la solicitud, o de lo contrario el nivel predeterminado de la instancia. El modelo del nivel predeterminado es el que publica instance.ai.model (§5.6). Una instancia sin archivo de niveles tiene un solo nivel, cuyo modelo es AI_ADVERTISED_MODEL. Cuando el operador no especificó ninguno, se envía el model del emisor sin modificar. |
max_tokens, max_completion_tokens | como máximo AI_MAX_OUTPUT_TOKENS (8192 por defecto), o el propio límite inferior del nivel de la solicitud cuando disponga de uno. Un valor superior a este, o uno que no sea un número, se convierte en el límite. A un cuerpo que no incluya ninguno se le añade max_tokens. |
reasoning.max_tokens | como máximo el mismo límite. Se conserva reasoning.effort, a menos que el nivel de la solicitud defina su propio esfuerzo: en ese caso el servicio escribe dicho esfuerzo y descarta el reasoning.max_tokens del emisor, ya que un proveedor solo admite uno de los dos. |
n | 1, si está presente. |
usage, en un servidor upstream de OpenRouter | escrito como {"include":true}, para que la respuesta informe de su recuento de tokens y su precio (abajo). Cualquier otro upstream no recibe ninguno. Se elimina el usage propio del emisor. |
usage, en un servidor upstream de OpenRouter | escrito como {"include":true}, para que la respuesta informe de su recuento de tokens y su precio («Lo que costó una compleción», abajo). Cualquier otro upstream no recibe ninguno. Se elimina el usage propio del emisor. |
| cualquier campo fuera de la lista de permitidos anterior | eliminados, por ejemplo models, route, plugins, web_search_options, prediction, tools. |
provider, en un servidor upstream de OpenRouter | se responde con {"data_collection":"deny"}: únicamente endpoints que no almacenen la solicitud ni entrenen con ella. El operador puede añadir "zdr":true y "only":[...] con "allow_fallbacks":false, a través del nivel de la solicitud (routing) o a través de UPSTREAM_ZDR y UPSTREAM_PROVIDER_ONLY. El provider propio del emisor nunca se reenvía. Cualquier otro proveedor ascendente no recibe ningún campo provider, se configure lo que se configure. |
El nivel lo define el operador, nunca quien llama. El operador define los niveles en un archivo (AI_TIERS_FILE, consulta el README): cada uno contiene un modelo, el enrutamiento de su proveedor y, de forma opcional, un límite de salida inferior y un esfuerzo de razonamiento, y un mapa routes asigna el nombre de un esquema de salida estructurada a un nivel. Toda solicitud cuyo esquema esté enrutado recibe dicho nivel; cualquier otra solicitud recibe el nivel predeterminado. Quien llama solo puede indicar un esquema, de modo que solo puede acceder a un nivel definido por el operador y a ningún otro, y nunca especifica un modelo ni un proveedor. El protocolo de enlace (instance.ai.model, §5.6) nombra el modelo del nivel predeterminado.
El límite máximo se aplica haya o no un modelo. Si un cliente necesita una respuesta más larga de la que permite el tope, recibe una respuesta truncada, y el operador debe aumentar AI_MAX_OUTPUT_TOKENS. Una instancia en Autoalojamiento que prefiera que sus usuarios elijan el modelo debe dejar AI_ADVERTISED_MODEL y AI_TIERS_FILE sin configurar.
POST /v1/chat/completions
Authorization: Bearer <accessToken>
Content-Type: application/json
X-Intake-Id: 2f9d0b416c3a4e579f10a1b2c3d4e5f6
{ "model": "…", "messages": [ … ], "stream": true }Tres propiedades que una implementación conforme DEBE cumplir, y cada una existe porque el cuerpo de la solicitud es una fotografía de la comida de alguien:
- La credencial de quien llama se sustituye, nunca se combina. Las cabeceras de la solicitud hacia el upstream se CONSTRUYEN en lugar de copiarse de la solicitud entrante y sobreescribirse. Copiar y luego sobreescribir reenvía cookies,
x-api-keyy cualquier cosa que el siguiente proveedor decida leer. - No se registra ningún cuerpo, en ninguna dirección. Ni un prefijo, ni un búfer decodificado, ni un documento de error. Lo que se puede registrar: un id de cuenta, el estado del upstream, recuentos de bytes, una duración y, leído de una respuesta correcta, el recuento de tokens y el precio reportado por el proveedor, más un nombre de modelo que parezca un nombre de modelo (más abajo).
- Toda cadena procedente de la conexión con el upstream se depura antes de que llegue a una línea de registro o a una respuesta. Un proveedor que rechaza una solicitud suele devolver la solicitud dentro de su cuerpo de error, con imagen incluida.
Lo que costó una completación
Un proveedor que reporta uso lo incluye en la respuesta. En un upstream de OpenRouter, el servicio escribe "usage": {"include": true} en el cuerpo reenviado (nunca tomado del emisor, cuyo propio campo usage se descarta como cualquier campo fuera de la lista de permitidos), y ningún otro upstream recibe dicho campo, por lo que sus cuerpos quedan intactos. Tras haber retransmitido la respuesta, el servicio registra, en su línea Proxied a completion, model, promptTokens, completionTokens y costMicroUsd (el usage.cost del proveedor, un precio en dólares expresado como un número entero de millonésimas de dólar), cada uno como null si la respuesta no lo indicó. También añade costMicroUsd al total de la instancia para el día UTC, ai_instance_days.cost_micro_usd, una suma sin cuentas asociadas que permanece como 0 para un proveedor que no reporte precio. Los números se leen al pasar la respuesta, sea JSON o un flujo server-sent, sin retrasar ni alterar un solo byte. Un cuerpo mayor de 1 MiB, una línea de flujo mayor de 64 KiB y cualquier campo que no sea un número verosímil no se leen y devuelven null. Jamás el texto de la respuesta. Si falla el registro del coste, se anota en el log y nunca se hace fallar una petición ya atendida.
Límite de entrada por solicitud
La salida tiene un tope por arriba; la entrada se acota aquí. Medida sobre el cuerpo que recibe el proveedor (después de la lista de permitidos), una petición se rechaza con 400 antes de cualquier reclamo de escaneo, cualquier reserva y cualquier llamada aguas arriba, de modo que no gasta nada, cuando contiene:
| Límite | Por defecto | limit |
|---|---|---|
partes image_url | 1 | image-parts |
| bytes UTF-8 de texto | 49152 | text-bytes |
| mensajes | 4 | messages |
El texto son las cadenas content, cada parte text, cada name del mensaje y el response_format serializado: un esquema es entrada que el modelo lee. Los bytes propios de la imagen no son texto. El operador define los límites con AI_MAX_IMAGE_PARTS, AI_MAX_TEXT_BYTES y AI_MAX_MESSAGES, y el rechazo indica el límite y su valor:
{ "error": "ai-request-too-large", "limit": "text-bytes", "max": 49152 }Los valores predeterminados son la petición real más grande de openplate con margen de sobra: una fotografía, dos mensajes y unos 12 KB de texto.
El límite del cuerpo
El cuerpo de la solicitud contiene una fotografía, por lo que el límite está dimensionado para una: AI_MAX_REQUEST_BYTES, 8,000,000 bytes por defecto. Base64 aumenta una imagen en 4/3, de modo que esto admite un JPEG de unos 5.7 MiB, una cámara de teléfono moderna con la calidad predeterminada, que es lo que envía el cliente tras reducir la escala.
Es deliberadamente independiente de MAX_BLOB_BYTES (§8). Aquel limita un diario que este servicio almacena; este limita una imagen que solo reenvía, y derivar uno del otro rechaza cualquier fotografía real.
Un cuerpo que supera el límite es 413, para un cliente con un token válido; un cuerpo no autenticado es 401 antes de leerse. El cuerpo de error en esta ruta tiene la estructura de OpenAI, no el {"error": "<sentence>"} de §4, porque quien llama es un cliente compatible con OpenAI que lee error.message de un objeto:
{
"error": {
"message": "Request body exceeds the maximum accepted size of 8000000 bytes. The operator can raise AI_MAX_REQUEST_BYTES.",
"type": "invalid_request_error",
"code": "request_too_large"
}
}Un cuerpo que no es un JSON válido recibe 400 en el mismo formato con "code": "invalid_json". Ninguno devuelve la entrada entrecomillada, por el motivo indicado en la regla estricta 2. Una implementación PUEDE responder en su lugar con la estructura del §4, pero un cliente programado para un proveedor de OpenAI no mostrará nada en absoluto en lugar de un error.
El streaming es directo. Cuando la solicitud lo pide, el cuerpo de la respuesta se retransmite conforme llega, con Cache-Control: no-cache, no-transform y sin Content-Length. Un servicio que usara un búfer seguiría entregando cada byte, de modo que un cliente no puede detectar la diferencia salvo por la latencia que intentaba evitar.
La cuota
Cada cuenta tiene dos límites diarios, cada uno en unidades por día UTC y cada uno con 0 por defecto: dailyAiLimit, el de la ventana de pago, y freeDailyAiLimit, el de la concesión gratuita permanente (§5.15). Cuál se aplica se decide por petición, en este orden:
| La cuenta contiene | Concesión | Límite contra el que se reserva |
|---|---|---|
allowanceExpiresAt posterior al instante de la petición, dailyAiLimit > 0 | ventana de pago | dailyAiLimit |
en caso contrario freeDailyAiLimit > 0 | concesión gratuita | freeDailyAiLimit |
de lo contrario, el DEFAULT_FREE_DAILY_AI_LIMIT de la instancia > 0 | concesión gratuita | ese valor predeterminado |
en caso contrario dailyAiLimit es 0 | ninguno | 403 ai-not-allowed |
en caso contrario allowanceExpiresAt está definido (por lo que ha vencido) | ninguno | 403 allowance-expired |
en caso contrario trialScans está definido | prueba de escaneo | dailyAiLimit |
| en caso contrario (un límite, sin fecha, sin prueba, sin concesión gratuita) | ninguno | 403 ai-not-allowed |
El valor predeterminado de la instancia (2026-10-05). Quien opera el sistema puede definir DEFAULT_FREE_DAILY_AI_LIMIT. Es la concesión gratuita de toda cuenta cuyo propio freeDailyAiLimit sea 0: la misma concesión en el mismo lugar del orden, de modo que una ventana de pago activa sigue teniendo prioridad, se conserva el límite propio sin importar el valor predeterminado y una cuenta con prueba de escaneos pasa a regirse por el valor predeterminado en vez de gastar un escaneo. Nunca caduca y no tiene control de escaneos. No se escribe en ninguna fila, así que reducirlo o eliminarlo afecta a todas las cuentas a la vez. Un día agotado corresponde al 429 indicado abajo, con Retry-After, y nunca al 403 ai-not-allowed de una cuenta sin concesión. Un servicio sin valor predeterminado se comporta exactamente igual que antes. El valor predeterminado no puede definirse junto con la prueba de escaneos: esa instancia se negará a arrancar.
La última fila cambió el 2026-09-30. Esa forma solía ser una concesión permanente sin fin; cada cuenta que la tenía se pasó a freeDailyAiLimit mediante una migración, y ya nada la escribe. La concesión gratuita nunca depende del escaneo y nunca termina, por lo que una prueba de escaneo con una concesión gratuita no se cuenta.
Una petición reserva max(1, ceil(estimated input tokens / AI_UNIT_INPUT_TOKENS)) unidades contra el límite que eligió el orden anterior, donde la estimación, hecha antes de la llamada, son los bytes de texto anteriores divididos por 4 más AI_IMAGE_INPUT_TOKENS (por defecto 1500) por imagen. Con el AI_UNIT_INPUT_TOKENS por defecto de 8192, el escaneo de plato de openplate pesa 1 unidad y una petición cercana al límite de texto pesa 2, así que para la aplicación una unidad es una petición. El mismo peso se descuenta de los topes de la instancia que figuran abajo y se devuelve, allí donde se devuelva, por completo. Cada respuesta intermediada incluye la posición de la cuenta en el límite que se aplicó:
| Cabecera | Significado |
|---|---|
X-Quota-Used | Unidades gastadas hoy, tras esta |
X-Quota-Limit | El límite elegido por el orden anterior: dailyAiLimit, freeDailyAiLimit o el valor predeterminado de la instancia |
X-Trial-Scans-Left | Escaneos gratuitos restantes tras esta solicitud, en una cuenta a la que se aplica la restricción de escaneo (abajo). Ausente en caso contrario |
| Estado | error | Cuándo |
|---|---|---|
401 | authentication required | Sin token de acceso, o con uno caducado o revocado |
403 | ai-not-allowed | La cuenta no tiene ninguna concesión (el orden anterior). Rechazado antes de que nada salga del host |
403 | allowance-expired | allowanceExpiresAt está definido y no es posterior al instante en que llegó la petición, y no hay concesión gratuita. Rechazado antes de que nada salga del host y antes de escribir una fila de uso |
403 | trial-scans-spent | Los escaneos gratuitos de la cuenta se han agotado y no tiene fecha de cuota. Se rechaza antes de que nada salga del host y antes de escribir una fila de uso. X-Trial-Scans-Left: 0. El cuerpo incluye "endedBy": "scans" |
403 | trial-expired | El trialEndsAt de la cuenta está definido y no es posterior al instante en que llegó la solicitud, sus escaneos no se han agotado y no tiene fecha de cuota. Se rechaza antes de escribir cualquier fila. El cuerpo incluye "endedBy": "days" |
403 | capability-required | La petición solicita una función que la cuenta no posee (más abajo). El cuerpo incluye capability, la etiqueta ausente. Se rechaza antes de contabilizar nada y antes de que nada salga del host |
403 | account-suspended | La cuenta está suspendida (el §5.9 usa el mismo código) |
403 | health-consent-required | La instancia solicita un consentimiento para datos de salud y la cuenta no dispone de su versión actual (§5.15.1). Se rechaza antes de que nada salga del host, y antes de que se escriba una fila de uso |
400 | request body must be a JSON object | El cuerpo no es un objeto. La entrada nunca se devuelve entre comillas |
400 | ai-request-too-large | El cuerpo contiene más partes de imagen, bytes de texto o mensajes de los que permite la instancia (arriba). El cuerpo indica limit y max. Rechazado antes de escribir cualquier fila |
400 | feature-header-invalid | X-Openplate-Feature está presente y no es una etiqueta, en una cuenta verificada (más abajo). Se rechaza antes de escribir cualquier fila |
400 | intake-id-invalid | X-Intake-Id está presente y no tiene de 16 a 64 caracteres de A-Z a-z 0-9 _ -. Se rechaza antes de escribir cualquier fila |
409 | intake-in-flight | Una solicitud anterior con el mismo X-Intake-Id sigue en curso, en una cuenta a la que se aplica la barrera de escaneo (abajo). No se gasta nada ni se escribe ninguna fila |
429 | una frase que indica el instante de restablecimiento | La cuota no puede admitir las unidades de esta solicitud. Retry-After son los segundos hasta la próxima medianoche UTC |
429 | una frase que indica el límite por minuto | Más de AI_RATE_LIMIT_PER_MINUTE peticiones en cualquier intervalo previo de 60 s |
503 | ai-instance-ceiling | Toda la instancia ha agotado su límite diario, o las cuentas con prueba de escaneo han agotado el suyo. Retry-After son los segundos hasta la próxima medianoche UTC |
403 ai-not-allowed es un código de máquina porque un cliente DEBE bifurcar según él; significa "esta cuenta nunca tendrá éxito aquí hasta que un operador cambie algo", un mensaje distinto al de "vuelve mañana". Los dos 429 son frases porque no hay nada sobre lo que bifurcar: los lee una persona.
403 allowance-expired es un código de máquina independiente, y es independiente porque las dos frases no son la misma frase: "tu operador nunca te dio IA" y "se te acabó el tiempo" exigen palabras distintas y pasos siguientes distintos. Un cliente que las unificara le diría a alguien cuyo periodo de prueba terminó que pida a un administrador una cuota que ya tenía. Ambos rechazos ocurren antes de la reserva, por lo que una cuenta que no obtuvo respuesta no tiene ninguna fila de uso contabilizada en su contra. La fecha se compara como "no posterior a": el instante límite rechaza en lugar de permitir. La sincronización no se ve afectada en una cuenta caducada (§5.15).
403 trial-scans-spent es un código de máquina tercero, para una tercera frase: "has agotado tus escaneos gratuitos". El cliente muestra la oferta del plan para este caso, no "consulta a tu administrador" (ai-not-allowed) ni "tu tiempo ha terminado" (allowance-expired). Un cliente más antiguo que el código lee un 403 desconocido, motivo por el cual va por separado en vez de integrarse en cualquiera de ellos.
403 trial-expired es un cuarto, para el otro límite de la prueba de escaneos: "tus días gratuitos han terminado". No es allowance-expired, que corresponde a una ventana de pago o concesión que expira; un cliente que interpretara uno por el otro le diría a alguien que pagó que su prueba ha terminado. Ambos rechazos de prueba incluyen endedBy, "scans" o "days", de modo que el cliente puede identificar qué límite puso fin a la prueba a partir de un solo campo:
{ "error": "trial-expired", "endedBy": "days" }El orden de los rechazos, que un servidor conforme DEBE mantener: identidad y suspensión; el consentimiento de datos de salud (health-consent-required, §5.15.1); la concesión (la tabla anterior: ai-not-allowed o allowance-expired); se lee el cuerpo y luego la capacidad (capability-required, feature-header-invalid, más abajo); lo que el cuerpo aporta (ai-request-too-large); la forma de X-Intake-Id; después, solo para la concesión de prueba de escaneos (escaneos gratis, fecha de cuota ninguna y sin concesión gratuita), el límite de días (trial-expired, consultado solo si los escaneos no se han agotado, conservando así los escaneos gastados su propio código) y la reclamación de escaneo (intake-in-flight, trial-scans-spent). Una fecha futura anula ambos: es una ventana de pago o concedida, y los límites de la prueba solo deciden si no hay fecha alguna. Una concesión gratuita también anula ambos. Por último, el tope de cuentas en prueba de escaneos, el tope de la instancia y la cuota diaria, como se indica abajo.
Capacidades
Una capacidad es una etiqueta corta, como scan o recipes, para un tipo concreto de petición de IA. Cada cuenta mantiene una lista de ellas, y el proxy rechaza cualquier petición cuya función no esté en la cuenta. El servicio no comprueba por qué una cuenta tiene una etiqueta: la lista la escribe quien opera el sistema (§5.20), al igual que la credencial de facturación, que puede indicar capabilities y ninguna condición más allá de sus otros dos campos.
Una etiqueta es una letra minúscula seguida de hasta 31 letras minúsculas, dígitos o guiones (^[a-z][a-z0-9-]{0,31}$). Una lista admite como máximo 32, se almacena sin duplicados y ordenada, y no puede contener none, que está reservado.
El registro propio de la cuenta tiene tres estados, y representan tres estados. null indica ausencia de registro, por lo que decide el valor predeterminado de la instancia. [] es un registro que no concede nada. Una lista concede exactamente esas etiquetas. El valor efectivo es el registro propio; si no existe, el DEFAULT_CAPABILITIES de la instancia (publicado como instance.defaultCapabilities, §5.6); de lo contrario null, y un null efectivo supone no realizar ninguna comprobación: toda petición pasa y ni siquiera se rechaza una cabecera con formato incorrecto. Eso es lo que siempre ha tenido una instancia sin nada configurado. Si DEFAULT_CAPABILITIES no está definido o está vacío, es null. La palabra none equivale a la lista vacía, porque los archivos de Compose pasan las variables no definidas como una cadena vacía.
Cómo identifica una petición su función.
- La cabecera de petición
X-Openplate-Feature: <label>. Una cabecera que no sea una etiqueta es400 feature-header-invalid. La cabecera es solo la palabra del cliente, por lo que por sí sola no protege nada. - La salida estructurada del cuerpo,
response_format.json_schema.name. Quien opera el sistema puede asociar el nombre de un esquema a una etiqueta medianteCAPABILITY_SCHEMA_MAP(paresschemaName:label). Un cuerpo que solicite un esquema registrado requiere esa etiqueta diga lo que diga la cabecera, de modo que mentir en la cabecera no le aporta nada al cliente. Cuando faltan ambas etiquetas, se reporta la del esquema.
Una petición que no indique función ni un esquema registrado no solicita nada que la comprobación pueda cotejar, y pasa. El mapa de esquemas es lo que permite exigir una función frente a un cliente que no envíe cabecera.
El rechazo es 403 {"error": "capability-required", "capability": "<label>"}. Se evalúa tras la cuota y el cuerpo, y antes del recuento diario, la reclamación de escaneo, los topes de la instancia y el proveedor: una petición rechazada no escribe filas de uso, no consume escaneos y no envía nada al upstream. Por tanto, a una cuenta sin una función se le devuelve 403 y nunca 429, y a una cuenta sin IA en absoluto se le sigue indicando ai-not-allowed primero. El cliente bifurca según el código: significa "esta cuenta no tendrá éxito aquí hasta que cambien sus capacidades", no "vuelve mañana". Un cliente web puede enviar la cabecera entre orígenes distintos: está en la lista de permitidos de CORS.
La prueba de escaneo
Una cuenta puede incluir escaneos gratuitos de IA (AccountView.trialScans, §5.15) y una fecha límite (AccountView.trialEndsAt), concedidos por la prueba de la instancia (instance.trial, §5.6): una cantidad fija de escaneos o de días, lo que ocurra primero. Un escaneo es una acción de IA iniciada por la persona, y una sola acción puede traducirse en más de una solicitud hacia el upstream: un cliente puede reintentar una vez sin response_format tras un rechazo del proveedor. (La comprobación del bearer, §4.1, rechaza cualquier reintento tras un bearer caducado antes de cualquier asignación). Cada escaneo cubre una respuesta entregada.
X-Intake-Id es la forma en que un cliente indica qué solicitudes forman una misma acción. Es opcional, de 16 a 64 caracteres de A-Z a-z 0-9 _ - (un UUID con o sin guiones encaja), un identificador nuevo por acción de la persona, reutilizado en cada reintento de esa acción, y se envía solo a este proxy, nunca a un proveedor configurado por la propia persona. El servicio:
- reserva un escaneo para un identificador que no ha visto, antes de la llamada upstream, en una única sentencia cuyo
WHEREes el límite, de modo que diez solicitudes paralelas sobre tres escaneos reservan tres; - rechaza una solicitud con un id cuya solicitud anterior está todavía en curso con
409 intake-in-flight, sin gastar nada: solicitudes superpuestas en un mismo id obtendrían dos respuestas por un solo escaneo. Un id puede volver a usarse en cuanto se resuelve su solicitud. Si falló, recuperó su escaneo, por lo que el reintento lo reclama de nuevo sin coste neto; una solicitud tras una respuesta entregada es una acción nueva con un escaneo nuevo, que se rechaza con403 trial-scans-spentcuando no queda ninguno; - trata una solicitud que sigue en curso tras 30 minutos como una que cayó sin resolverse, y permite que la siguiente solicitud con ese id asuma su escaneo sin uno nuevo, de modo que ningún id quede bloqueado por más tiempo que ese;
- vincula cada devolución y cada entrega a la asignación que le corresponde, de modo que una solicitud que falla tarde nunca devuelva un escaneo que haya reclamado una solicitud más reciente con el mismo id;
- serializa solicitudes paralelas con un nuevo id, de modo que exactamente una de ellas reclama un escaneo y las demás quedan
409 intake-in-flight; - trata una solicitud con identificador ninguna como su propia acción, de modo que un cliente que nunca envíe uno se contabiliza correctamente para cada acción de una sola solicitud.
Los identificadores se conservan durante 24 horas y luego se eliminan (§9.2). Nunca se registran.
Una solicitud que obtuvo la ausencia de respuesta devuelve su escaneo: la devolución se ejecuta en cada fila de la tabla siguiente excepto en un 2xx entregado, y en cada rechazo tras la reserva (los límites y la cuota diaria). Difiere a propósito de la unidad diaria, fila por fila:
| Resultado | Unidad diaria | Escaneo | Por qué el escaneo difiere, donde lo hace |
|---|---|---|---|
| Conexión rechazada / tiempo de espera agotado para la cabecera | liberada | liberada | |
4xx upstream | liberada | liberada | |
5xx upstream | gastado | liberada | La unidad protege la factura: puede que la generación se haya ejecutado. El escaneo protege la promesa de que un intento fallido no cuesta nada y de que la persona no recibió respuesta. Un bucle de reintentos contra un proveedor inestable sigue estando limitado por la unidad diaria |
| Tiempo de espera agotado para el cuerpo / flujo cancelado por el proveedor | gastado | liberada | Llegaron las cabeceras, por lo que el proveedor puede facturar; la persona siguió sin recibir respuesta |
2xx ascendente, luego quien llama cuelga | gastado | gastado | La respuesta estaba en camino |
2xx upstream | gastado | gastado | |
| Un tope o la cuota diaria rechazan la petición tras la reclamación | no consumida, o liberada | liberada | La petición no llegó a nadie |
El tope de la instancia
Un operador PUEDE definir un tope para toda la instancia, en la misma unidad que la cuota anterior: unidades por día UTC, sumando todas las cuentas (AI_INSTANCE_DAILY_LIMIT). Si no se define, significa que no hay ninguno, que es lo que mantiene una instancia en Autoalojamiento y lo que mantiene cada despliegue existente.
Existe porque cualquier otro límite aquí es por cuenta. Diez cuentas a 200 peticiones al día son 2000 peticiones al día contra la clave de proveedor del operador, por lo que las invitaciones multiplican las cuentas sin multiplicar el límite.
Al alcanzar el tope, se rechaza a todas las cuentas, incluida una que no haya gastado nada de su propia cuota, hasta el siguiente día UTC. El rechazo es 503 ai-instance-ceiling con Retry-After en segundos. Es un 503 en lugar de un 429 o un 403 porque no es culpa de quien llama ni se debe a su cuota: el servicio se ha quedado sin la capacidad que pagó su operador. Un cliente DEBE bifurcar según él, porque "el operador se ha quedado sin capacidad hoy" es una pantalla distinta de "te has quedado sin peticiones hoy", y solo la segunda se refiere a la persona que la lee.
Las unidades de la instancia se consumen antes que las de la cuenta, por lo que una instancia rechazada nunca factura a nadie, y se devuelven siempre que se devuelven las de la cuenta (la tabla siguiente se aplica a ambas, fila por fila).
El tope no se publica en /health: es el presupuesto del operador, y ese protocolo de enlace no tiene autenticación. GET /v1/admin/stats lo reporta como aiInstanceDailyLimit, junto a la aiRequestsToday que limita.
Las cuentas de prueba de escaneo pueden tener su propio tope (AI_TRIAL_INSTANCE_DAILY_LIMIT): unidades por día UTC sumando todas las cuentas a las que se aplica la barrera de escaneo. Rechaza esas cuentas, y solo esas, con el mismo 503 ai-instance-ceiling. Cuando está definido, una solicitud de prueba de escaneo cuenta solo para él y nunca para AI_INSTANCE_DAILY_LIMIT, que limita entonces al resto de las cuentas, para que el tráfico de prueba nunca agote la capacidad que necesitan las cuentas de pago. La factura del proveedor que se puede alcanzar en un día es la suma de ambas. Si no se define, las solicitudes de la prueba de escaneo cuentan para el tope de la instancia como las de los demás. Tampoco se publica; GET /v1/admin/stats lo reporta como aiTrialInstanceDailyLimit, junto a signup.trialRequestsToday.
Si se establece el techo de prueba, cada red de origen recibe una parte de él (AI_TRIAL_NETWORK_DAILY_LIMIT, una décima parte del techo de prueba por defecto, redondeado a la baja, al menos 1): unidades por día UTC que pueden consumir las peticiones de escaneo de prueba de una red. Una red es una IPv6 /64 o una única dirección IPv4, tal como las cuentan los limitadores de inicio de sesión. Una petición de escaneo de prueba desde una red que ha consumido su cuota recibe el mismo 503 ai-instance-ceiling con el mismo Retry-After, de modo que el cliente no necesita ninguna bifurcación nueva; no consume ningún escaneo ni ninguna unidad, y no se llama al proveedor. Las peticiones bajo una ventana de pago o una concesión gratuita permanente nunca se cuentan ni se rechazan mediante este límite. Sus unidades se reponen siempre que se repongan las del techo de prueba. Varios usuarios detrás de un mismo CGNAT IPv4 comparten un único depósito; quien llama por IPv6 tiene su propio /64. El servicio no almacena ninguna dirección para ello: una fila por red y día contiene un hash con clave (HMAC-SHA256 con TRIAL_ADDRESS_PEPPER) de la red y el día, y la fila se elimina al día siguiente.
Qué se gasta y qué se devuelve
Una unidad se reserva antes de la llamada ascendente, nunca se cuenta después. Contar después deja una ventana en la que N peticiones paralelas leen el recuento anterior y pasan todas, y un cliente que reintenta tras un error es precisamente el cliente que las lanza juntas.
| Resultado | Unidad | Motivo |
|---|---|---|
| Conexión rechazada / fallo de DNS | liberada | La petición nunca salió de este host |
| Tiempo de espera agotado para las cabeceras (todavía no hay bytes) | liberada | No se nos sirvió nada; nuestro propio límite se agotó antes de que respondiera el proveedor |
4xx upstream | liberada | El proveedor la RECHAZÓ. No llegó a ningún modelo, así que nadie la facturó, y cobrar a la cuenta por una mala configuración del propio operador permitiría que un proxy roto consumiera toda la cuota de una organización en un minuto |
5xx upstream | gastado | El proveedor la aceptó y falló durante la entrega. Puede que la generación se haya ejecutado. Liberar aquí implica un bucle infinito y gratuito de reintentos contra el mismo proveedor que está fallando |
| Tiempo de espera agotado para el cuerpo / flujo cancelado | gastado | Las cabeceras ya llegaron, así que el proveedor la ejecutó. Que no hayamos podido leer la respuesta es problema nuestro, no un reembolso |
2xx upstream | gastado | Evidentemente |
El servicio registra un entero por cuenta por día UTC y nada más: ni prompt, ni respuesta, ni nombre del modelo, ni marca de tiempo con más detalle que el día (§9.2).
5.20 La API de administración: /v1/admin
Superficie de operador, no superficie de cliente. Un cliente de openplate usa exactamente uno de estos endpoints, y solo cuando la cuenta que ha iniciado sesión es administradora: la consola que la app renderiza en /admin. Un cliente alternativo puede ignorar esta sección por completo.
Llegan a ella dos credenciales, y ambas lo hacen como un Authorization: Bearer normal:
- El token estático de operador (
ADMIN_TOKEN), que sigue funcionando cuando todas las cuentas están bloqueadas. - Una cuenta cuyo
roleesadmin, usando su propio token de acceso. Esto es lo que sitúa la consola en la app y no en una shell. - Un token de servicio con alcance limitado (
BILLING_TOKEN). Es un TERCER principal, no una segunda copia del primero: tiene acceso a tres rutas y tres campos, y se rechaza en cualquier otro lugar. Consulta "El principal de facturación" más abajo.
Sin ninguno configurado ni coincidente, todo el subárbol responde el mismo 404 que cualquier ruta desconocida, para todo el mundo. Una instancia que no haya configurado ningún token resulta indistinguible de una compilada antes de que existiera la funcionalidad. Un 401 ahí anunciaría que existe una credencial y que simplemente está bloqueada. Configurar cualquiera de los tokens convierte ese 404 en el 401 que recibe un valor incorrecto.
| Endpoint | Hace | ||
|---|---|---|---|
GET /v1/admin/stats | Conteos agregados: cuentas, blobs, bytes, registros de clave, pendingInvites, admins, aiRequestsToday, y el aiInstanceDailyLimit que lo limita (null si no hay tope); aiTrialInstanceDailyLimit; y signup: invitaciones generadas hoy y en los últimos siete días por la puerta de peticiones de §5.8.3, pruebas concedidas en los últimos siete días, y las peticiones de prueba de escaneo de hoy | ||
GET /v1/admin/ai/budget | El presupuesto de la clave del proveedor y la capacidad de IA de hoy, consulta "El presupuesto de IA" más abajo. 404 en una instancia sin IA. No accesible con BILLING_TOKEN | ||
GET /v1/admin/accounts | Una página de AccountView, más total | ||
GET /v1/admin/accounts/expiring | Una página de { id, allowanceExpiresAt } para cuentas cuya cuota termina en el futuro, más total | ||
GET /v1/admin/accounts/:id | Un AccountView | ||
GET /v1/admin/accounts/:id/activity | Último inicio de sesión, y una entrada por día UTC a lo largo de una ventana acotada | ||
GET /v1/admin/activity | La misma franja día a día para una PÁGINA entera de cuentas, en el orden de la lista | ||
PATCH /v1/admin/accounts/:id | role, dailyAiLimit, allowanceExpiresAt (un instante ISO, o null para borrarlo), freeDailyAiLimit (la asignación gratuita permanente, un entero de 0 a 10000; no modificable con BILLING_TOKEN), capabilities (la lista de capacidades propia de la cuenta, un array de etiquetas, [] para un registro que no concede nada, o null para eliminar el registro y que decida el valor por omisión de la instancia; modificable con BILLING_TOKEN, §5.19), trialScans (los escaneos gratuitos concedidos, un entero de 0 a 100, o null para retirar la prueba de escaneos; nunca modifica cuántos se han usado), suspended, displayName, label (la nota del operador, consulta más abajo, o null para borrarla). Se requiere al menos uno | ||
POST /v1/admin/accounts/:id/reset-mail | Inicia el restablecimiento del §5.12 por iniciativa del operador | ||
DELETE /v1/admin/accounts/:id | Borra la cuenta y todo lo asociado a ella | ||
GET /v1/admin/accounts/:id/blob/versions | Cada versión de blob retenida: número, versión del sobre, recuento de bytes, hora y el pin si tiene uno. Nunca texto cifrado | ||
POST /v1/admin/accounts/:id/blob/rollback | {"targetVersion": n}. Hace que esa versión vuelva a ser la actual BORRANDO toda versión posterior a ella (la protección de reducción del §5.1, ADR-0009). Rechaza una versión desconocida, la versión actual, una versión de sobre que esta compilación no acepte y una fila de cero bytes. Se trata de una reversión y no de una resubida, porque el AAD del §3.2 vincula blobVersion: reinsertar bytes antiguos como una nueva versión genera algo que ningún cliente puede descifrar | ||
GET /v1/admin/invites | Una página de invitaciones pendientes, más total | ||
POST /v1/admin/invites | Emite uno (§5.8). El token se devuelve una sola vez. "trial": true escribe la prueba de escaneo de la instancia en lugar de una cuota: 400 en una instancia que no ejecuta ninguna, y 400 junto a un dailyAiLimit. Sin el campo, el dailyAiLimit de la emisión pasa a ser la concesión gratuita permanente de la cuenta (freeDailyAiLimit) al canjearlo | ||
POST /v1/admin/trials/grant-lapsed | {"trialDays": n, "apply": false, "excludeAccountIds": []}. Lista, o con apply: true concede la prueba de escaneo de la instancia a, cada miembro cuya prueba diaria de trialDays terminó y nunca se movió: la fecha de su cuota sigue siendo igual a su canje más trialDays al milisegundo, algo que solo cambia un pago o un operador. Borra la fecha y establece el límite diario de la prueba. Idempotente: una cuenta concedida nunca se lista de nuevo. Responde {"accountIds": [...], "applied": bool} | ||
POST /v1/admin/invites/:id/resend | Un NUEVO token en la MISMA fila, y una nueva caducidad | ||
DELETE /v1/admin/invites/:id | Retira una invitación pendiente | ||
PATCH /v1/admin/settings | `{"nutrientReferenceBasis": "dge" \ | "efsa" \ | "us"}. The instance-wide reference basis (§5.6). Required; anything else is 400 and NOTHING is written. Answers {"settings": {...}}` con lo que la instancia contiene ahora |
GET /v1/admin/feedback | Una página de estimaciones reportadas (§5.25), las más recientes primero: { id, accountId, hasImage, consentWordingVersion, createdAt } cada una, más total, limit y offset. Sin cifras y sin fotografía | ||
GET /v1/admin/feedback/:id | Un reporte: los campos de la lista, measurements exactamente como los envió el dispositivo, y consent: { agreedAt, wordingVersion } | ||
GET /v1/admin/feedback/:id/image | Los bytes de la fotografía bajo su Content-Type almacenado, con Cache-Control: no-store y X-Content-Type-Options: nosniff. 404 cuando el reporte no tiene ninguna. Cada lectura se registra con el id del reporte y la credencial que la solicitó | ||
DELETE /v1/admin/feedback/:id | Elimina la fotografía y luego el reporte. 204, o 404 si el id no existe |
GET /v1/admin/ai/budget es el presupuesto de IA del operador: lo que le queda a la clave del proveedor y cuánto se ha usado de la capacidad de la instancia de hoy.
{
"day": "2026-09-30",
"capacity": {
"paid": { "used": 412, "limit": 2000 },
"trial": { "used": 37, "limit": 500 }
},
"upstream": {
"status": "ok",
"limitUsd": 5,
"remainingUsd": 3.94,
"reset": "monthly",
"usageDailyUsd": 0.12,
"usageWeeklyUsd": 0.4,
"usageMonthlyUsd": 1.06,
"checkedAt": "2026-09-30T10:00:00.000Z"
}
}dayes el día UTC en el que se calculan los topes.capacityestá en unidades, los recuentos ponderados por tamaño que reserva el proxy.paid.usedes lo que contó paraAI_INSTANCE_DAILY_LIMITytrial.usedlo que gastaron las cuentas en prueba de escaneo. Cadalimites el tope configurado, onullsi no hay ninguno. Sin un tope de prueba, las solicitudes de prueba también cuentan enpaid.used.upstreamesnullsi el proveedor ascendente no es OpenRouter. De lo contrario, es la lectura de clave delGET /keyde OpenRouter, en dólares:limitUsdyremainingUsdsonnullpara una clave sin límite, yresetes"daily","weekly","monthly"onullpara un límite que nunca se restablece. Una lectura fallida es{"status": "unavailable", "checkedAt": ...}, ycapacityse sigue reportando.- La lectura de la clave se ejecuta en el servidor con un tiempo límite de 5 segundos, y se sirve desde la memoria durante 60 segundos, o durante 15 si falló. El cuerpo no incluye ninguna clave, ninguna etiqueta de clave ni nada más de lo enviado por el proveedor.
- En la misma lectura, si
remainingUsdcae por debajo deAI_BUDGET_ALERT_FRACTION(por defecto 0.2) delimitUsd, el operador recibe un correo por periodo de reinicio enMAIL_OPERATOR_EMAIL. El servicio también lee la clave cada 15 minutos, para que el correo no espere a que alguien abra la consola.
PATCH es la única escritura vinculada a la autenticación que tiene un operador, y está delimitado de forma deliberada. No puede establecer una frase de contraseña, y no existe ningún endpoint que pueda: la frase de contraseña envuelve la clave de datos en el cliente, por lo que un cambio de credenciales en el servidor produciría una cuenta que inicia sesión y no descifra nada. No puede cambiar el email de una cuenta, porque la dirección es lo que verificó la invitación. No puede imprimir un código de recuperación.
Suspender revoca todas las sesiones en la misma operación. Un suspended_at por sí solo dejaría el teléfono en el bolsillo de alguien sincronizando durante otro cuarto de hora, que no es lo que un operador entiende por esa palabra. Reactivar no restaura ninguna sesión; la persona inicia sesión de nuevo.
Una CUENTA de administrador no puede suspenderse, degradarse ni borrarse a sí misma: 400, con {"error": "self-change"}. Una organización con un único administrador que haga eso dejará a todo el mundo fuera de este árbol, y el único remedio será una shell en el contenedor. El token estático queda exento, porque no tiene identidad propia y es la credencial que existe precisamente para esa situación.
label es la nota propia del operador sobre una cuenta, como "Beta supporter", o null si no hay ninguno. Cada cuenta en GET /v1/admin/accounts y GET /v1/admin/accounts/:id incluye la clave.
PATCHcon{"label": "Beta supporter"}la define y{"label": null}la borra. El valor se recorta, y una cadena que quede vacía tras recortarla también la borra, por lo que nunca se almacena una etiqueta vacía.- Como máximo 40 caracteres, contados como puntos de código Unicode, la unidad que cuenta
char_lengthde Postgres. Una etiqueta más larga, una con un salto de línea, una tabulación o cualquier otro carácter de control, y cualquier cosa que no sea una cadena onulles400y no se escribe nada del cuerpo. Una restricción CHECK en la columna impone el mismo límite, por lo que una herramienta que escriba directamente en ella también lo cumple. - Un dato del operador, nunca una entrada de autorización. Ninguna ruta lo lee para decidir nada. El
GET /v1/auth/accountpropio de la cuenta no lo incluye, la cuenta no puede definirlo (PATCH /v1/auth/accountsolo leedisplayName) y el usuario principal de facturación no puede leerlo ni escribirlo. pnpm core-api accounts set-label <id> "Beta supporter"lo define ypnpm core-api accounts clear-label <id>lo borra.
GET /v1/admin/accounts/:id/activity responde a la pregunta con la que un operador abre la consola: si esta persona sigue usando la instancia. Lee lo que el servicio ya almacena y no recopila nada nuevo.
{
"accountId": 7,
"lastSeenAt": "2026-09-06T18:30:00.000Z",
"window": { "days": 90, "fromDay": "2026-06-10", "toDay": "2026-09-07" },
"days": [
{ "day": "2026-06-10", "count": 0 },
{ "day": "2026-06-11", "count": 3 }
]
}lastSeenAtesnullpara una cuenta que nunca ha iniciado sesión, y solo se escribe mediante un inicio de sesión y una compleción con proxy, nunca mediante una renovación de token ni un sondeo de sincronización (§9.2). Se transmite como un marca de tiempo; una frase relativa es una decisión de renderizado y corresponde al cliente.dayscontiene todas las día de la ventana, en orden, concount: 0para un día que no tenga fila. Un día ausente y un día sin actividad no deben verse iguales para quien lea la franja.?days=Nreduce la ventana.Ndebe ser un entero de al menos 1, o la respuesta será400. Una ventana superior a 90 días se responde con 90, ywindowinforma de lo que realmente se representó. Noventa es la ventana de retención inferior, por lo que una franja más larga solo contendría ceros para las filas que se han eliminado.- Un id desconocido devuelve el mismo
404que cualquier otra ruta de cuentas, y todo el árbol está protegido por las credenciales anteriores.
GET /v1/admin/activity responde a esa misma pregunta para una página completa de una vez, porque una lista de personas dibuja una franja junto a cada fila y pedirlo una vez por fila generaría un N+1.
{
"window": { "days": 7, "fromDay": "2026-09-02", "toDay": "2026-09-08" },
"accounts": [{ "accountId": 2, "days": [{ "day": "2026-09-02", "count": 0 }] }],
"total": 4
}?limit=y?offset=se comportan exactamente que enGET /v1/admin/accounts: mismos valores predeterminados, mismo tope, mismo400con la misma frase. Es el contrato, no una coincidencia: quien llama pagina los dos endpoints en sincronía y dibuja la franjanjunto a la personan, por lo queaccountsaquí sigue el orden que devuelve esa lista para la misma página, ytotales eltotalde esa lista.?days=Nes la ventana del endpoint anterior, acotada de la misma manera: un entero de al menos 1 o un400, más de 90 se responde con 90, ywindowinforma de lo que se representó.- Aparecen todas las cuentas de la página, incluida una que nunca haya hecho una petición, cuyo
dayses una serie de ceros. Si se omitiera una cuenta, «esta persona no hizo nada» y «esta persona no estaba en la respuesta» serían el mismo hecho, que es el error que el relleno con ceros por día existe para evitar, un nivel más arriba. - Cada entrada es
accountIdydays, y nada más. La dirección, el nombre y la cuota pertenecen aGET /v1/admin/accounts, que quien llama ya está leyendo.
Retención: los contadores de uso se conservan durante 90 días. ai_usage_days guarda un entero por cuenta y por día UTC (§9.2). Un barrido horario dentro del servicio elimina todas las filas con más de 90 días de antigüedad, contando hoy, en cada instancia y sin que intervenga el operador ni exista una entrada de cron. Al eliminar una cuenta se borran sus contadores y su lastSeenAt en la misma sentencia que el resto del borrado, mediante ON DELETE CASCADE. Noventa es un solo número en un único lugar: es el límite en el que poda el barrido y la ventana más larga a la que el endpoint anterior puede responder.
La entidad principal de facturación (BILLING_TOKEN). Un servicio de pagos necesita modificar dos números y una lista en una cuenta: el fin de una cuota, el número de peticiones de IA al día que esta adquiere y las etiquetas de las funciones de IA que activa. Darle el token de operador le daría cada dirección de la instancia, el botón de borrado y las fotos reportadas, por lo que la credencial se delimita a la entrada. Es opcional, no está configurada por omisión y exige el mismo mínimo de 24 caracteres que el token de operador.
| Endpoint | La entidad principal de facturación puede |
|---|---|
GET /v1/admin/accounts/expiring | Leer { id, allowanceExpiresAt } para las cuentas cuya fecha de fin esté en el futuro, paginado con la misma sentencia de limit, offset y 400 que cualquier otro endpoint paginado de aquí |
GET /v1/admin/accounts/:id | Lee { id, allowanceExpiresAt, dailyAiLimit, capabilities } para esa cuenta concreta, donde capabilities es el registro propio de la cuenta (null significa sin registro) |
PATCH /v1/admin/accounts/:id | Escribe allowanceExpiresAt, dailyAiLimit y capabilities, y nada más. trialScans se rechaza como cualquier otro campo: una credencial que paga una cuota no reparte escaneos gratuitos |
- Cualquier otra ruta de esta sección responde
403con{"error": "service-scope"}, incluidas las cuatro rutas de comentarios y cualquier ruta añadida después de escribir esto. El rechazo se produce en el punto de montaje, antes de que se ejecute ningún manejador y antes de que se lea ninguna fila, por lo que no sirve como oráculo para saber si una cuenta existe. - Un cuerpo
PATCHque mencione cualquier otro campo recibe403con{"error": "service-scope-field"}, y no se escribe nada, ni siquiera los campos permitidos que tenga al lado. Un descarte silencioso haría que un defecto en el servicio de facturación se interpretara como un éxito. - Los valores también tienen un ámbito limitado.
allowanceExpiresAt: null(una cuota sin fin) y undailyAiLimitsuperior alBILLING_MAX_DAILY_AI_LIMITde la instancia (1000 por omisión) dan403con{"error": "service-scope-value"}, y no se escribe nada. Las credenciales de operador pueden escribir ambos.capabilitiesadmite cualquier lista válida, además denull, que elimina el registro y por tanto nunca concede más que el valor por omisión de la instancia elegido por el operador. Una lista mal formada devuelve el400habitual, tanto para esta credencial como para un operador. - Aparte de eso,
dailyAiLimitse valida exactamente igual que para un operador. La credencial no relaja ninguna validación. - Las dos lecturas son proyecciones y nunca un
AccountView. Ni dirección, ni nombre visible, ni rol, ni suspensión, ni uso, ni blob. `GET
/v1/admin/accounts/expiring` selecciona dos columnas en la consulta en lugar de filtrar una fila después.
- Una cuenta eliminada y un id desconocido son el mismo
404. El borrado aquí es en cascada y no una marca de baja (§9), por lo que no queda nada con lo que distinguirlos, y undeletedAtque esta ruta pudiera devolver sería el registro de una persona conservado tras el borrado que la eliminó. Ambos significan «deja de cobrar». - La entidad principal no tiene un «yo», por lo que la regla anterior de automodificación no puede aplicársele: no puede suspender, degradar ni eliminar a nadie, incluyéndose a sí misma, porque ninguna de esas rutas es accesible.
AccountView tiene la misma estructura que devuelve el GET /v1/auth/account propio de la cuenta (§5.15), con invitesLeft incluido y calculado de la misma forma, más aiUsedToday, y en la superficie de administración más lastSeenAt, label, blob y keyRecordKinds. capabilities y freeDailyAiLimit en la superficie de administración son el registro PROPIO de la cuenta (para capabilities, null significa sin registro), mientras que la vista propia de la cuenta informa del valor efectivo. healthConsent también está incluido, y aquí es solo lectura: PATCH /v1/admin/accounts/:id no lo lee, porque un consentimiento que un operador pudiera fijar en nombre de otra persona no demostraría nada (§5.15.1). Contiene sin verificador, sin descriptor de KDF, sin depósito de claves y sin texto cifrado. Un blob se reporta como un recuento de bytes y una marca de tiempo. La justificación es docs/adr/0001-an-admin-api-for-a-zero-knowledge-service.md, cuyas prohibiciones 1, 2, 3, 5 y 8 quedan sustituidas por ADR-0005, pero no su prohibición sobre secretos en una respuesta.
5.21 POST /v1/auth/invites: un miembro invita a alguien
Bearer, con limitación de frecuencia por dirección de origen mediante cada intento contabilizado. Solo presente cuando el despliegue define tanto MEMBER_INVITE_DAILY_AI_LIMIT como MEMBER_INVITE_ALLOWANCE_DAYS, o en su lugar MEMBER_INVITE_TRIAL junto a la prueba de escaneo de la instancia; sin ninguna de las dos opciones, esta ruta responde con el 404 habitual de ruta desconocida a cualquiera que llame, haya iniciado sesión o no, y instance.memberInvites es false (§5.6).
Petición: {"email": "boris@example.org"}, y nada más.
{}→ 202 con ese cuerpo, vacío y fijo.
Las condiciones son las de la instancia, nunca las de quien llama. La cuenta invitada obtiene role: "member", la misma duración de invitación predeterminada para la generación de admin, y UNA de dos concesiones, nunca ambas: bajo el par diario, dailyAiLimit de MEMBER_INVITE_DAILY_AI_LIMIT y una allowanceExpiresAt de canje más MEMBER_INVITE_ALLOWANCE_DAYS escrita al registrarse; bajo MEMBER_INVITE_TRIAL, la prueba de escaneo de la instancia (§5.19) sin fecha. Una invitación de miembro generada bajo el par diario y canjeada después de que la instancia cambiara obtiene la prueba de escaneo, no una fecha. Un dailyAiLimit, un role o un expiresInDays en el cuerpo no se rechaza, simplemente no se lee. Esos tres SÍ son campos del cuerpo en POST /v1/admin/invites (§5.20), que es lo que distingue a un miembro de un operador.
La respuesta NO DEBE variar según el estado real de la dirección. Una dirección nueva, una dirección que ya tiene una invitación pendiente y una dirección que ya tiene una cuenta devuelven el mismo 202 con el mismo cuerpo. Esta es la propiedad antienumeración de §5.7 y §5.12 aplicada al único endpoint que un miembro dirige al buzón de otra persona: alguien que escribe la dirección de un colega no debe enterarse, ni por un código de estado, ni por un cuerpo, ni por una cabecera, de que su colega ya está aquí. El 409 {"error":"an account already exists for this email"} de creación del administrador está exento, y únicamente porque se encuentra tras la propia credencial del operador.
Cuando la dirección ya tiene una cuenta, el servicio le envía a esa persona una nota breve en lugar de una invitación. No incluye ningún enlace: un enlace para unirse crearía una segunda cuenta para alguien que ya tiene una, y un enlace de restablecimiento sería un cambio de contraseña que nadie ha pedido. Sin esta carta la invitación desaparecería en silencio y ambas personas se quedarían esperándola.
Una dirección que ya ha canjeado una invitación generada por un miembro no recibe una segunda, y aun así se le dice 202 a quien llama. La prueba sobrevive a la cuenta: la fila de la invitación conserva su dirección y su instante de canje cuando se elimina cualquiera de las dos cuentas, por lo que una autoeliminación seguida de una reinvitación de un amigo no supone una cuota nueva. En una instancia que ejecuta una prueba de escaneo, la eliminación quita en su lugar la dirección de la fila y conserva el hash con clave de §5.15, y la regla lee ese hash. La generación realizada por un operador no es una invitación provocada por un miembro y esta regla nunca la retiene.
Una invitación pendiente de otra puerta se deja intacta, y se sigue respondiendo 202 a quien llama. Una emisión sustituye la invitación pendiente de la dirección, por lo que sin esta regla un miembro podría retirar la carta que acababa de enviar un operador, la puerta de solicitud de §5.8.3 u otro miembro, y poner en su lugar las condiciones de su propia puerta. No se escribe ninguna fila ni se envía ninguna carta. Un miembro PUEDE reenviar su propia invitación pendiente, lo que la sustituye como antes. El rechazo es silencioso en vez de explícito porque un rechazo explícito indicaría a quien llama que otra persona ya ha invitado a este usuario. Un administrador que use esta ruta queda exento, al igual que en la emisión de administración.
Cuando se elimina una cuenta, se retiran las invitaciones que envió y que aún siguen pendientes en la misma transacción (§5.15). Las canjeadas y las caducadas se conservan como están, y siguen sin contar contra nadie: quien invitó ya no existe.
El límite absoluto es de cinco por cuenta en total, contabilizadas como filas. Las invitaciones retiradas y caducadas cuentan: el límite se aplica a cuántas cartas generó una cuenta, no a cuántas funcionaron. Superarlo devuelve 403 {"error":"member-invite-cap-reached"}, y es lo único que este endpoint revela sobre la propia cuenta del emisor, siendo un dato sobre él y sobre nadie más. Un administrador está exento, tanto en esta ruta como en la de administración, que es lo que significa invitesLeft: null (§5.15).
Una prueba de escaneo que nadie ha pagado no invita a nadie. Cada invitación de miembro en MEMBER_INVITE_TRIAL es una nueva prueba de escaneo, por lo que una cuenta gratuita que pudiera invitar crearía más cuentas gratuitas. Una cuenta que tiene trialScans y no tiene ningún allowanceExpiresAt en el futuro responde 403 {"error":"invites-need-a-plan"}, no escribe ninguna fila y no envía ninguna carta. Una fecha futura abre la ruta, sin importar quién la haya escrito: el facturador al pagar, o un operador. El límite total se comprueba primero, de modo que una cuenta que ha agotado su cuota recibe member-invite-cap-reached, porque pagar no le serviría de nada. Un administrador también está exento aquí, y la creación por parte de administradores (§5.20) no cambia. invitesNeedAPlan en la vista de la cuenta (§5.15) dice lo mismo antes de que la persona lo intente.
202 tampoco incluye ningún token ni ningún enlace, a diferencia de la creación por el administrador. El emisor de la llamada no es el operador y no debe tener permisos para crear una cuenta.
5.22 /v1/plans/*: la redirección directa a un sistema de facturación
Presente solo si el operador ha configurado un sistema de facturación. Sin él, todo el subárbol responde con el 404 habitual de ruta desconocida a todo el mundo, con o sin credenciales, y instance.plans vale false en el protocolo de enlace (§5.6). Una implementación de este protocolo PUEDE omitir el subárbol por completo; un cliente DEBE leer instance.plans antes de mostrar una opción de plan en lugar de sondear la ruta.
Nada de lo que hay tras este prefijo forma parte de este protocolo. Las rutas, los cuerpos de las peticiones y los cuerpos de las respuestas pertenecen al sistema de facturación, que es un servicio independiente con su propio ciclo de versiones. Este documento solo especifica lo que hace la pasarela con una petición en su camino hacia allí y con una respuesta al volver. Esto es deliberado: la alternativa sería un documento normativo inservible para quienes se autoalojan, atado a los cambios del calendario de IVA de un tercero.
Autenticado con el token de acceso habitual de la cuenta (§4.1). Un cliente anónimo recibe el 401 habitual. La única excepción es GET /v1/plans/prices, más abajo: una sola ruta y un solo método, y nada más en el subárbol.
POST /v1/plans/order
Authorization: Bearer <accessToken>
Content-Type: application/json
{ "plan": "…", "locale": "…", "consentVersion": "…", "consents": { … } }El ejemplo es ilustrativo: las rutas del facturador son suyas propias. El facturador de openplate sirve GET /v1/plans/prices, GET /v1/plans/offer, POST /v1/plans/order, GET /v1/plans/me, la ruta del portal y POST /v1/plans/pending-change/cancel; su antiguo POST /v1/plans/checkout responde ahora 410.
Cinco propiedades que una implementación conforme DEBE cumplir:
- Solo se reenvían
GETyPOST. Cualquier otro método del subárbol devuelve405 {"error":"plans-method-not-allowed"}con una cabeceraAllow, y nunca llega al servicio ascendente. La pasarela no conoce las rutas del sistema de facturación, por lo que un proxy que lo reenviara todo equivaldría a un túnel de propósito general hacia un servicio que almacena el estado de las suscripciones. - Las cabeceras reenviadas se CONSTRUYEN, nunca se copian y sobreescriben. Son exactamente
X-Account-Idde la sesión resuelta,X-Account-Emailleída de la fila de la cuenta,X-Plans-Secretcon el secreto compartido y laContent-Typeentrante. Un esquema de copiar y sobreescribir reenviaría las cookies y cualquier otra cosa que el siguiente cliente decida mandar. - La propia credencial del emisor de la llamada nunca se reenvía. Esta es la regla en la que se basa todo el diseño: reenviar el token de acceso convertiría el sistema de facturación en un segundo lugar donde funcionaría un token robado.
- El id de la cuenta es el de la sesión, y la dirección es la de la fila. Un cliente que envía su propio
X-Account-IdoX-Account-Emailno puede influir en lo que lee el servicio ascendente. UnaccountIdque un navegador pueda elegir es un fallo de autorización, y un sistema de facturación que leyera la dirección de esa cuenta para rellenar un proceso de pago sería un oráculo de revelación de direcciones. - La respuesta pasa directamente con su estado y su cuerpo JSON, y de vuelta solo la acompaña
Content-Type. Un402o un409del facturador es una respuesta real sobre el plan del solicitante y se retransmite como tal. Dos de las rutas del facturador de openplate muestran el motivo. Una subida de nivel se factura y se paga al momento, por lo quePOST /v1/plans/orderresponde a una tarjeta rechazada con402 {"error":"payment-failed"}y el solicitante permanece en el nivel anterior. Una bajada de nivel se programa para el final del periodo pagado, yPOST /v1/plans/pending-change/cancel(sin cuerpo) la cancela:200 {"kept":{"plan":"…","tier":"…"}},409 {"error":"no-pending-change"}cuando no hay nada programado (también la respuesta a una segunda llamada), o502 {"error":"pending-change-cancel-failed"}cuando el proveedor de pagos falló y el cambio sigue programado. La pasarela retransmite cada una sin cambios y no añade ninguna ruta, ningún código ni ninguna comprobación propia. Ese502es la respuesta propia del facturador y no es uno de los códigosplans-upstream-*de la pasarela que figuran más abajo. La respuestaGET /v1/plans/mey la respuesta de la orden200de la bajada de nivel programada pueden incluirpendingTierypendingChangeAt, ausentes si no hay nada programado; un cliente que no los reconozca los ignora.
Un servicio ascendente inalcanzable, que agota el tiempo de espera, responde algo que no es JSON o devuelve un cuerpo que supera el límite de retransmisión genera 502 dentro de la envoltura de §4 con un código legible por máquina: plans-upstream-unreachable, plans-upstream-timeout o plans-upstream-invalid. Un cuerpo de petición que supere el límite reducido del propio subárbol devuelve 413 {"error":"plans-request-too-large"}, lo cual indica algo distinto: el sistema de facturación funciona bien, pero lo que has enviado jamás se aceptará. No se registra ningún cuerpo en ninguna de las dos direcciones; un rechazo se registra con el estado y la ruta, y nada más.
La llamada saliente incluye un tiempo de expiración explícito. Es corto, porque cada ruta aquí corresponde a un botón que alguien acaba de pulsar, y sirve tanto para acotar el tope oculto de 300 segundos de undici como para limitar un sistema de facturación lento.
Aviso de borrado (del servicio al facturador). Antes de que cualquiera de las vías de borrado (§5.15, §5.20) elimine una cuenta, el servicio envía POST <PLANS_UPSTREAM_URL>/erase exactamente con X-Plans-Secret y X-Account-Id, y un cuerpo vacío. El facturador responde 204 una vez canceladas todas las suscripciones activas de esa cuenta, y 204 cuando no hay ninguna. La llamada tiene un tiempo límite de cinco segundos. Un rechazo, un agotamiento de tiempo de espera o un host caído se registra en error con el id de cuenta, y la cuenta se elimina de todos modos; la conciliación nocturna del facturador permanece como respaldo.
El operador configura PLANS_UPSTREAM_URL y PLANS_UPSTREAM_SECRET, ambos o ninguno. Una URL sin secreto provoca que el sistema se niegue a arrancar en vez de aplicar una degradación silenciosa: el secreto es lo único que indica al facturador que el identificador de cuenta que lee procede de una pasarela que ha autenticado a alguien.
GET /v1/plans/prices: la lista de precios, antes de iniciar sesión
Una pantalla de registro muestra el precio antes de que nadie tenga un token, por lo que esta ÚNICA ruta, con este ÚNICO método, es anónima. No hace falta ningún token. Si se envía un token de todos modos, no se lee y nunca se reenvía, de modo que uno caducado o ajeno no puede convertir la lectura en un 401. Cualquier otra ruta del subárbol, así como un POST o un HEAD en esta misma ruta, sigue respondiendo 401 a un cliente anónimo.
GET /v1/plans/prices→ 200 con Cache-Control: public, max-age=300:
{
"currency": "EUR",
"plans": [
{ "key": "monthly", "interval": "month", "grossCents": 500 },
{ "key": "yearly", "interval": "year", "grossCents": 4000 }
]
}El cuerpo pertenece al facturador y se retransmite sin leer, como todas las respuestas de este subárbol. El ejemplo muestra lo que sirve el facturador de openplate: los planes que vende, cada uno con el importe cobrado por interval en la unidad fraccionaria de currency, impuestos incluidos, leídos desde su proveedor de pagos al arrancar. Las cifras anteriores son un ejemplo, nunca una lista de precios.
La pasarela retransmite el cuerpo intacto, por lo que el facturador puede añadirle datos. La pasarela analiza el cuerpo solo para comprobar que es un JSON del tamaño permitido, y ni lee ni reescribe ningún campo. Por lo tanto, un facturador que venda niveles puede añadir un array tiers junto a currency y plans. Una entrada de tiers tiene la misma estructura que una entrada del array tiers de la oferta del facturador (GET /v1/plans/offer): id, name, description, isSold, dailyAiLimit, capabilities y su propio plans. La oferta es el contrato del facturador y no forma parte de este protocolo (ver más arriba), por lo que la entrada se describe aquí solo para que quien lea sepa qué esperar:
{
"currency": "EUR",
"plans": [
{ "key": "monthly", "interval": "month", "grossCents": 500 },
{ "key": "yearly", "interval": "year", "grossCents": 4000 }
],
"tiers": [
{
"id": "tier-a",
"name": "…",
"description": "…",
"isSold": true,
"dailyAiLimit": 10,
"capabilities": ["scan"],
"plans": [
{ "key": "monthly", "interval": "month", "grossCents": 500 },
{ "key": "yearly", "interval": "year", "grossCents": 4000 }
]
}
]
}Un cliente DEBE ignorar cualquier campo que no conozca, en el nivel superior y dentro de una entrada, y NO DEBE rechazar el cuerpo por ello. Un cuerpo sin tiers es tan válido como antes, y un cliente que nunca lea tiers lee currency y plans exactamente igual que antes. Los identificadores son etiquetas que eligió el facturador (tier-a es un marcador de posición), el orden de tiers es el orden propio del facturador, y los importes repiten las cifras del ejemplo anterior solo para que la estructura sea visible.
Cuatro propiedades diferencian esta ruta del resto del subárbol:
- Sale únicamente con
X-Plans-Secret. No hay cuenta, por lo que no hayX-Account-IdniX-Account-Email, y nada de la petición entrante viaja: ni una cabecera, ni los parámetros de consulta. - Un
200se conserva durante cinco minutos y servido desde memoria, de modo que una ráfaga de lectores equivale a una sola llamada al facturador. Un rechazo del facturador y una llamada fallida se retransmiten como se indicó antes y no se guardan, por lo que el siguiente lector vuelve a consultar. - Una dirección de origen puede leerlo 60 veces en cualquier intervalo de un minuto. Quien llama con IPv6 cuenta como su /64, y una dirección IPv6 mapeada a IPv4 cuenta como la dirección IPv4 que contiene. La siguiente lectura es
429 {"error":"plans-prices-rate-limited"}conRetry-Afteren segundos. - Sin un sistema de facturación, devuelve el
404habitual para rutas desconocidas, como el resto del subárbol, y/healthno publica nada nuevo para él: un cliente que leeinstance.plansya sabe si debe solicitarlo.
5.23 /v1/pulse/*: el pulso de la comunidad (ADR-0007)
Activación voluntaria en el dispositivo, y apagado hasta que una persona lo active. Nada de lo que hay aquí se deriva de un diario: ninguna ruta de código en el servidor descifra ninguno. Cada número que aparece a continuación llega como un pequeño delta desde un dispositivo cuyo propietario lo solicitó, y ADR-0007 detalla con precisión qué sale del dispositivo y por qué.
Cuatro rutas, todas protegidas por la token de acceso habitual de la cuenta (§4.1). Una llamada anónima recibe el 401 habitual.
POST /v1/pulse/meal
Authorization: Bearer <accessToken>
Idempotency-Key: 6f1c3a1e-9d7b-4a2f-8b31-0f4e9a2c7d55
Content-Type: application/json
{ "kcal": 1234, "protein": 33 }POST /v1/pulse/photo
POST /v1/pulse/fasting
Authorization: Bearer <accessToken>
Idempotency-Key: <uuid>Ambos llevan un cuerpo vacío.
GET /v1/pulse/today
Authorization: Bearer <accessToken>
200 {
"day": "2026-09-12",
"meals": 42,
"photos": 17,
"kcal": 68350,
"protein": 2140,
"contributors": 9,
"fastingNow": 4
}Siete propiedades que una implementación conforme DEBE mantener:
- El servidor vuelve a redondear y a limitar los valores.
kcalse redondea al múltiplo de 50 más cercano y se limita al rango de 0 a 5000;proteinse redondea al múltiplo de 5 más cercano y se limita al rango de 0 a 500. Si un dispositivo envía una cifra exacta, un valor negativo o un valor absurdo, cae igualmente en la cuadrícula en la que están todos los demás. Un cuerpo que no contenga dos números finitos da400 {"error":"invalid request body"}. - Cada escritura lleva una cabecera
Idempotency-Key, un uuid, y una petición sin ella devuelve400 {"error":"idempotency key required"}. La clave se conserva 24 horas. Una repetición dentro de esa ventana responde con200 {"duplicate": true}y no modifica nada, lo que hace seguro un reenvío sin conexión o un reintento. - Los límites de tasa se aplican por cuenta:
POST /v1/pulse/mealyPOST /v1/pulse/photoa una por minuto cada una,POST /v1/pulse/fastinga una cada 10 minutos. Superar el límite devuelve429con una cabeceraRetry-Afteren segundos y un cuerpo que no incluye ningún identificador. - Un latido de ayuno es un upsert sobre el identificador de la cuenta. Dos latidos dejan una única fila, con la expiración más tardía. La fila expira 30 minutos después del último latido, y
fastingNowcuenta solo las filas que no han expirado. La presencia se vincula a la cuenta en lugar de ser anónima porque tanto el límite de tasa como la deduplicación necesitan una identidad, y un latido anónimo podría reproducirse para inflar la cifra (ADR-0007). GET /v1/pulse/todayse sirve desde una caché en memoria del proceso de cinco minutos, una única entrada para toda la instancia, invalidada por tiempo y nunca por una escritura. El cliente la solicita como máximo cada cinco minutos. Por tanto, una escritura realizada dentro de la ventana no resulta visible hasta que la entrada expira, algo deliberado y no un defecto: las cifras son una señal de compañía, no un acuse de recibo.- Las sumas del día se conservan 30 días.
pulse_daysy las filas de colaboradores asociadas se eliminan mediante un barrido horario al superar esa antigüedad, las filas de presencia expiradas se van con ellas, y las claves de idempotencia se borran a las 24 horas. - Las rutas de pulso solo registran un código de estado y un conteo de bytes. Nunca el identificador de la cuenta, y nunca un valor del cuerpo.
GET /v1/admin/stats (§5.20) notifica el pulso de hoy al operador como pulse: { meals, photos, kcal, protein, contributors, fastingNow }, que es el mismo conjunto que cualquier miembro ya puede leer.
5.24 /v1/push/*: web push (ADR-0008)
*Desactivado a menos que el operador haya configurado las tres `VAPID_ variables**, and then opt in per device. With none of them set the whole subtree answers the ordinary unknown-path 404 to everybody, credentialed or not, and GET /health reports instance.push: false`.
El servidor no redacta texto de notificación alguno. Cada push que envía tiene un único campo:
{ "kind": "catch-up" }
{ "kind": "fast-target" }El dispositivo se activa, lee el diario que solo él puede leer y escribe las palabras. Un cliente conforme DEBE poder renderizar algo para cualquiera de los dos tipos sin que la carga útil le indique nada, porque la carga útil nunca lo hará.
Cuatro rutas, todas tras el token de acceso habitual de la cuenta (§4.1). Quien llame de forma anónima a una instancia configurada recibe el 401 habitual.
GET /v1/push/config
Authorization: Bearer <accessToken>
200 { "publicKey": "<VAPID application server key, base64url>" }PUT /v1/push/subscriptions
Authorization: Bearer <accessToken>
Content-Type: application/json
{
"endpoint": "https://push.example.org/f7a1…",
"keys": { "p256dh": "<base64url>", "auth": "<base64url>" },
"replaces": "https://push.example.org/older…",
"timeZone": "Europe/Berlin",
"locale": "de",
"catchUpMinute": 480,
"fastTargetEnabled": true
}
201 { "subscribed": true } a device seen for the first time
200 { "subscribed": true } the same endpoint againPATCH /v1/push/subscriptions
{ "endpoint": "…", "timeZone": "…", "locale": "…", "catchUpMinute": 420, "fastTargetEnabled": false, "wakeAt": "2026-01-15T18:30:00Z" }
200 { "updated": true }
404 { "error": "no such subscription" }DELETE /v1/push/subscriptions
{ "endpoint": "…" }
200 { "unsubscribed": true }Nueve propiedades que una implementación conforme DEBE cumplir:
- Una suscripción se identifica por su endpoint, generado por el servicio push y único a nivel global.
PUTrealiza una inserción o actualización (upsert) sobre él: un dispositivo que vuelva a registrar el mismo endpoint recibe200y conserva la fecha en que se vio por primera vez. replacesindica el endpoint al que este registro reemplaza, y se elimina solo cuando pertenece a la misma cuenta. Cuando un service worker vuelve a registrarse genera un endpoint nuevo sin cancelar la suscripción del anterior, de modo que sin esto el huérfano quedaría ahí respondiendo201a nadie para siempre. Unreplacesigual aendpointes un dispositivo nombrándose a sí mismo y no elimina nada.timeZonees un nombre IANA y se valida al escribirlo. Una zona desconocida es400. Todo el recordatorio depende del reloj local, por lo que una zona que el servidor no pueda leer causaría una notificación a una hora incorrecta en lugar de un error.catchUpMinutees un minuto del día local, de 0 a 1439, onullpara "sin recordatorios en este dispositivo".nulles el valor predeterminado silencioso.wakeAtes un instante único, ISO 8601, onullpara borrarlo. El servidor envía la alerta de objetivo rápido cuando este se supera y borra la columna en la misma escritura, por lo que nunca puede dispararse dos veces. Si un campo falta en unPATCH, se deja exactamente como estaba.- El recordatorio se envía una vez por día LOCAL, cuando el propio reloj de la suscripción ha superado su minuto y todavía no ha salido hoy allí. Un día local durante un cambio de hora dura 23 o 25 horas, por lo que un intervalo UTC no implementa esta regla.
- Siete días de silencio lo pausan. Una suscripción cuyo último registro o cambio de horario ocurrió hace más de siete días locales no recibe recordatorios hasta que regrese.
- Como máximo dos envíos push por suscripción por día UTC. Un tercero se descarta, nunca se pone en cola.
- Un
404o un410del servicio push elimina la fila. Ninguna otra cosa lo hace: un400, un401, un403, un429y cada5xxson transitorios o conciernen al remitente, y purgar basándose en ellos vaciaría la tabla la primera vez que se pegara mal una clave. - No se envía nada a una cuenta sin consentimiento. Cuando
instance.healthConsentno seanull(§5.6), el servidor no envía ninguna notificación push a una cuenta que carezca de esa versión exacta (§5.15.1), sin importar la programación. Una notificación push retenida no deja marca, por lo que una puesta al día pendiente se envía en el siguiente intervalo tras recibir el consentimiento de la persona.
Los temas de colapso son openplate-catchups y openplate-fast, el TTL es de 6 horas, y la urgencia es normal para el recordatorio y alta para el objetivo rápido. Un tema DEBE consistir en caracteres base64 seguros para URLs, 32 como máximo, y una longitud que sea nunca 1 mod 4: en caso contrario, Apple decodifica el tema y responde 400 BadWebPushTopic, mientras que otros servicios push lo aceptan, por lo que el fallo pasa desapercibido en todo salvo en un iPhone.
Los cinco límites (2026-09-30). Para que una cuenta no pueda bloquear todos los envíos ni dirigir este servidor a un host interno:
- El endpoint debe ser
https, en el puerto por defecto, sin nombre de usuario ni contraseña, en un servicio de notificaciones push conocido:fcm.googleapis.com,updates.push.services.mozilla.comy*.push.services.mozilla.com,web.push.apple.comy*.push.apple.com,*.notify.windows.com, más cualquier host que el operador indique enPUSH_ENDPOINT_HOSTS. Cualquier otra cosa es400 {"error":"endpoint must be an https URL at a known push service"}y no escribe nada. Si una fila almacenada no cumple esta regla, el siguiente tick la elimina sin enviarla. - Una cuenta mantiene como máximo 10 suscripciones. Si un registro supera ese límite, se eliminan las demás filas más antiguas de la cuenta; la fila que se acaba de registrar siempre se conserva.
- Un envío se cancela tras 10 segundos, y el tick envía a 8 endpoints a la vez, de modo que un endpoint lento no retrasa a nadie más.
- Un envío que falla con cualquier cosa salvo
404o410aplica un retraso a la fila: el siguiente intento es un minuto después, luego dos, cuatro, y así sucesivamente hasta un día. Tras 15 fallos seguidos, unos cuatro días y medio, la fila se elimina. Un envío que se completa con éxito y un nuevo registro del endpoint reinician el recuento. - Si un tick dura más de un minuto, no se ejecuta un segundo tick a la vez.
PUT en un endpoint que pertenece a otra cuenta transfiere la fila al emisor de la llamada. Esto es necesario: la app reutiliza la suscripción existente del navegador, y cuando el borrado de un dispositivo no pudo descartarla, la siguiente cuenta en ese navegador registra el mismo endpoint. La fila del propietario anterior deja entonces de activar ese dispositivo, que es lo que el nuevo propietario busca.
Ninguna ruta devuelve jamás un endpoint o una clave de dispositivo, y las rutas solo registran una ruta, un método, un estado y un recuento de bytes: nunca el id de cuenta y nunca el endpoint, que constituye una capacidad.
GET /v1/admin/stats (§5.20) informa push: { subscriptions, sentToday } a quien opera el sistema, que son dos enteros y nunca una fila.
5.25 POST /v1/feedback: una estimación reportada (ADR-0006)
Presente solo cuando el operador configuró SYNC_FEEDBACK. Sin él, la ruta responde a todo el mundo con el 404 habitual de ruta desconocida, con o sin credenciales, y GET /health no incluye instance.feedback (§5.6). Un cliente DEBE leer instance.feedback antes de ofrecer un reporte. DEBE indicar la ventana de retención que ese campo anuncia y ninguna otra.
Esta es la única escritura de este protocolo que el servidor puede leer. Una persona que considera errónea una estimación envía las cifras de esa entrada. Si el dispositivo aún conserva la foto del plato, también la envía. La persona debe aceptar antes que ambas salgan del dispositivo. El servidor las mantiene legibles hasta que venza la ventana. ADR-0006 explica por qué existe esta excepción.
Autenticado con el token de acceso habitual de la cuenta (§4.1). Un emisor anónimo recibe el 401 habitual.
POST /v1/feedback
Authorization: Bearer <accessToken>
Content-Type: application/json
{
"idempotencyKey": "6f1c3a1e-9d7b-4a2f-8b31-0f4e9a2c7d55",
"measurements": { "…": "…" },
"consent": { "agreedAt": "2026-09-19T08:12:00.000Z", "wordingVersion": "1" },
"image": { "contentType": "image/jpeg", "data": "<base64>" }
}
201 { "reportId": 17, "hasImage": true, "createdAt": "2026-09-19T08:12:03.000Z" }Siete propiedades que una implementación conforme DEBE mantener:
- Todos los campos salvo
imageson obligatorios.idempotencyKeytiene de 1 a 128 caracteres tras eliminar espacios,consent.wordingVersiontiene de 1 a 64 yconsent.agreedAtes un instante. Se almacena según el propio reloj del dispositivo y nunca se corrige. ElcreatedAtdel servidor se guarda a su lado.measurementses un objeto JSON de como máximo 16 KB una vez serializado. El servidor no tiene un esquema para él y NO DEBE definir ninguno: el límite existe para que nadie pueda alojar un diario en el campo. Cualquier otro cuerpo da400 {"error":"invalid request body"}, una frase por cada campo. imagees opcional, y omitirlo no es un error. La ausencia de clave onullsignifica que no hay fotografía. Puede que la caché de fotos del dispositivo ya la haya descartado, y aún vale la pena revisar las cifras. Cuando está presente, es{ "contentType", "data" }.contentTypeesimage/jpeg,image/pngoimage/webp. Nunca esimage/svg+xml, que puede contener scripts.dataes base64 que se decodifica en 1 a 5.000.000 de bytes. Si es mayor da413, y si está vacío o es de otro tipo da400.- El límite del cuerpo es
FEEDBACK_MAX_REQUEST_BYTES, 8 MB por defecto, y solo aplica a esta ruta. Está por encima del límite de la imagen porque base64 aumenta el tamaño en un tercio. Un cuerpo mayor da413 {"error":"request body exceeds the maximum accepted size"}. - La clave de idempotencia permite reintentar de forma segura. Es única por cuenta. Un segundo envío con una clave existente responde
200con el reporte almacenado en lugar de201. No guarda un segundo reporte ni cuenta para el límite diario. Escribe de nuevo la fotografía si se incluyó una. Esto repara un reporte cuya primera escritura de fotografía se interrumpió. - Un límite diario por cuenta,
FEEDBACK_DAILY_LIMIT, 5 por defecto, contados por día UTC en la misma transacción que la inserción. Superarlo da429 {"error":"daily limit reached: 5 reports per day for this account"}, que no menciona ningún identificador. - Lo que se almacena es la fotografía, las cifras, el registro de consentimiento, el id de cuenta y la hora de llegada, y nada más. No los encabezados de la petición, la dirección IP, el user-agent o un identificador del dispositivo.
- Un reporte y su fotografía desaparecen tras
instance.feedback.retentionDays(30 en esta implementación, eliminados mediante un barrido cada hora), antes si un operador elimina el reporte (§5.20), y junto con la cuenta.
6. Negociación de versión: obligatoria, y obligada a fallar de forma cerrada
Un cliente DEBE leer este documento del servicio y comprobarlo antes de la primera sincronización de una sesión.
Esto sustituye a una comprobación de versión interna que existía cuando el cliente y el servidor se distribuían en un solo artefacto. Ya no es así: un cliente desplegado y un servicio desplegado pueden diferir en una versión en cualquier dirección, y quien autoaloja el servicio puede apuntar un cliente actual a un servicio que actualizó hace ocho meses. No hay nada en esa situación que pueda detectarse a partir de un 200 correcto en un push.
Reglas:
protocolVersiondebe ser igual a la del propio cliente. Ni «≥», ni «más o menos compatible».envelopeVersiondebe ser igual a la del propio cliente.- Ante cualquier discrepancia, el cliente se niega a sincronizar y muestra al usuario qué parte es la más antigua. No envía datos, no los descarga, no reintenta la operación y no degrada el servicio en silencio.
- Si no se puede acceder al saludo o este tiene un formato incorrecto, trátalo como una discrepancia. Un servicio que no se puede verificar no es un servicio compatible.
La implementación de referencia es checkProtocolCompatibility() en ambos archivos protocol.ts, pura, total y devuelve una frase que se puede mostrar al usuario en lugar de un booleano.
Por qué rechazar en lugar del mejor esfuerzo: el blob suele ser la única copia que el usuario tiene de sus datos. Si un cliente envía un sobre que un servicio más reciente estructura de otra forma, o descifra uno que solo entiende a medias, puede corromper esa copia de manera irrecuperable. Una sincronización rechazada es una molestia visible; una sincronización que falla en silencio es un incidente de pérdida de datos que se descubre semanas después. Este protocolo elige la molestia en todos los casos.
7. Política de versiones
PROTOCOL_VERSIONabarca los endpoints, la estructura de peticiones y respuestas, la semántica de los códigos de estado, el esquema de autenticación y la semántica de CAS. Incrementa este número ante cualquier cambio incompatible en estos elementos. Los cambios puramente acumulativos (un campo de respuesta nuevo y opcional, un endpoint nuevo al que los clientes antiguos nunca llaman) no lo incrementan.ENVELOPE_VERSIONabarca únicamente la criptografía y la estructura del blob: cifrado, ubicación del IV, códec de compresión y gestión de etiquetas. Incrementa este número ante cualquiera de estos cambios. Nunca lo incrementes por un cambio en el esquema de la carga útil.payloadSchemaVersiones la versión del esquema del almacén local del cliente. Viaja por este protocolo como un entero opaco vinculado a los AAD. El servidor nunca lo interpreta y nunca afecta a ninguna de las dos versiones anteriores.
Los dos números de versión son independientes de forma deliberada: reestructurar la criptografía y rediseñar la API HTTP son tipos de cambio distintos con radios de impacto diferentes.
Margen previo a la versión 1.0. Hasta el primer lanzamiento público, se pueden introducir cambios incompatibles sin la ruta de migración que requeriría un protocolo ya publicado. Se introdujeron dos SIN incrementar la versión: el paso de autenticación por cookies a autenticación por bearer, y el traslado de las rutas de sincronización de /api/sync a /v1/sync. Un tercer cambio, la eliminación del correo electrónico en la versión 0.5.0, también se introdujo sin incrementarla y no debió hacerse así (mira más abajo). Este párrafo se eliminará en el lanzamiento público y, a partir de ese momento, las reglas anteriores se seguirán al pie de la letra.
La versión 0.5.0 modificó el contrato de autenticación y NO incrementó la versión, y ese fue el error que ahora documenta esta sección. Sustituyó email por handle, eliminó verify-email y request-reset, y añadió recover y recover-rotate (§5.14). Como el número se mantuvo en 1, el saludo de la sección §6 no lo detectó: un cliente anterior a la versión 0.5.0 que enviaba email recibía un 400 que no podía solucionar, mientras que los números de versión coincidían y le indicaban que todo estaba bien.
La versión 0.6.0 incrementa PROTOCOL_VERSION a 2, y lo hace exactamente por esa razón. Los cambios son de la misma clase (el campo de autenticación vuelve a ser email, el registro exige una invitación dirigida y ambos registros de clave, signupMode desapareció del saludo, AccountView sustituyó al cuerpo de la cuenta anterior y dos endpoints de restablecimiento reutilizan la sección §5.12), pero esta vez la sección §6 sí los detecta: un cliente que utiliza la versión 1 se niega a comunicarse en lugar de funcionar a medias. Motivo: docs/adr/0005-organization-accounts-and-escrowed-recovery.md.
8. Límites de tamaño y plan de capacidad
| Límite | Valor | Aplicado por |
|---|---|---|
| Tamaño máximo del blob | 2 MiB (MAX_BLOB_BYTES) | Servicio (413), replicado en el cliente para ofrecer un error más claro |
| Versiones de blob conservadas | Tres niveles, consulta más abajo | Servicio, purgado tras cada escritura aceptada |
| Registros de clave por cuenta | 2 (uno por kind) | Servicio |
La retención está dividida en niveles (M224). Se conserva una versión si CUALQUIER nivel la conserva:
| Nivel | Regla | Límite |
|---|---|---|
| Recientes | Las versiones más nuevas (BLOB_VERSION_RETENTION) | 5 |
| Diario | La versión más nueva de cada día natural UTC durante BLOB_DAILY_RETENTION_DAYS | 14 |
| Fijaciones previas a reducción | Versiones sustituidas tras confirmarse una gran reducción, durante BLOB_PRE_SHRINK_PIN_DAYS, las más nuevas primero hasta BLOB_PRE_SHRINK_PIN_LIMIT | 14 |
Así que como máximo 33 versiones, y por tanto como máximo 66 MiB, por cuenta. El nivel diario se basa a propósito en DÍAS naturales y no en un recuento fijo: dos dispositivos en un bucle de fusión generan versiones tan rápido como permita la red, y agotarían un nivel basado en recuento en cuestión de minutos. El nivel de fijaciones tiene límite porque una fijación se crea a partir de lo que declare el propio cliente.
Antes de M224, la regla completa se limitaba a cinco versiones fijas, una red de seguridad insuficiente: una cuenta cuyo diario quedaba borrado por un fallo del cliente solo se podía recuperar mientras la versión correcta siguiese dentro de una ventana que dos dispositivos pueden agotar en un minuto.
El límite crítico de capacidad, explicado con claridad. Un único blob contiene el almacenamiento completo de la cuenta. Las entradas del registro de comidas ocupan aproximadamente entre 400 y 700 bytes de JSON cada una antes de comprimirse, por lo que un blob sin comprimir superaría los 2 MiB tras unos 2 a 4 años de registros diarios. No se trata de un problema teórico, sino de una fecha fijada.
ENVELOPE_VERSION 1 comprime el texto en claro con gzip, lo que ahorra aproximadamente un orden de magnitud con un JSON tan repetitivo (los mismos nombres de clave en cada uno de miles de registros) y aleja el límite crítico lo bastante como para que no sea un problema inmediato. No obstante, no lo elimina.
La solución prevista, pensada para no tener que improvisarla bajo presión: blobs fragmentados o por entidad, muchos textos cifrados pequeños con versiones independientes en lugar de un único monolito. Supone un cambio real en el diseño y en los endpoints, por lo que requerirá un cambio de versión del protocolo y no un parche. En la práctica, la señal para iniciar ese trabajo será que el tamaño de los blobs supere el ~80 % del límite en producción, algo sobre lo que el servicio registra una advertencia (especificación 02 de M128). Debería ser posible observar el límite crítico mucho antes de que lo alcance cualquier usuario.
9. Qué sabe el servidor
9.1 Lo que no puede saber
El servidor nunca recibe la DEK, ninguna KEK ni la frase de contraseña. Sí recibe el código de recuperación al registrarse y en cada rotación, y mantiene ese código sellado (§3.1 y la entrada de custodia en §9.2). Ninguna ruta de código en este servicio deriva una clave a partir del código ni descifra un blob. Para el propio código del servicio, el descifrado no está retenido, sino que no está disponible. Para quien conserve tanto la base de datos como SERVER_SECRET, sí está disponible. Eso significa el operador de una instancia administrada, o tú por tu cuenta.
Y sigue sin poder agregar uno. El pulso comunitario del §5.23 parece el servidor contando comidas, y no lo es: nada en el §9.2 se deriva de un blob, y un despliegue donde nadie activó el pulso no cuenta absolutamente nada. Las sumas existen porque los dispositivos cuyos propietarios lo aceptaron las enviaron, motivo por el cual el pulso se lista abajo como algo que el servidor sabe y no como algo que calcula.
9.2 Lo que sí sabe
Para ser honestos con los metadatos, ya que "cifrado de extremo a extremo" suele entenderse como "el servidor no sabe nada":
- Tamaño del blob, y por tanto una aproximación de cuántos datos contiene la cuenta. La compresión hace que esta señal sea más difusa de lo que era, pero no oculta.
- Frecuencia y tiempos de escritura: cuándo sincroniza un dispositivo y con qué frecuencia.
- Números de versión:
blobVersion,envelopeVersiony el número de versiones retenidas. - Parámetros KDF y sal para el registro de la frase de contraseña. No son secretos; existen para servirse a un dispositivo nuevo antes de iniciar sesión.
- Si una cuenta ha completado la configuración (tiene registros de claves) y si alguna vez ha sincronizado (tiene un blob).
- La propia cuenta: una dirección de correo electrónico, un nombre para mostrar opcional, un rol, una cuota diaria de IA, un instante de suspensión, un verificador de autenticación (un hash con clave de un hash con clave de la frase de contraseña, consulta el §5.8), un segundo verificador con la misma construcción sobre la prueba de recuperación y los parámetros KDF de la cuenta. La dirección nombra a una persona en el mundo, que es una clase de datos personales que la versión 0.5.0 eliminó y la 0.6.0 recuperó deliberadamente (ADR-0005): a las personas de una organización se las identifica por la dirección a la que llegó su invitación, porque ese es el identificador que seguirán recordando dentro de un mes.
- El CÓDIGO DE RECUPERACIÓN de la cuenta, sellado (
accounts.recovery_code_escrow, §3.1). Esta es la entrada de esta lista en la que deberías detenerte. Es AES-256-GCM bajo una subclave deSERVER_SECRET, por lo que un volcado de la base de datos por sí solo no lo abre, pero quien opera una instancia administrada tiene ambas cosas. Quien opera una instancia administrada puede abrir cualquier cuenta en ella. No mediante un endpoint, ni a través de ninguna ruta de código en este servicio, sino leyendo esa columna con el secreto en mano y ejecutando el propio HKDF del cliente. Una instancia autoalojada es su propio operador, así que la promesa anterior se mantiene allí. Decidir si confiar en una instancia alojada es, por tanto, una decisión sobre quién la opera. - Invitaciones pendientes: para cada una, una dirección, un nombre opcional, un rol y una cuota, pertenecientes a alguien que aún NO tiene cuenta ni ha dado su consentimiento. Crearla es una acción del operador, y
DELETE /v1/admin/invites/:idretira la fila. Las filas creadas mediante la vía de solicitud del §5.8.3 quedan marcadas como tales para que el operador pueda contarlas. Una invitación finalizada pierde su dirección y su nombre en el plazo de una hora: una revocada o expirada en cualquier instancia, una canjeada en una instancia conTRIAL_ADDRESS_PEPPER, que solo conserva el hash con clave indicado abajo. Sin la clave pimienta, una fila canjeada conserva su dirección, ya que la regla de reinvitación de miembros del §5.21 la consulta. - La prueba de escaneo, en una instancia que ejecute una: los escaneos gratuitos concedidos y usados, dos enteros en la fila de la cuenta. Solo para una cuenta de prueba de escaneo, una fila por acción de IA: un id opaco elegido por el cliente, una hora, un conteo de peticiones y si se entregó una respuesta, conservado 24 horas y luego eliminado, y nunca registrado. Cada fila de invitación lleva un hash unidireccional con clave de su buzón de correo (HMAC-SHA256 bajo
TRIAL_ADDRESS_PEPPER, un secreto que solo posee el operador, sobre la clave de prueba de §5.8.3), nunca una segunda copia de la dirección. - Tras eliminar una cuenta, en una instancia que ejecuta una prueba de escaneos: la dirección y el nombre se eliminan de cada fila de invitación correspondiente a ese buzón y, solo si la cuenta tenía una prueba, se conserva un hash con clave del buzón de correo, y nada más: sin nombre, sin id y con una sola fecha, el instante de la eliminación, que es lo que permite finalizar la fila. Esto es lo que impide que el mismo buzón reciba una segunda prueba. Sin el secreto del operador, el hash no se puede revertir ni contrastar con una lista de direcciones. Un barrido lo elimina
TRIAL_HASH_RETENTION_DAYS(365 por omisión) después de ese instante, sobre la base jurídica del art. 6(1)(f) del GDPR (un interés legítimo en evitar el abuso de los escaneos gratuitos, no revisado por un abogado, ADR-0010). Una instancia que no concede pruebas de escaneos no guarda ningún hash. En una instancia que no ejecuta pruebas de escaneos, las filas de invitación conservan su dirección tras una eliminación, según la regla de reinvitación de miembros del §5.21. - Uso de IA: un entero por cuenta por día UTC, conservado durante 90 días y luego eliminado (§5.20). Un conteo, nunca un registro detallado: sin prompt, sin respuesta, sin modelo, sin marca temporal más allá del día. Un operador puede leer los contadores de una cuenta como una tira día a día (
GET /v1/admin/accounts/:id/activity), lo cual son metadatos sobre cuándo usó una persona una aplicación de salud y está acotado exactamente por esa razón. - El pulso comunitario, para las cuentas que lo activaron (§5.23, ADR-0007): sumas diarias a nivel de instancia de comidas, fotografías, calorías y gramos de proteína, una fila por cuenta contribuyente al día y una fila de presencia de corta duración que indica que una cuenta está ayunando en este momento. Las sumas no se pueden atribuir a nadie; la fila del contribuyente y la de presencia sí, y solo dicen "esta cuenta contribuyó hoy" y "esta cuenta está ayunando". Las sumas diarias y las filas de contribuyentes se se conservan 30 días, la presencia expira 30 minutos después del último latido y las rutas no registran ningún id de cuenta. Quien nunca lo activó no envía nada ni aparece en ninguna parte de ello.
- Una suscripción push, para un dispositivo cuyo propietario activó las notificaciones (§5.24, ADR-0008): el endpoint del servicio push, las dos claves con las que cifra, una cadena de user agent acotada, una zona horaria de IANA, una configuración regional, el minuto del día local en el que corresponde ponerse al día, el día local en que salió el último envío, el día local en que se vio el dispositivo por última vez, el instante en que solicitó que se le despertara y un recuento de lo enviado hoy. En conjunto indican aproximadamente cuándo está despierta esta persona, en qué lugar del mundo se encuentra aproximadamente y, mediante
wake_at, cuándo termina uno de sus ayunos. Ese último coincide con la fila de presencia del pulso, lo que indica que ese mismo ayuno sigue en curso; ADR-0008 identifica la correlación en lugar de dejar que se descubra después. Lo que NO se almacena es ni una sola palabra del texto de ninguna notificación: cada push lleva un tipo. La fila desaparece cuando el dispositivo cancela la suscripción, cuando el servicio push lo desconoce o junto con la cuenta. - Un consentimiento de datos de salud, en una instancia que lo solicite (§5.15.1): la versión del texto que la persona aceptó y el instante en que este servicio lo registró, dos columnas en la fila de la cuenta. Constata que la persona usa una app de salud y aceptó que el operador procese esos datos, algo que el operador debe poder demostrar. Queda visible para un operador (§5.20), ninguna ruta lo borra y se elimina junto con la fila de la cuenta al suprimirla.
- Cuándo hizo algo una persona por última vez:
accounts.last_seen_at, escrito por un inicio de sesión y por una compleción a través de proxy, y deliberadamente no por una renovación de token ni por una consulta periódica de sincronización, de modo que significa «alguien interactuó» en lugar de «un cliente estaba en ejecución». Resulta visible para un operador (§5.20) y desaparece junto con la fila de la cuenta al eliminarla. - Declaraciones legales (
POST /v1/legal/declarations, una cancelación o un desistimiento, en cada instancia): el nombre, la dirección, la referencia del contrato, el motivo y las fechas que introdujo la persona, la hora de llegada y la cuenta con la que coincidió, si la hubiera. Se mantiene se conserva hasta el final del tercer año natural posterior al año en que llegó, computado en hora de Europe/Berlin, y luego se elimina en el barrido horario: una recibida el 2026-09-21 se elimina a partir del 2030-01-01 00:00 en Berlín. Eliminar la cuenta no hace que se borre antes; la fila pierde su identificador de cuenta y se conserva, ya que es el registro de lo declarado por la persona. El correo de acuse de recibo está limitado a tres por dirección normalizada, aLEGAL_DECLARATION_RECEIPTS_PER_NETWORK_PER_DAY(10 por defecto) por red emisora y aLEGAL_DECLARATION_RECEIPTS_PER_DAY(200 por defecto) por instancia. Cada límite se aplica sobre cualquier periodo móvil de 24 horas. Los totales se computan a partir de estas filas, salvo el recuento por red, que se mantiene en memoria. Una declaración que supere un límite se sigue guardando, reenviando y remitiendo al operador, y el202es el mismo. El acuse de recibo nunca repite el nombre, la referencia del contrato ni el motivo; solo la copia para el operador los incluye. - Metadatos de sesión: cuántas sesiones activas existen, cuándo se creó cada una y cuándo se rotaron o revocaron tokens por última vez. Los valores de los tokens en sí solo se guardan como resúmenes criptográficos.
- El grafo de estudios, en un despliegue con
SYNC_RESEARCHconfigurado (§5.18): qué cuenta contribuye a qué estudio, cuándo, con qué frecuencia y qué tamaño tiene cada contribución. Una arista aquí indica «los datos de salud de esta persona están en el estudio Y», lo que constituye información personal vinculada a la salud de la misma categoría que la arista de atención médica descrita más abajo. Es inevitable, y la retirada es la prueba: borrar la fila de un colaborador exige localizarla, la eliminación de la cuenta debe propagarse en cascada a través de ella, y tanto el compare-and-swap como el control de abusos toman como clave la cuenta. Un esquema que ocultara estos datos al servidor rompería alguno de esos mecanismos y, de todos modos, el análisis de tráfico los revelaría, por lo que esto se expone de forma explícita en vez de evitarse a medias. Quien investiga nunca recibe la correspondencia (§5.18 no lleva identificador de cuenta), la retirada elimina la arista de forma permanente y deja solo un seudónimo, y un despliegue sin este flag carece de tabla para albergar un grafo de estudios. - El grafo de uso compartido, en un despliegue con
SYNC_SHARINGconfigurado (§5.16): qué cuenta ha concedido acceso de lectura a qué otra cuenta, cuándo se otorgó el permiso y cuándo lo ejerce la parte autorizada. Se trata de un grafo de relaciones y de una ampliación real de lo que este servicio conoce, y en el contexto para el que se diseñó la funcionalidad (un paciente y su dietista), una arista en dicho grafo constituye en sí misma información personal vinculada a la salud, pues revela que alguien recibe atención médica. Es el mínimo necesario para autorizar la lectura; ambas partes dan su consentimiento, ya que la parte otorgante crea la fila y la parte receptora puede borrar su lado; además, la arista se elimina de forma permanente al revocar el acceso y se borra en cascada cuando se elimina cualquiera de las dos cuentas. Un despliegue que no configureSYNC_SHARINGno almacena dicho grafo ni tiene tabla donde guardarlo.
- Estimaciones notificadas, en un despliegue con
SYNC_FEEDBACKconfigurado (§5.25, ADR-0006): las cifras de cada entrada que una persona decidió reportar, la foto del plato si el dispositivo aún conservaba una, el id de cuenta, el registro de consentimiento y la hora de llegada, todo legible. Se conservan duranteinstance.feedback.retentionDays, luego los borra un barrido, y desaparecen con la cuenta. Un operador puede leerlos mediante el §5.20, y cada lectura de una fotografía queda registrada. Un despliegue sin esa opción no tiene ningún reporte ni ninguna fotografía que guardar.
No se puede saber a partir de los metadatos anteriores: qué se comió, cuándo, cuánto, ni nada dentro del contenido útil. Dos de las entradas anteriores sí lo revelan. El código de recuperación sellado abre todo el diario a quien también tenga SERVER_SECRET, y una estimación reportada muestra la única entrada que contiene.
10. Implementar un servidor alternativo
Un servidor de sincronización conforme necesita, en su totalidad:
- Los cuatro endpoints de §5.1 a §5.4 más el protocolo de enlace
/healthde §5.6. Se eliminó §5.5; un servidor no debe ofrecer una eliminación del registro de clave solo con token portador. - CAS por cuenta en
blobVersion: atómico. La implementación de referencia utiliza un índiceUNIQUE (accountId, blobVersion)y trata cualquier infracción de unicidad como un conflicto, en lugar de recurrir al bloqueo de filas; esto mantiene la corrección bajoREAD COMMITTEDy resulta más simple queSELECT ... FOR UPDATE. Cualquier mecanismo que ofrezca la misma garantía es válido; una lectura seguida de escritura sin atomicidad es no. - CAS por cuenta y tipo en registros de claves mediante
expectedUpdatedAt, con la misma regla de "campo ausente es un400", y una comprobación de frase de contraseña (currentAuthHash) en cada sobrescritura. - Depuración por retención ajustada a los tres niveles de §8, y la protección contra reducciones de §5.1. Un servidor que acepte una reducción drástica no confirmada destruirá el diario de una cuenta la primera vez que un cliente de la compilación afectada pierda su almacenamiento local; un cliente programado para un servidor que la rechaza y apuntado a uno que no lo hace queda desprotegido de forma silenciosa.
- Almacenamiento byte a byte exacto de
ciphertextywrappedDek. Nunca los recodifiques, normalices, recortes ni «corrijas». Cualquier alteración destruye la etiqueta GCM y, con ella, los datos del usuario.
Además, un servidor que también implemente los endpoints de cuenta de §5.7 a §5.15 debe:
- Servir un descriptor de KDF estable y con formato real para direcciones desconocidas (§5.7), ejecutando el mismo trabajo en ambas ramas, y limitar la tasa del endpoint según la dirección de origen. Un
404, un valor ficticio derivado de forma perezosa o un endpoint sin limitación de tasa reabren, cada uno por su cuenta, el oráculo de enumeración que el resto del diseño clausura, ya sea por respuesta, por tiempos o por volumen. - Almacenar ambos verificadores como hashes con clave de los valores
authHashyrecoveryAuthHashenviados, bajo un secreto guardado fuera de la base de datos. Nunca el valor enviado en sí, y jamás en texto no cifrado. - Aplicar las solicitudes de rotación de §5.14 de forma atómica, incluido el depósito en custodia revinculado, y revocar cada sesión pendiente ante cualquiera de los activadores de §4.2.
- Tomar la dirección de la cuenta a partir de INVITE durante el registro y nunca del cuerpo de la petición (§5.8), y limitar la tasa de
recover,recover-rotateyreset/requestsobre un único depósito compartido por (IP, correo electrónico) que jamás se vacíe tras un intento correcto. Un servidor que permita que el cuerpo del registro defina su propia dirección habrá eliminado lo único que la comprueba. - Responder
202a cadareset/requesttras realizar un trabajo idéntico, y hacer quereset/openno escriba nada en la cuenta (§5.12). Un restablecimiento que sustituye un verificador es la vía de apropiación de cuentas que este protocolo eliminó, se le llame como se le llame. - Rechazar una cuenta suspendida en el inicio de sesión, en la renovación y en cada ruta con token de portador, respondiendo con
403 {"error":"account-suspended"}, esa cadena exacta. - Propagar la eliminación de la cuenta en cascada a los blobs, los registros de claves, los tokens de restablecimiento y las filas de uso.
- Si implementa ese endpoint, responde a la creación de miembros de §5.21 con UNA sola respuesta para una dirección nueva, una dirección con una invitación pendiente y una dirección con una cuenta. Un servidor que responda
409en el tercer caso le entrega a cada miembro un oráculo sobre quién más está en la instancia, y un servidor que responda500cuando su relay de correo esté caído les entrega uno más lento. Un servidor que no implemente invitaciones de miembros responde con el404habitual de ruta desconocida en esa ruta y notificainstance.memberInvites: false.
Un servidor conforme necesita ninguno de: la criptografía de §3, el parseo de JSON de cualquier payload, o conocimiento de qué es un registro de comidas.
11. Implementar un cliente alternativo
Más allá de §3 y del bucle 409 de §5.1:
- Ejecuta el protocolo de enlace de §6 antes de la primera sincronización y rechaza si no coincide.
- Nunca persistas en almacenamiento duradero la frase de contraseña, ninguna de las KEK, ni la DEK. Deriva al desbloquear, mantén en memoria, descarta.
- Ejecuta Argon2id fuera del hilo principal. Con 64 MiB congela de forma visible los teléfonos de gama baja.
- Genera el código de recuperación durante el registro, envuelve la DEK con él y envíalo al servidor en el cuerpo del registro para ponerlo en custodia (§3.1). Un cliente que se salte este paso crea una cuenta que ningún restablecimiento puede recuperar. MOSTRARLO o no a la persona queda a criterio del cliente; en una instancia administrada el propósito de la custodia es no tener que hacerlo.
- Indica a qué tipo de instancia se conecta la persona antes de que guarde un diario en ella. En una instancia administrada el operador tiene el código en custodia y puede abrir la cuenta; en una autohospedada el operador es la persona misma. Ambas opciones son legítimas; solo una es la que presupone alguien que no lo sabe.
- Lee la dirección desde
POST /v1/auth/invite-lookup(§5.8.2) y muéstrala, en lugar de pedir a la persona que escriba la suya. No puede equivocarse al escribirla y dejarla en una cuenta inaccesible si nunca llega a teclearla. - Deriva la prueba de recuperación bajo
openplate-sync:recovery-auth:v1y nunca envíesKEK_r. Ambas derivan del mismo código, y enviar la rama KEK le daría al servidor un HMAC del valor que abre el diario (§3.1). - Cuando
POST /v1/auth/reset/opendevuelva el código de recuperación, ejecuta elrecover-rotateORDINARIO de §5.14 con él: una frase de contraseña nueva, un registropassphrasereenvuelto, un código nuevo, un registrorecoveryreenvuelto y el nuevorecoveryCodepara la custodia. Detenerse a mitad de camino deja una cuenta cuya custodia ya no coincide con su verificador. - Trata
404deGET /blobcomo "cuenta nueva", no como un error. - Envía
authHash(la rama HKDFauthde §3.1) y nunca la frase de contraseña, la salida de Argon2id, oKEK_p. Derivar la rama equivocada ocurre de forma silenciosa: se autentica bien y produce una clave que no descifra nada. - Obtén el descriptor KDF (§5.7) antes de derivar nada en un dispositivo nuevo. No asumas los valores por defecto; una cuenta creada con parámetros más altos no derivará de forma correcta con ellos.
- Guarda el token de refresco en el mismo nivel de almacenamiento que el token de acceso y nunca reutilices uno ya consumido: un reintento revoca a toda la familia y cierra la sesión del usuario (§4.2). Serializa los refrescos; dos pestañas compitiendo por el mismo token de refresco se ven exactamente como un robo.
- Con
401, refresca una vez y reintenta una vez. Con un segundo401, envía al usuario a iniciar sesión en vez de entrar en bucle.