# Diseño — `dotrino-content` (servidor de contenido del ecosistema) > **Estado:** diseño cerrado y **sin decisiones abiertas**; **Fases 1 (core local) y > 2 (aparato del vault) implementadas** (ver `HANDOFF.md`). Este doc define el *qué* y > el *cómo*. > > **Idioma/estilo:** español neutro (tuteo). Fuente de verdad del ecosistema: > [`CLAUDE.md`](../../CLAUDE.md) y [`CONVENCIONES-APPS.md`](../../CONVENCIONES-APPS.md). ## 1. Propósito Un **almacén de contenido autohospedado** por el usuario que guarda **cualquier byte suyo** y produce **enlaces compartibles**. Es el pilar que faltaba: guardar lo que el usuario tiene y servirlo, con streaming cuando hace falta. > **Corregido el 2026-08-17 (decisión del dueño): esto NO es "el servidor de media > pesada".** Así estaba escrito, y era una herencia del primer caso de uso (compartir > video) que limitaba el pilar sin motivo: lo que guarda son **bytes direccionados por > su hash**, y a eso le da igual si dentro hay un video, un PDF, un `.zip` de > respaldo, un `.vcf`, un APK o una nota de dos líneas. **La respuesta a "¿qué puede > almacenar?" es TODO** — con la única frontera del §3.2, que no es de tipo ni de > tamaño. **Misión Dotrino:** tu contenido, en tu servidor, bajo tus reglas — sin anuncios, sin rastreo, sin vender tu identidad. El content server es *dónde* vive lo que compartes. ### Qué NO es (deslindes) - **No es `@dotrino/store`, y la frontera NO es el tamaño.** Regla del dueño, y es la que se aplica primero: **el store guarda lo que debe estar SIEMPRE disponible.** Vive en el navegador (IndexedDB, offline, instantáneo, con sync cifrado), así que responde aunque no haya ningún node encendido: preferencias, el índice de lo tuyo, el puntero que dice **cuál es el `cid` vigente**. El content guarda **el resto** — los bytes—, y para eso hace falta que alguien lo esté sembrando. Detalle, razón estructural y ejemplo trabajado en el **§3.2**. - **No es `qrshare`.** qrshare es transferencia **P2P efímera** (WebRTC, sin hospedaje). El content server es **hospedaje persistente** con URL estable. - **No es el `vault`.** El vault (`dotrino-vault`) es tu **CA/identidad** (guarda la llave maestra, firma, emite certs). El content server **usa** la identidad del vault para autorizar, pero **no** guarda llaves ni es crítico de seguridad. ## 2. Decisión de arquitectura: lógicamente integrado, físicamente separado **No** se fusiona con el vault en un solo proceso. Sí se **co-empaqueta** para que sea una sola instalación. Razones (analizadas con el dueño): | Motivo | Por qué separar procesos | |---|---| | **Seguridad** | El vault tiene la **llave maestra**. Un servidor de media parsea archivos, transcodifica y atiende internet: máxima superficie de ataque. No debe compartir proceso con la CA. | | **Recursos** | Identidad = mínima, ráfagas. Media = disco + ancho de banda + conexiones largas. Deben poder vivir en hosts distintos (media en NAS/VPS barato, identidad en la máquina de confianza). | | **Disponibilidad** | Un link de media debe estar arriba 24/7; el vault puede ser intermitente. | | **Aislamiento de fallos** | Un crash del transcodificador no debe tumbar tu CA. | **Cómo se resuelve la fricción de "instalar dos cosas":** un **solo instalador / `npx` / `.deb`** levanta **dos procesos aislados** bajo un supervisor; el contenido es un **toggle opcional**. El usuario percibe "instalo el nodo Dotrino". Identidad y túnel **compartidos** (abajo). ``` ┌───────────────────── nodo Dotrino (una instalación) ─────────────────────┐ │ │ │ dotrino-vault (proceso A, crítico) dotrino-content (proceso B) │ │ · llave maestra, firma, certs · blobs en disco (hash) │ │ · API local (IPC) de identidad/caps · sirve HTTP + streaming │ │ ▲ │ pide caps por IPC ─────────┼──┐ │ └─────────── IPC local ───────────┘ │ │ └─────────────────────────────────────────────────────────────────────────────┘ │ (exposición al mundo por túnel / transporte, §7) ◄────────────────┘ ``` ## 2.1. Topología: el "node" es la PWA del usuario (+ standalone opcionales) **"Node" (identidad + contenido) es un ROL**, no una máquina; lo cumplen uno o varios perfiles de dispositivo, todos bajo **la misma identidad** (una maestra). Es el **patrón del ecosistema** —el mismo que hace que un dispositivo pueda ser bóveda cuando no hay daemon del vault—, escrito en `CLAUDE.md`: el aparato cumple el rol y la pieza dedicada es un upgrade, **nunca un requisito**. - **PWA-node (default, CERO instalación):** la propia app del usuario **es** el node. Guarda identidad (maestra o dispositivo primario) + **tu contenido local** (OPFS/IndexedDB — el navegador ya aguanta GB) y comparte **P2P por WebRTC** (`@dotrino/proxy-client`) mientras está abierta/online. Hogar de la identidad y tu almacén. No instalas nada. - **Node standalone (opcional, enrolado):** daemon (Docker/`npx`) en VPS/NAS, **siempre encendido y alcanzable** (HTTP por túnel/puerto), disco/BW grandes. Enrolado al **mismo** vault (cert delegado, §5). **Caveat honesto (define qué necesitas):** una **PWA NO es un endpoint público alcanzable** (el navegador no atiende `GET` entrante; el móvil se duerme). Por eso: | Necesito… | Basta con | |---|---| | Compartir en vivo / P2P / 1-a-pocos **mientras estoy online** | **la PWA** (WebRTC) | | Un **link que abra cualquiera, cuando sea (24/7, persistente)** | un **node standalone** sembrando (§7) | No compiten: la **PWA es el node base**; el **standalone es el upgrade de disponibilidad/alcance**. Con ambos, tu contenido vive en la PWA y lo **fijas (pin)** en el box para que esté siempre arriba (el box = tu "servidor de casa" que espeja lo que elijas). **Ya resuelto en el §7 (2026-08-17):** el transporte del plano de datos es **WebRTC en los dos tiers** — el standalone es un **sembrador headless** y no necesita servir por HTTP para que las apps consuman (el modo público HTTP es un extra opt-in, §7.2). El `ownerId + cid` resuelve al node que tenga el blob (§3.1). ## 3. Modelo de datos: direccionado por contenido (hash) - Cada blob se identifica por el **hash de su contenido** (p. ej. `BLAKE3` o `SHA-256`): `cid = -`. Ventajas: - **Inmutable** (la URL nunca "cambia de significado") → cacheable a full. - **Dedup gratis** (el mismo archivo subido dos veces = un blob). - **Verificable** (el receptor comprueba que los bytes coinciden con el hash). - **Almacenamiento en disco:** `blobs///` (sharding por prefijo). Un índice ligero (SQLite o el mismo `@dotrino/store` para metadatos) guarda: `cid, size, mime, createdAt, owner, enc(bool), acl, refs, ttl?, thumbnailCid?`. - **Metadatos ≠ bytes:** el **índice** (chico) puede sincronizarse por el store; los **bytes** viven solo en el content server. - **Referencia compartible = `ownerId + cid` (+ `#fragment` con la llave si es privado).** El `cid` da inmutabilidad/dedup; el **`ownerId`** (pubkeyId de la maestra del dueño) **es indispensable para el ruteo**: `ownerId → nodes del dueño` (cómo se resuelve, en el **§3.1**). Un `cid` suelto es ambiguo (varios nodes podrían tenerlo/reclamarlo); el `ownerId` desambigua y, como el node firma con su `D` (cadena `D ← ownerId`), el cliente **verifica** que el contenido viene del dueño declarado (ningún relay ni node ajeno puede suplantarlo). ### 3.1. Enrutamiento: cómo se sabe DÓNDE está el contenido (y qué pasa con dos nodes) > Escrito el 2026-08-17 a partir de la pregunta del dueño («¿qué pasa si tengo dos > content server, y cómo se sabe dónde está el contenido?»). Era la última pieza del > modelo que estaba nombrada pero sin especificar. **Un dueño puede tener N nodes, y eso NO es un conflicto: es un enjambre.** La referencia nombra al **dueño**, no a la máquina (`ownerId` = huella de la maestra), así que todos los aparatos de la misma acta son tenedores legítimos del mismo `cid`. Dos respuestas al mismo pedido no se contradicen: los bytes se verifican contra el hash, y además cada node firma con su `D` (cadena `D ← ownerId`), así que el consumidor comprueba las dos cosas — que los bytes son los pedidos y que quien los sirvió es un aparato del dueño declarado. **No hace falta saber dónde está: hace falta alguien que lo tenga.** La resolución `ownerId → nodes` tiene **dos caminos, según quién pregunte**: | Pregunta | Directorio | Estado | |---|---|---| | **El dueño** (sus propias apps, aparatos del acta) | **la bóveda**: `vault.devices` da la pubkey (`sub`) y el label de cada aparato = su dirección en el proxy. Es `listAgentsByLabel(id, 'content')` de `@dotrino/remote-agent/discover`, lo mismo que usa la terminal para encontrar máquinas. Luego a cada node se le pregunta `stat ` (§Fase 2) | **las dos piezas ya existen**; falta cablearlas | | **Un tercero con el enlace** (no es del acta) | **canal firmado en el proxy**: el node se publica en `/content_` y cualquiera lista quién está en línea. NO puede consultar la bóveda del dueño — ni debe | **NO implementado**: hoy el node no anuncia nada | **El prefijo de nodo en el canal no es decorativo.** Hay dos proxios federados y un canal **sin** el id del nodo dueño es local a cada uno: dos consumidores en proxios distintos verían listas distintas del mismo dueño. Por eso el anuncio va en `/content_`, que es exactamente para lo que existe esa forma (`dotrino-proxy/API.md`, «Canales con nodo dueño»). El bloque publicado va firmado y cabe en 1000 caracteres; la lista devuelve hasta 100 miembros vivos. **⚠️ Los nodes de un mismo dueño NO se replican todavía.** El almacén, el índice y el dedup por `cid` son de cada node. Si subes algo al portátil, el VPS no lo tiene: el enlace resuelve a *quien lo tenga*, así que con el portátil apagado el contenido no está disponible aunque el VPS esté encendido y sea del mismo dueño. **Eso se arregla en la Fase 3 con §13.1** (el sembrador se alimenta de los otros nodes), que existe precisamente para esto. Hasta entonces, y como criterio permanente: **lo que se comparte debe vivir en el node que está siempre** (para la cuenta oficial, el sembrador del VPS); el portátil es origen y caché, no respaldo. ### 3.2. Qué puede guardar: TODO — y la única frontera (con eco como ejemplo) > Decidido por el dueño el 2026-08-17: *"¿qué puede almacenar dotrino-content? y la > respuesta debería ser todo"*, con el caso concreto *"debería poder almacenar los > posts de eco"*. **Cualquier byte del usuario, de cualquier tipo y cualquier tamaño**, cifrado o en claro: documentos, fotos, respaldos, exportaciones, adjuntos de mensajería, pases de la wallet, archivos sueltos… y **los posts de las apps**. No hay lista de tipos permitidos y no debe haberla. **La frontera no es el tipo ni el tamaño. Son dos criterios que apuntan al mismo sitio**, uno operativo y otro estructural: 1. **El operativo, y es la regla de entrada (del dueño): al store va lo que debe estar SIEMPRE disponible.** El store vive en el aparato del usuario y responde offline, al instante, haya o no un node encendido. El content depende de que alguien esté sembrando: perfecto para los bytes, inaceptable para lo que la app necesita para arrancar. Si sin ese dato la app no funciona, es del store. 2. **El estructural, que explica por qué lo anterior no es una preferencia:** el `cid` *es* el hash, así que el content guarda **versiones, no variables**. No puede guardar *«lo actual»* de algo —un documento que editas, una lista que crece— porque cada cambio produce un `cid` distinto y el content no sabe cuál es el vigente. Eso solo lo puede saber algo mutable, y eso es el store. | Va en el **content** | Va en el **store** | |---|---| | el objeto, tal como quedó (bytes) | **qué `cid` es el vigente** | | cada versión, con su propio `cid` | los índices que crecen (mi línea de tiempo, mis carpetas) | | lo que tiene tamaño o se comparte por enlace | lo que la app necesita para arrancar y operar | | lo que puede esperar a que haya un node | preferencias, sesión, lo chico de la UI | > **Matiz honesto del tier PWA:** cuando el node *es* la propia app (§2.1), sus blobs > están en el aparato (OPFS) y también responden offline. La regla sigue valiendo igual, > porque lo que no se puede dar por disponible es el contenido que vive **en otro** > aparato o en el sembrador; y porque el índice de qué hay sigue siendo mutable. **Ejemplo trabajado: los posts de eco.** Es el caso que mejor parte por esa línea. - **Cada eco = un blob.** Un eco es un objeto **firmado e inmutable** (texto, enlaces, tags, geohash grueso, firma) que no se edita nunca: encaja exacto en el direccionado por hash. Sus **adjuntos** (imagen, audio, video) son blobs aparte, referenciados por `cid` desde el eco. - **Mi línea de tiempo NO es un blob**: es una lista que crece → índice mutable en el store, o un **blob índice** cuyo `cid` vigente guarda el store. Ese es el patrón general para cualquier app, no un truco de eco. - **Qué cambia en la promesa de eco, y hay que decirlo en la app.** Eco es *"efímero en la red, durable solo en tu copia local"*: el **TTL de 24 h gobierna el descubrimiento** (el beacon geo), no la existencia — su propio diseño ya dice que sobrevive la copia local de quien lo guardó. Guardar tus ecos en tu node añade **tu propia copia**, tuya y en tu máquina, alcanzable solo con la referencia (`ownerId + cid`, más la llave si va cifrado). Eso no rompe la promesa, la vuelve honesta — **pero la durabilidad tiene que ser opt-in por post** («guardar este eco en mi node»), con lo efímero como default. Publicar creyendo que se borra solo y toparse un año después con el enlace vivo es exactamente lo que el ecosistema no hace. - **Muchos blobs chiquitos:** un eco pesa cientos de bytes y esto genera miles de blobs diminutos (un archivo + una fila de índice cada uno). Aguanta, pero si algún día pesa, la salida es **empaquetar por periodo** (un blob archivo por día/mes) y dejar el índice en el store. No se optimiza antes de tiempo, pero queda dicho. ## 4. Privacidad: cifrado E2E por defecto, público opt-in Dos modos por blob (lo elige el usuario al compartir): 1. **Privado (default): cifrado extremo a extremo.** El cliente cifra el blob (AES-GCM / secretbox) **antes** de subir; el server solo ve **ciphertext**. La **llave viaja en el `#fragment`** del enlace (`…/c/#k=`), que **nunca llega al servidor** ni es indexable (regla de `CLAUDE.md`). Quien tiene el link descifra en su navegador. El server no puede leer tu contenido. 2. **Público (opt-in):** blob en claro, servido tal cual (p. ej. un meme, un `og.jpg`). Útil para lo que quieres abierto. El usuario decide, caso por caso. > El cifrado del contenido privado es **independiente** de la identidad: no > requiere que el receptor tenga cuenta. La identidad/caps del vault se usan para > **autorizar escritura** y **ACLs por círculo** (abajo), no para el descifrado > del link público-por-fragmento. ## 5. Identidad y autorización: **agente enrolado al vault** (igual que la terminal) El content node **SÍ porta llave**, pero **delegada, no la maestra**. Se **enrola** al vault del dueño con el **mismo mecanismo que `dotrino-terminal`** (no se reinventa): - **Enrolar una vez:** en el vault `dotrino-vault pair` (QR/JSON) → en el node `npx dotrino-content enroll` (pega el QR). El node recibe su **llave de dispositivo `D` + cert** encadenado a la maestra (`D ← maestra`). No necesita correr en la máquina del vault (puede ser un VPS/NAS). - **Confianza:** cada extremo verifica que el `cert` del otro **encadena a la misma maestra pineada** (`@dotrino/identity` `verifyChain`), mismo trust anchor que la terminal. Sin enrolamiento a ESE vault (o revocado) → no sirve nada. - **Autorización de operaciones (CORREGIDO en la Fase 2):** administrar exige un cert **de la misma maestra**, con el scope **`vault:sign`** que el vault ya emite a cada aparato del acta. Los scopes `content:write` / `content:admin` que decía este documento **no existen**: el vault emite un juego fijo (`vault:sign`/`read`/`store`, `vault:admin` para la consola remota, y `vault:secrets:` para servicios), así que pedirlos habría exigido cambiar la emisión de certificados y el acta. Si algún día se quieren permisos por app, ese es el cambio a hacer — en el vault, no aquí. Lectura privada por link-con-llave no requiere cuenta (la llave va en el `#fragment`). - **ACL por círculo (opcional):** media privada compartida con un grupo por membresía → scope `content:read:` firmado por el dueño (patrón `here`/geo + web-of-trust). - **Revocación real:** `dotrino-vault revoke ` corta ese node **sin tocar la maestra** ni los demás dispositivos (+ feed de nonces revocados). > **La maestra (Mpriv) se queda en el vault; el node solo tiene `D` + cert.** Si > comprometen el box de media, revocas su `deviceId` y listo. ### 5.1. Dos planos (única diferencia con la terminal) La terminal manda TODO por el **proxy** (mensajes chicos). El content node hace igual para el **control**, pero la **media no cabe en el proxy** (video), así que se parte en dos: - **Plano de control → `@dotrino/proxy-client`** (`identify` firmado, `sendByPubkey`, cola offline, E2E): autorizar, ACLs, resolver "¿quién es dueño del `cid`?", firmar/entregar manifiestos. Igual que la terminal. - **Plano de datos (bytes/streaming) → transporte del §7** (túnel de streaming / puerto propio / etc.). Este es el añadido sobre el modelo de la terminal. ### 5.2. El helper compartido YA EXISTE: `@dotrino/remote-agent` > Corregido el 2026-08-17. Este documento proponía **extraer** un `@dotrino/enroll`. > No hace falta: la pieza está escrita, publicada y en producción. **No la > reescribas ni escribas otra.** **`@dotrino/remote-agent`** es el middleware de "aparato remoto enrolado al vault" del ecosistema, y ya lo consumen `dotrino-terminal` y `dotrino-ia`. De ahí sale todo lo que este node necesitaba para la Fase 2: | Del paquete | Qué resuelve | |---|---| | `/link` → `enroll()`, `parseQr()` | emparejamiento endurecido (llave `D` propia, código que se muestra y NO viaja, `commit` del código, verificación de la cadena, `link.json` en 0600) | | `/agent` → `startRemoteAgent()` | `identify` firmado en el proxy, canal cifrado por sesión (ECDH → AES-GCM), refresco de revocados, **renovación del cert** antes de vencer y auto-borrado al recibir un `vault.revoked` firmado | | raíz | constantes del protocolo + el `e2e` isomórfico (lo usan las pruebas) | Lo único propio de `dotrino-content` es el pegamento (`src/agent.js`) y las operaciones (`src/ops.js`). **Deuda conocida, ajena a este repo:** el agente de `dotrino-terminal` sigue con su copia inline anterior a la extracción (y por eso sin renovación de cert); migrarlo al paquete es tarea suya, con su republicación. ## 6. API (borrador) HTTP, `Bearer ` o Basic (como `here`) para operaciones autenticadas. Lectura pública/por-fragmento sin auth. **Dos servidores, y no se mezclan.** El de abajo es el **local** (loopback, sin auth, la vía de subida del propio aparato). El **público** (§7.2) es otro proceso HTTP con otras reglas y solo tres rutas: `GET|HEAD /c/` (imágenes públicas comprobadas, bajo el tope), `GET /p/` (el permalink con la tarjeta) y `GET /robots.txt` + `/health`. Nada de subir, borrar ni listar por ahí. ``` POST /c # subir (streaming). body = bytes (ya cifrados si privado) # → { cid, size, mime } GET /c/ # descargar/streamear (soporta Range → video seek) HEAD /c/ # size/mime/etag sin cuerpo GET /c//thumb # miniatura (si existe; ver §9) DELETE /c/ # borrar (auth: content:admin del dueño) GET /list # índice del dueño (auth) → [{cid,mime,size,createdAt,...}] POST /pin / /unpin # retención (evita GC) / liberar GET /stats # uso de disco, cuota, nº blobs ``` - **Streaming:** `GET /c/` **debe** soportar `Range` (206 Partial Content) para *seek* de video y para que `