Saltar al contenido
openplate

El servidor central

openplate-core transfiere tu diario entre tus dispositivos. Almacena una dirección de correo electrónico y una copia cifrada de tu diario. También guarda tu código de recuperación, sellado bajo su propio secreto, de modo que si olvidas tu contraseña puedas recuperar tu diario. Ese mismo código permite al operador de una instancia abrir un diario en ella.

Qué puede leer y qué no

openplate-core mueve un diario entre dispositivos. Es el único servicio en openplate que almacena cuentas. Es un desplegable independiente con su propia imagen, base de datos y secreto, y el navegador habla con él directamente. El servidor de aplicaciones no actúa como proxy en su nombre ni sirve ninguna ruta de sincronización.

El diario se cifra antes de salir del dispositivo. El cliente serializa el almacén local, lo comprime con gzip, lo cifra con AES-256-GCM bajo una clave de datos aleatoria y sube el resultado como un único blob opaco. La clave de datos se envuelve bajo una clave derivada de tu frase de contraseña: la frase de contraseña se extiende con Argon2id y se divide mediante HKDF en ramas independientes. Dos de ellas permanecen en el dispositivo y desenvuelven lo que es tuyo, y una tercera se envía como credencial de inicio de sesión. Son hermanas, no padre e hija, por lo que poseer la credencial no revela nada sobre la clave.

El operador guarda una clave de recuperación. Al registrarte, la app genera un código de recuperación y envuelve la clave de datos bajo él. Envía el código a openplate-core, que lo sella bajo su propio secreto. Eso es lo que permite que un restablecimiento de contraseña por correo devuelva tu diario en lugar de una cuenta vacía. También significa que el operador de una instancia puede restaurar, y en principio leer, un diario alojado en ella. En una instancia que hospedes tú, tú eres ese operador. sync.md expone el intercambio al completo.

Lo que el servidor ve además del texto cifrado se detalla con claridad en PROTOCOL.md §9: una dirección de correo, el tamaño del blob, la frecuencia y hora de las escrituras, números de versión y parámetros KDF.

La consola de estudio opcional mantiene sus propias cuentas separadas en el mismo servidor. Sincronización indica cuándo está activada, y ADR-0008 explica por qué reside en la aplicación.

Qué 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, envelopeVersion y 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 de SERVER_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/:id retira 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 con TRIAL_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, a LEGAL_DECLARATION_RECEIPTS_PER_NETWORK_PER_DAY (10 por defecto) por red emisora y a LEGAL_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 el 202 es 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_RESEARCH configurado (§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_SHARING configurado (§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 configure SYNC_SHARING no almacena dicho grafo ni tiene tabla donde guardarlo.
  • Estimaciones notificadas, en un despliegue con SYNC_FEEDBACK configurado (§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 durante instance.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.

Instancias gestionadas

Una instancia puede definir INSTANCE_MODE=managed (consulta configuration.md). Eso declara una sola cosa: una organización opera esta instancia, invita a sus miembros por correo electrónico y asigna a cada uno una cuota diaria de IA. openplate-core gestiona eso; la cuenta que ya almacena para sincronización también almacena la cuota, por lo que no hay un segundo paso de conexión ni una segunda credencial.

Para el navegador nada cambia: una cuenta iniciada con cuota escanea mediante el proxy de IA que expone openplate-core, el mismo servicio con el que el cliente ya se comunica para sincronizar. Para lo que hay detrás, openplate-core actúa como cliente: apunta a un proveedor en la nube o a tu propio contenedor de openplate-inference. La inferencia es la capa de cómputo, openplate-core es la capa multiinquilino en una instancia gestionada, y ambas se componen: el servidor central no aloja ningún modelo ni responde a ningún escaneo por sí mismo.

Está en la ruta de las fotos, lo cual es el coste real del enfoque, y la mitigación es una propiedad del código más que un ajuste: el tipo de campo del registrador solo admite tipos primitivos, de modo que ningún cuerpo puede llegar a una línea de registro, y las cadenas de error del proveedor upstream se depuran antes de registrarlas o devolverlas. Los miembros de una organización comparten gastos, no datos; la foto del plato que llega al proxy se lee una sola vez y no se almacena.

La cuota cuenta peticiones, no dinero. Un operador también puede fijar un límite diario para toda la instancia (AI_INSTANCE_DAILY_LIMIT), y sigue siendo necesario un límite de gasto en la clave principal ante el proveedor.

Un administrador gestiona la instancia desde /admin en la app: las personas y sus cuotas, invitaciones, actividad, estimaciones reportadas y qué valores de referencia cita la pantalla Nutrientes.

Historial

De agosto a septiembre de 2026, esto era un servicio independiente, openplate-gateway: un proxy pequeño compatible con OpenAI que contenía una clave upstream y emitía a cada miembro un token opk_… con su propio límite diario. La versión M192 (septiembre de 2026) lo integró en openplate-core: una misma cuenta contiene ahora tanto el diario como la cuota, por lo que no hay un segundo servicio, ni un segundo enlace de invitación, ni una segunda credencial que repartir.