dotrino-content
Nodo de contenido del ecosistema Dotrino: guarda y sirve media pesada
(video/imagen/audio/archivos) direccionada por hash de contenido (cid),
autohospedado por el usuario. Diseño completo en
docs/DISENO.md; estado/continuación en
docs/HANDOFF.md.
Estado: Fase 2 (aparato del vault) + vistas previas públicas. Al core local se le suma la identidad: el node se enrola a tu bóveda y se puede administrar desde tus apps por el proxy, sin abrir puertos. El HTTP de administración sigue escuchando solo en
127.0.0.1; con--publicse abre, aparte, un puerto que sirve únicamente vistas previas para que un enlace compartido tenga tarjeta en las redes. El transporte P2P entre aparatos es lo que queda de la Fase 3.
Uso
# una vez: enlazar este node a tu bóveda (saca el código de `dotrino-vault pair`)
npx dotrino-content enroll <código>
npx dotrino-content start [--port 3777] [--dir ~/.dotrino-content] \
[--max-gb 50] [--max-blob-mb 512] [--gc-min 60] [--no-agent]
# con vistas previas públicas (ver más abajo antes de encenderlo)
npx dotrino-content start --public --public-port 3778 --public-egress-gb 5 \
--public-url https://content.tudominio.com
Al enrolar, el node genera su propia llave y muestra un código que tienes que
tipear en la bóveda para aprobarlo: ese código no viaja por la red, así que aprobar
exige tener delante esta máquina. La clave maestra nunca llega aquí — solo un
certificado con caducidad, que el node renueva solo y que puedes revocar
(dotrino-vault revoke <deviceId>) sin tocar el resto de tus aparatos.
Env: DOTRINO_CONTENT_DIR (datos), DOTRINO_CONTENT_LINK_DIR (enlace), PORT.
Requiere Node ≥ 22.5. El core usa solo node:crypto y node:sqlite; la
identidad viene de los pilares del ecosistema (@dotrino/remote-agent,
@dotrino/identity).
Plano de control (Fase 2, por el proxy, cifrado)
Con el node enlazado, tus propias apps —cualquier aparato con un certificado de la misma bóveda— pueden administrarlo a distancia dentro de una sesión cifrada:
hello quién es el node (owner, versión, uso de disco, tope)
put {data,mime,…} guardar algo pequeño desde otro aparato tuyo (≤ 256 KB)
get <cid> leerlo de vuelta (≤ 256 KB)
list · stat <cid> · stats qué guarda
pin <cid> · unpin <cid> retener / soltar
remove <cid> borrar
acl <cid> public|private abrir o cerrar un blob (público es opt-in explícito)
meta <cid> {…} nombre/título/descripción para la tarjeta de la vista previa
thumb <cid> <thumbCid> enlazar la miniatura (otro blob, público por su cuenta)
gc recolectar vencidos ahora
put y get tienen un tope duro de 256 KB, y eso NO es un límite a subir: es la
frontera. El plano de control es el proxy del ecosistema —trama de 1 MB, cola de
mensajes, no un almacén—, así que por aquí pasa lo que es un mensaje: un post (un
eco pesa cientos de bytes) y una miniatura (decenas de KB). Por eso no hay subida
por partes: trocear sería disimular la frontera y acabar usando la infraestructura
del ecosistema como transporte. Los originales suben en local por HTTP y, entre
aparatos, por P2P.
Vistas previas públicas (--public, apagado por defecto)
Para qué es: para que un enlace que compartes tenga TARJETA en X, LinkedIn,
WhatsApp o Telegram. No es para servir tu contenido — eso se sigue abriendo en la app,
con la referencia en el #fragment, que nunca llega a ningún servidor. Lo que sale por
este puerto es la miniatura que tú marcaste pública, no el archivo.
GET|HEAD /c/<cid> los bytes, si pasan TODOS los cerrojos de abajo
GET /p/<cid> permalink: tarjeta (og:*) + botón "Abrir" hacia la app
GET /robots.txt · /health
Cinco cerrojos, y ninguno se puede saltar desde fuera:
| Solo lo público y en claro | lo cifrado no sale ni marcado público a mano en el índice |
| Solo imágenes de mapa de bits | JPEG, PNG, GIF, WebP, AVIF. SVG no: es un documento que ejecuta scripts |
| El tipo se comprueba en los bytes | el Content-Type lo declara quien sube, así que no se cree: un HTML subido como image/png responde 404 |
Tope de tamaño (--public-max-kb, 512) |
es lo que hace que esto sea un servidor de miniaturas y no un CDN. 0 lo quita y entonces sirve originales: el ancho de banda lo pagas tú |
| Límite por IP + techo diario | --public-rate (60/min) y --public-egress-gb, que se persiste y corta antes de mandar una respuesta que no quepa |
Lo privado responde 404, nunca 403: un 403 confirmaría que ese cid está aquí. Y
robots.txt prohíbe todo (las tarjetas funcionan igual: los rastreadores de las redes
piden la página cuando alguien pega el enlace, no indexan). --public-index lo levanta.
La miniatura la genera tu app al subir (con un canvas) y se sube como otro
blob, que se enlaza con la op thumb. El node no decodifica imágenes: así no
arrastra dependencias nativas. Enlazar una miniatura no la publica — se marca
pública por su cuenta.
API HTTP (localhost)
POST /c?ttl=<ms>&enc=1 subir (streaming; Content-Type = mime) → { cid, size, mime, existed }
GET /c/<cid> descargar/streamear (Range → 206; ETag = cid, immutable)
HEAD /c/<cid> size/mime/etag sin cuerpo
DELETE /c/<cid> borrar
GET /list índice de blobs
POST /pin/<cid> retención (excluye del GC) POST /unpin/<cid>
GET /stats nº de blobs, bytes usados, cuota
cid = sha256-<hex>(prefijo de algoritmo → extensible a BLAKE3 después; se usa SHA-256 denode:cryptoporque no exige dependencias nativas y el.npmrcdel ecosistema bloquea build scripts de npm).- Disco:
blobs/<aa>/<bb>/<cid>(sharding); índice en SQLite (index.db). - Dedup por contenido: re-subir el mismo archivo devuelve
existed: true. - Cuota (
--max-gb): al no caber, el GC desaloja no-pineados más viejos; los pineados jamás se borran (si solo quedan pineados →507). - TTL opcional por blob (
?ttl=<ms>): vencido = candidato a GC. - El cifrado E2E es del lado del cliente (el node solo ve ciphertext si
subes cifrado y marcas
enc=1); la llave viaja en el#fragmentdel enlace. owneryacl: con el node enlazado, todo lo que se sube queda estampado con elownerIdde tu bóveda (la mitad izquierda de la referencia compartibleownerId + cid). Elaclnace privado: lo que no se marcapublica mano no sale del node cuando llegue el modo público, y un blob cifrado no puede marcarse público (nadie sin la llave podría leerlo).
@dotrino/content-client (lo que usa una app)
El cliente de navegador vive en lib/ y se publica desde este mismo repo,
igual que dotrino-vault publica su lib. Está aquí a propósito: el protocolo —los
nombres de las ops, el tope de 256 KB, el formato de la referencia— es de las dos
puntas, y separarlas en dos repos es la forma de que acaben diciendo cosas distintas.
import { ContentClient, buildUrl } from '@dotrino/content-client'
const cc = await ContentClient.connect({ link }) // link del vault: { id, cert, iss }
const ref = await cc.put(bytes, { mime: 'image/png' }) // cifra por defecto
const url = buildUrl(ref) // https://eco.dotrino.com/#<owner>/<cid>/<llave>
const back = await cc.get(ref) // comprueba el hash antes de devolver nada
- Cifra por defecto (§4): el node guarda ciphertext y la llave sale en la
referencia, para el
#fragment. Conencrypt: falsequeda en claro, que es lo que hace falta para poder marcarlopublicy que tenga tarjeta. connect()falla concode: 'no-node'si no tienes ninguno encendido, en vez de esperar. Es lo esperable y la app tiene que saber seguir sin node: al@dotrino/storeva lo que debe estar siempre disponible; aquí van los bytes.get()comprueba el hash de lo que llega: elcides el hash, así que unos bytes que no cuadren se rechazan vengan de donde vengan.- Miniaturas (
@dotrino/content-client/thumb):makeThumbnail()en canvas yputImageWithThumbnail(), que sube el original cifrado y privado y la miniatura en claro y pública — que es el reparto que hace que haya tarjeta sin publicar el archivo. - Lo PÚBLICO de otro usuario ya se lee por la red (
fetchPublicen la lib, DISENO §16): un tercero con tu enlace encuentra tu node por el proxio y le pide elcid; el node contesta solo lo marcado público y en claro, y los bytes se verifican contra elcid. Lo privado sigue siendo de tus aparatos.
Tests
npm test
Fases
- Core local: blobs por
cid, Range/206, índice, cuota+GC. - Aparato del vault (este estado): enrolamiento, plano de control cifrado,
owner+acl. - Exposición: P2P/swarm por WebRTC + modo público HTTP opt-in + sembrador 24/7.
- Integración con eco (la app que resuelve el
#fragment) + catálogo.
Licencia MIT.