dotrino-content 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,173 @@
1
+ # dotrino-content
2
+
3
+ Nodo de contenido del ecosistema **Dotrino**: guarda y sirve **media pesada**
4
+ (video/imagen/audio/archivos) direccionada por **hash de contenido** (`cid`),
5
+ autohospedado por el usuario. Diseño completo en
6
+ [`docs/DISENO.md`](./docs/DISENO.md); estado/continuación en
7
+ [`docs/HANDOFF.md`](./docs/HANDOFF.md).
8
+
9
+ > **Estado: Fase 2 (aparato del vault) + vistas previas públicas.** Al core local se
10
+ > le suma la identidad: el node se enrola a tu bóveda y se puede administrar desde tus
11
+ > apps por el proxy, sin abrir puertos. El HTTP de administración sigue escuchando
12
+ > **solo en `127.0.0.1`**; con `--public` se abre, aparte, un puerto que sirve
13
+ > **únicamente vistas previas** para que un enlace compartido tenga tarjeta en las
14
+ > redes. El transporte P2P entre aparatos es lo que queda de la Fase 3.
15
+
16
+ ## Uso
17
+
18
+ ```sh
19
+ # una vez: enlazar este node a tu bóveda (saca el código de `dotrino-vault pair`)
20
+ npx dotrino-content enroll <código>
21
+
22
+ npx dotrino-content start [--port 3777] [--dir ~/.dotrino-content] \
23
+ [--max-gb 50] [--max-blob-mb 512] [--gc-min 60] [--no-agent]
24
+
25
+ # con vistas previas públicas (ver más abajo antes de encenderlo)
26
+ npx dotrino-content start --public --public-port 3778 --public-egress-gb 5 \
27
+ --public-url https://content.tudominio.com
28
+ ```
29
+
30
+ Al enrolar, el node genera **su propia llave** y muestra un código que tienes que
31
+ tipear en la bóveda para aprobarlo: ese código no viaja por la red, así que aprobar
32
+ exige tener delante esta máquina. La **clave maestra nunca llega aquí** — solo un
33
+ certificado con caducidad, que el node renueva solo y que puedes revocar
34
+ (`dotrino-vault revoke <deviceId>`) sin tocar el resto de tus aparatos.
35
+
36
+ Env: `DOTRINO_CONTENT_DIR` (datos), `DOTRINO_CONTENT_LINK_DIR` (enlace), `PORT`.
37
+ Requiere **Node ≥ 22.5**. El core usa solo `node:crypto` y `node:sqlite`; la
38
+ identidad viene de los pilares del ecosistema (`@dotrino/remote-agent`,
39
+ `@dotrino/identity`).
40
+
41
+ ## Plano de control (Fase 2, por el proxy, cifrado)
42
+
43
+ Con el node enlazado, tus propias apps —cualquier aparato con un certificado de **la
44
+ misma** bóveda— pueden administrarlo a distancia dentro de una sesión cifrada:
45
+
46
+ ```
47
+ hello quién es el node (owner, versión, uso de disco, tope)
48
+ put {data,mime,…} guardar algo pequeño desde otro aparato tuyo (≤ 256 KB)
49
+ get <cid> leerlo de vuelta (≤ 256 KB)
50
+ list · stat <cid> · stats qué guarda
51
+ pin <cid> · unpin <cid> retener / soltar
52
+ remove <cid> borrar
53
+ acl <cid> public|private abrir o cerrar un blob (público es opt-in explícito)
54
+ meta <cid> {…} nombre/título/descripción para la tarjeta de la vista previa
55
+ thumb <cid> <thumbCid> enlazar la miniatura (otro blob, público por su cuenta)
56
+ gc recolectar vencidos ahora
57
+ ```
58
+
59
+ **`put` y `get` tienen un tope duro de 256 KB, y eso NO es un límite a subir: es la
60
+ frontera.** El plano de control es el proxy del ecosistema —trama de 1 MB, cola de
61
+ mensajes, no un almacén—, así que por aquí pasa lo que **es** un mensaje: un post (un
62
+ eco pesa cientos de bytes) y una miniatura (decenas de KB). Por eso **no hay subida
63
+ por partes**: trocear sería disimular la frontera y acabar usando la infraestructura
64
+ del ecosistema como transporte. Los originales suben en local por HTTP y, entre
65
+ aparatos, por P2P.
66
+
67
+ ## Vistas previas públicas (`--public`, apagado por defecto)
68
+
69
+ **Para qué es: para que un enlace que compartes tenga TARJETA** en X, LinkedIn,
70
+ WhatsApp o Telegram. No es para servir tu contenido — eso se sigue abriendo en la app,
71
+ con la referencia en el `#fragment`, que nunca llega a ningún servidor. Lo que sale por
72
+ este puerto es la **miniatura** que tú marcaste pública, no el archivo.
73
+
74
+ ```
75
+ GET|HEAD /c/<cid> los bytes, si pasan TODOS los cerrojos de abajo
76
+ GET /p/<cid> permalink: tarjeta (og:*) + botón "Abrir" hacia la app
77
+ GET /robots.txt · /health
78
+ ```
79
+
80
+ Cinco cerrojos, y ninguno se puede saltar desde fuera:
81
+
82
+ | | |
83
+ |---|---|
84
+ | **Solo lo público y en claro** | lo cifrado no sale ni marcado público a mano en el índice |
85
+ | **Solo imágenes de mapa de bits** | JPEG, PNG, GIF, WebP, AVIF. **SVG no**: es un documento que ejecuta scripts |
86
+ | **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 |
87
+ | **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ú |
88
+ | **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 |
89
+
90
+ Lo privado responde **404, nunca 403**: un 403 confirmaría que ese `cid` está aquí. Y
91
+ `robots.txt` prohíbe todo (las tarjetas funcionan igual: los rastreadores de las redes
92
+ piden la página cuando alguien pega el enlace, no indexan). `--public-index` lo levanta.
93
+
94
+ **La miniatura la genera tu app al subir** (con un canvas) y se sube como **otro
95
+ blob**, que se enlaza con la op `thumb`. El node no decodifica imágenes: así no
96
+ arrastra dependencias nativas. Enlazar una miniatura **no** la publica — se marca
97
+ pública por su cuenta.
98
+
99
+ ## API HTTP (localhost)
100
+
101
+ ```
102
+ POST /c?ttl=<ms>&enc=1 subir (streaming; Content-Type = mime) → { cid, size, mime, existed }
103
+ GET /c/<cid> descargar/streamear (Range → 206; ETag = cid, immutable)
104
+ HEAD /c/<cid> size/mime/etag sin cuerpo
105
+ DELETE /c/<cid> borrar
106
+ GET /list índice de blobs
107
+ POST /pin/<cid> retención (excluye del GC) POST /unpin/<cid>
108
+ GET /stats nº de blobs, bytes usados, cuota
109
+ ```
110
+
111
+ - `cid = sha256-<hex>` (prefijo de algoritmo → extensible a BLAKE3 después; se
112
+ usa SHA-256 de `node:crypto` porque no exige dependencias nativas y el
113
+ `.npmrc` del ecosistema bloquea build scripts de npm).
114
+ - Disco: `blobs/<aa>/<bb>/<cid>` (sharding); índice en SQLite (`index.db`).
115
+ - Dedup por contenido: re-subir el mismo archivo devuelve `existed: true`.
116
+ - **Cuota** (`--max-gb`): al no caber, el GC desaloja no-pineados más viejos;
117
+ los **pineados jamás se borran** (si solo quedan pineados → `507`).
118
+ - **TTL** opcional por blob (`?ttl=<ms>`): vencido = candidato a GC.
119
+ - El cifrado E2E es **del lado del cliente** (el node solo ve ciphertext si
120
+ subes cifrado y marcas `enc=1`); la llave viaja en el `#fragment` del enlace.
121
+ - **`owner` y `acl`:** con el node enlazado, todo lo que se sube queda estampado con
122
+ el `ownerId` de tu bóveda (la mitad izquierda de la referencia compartible
123
+ `ownerId + cid`). El `acl` nace privado: lo que no se marca `public` a mano no sale
124
+ del node cuando llegue el modo público, y un blob cifrado no puede marcarse
125
+ público (nadie sin la llave podría leerlo).
126
+
127
+ ## `@dotrino/content-client` (lo que usa una app)
128
+
129
+ El cliente de navegador vive en [`lib/`](./lib) y se publica desde este mismo repo,
130
+ igual que `dotrino-vault` publica su lib. Está aquí a propósito: el protocolo —los
131
+ nombres de las ops, el tope de 256 KB, el formato de la referencia— es de las dos
132
+ puntas, y separarlas en dos repos es la forma de que acaben diciendo cosas distintas.
133
+
134
+ ```js
135
+ import { ContentClient, buildUrl } from '@dotrino/content-client'
136
+
137
+ const cc = await ContentClient.connect({ link }) // link del vault: { id, cert, iss }
138
+ const ref = await cc.put(bytes, { mime: 'image/png' }) // cifra por defecto
139
+ const url = buildUrl(ref) // https://eco.dotrino.com/#<owner>/<cid>/<llave>
140
+ const back = await cc.get(ref) // comprueba el hash antes de devolver nada
141
+ ```
142
+
143
+ - **Cifra por defecto** (§4): el node guarda ciphertext y la llave sale en la
144
+ referencia, para el `#fragment`. Con `encrypt: false` queda en claro, que es lo que
145
+ hace falta para poder marcarlo `public` y que tenga tarjeta.
146
+ - **`connect()` falla con `code: 'no-node'`** si no tienes ninguno encendido, en vez
147
+ de esperar. Es lo esperable y **la app tiene que saber seguir sin node**: al
148
+ `@dotrino/store` va lo que debe estar siempre disponible; aquí van los bytes.
149
+ - **`get()` comprueba el hash** de lo que llega: el `cid` **es** el hash, así que unos
150
+ bytes que no cuadren se rechazan vengan de donde vengan.
151
+ - **Miniaturas** (`@dotrino/content-client/thumb`): `makeThumbnail()` en canvas y
152
+ `putImageWithThumbnail()`, que sube el original **cifrado y privado** y la miniatura
153
+ **en claro y pública** — que es el reparto que hace que haya tarjeta sin publicar el
154
+ archivo.
155
+ - **Todavía NO lee el contenido de otro usuario**: hoy solo se abre sesión con un
156
+ aparato de tu misma acta. Un tercero con tu enlace ve la vista previa (si la
157
+ encendiste) y podrá pedir los bytes cuando exista el transporte P2P.
158
+
159
+ ## Tests
160
+
161
+ ```sh
162
+ npm test
163
+ ```
164
+
165
+ ## Fases
166
+
167
+ 1. ✅ **Core local**: blobs por `cid`, Range/206, índice, cuota+GC.
168
+ 2. ✅ **Aparato del vault** (este estado): enrolamiento, plano de control cifrado,
169
+ `owner` + `acl`.
170
+ 3. Exposición: P2P/swarm por WebRTC + modo público HTTP opt-in + sembrador 24/7.
171
+ 4. Integración con **eco** (la app que resuelve el `#fragment`) + catálogo.
172
+
173
+ Licencia MIT.
package/bin/cli.js ADDED
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI de dotrino-content.
4
+ *
5
+ * dotrino-content enroll <código> enlaza este node a tu vault (una vez)
6
+ * dotrino-content start [--port 3777] [--dir <ruta>] [--max-gb <n>] [--gc-min <min>]
7
+ * [--no-agent] arranca sin el plano de control
8
+ * [--public] abre el puerto de VISTAS PREVIAS (§7.2)
9
+ *
10
+ * El HTTP de administración sigue escuchando SOLO en loopback: es la vía local
11
+ * para subir y leer. Lo que añade el enlace es el plano de CONTROL (administrar el
12
+ * node desde tus apps, por el proxy, sin abrir puertos) — ver DISENO.md §7.
13
+ *
14
+ * `--public` levanta un SEGUNDO servidor, aparte y con sus propias reglas
15
+ * (src/public.js): sirve las **vistas previas** de lo que marcaste público —solo
16
+ * imágenes comprobadas, con tope de tamaño, límite por IP y techo de salida— para
17
+ * que un enlace compartido tenga tarjeta en las redes. No es un CDN y no lo va a
18
+ * ser: el contenido se sigue abriendo en la app.
19
+ *
20
+ * Env: DOTRINO_CONTENT_DIR (datos), DOTRINO_CONTENT_LINK_DIR (enlace), PORT.
21
+ */
22
+ import os from 'node:os'
23
+ import path from 'node:path'
24
+ import { parseArgs } from 'node:util'
25
+ import { ContentNode } from '../src/node.js'
26
+ import { createServer } from '../src/server.js'
27
+ import { createPublicServer, DEFAULT_PUBLIC_PORT, DEFAULT_MAX_BYTES, DEFAULT_RATE_PER_MIN } from '../src/public.js'
28
+ import { isLinked, linkDir, startContentAgent } from '../src/agent.js'
29
+
30
+ const USAGE = `uso:
31
+ dotrino-content enroll <código>
32
+ dotrino-content start [--port 3777] [--dir <ruta>] [--max-gb <n>] [--max-blob-mb <n>] [--gc-min <min>] [--no-agent]
33
+ [--public] [--public-port 3778] [--public-host 0.0.0.0] [--public-max-kb 512]
34
+ [--public-rate 60] [--public-egress-gb <n>] [--public-url https://…] [--app-url https://…]
35
+ [--public-index]`
36
+
37
+ const { values, positionals } = parseArgs({
38
+ allowPositionals: true,
39
+ options: {
40
+ port: { type: 'string' },
41
+ dir: { type: 'string' },
42
+ 'max-gb': { type: 'string' },
43
+ 'max-blob-mb': { type: 'string' },
44
+ 'gc-min': { type: 'string' },
45
+ 'no-agent': { type: 'boolean' },
46
+ // --- modo público (§7.2): apagado por defecto, y cada límite es un flag ---
47
+ public: { type: 'boolean' },
48
+ 'public-port': { type: 'string' },
49
+ 'public-host': { type: 'string' },
50
+ 'public-max-kb': { type: 'string' },
51
+ 'public-rate': { type: 'string' },
52
+ 'public-egress-gb': { type: 'string' },
53
+ 'public-url': { type: 'string' },
54
+ 'app-url': { type: 'string' },
55
+ 'public-index': { type: 'boolean' }
56
+ }
57
+ })
58
+
59
+ const cmd = positionals[0] || 'start'
60
+ if (cmd !== 'start' && cmd !== 'enroll') {
61
+ console.error(`comando desconocido: ${cmd}\n${USAGE}`)
62
+ process.exit(1)
63
+ }
64
+
65
+ /**
66
+ * Enrolamiento: el flujo endurecido del ecosistema, tal cual lo hace la terminal.
67
+ * Este node genera su llave, MUESTRA un código y espera a que lo apruebes tipeando
68
+ * ese código en tu bóveda — así aprobar exige tener delante esta máquina. La clave
69
+ * maestra nunca llega aquí: solo un certificado con fecha de caducidad.
70
+ */
71
+ if (cmd === 'enroll') {
72
+ const pairing = positionals[1]
73
+ if (!pairing) {
74
+ console.error('falta el código de emparejamiento.\n' +
75
+ 'Sácalo de tu bóveda (dotrino-vault pair, o profile.dotrino.com/#myvault) y pásalo aquí:\n' +
76
+ ' dotrino-content enroll <código>')
77
+ process.exit(1)
78
+ }
79
+ // Se enrola como SERVICIO (`ns:content`), que es lo que le da además la llave de
80
+ // CIFRADO a la que el vault le sella sus variables. El enlace del plano de control
81
+ // queda escrito con la MISMA llave: un aparato, una identidad (ver `vaultEnv.js`).
82
+ const { enrollToVault, serviceDir, NS } = await import('../src/vaultEnv.js')
83
+ try {
84
+ const res = await enrollToVault(pairing, {
85
+ onReplace: (prev) => {
86
+ console.log(`\n⚠ este node YA estaba enrolado (aparato ${prev.deviceId}).`)
87
+ console.log(' Se descarta esa identidad; con ella se va su cajón de variables,')
88
+ console.log(' que va indexado por su llave. Si solo querías recargar la')
89
+ console.log(' configuración, NO enroles: reinicia el node.\n')
90
+ },
91
+ onCode: ({ deviceId, code }) => {
92
+ console.log(`\n este node es el aparato ${deviceId}`)
93
+ console.log(` apruébalo en tu bóveda: dotrino-vault approve ${code}\n`)
94
+ console.log(' (el código NO viaja por la red: lo tipeas tú)')
95
+ }
96
+ })
97
+ const days = Math.round((res.cert.exp - Date.now()) / 86400000)
98
+ console.log(`\nlisto: node enlazado. Certificado válido ${days} días (se renueva solo).`)
99
+ console.log(`identidad en ${serviceDir()} · enlace en ${linkDir()}`)
100
+ console.log(`\nahora carga su configuración en la bóveda (namespace «${NS}»):`)
101
+ console.log(' dotrino-vault secret set content CONTENT_STORAGE=local --public')
102
+ } catch (e) {
103
+ console.error(`\nno se pudo enlazar: ${e.message}`)
104
+ process.exit(1)
105
+ }
106
+ process.exit(0)
107
+ }
108
+
109
+ const dir = values.dir || process.env.DOTRINO_CONTENT_DIR ||
110
+ path.join(os.homedir(), '.dotrino-content')
111
+ const port = Number(values.port || process.env.PORT || 3777)
112
+ const maxBytes = values['max-gb'] ? Number(values['max-gb']) * 1024 ** 3 : 0
113
+ const maxBlobBytes = values['max-blob-mb'] ? Number(values['max-blob-mb']) * 1024 ** 2 : 0
114
+ const gcMin = Number(values['gc-min'] || 60)
115
+
116
+ // La configuración la sirve el vault (§15.14). Se pide ANTES de levantar nada: de
117
+ // ahí sale `CONTENT_STORAGE` y, con él, qué almacén usa este node. Sin vault esto no
118
+ // hace nada y el node corre en local, que es el modo normal de un autohospedado.
119
+ const { startVaultConfig, isEnrolled } = await import('../src/vaultEnv.js')
120
+ const log = (m) => console.log(m)
121
+ const vaultConfig = startVaultConfig({ log })
122
+ if (!isEnrolled()) console.log('sin vault: configuración local (enrola con: dotrino-content enroll <código>)')
123
+ // Se ESPERA a la configuración antes de montar el almacén, porque de ella sale cuál es
124
+ // (§15.14). Con plazo: si la bóveda no contesta, se arranca con el disco y ya se
125
+ // reiniciará cuando llegue — un node no puede quedarse sin servir por eso.
126
+ await vaultConfig.ready
127
+
128
+ const { openStore } = await import('../src/storage.js')
129
+ const { store } = await openStore({ dir, log })
130
+
131
+ const node = await new ContentNode({ dir, maxBytes, maxBlobBytes, store, log }).init()
132
+ // Lo que quedó sin subir en un arranque anterior. Se lanza y no se espera.
133
+ node.backupPending().then(({ pending }) => { if (pending) log(`[almacén] ${pending} pendiente(s) de subir al bucket`) })
134
+ const server = createServer(node)
135
+
136
+ // GC periódico de vencidos (ttl); el GC por cuota corre inline en cada put.
137
+ const gcTimer = setInterval(() => node.gc(), gcMin * 60_000)
138
+ gcTimer.unref()
139
+ node.gc()
140
+
141
+ // Escuchar SOLO en loopback. La exposición al mundo es la Fase 3 (§7.2) y va con su
142
+ // propia ACL, su límite por IP y su techo de salida: no se activa por descuido.
143
+ server.listen(port, '127.0.0.1', () => {
144
+ const s = node.stats()
145
+ console.log(`dotrino-content en http://127.0.0.1:${port} · datos: ${dir}`)
146
+ console.log(`blobs: ${s.blobs} · bytes: ${s.bytes}${maxBytes ? ` / ${maxBytes}` : ''}`)
147
+ })
148
+
149
+ // Plano de control: solo si este node ya está enlazado a un vault. Sin enlace sigue
150
+ // siendo lo de antes (un node local), y se dice en voz alta para que nadie crea que
151
+ // tiene administración remota cuando no la tiene.
152
+ let agent = null
153
+ if (values['no-agent']) {
154
+ console.log('plano de control: apagado (--no-agent)')
155
+ } else if (isLinked()) {
156
+ try {
157
+ agent = await startContentAgent({ node })
158
+ } catch (e) {
159
+ console.error(`plano de control: no arrancó (${e.message})`)
160
+ }
161
+ } else {
162
+ console.log('plano de control: sin enlace (corre `dotrino-content enroll <código>` para administrarlo desde tus apps)')
163
+ }
164
+
165
+ // Modo público (§7.2). Va DESPUÉS del agente a propósito: el `owner` del node lo
166
+ // resuelve el enlace con el vault, y es la mitad izquierda de la referencia
167
+ // `ownerId + cid` que la tarjeta necesita para armar el enlace "Abrir".
168
+ let publicServer = null
169
+ if (values.public) {
170
+ const maxKb = values['public-max-kb'] !== undefined ? Number(values['public-max-kb']) : DEFAULT_MAX_BYTES / 1024
171
+ const publicPort = Number(values['public-port'] || DEFAULT_PUBLIC_PORT)
172
+ const publicHost = values['public-host'] || '0.0.0.0'
173
+ const egressGb = Number(values['public-egress-gb'] || 0)
174
+ publicServer = createPublicServer(node, {
175
+ maxBytes: maxKb * 1024,
176
+ ratePerMin: Number(values['public-rate'] || DEFAULT_RATE_PER_MIN),
177
+ maxEgressBytes: egressGb ? egressGb * 1024 ** 3 : 0,
178
+ publicUrl: values['public-url'] || null,
179
+ appUrl: values['app-url'] || undefined,
180
+ index: !!values['public-index'],
181
+ owner: node.owner
182
+ })
183
+ publicServer.listen(publicPort, publicHost, () => {
184
+ console.log(`vistas previas públicas en http://${publicHost}:${publicPort} · solo imágenes` +
185
+ `${maxKb ? ` de hasta ${maxKb} KB` : ' (SIN tope de tamaño)'}` +
186
+ `${egressGb ? ` · techo de salida ${egressGb} GB/día` : ''}`)
187
+ if (!node.owner) {
188
+ console.log(' ojo: este node no está enlazado a un vault, así que las tarjetas salen sin enlace "Abrir"')
189
+ }
190
+ if (!maxKb) {
191
+ console.log(' ojo: sin tope de tamaño esto sirve originales, no vistas previas — y el ancho de banda lo pagas tú')
192
+ }
193
+ })
194
+ }
195
+
196
+ const shutdown = () => {
197
+ clearInterval(gcTimer)
198
+ try { agent?.close() } catch (_) {}
199
+ try { publicServer?.close() } catch (_) {}
200
+ server.close(() => { node.close(); process.exit(0) })
201
+ }
202
+ process.on('SIGINT', shutdown)
203
+ process.on('SIGTERM', shutdown)
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "dotrino-content",
3
+ "version": "0.2.0",
4
+ "description": "Nodo de contenido del ecosistema Dotrino: guarda los bytes del usuario direccionados por su hash (cid), en su propia maquina y, si el dueno quiere, respaldados en su propio bucket (R2, Backblaze, Hetzner, Storj o S3). Lo publico sale por URL directa; lo privado, cifrado y solo por la red Dotrino.",
5
+ "type": "module",
6
+ "bin": {
7
+ "dotrino-content": "bin/cli.js"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.js"
11
+ },
12
+ "scripts": {
13
+ "start": "node bin/cli.js start",
14
+ "test": "node --test 'test/**/*.test.js'",
15
+ "type-check": "tsc --noEmit && tsc --noEmit -p lib"
16
+ },
17
+ "engines": {
18
+ "node": ">=22.5"
19
+ },
20
+ "license": "MIT",
21
+ "devDependencies": {
22
+ "@types/node": "22.20.1",
23
+ "typescript": "5.9.3"
24
+ },
25
+ "dependencies": {
26
+ "@dotrino/identity": "0.55.0",
27
+ "@dotrino/remote-agent": "0.4.1",
28
+ "@dotrino/vault": "0.27.0"
29
+ },
30
+ "keywords": [
31
+ "dotrino",
32
+ "content-addressed",
33
+ "cid",
34
+ "self-hosted",
35
+ "privacy",
36
+ "s3",
37
+ "r2",
38
+ "blob-storage"
39
+ ],
40
+ "homepage": "https://github.com/imdotrino/dotrino-content#readme",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/imdotrino/dotrino-content.git"
44
+ },
45
+ "bugs": {
46
+ "url": "https://github.com/imdotrino/dotrino-content/issues"
47
+ },
48
+ "author": "Dotrino",
49
+ "files": [
50
+ "bin",
51
+ "src",
52
+ "README.md"
53
+ ],
54
+ "publishConfig": {
55
+ "access": "public"
56
+ }
57
+ }
package/src/agent.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * agent.js — Fase 2 (DISENO.md §5, §11): este node deja de ser una pieza local y
3
+ * pasa a ser un APARATO DEL VAULT, administrable a distancia desde tus propias apps
4
+ * sin abrir un solo puerto.
5
+ *
6
+ * NO IMPLEMENTA NADA DE ESO: lo hace `@dotrino/remote-agent`, el middleware que ya
7
+ * usan `dotrino-terminal` y `dotrino-ia`. De ahí salen, y por eso no se reescriben
8
+ * aquí: el enrolamiento contra el vault (llave `D` propia + cert `D ← maestra`, la
9
+ * maestra nunca vive aquí), el `identify` firmado en el proxy, el canal cifrado por
10
+ * sesión, el refresco de la lista de revocados, la RENOVACIÓN del cert antes de que
11
+ * venza y el auto-borrado cuando llega un `vault.revoked` firmado por la maestra.
12
+ *
13
+ * Lo único de este archivo es el pegamento: cada payload que llega por una sesión
14
+ * ya autorizada se despacha en `ops.js`, y la respuesta vuelve por el mismo canal
15
+ * cifrado. Autorizar = tener un cert de LA MISMA maestra que este node (eso lo
16
+ * comprueba `verifyChain` dentro del middleware); no hay lista de invitados propia.
17
+ *
18
+ * El `owner` del node se deriva de la maestra a la que está enlazado: es el
19
+ * `ownerId` de la referencia compartible `ownerId + cid` (§3), y se estampa en todo
20
+ * lo que se suba mientras el node esté enlazado.
21
+ */
22
+ import { pubkeyId } from '@dotrino/identity/capabilities'
23
+ import { startRemoteAgent } from '@dotrino/remote-agent/agent'
24
+ import { dataDir, loadLink } from '@dotrino/remote-agent/link'
25
+ import { startAnnounce } from './announce.js'
26
+ import { createOps } from './ops.js'
27
+
28
+ /** Carpeta de datos del enlace (NO es la de los blobs: el enlace es del aparato). */
29
+ export const linkDir = () => process.env.DOTRINO_CONTENT_LINK_DIR || dataDir('dotrino-content')
30
+
31
+ /** ¿Está este node enlazado a un vault? (sin enlace, Fase 1: solo loopback) */
32
+ export const isLinked = (dir = linkDir()) => {
33
+ const link = loadLink(dir)
34
+ return !!(link?.device?.privateJwk && link?.cert && link?.iss)
35
+ }
36
+
37
+ /**
38
+ * Arranca el plano de control del node.
39
+ *
40
+ * @param {{
41
+ * node?: import('./node.js').ContentNode,
42
+ * dir?: string, proxyUrl?: string, version?: string|null,
43
+ * client?: any, quiet?: boolean, onRevoked?: () => void, announce?: boolean
44
+ * }} opts
45
+ * client: transporte inyectado — SOLO para pruebas (bus en memoria). En
46
+ * producción lo levanta el middleware con el proxy del ecosistema.
47
+ * announce: publicarse en el canal del dueño para que un TERCERO con el enlace
48
+ * pueda encontrar este node (§3.1). Encendido por defecto; se apaga con `false`
49
+ * en un node que solo quieras usar desde tus propios aparatos.
50
+ * @returns {Promise<{ machine: string, machineId: string, owner: string, close: () => void }>}
51
+ */
52
+ export async function startContentAgent ({
53
+ node, dir = linkDir(), proxyUrl, version = null, client, quiet = false, onRevoked, announce = true
54
+ } = {}) {
55
+ if (!node) throw new Error('startContentAgent: falta node')
56
+ const link = loadLink(dir)
57
+ if (!link?.iss) {
58
+ throw new Error('este node no está enlazado a un vault. Ejecuta primero: dotrino-content enroll <código>')
59
+ }
60
+
61
+ // El node representa a la maestra que lo certificó: su huella es el `ownerId`.
62
+ // Se resuelve ANTES de abrir el transporte, para que la primera sesión que entre
63
+ // ya encuentre el despachador armado.
64
+ const owner = await pubkeyId(link.iss)
65
+ node.owner = owner
66
+ const dispatch = createOps(node, { owner, version })
67
+
68
+ const agent = await startRemoteAgent({
69
+ dir,
70
+ proxyUrl,
71
+ client,
72
+ quiet,
73
+ onRevoked,
74
+ onSession: (session) => {
75
+ session.on('message', (msg) => {
76
+ dispatch(msg)
77
+ .then((reply) => session.send(reply))
78
+ .catch((e) => session.send({ rid: msg?.rid, ok: false, code: 'failed', error: e.message }))
79
+ })
80
+ }
81
+ })
82
+
83
+ if (!quiet) console.log(`[content] control plane ready · owner ${owner.slice(0, 16)}`)
84
+
85
+ // ANUNCIO (§3.1): sin esto el node es administrable por sus dueños pero
86
+ // INVISIBLE para quien recibe un enlace — la referencia nombra al dueño, y hay
87
+ // que poder pasar de ahí a «qué nodes suyos están vivos». Reusa la conexión del
88
+ // middleware: nunca se abre una segunda.
89
+ const beacon = announce && agent.client
90
+ ? startAnnounce({ client: agent.client, owner, quiet })
91
+ : null
92
+
93
+ return {
94
+ ...agent,
95
+ owner,
96
+ channels: () => beacon?.channels() || [],
97
+ close () {
98
+ try { beacon?.close() } catch (_) {}
99
+ agent.close()
100
+ }
101
+ }
102
+ }
103
+
104
+ export default { startContentAgent, isLinked, linkDir }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * announce.js — ANUNCIO del node (DISENO.md §3.1). Es lo que hace que se sepa
3
+ * **dónde** está el contenido de un dueño.
4
+ *
5
+ * La referencia compartible nombra al DUEÑO, no a la máquina (`ownerId + cid`), así
6
+ * que resolverla es contestar «¿qué nodes de este dueño están vivos ahora mismo?».
7
+ * Hay dos caminos, según quién pregunte, y este archivo es el segundo:
8
+ *
9
+ * - **El dueño** pregunta a su bóveda (`listAgentsByLabel(id, 'content')`): sus
10
+ * aparatos y sus direcciones. No necesita ningún anuncio.
11
+ * - **Un tercero con el enlace** no puede consultar la bóveda de nadie — ni debe.
12
+ * Para él, el node se publica en un **canal del proxy** y cualquiera lista quién
13
+ * está dentro. Eso es esto.
14
+ *
15
+ * **El nombre del canal lleva el `ownerId`, no el `cid`.** Un canal por contenido
16
+ * filtraría qué guarda cada quien con solo mirar la lista de canales; y además
17
+ * serían miles. Con uno por dueño, lo que se sabe al listar es «este dueño tiene
18
+ * nodes en línea», que es justo lo que hace falta para pedirle algo.
19
+ *
20
+ * **Por qué el canal va con id de proxio delante.** Hay dos proxios federados y un
21
+ * canal SIN prefijo es local a cada uno: el node anunciado en uno sería invisible
22
+ * para quien esté conectado al otro. Se publica en el canal de **cada nodo
23
+ * conocido** y se lista en todos, en vez de elegir uno "dueño" por una fórmula: la
24
+ * lista de nodos de la malla cambia cuando entra o sale un proxio, y una fórmula
25
+ * que dependa de ella reasigna todos los canales el día que eso pase. Son dos
26
+ * canales hoy; publicar en los dos cuesta dos mensajes y no se rompe nunca.
27
+ *
28
+ * **Anunciarse NO da acceso a nada.** Es una guía de teléfonos: dice a quién
29
+ * llamar. Quién puede pedir qué lo siguen decidiendo la ACL y el certificado, en
30
+ * el sitio de siempre.
31
+ */
32
+
33
+ /** Nombre del canal de un dueño dentro de un proxio concreto. */
34
+ export const channelFor = (nodeId, ownerId) => `${nodeId}/content_${ownerId}`
35
+
36
+ /** Cada cuánto se re-publica (el proxio caduca las entradas de canal). */
37
+ export const REPUBLISH_MS = 4 * 60 * 1000
38
+
39
+ /**
40
+ * Publica este node en el canal de su dueño, en todos los proxios conocidos, y lo
41
+ * mantiene publicado. Best-effort a propósito: si el proxy está caído, el node
42
+ * sigue funcionando —sirve en local y atiende a los aparatos del acta— y el
43
+ * siguiente tic lo vuelve a intentar. Un anuncio perdido no rompe nada; lo único
44
+ * que pasa es que un tercero no lo encuentra hasta que vuelva.
45
+ *
46
+ * @param {{ client: any, owner: string, quiet?: boolean, intervalMs?: number }} opts
47
+ * client: el `WebSocketProxyClient` YA conectado del agente (`ra.client`) — no se
48
+ * abre otro: dos conexiones del mismo aparato son dos identidades de transporte.
49
+ * @returns {{ channels: () => string[], close: () => void }}
50
+ */
51
+ export function startAnnounce ({ client, owner, quiet = false, intervalMs = REPUBLISH_MS }) {
52
+ if (!client) throw new Error('startAnnounce: falta client')
53
+ if (!owner) throw new Error('startAnnounce: falta owner')
54
+
55
+ let stopped = false
56
+ let current = []
57
+
58
+ /** Los proxios donde hay que anunciarse: el que nos atiende y los que conoce. */
59
+ const targets = () => {
60
+ const known = Array.isArray(client.knownNodes) ? client.knownNodes : []
61
+ return known.length ? known : (client.node ? [client.node] : [])
62
+ }
63
+
64
+ async function publishAll () {
65
+ if (stopped) return
66
+ const names = targets().map((n) => channelFor(n, owner))
67
+ if (!names.length) return // aún sin `connected`: el próximo tic
68
+ const done = []
69
+ for (const name of names) {
70
+ try {
71
+ await client.publish(name, { app: 'content', owner })
72
+ done.push(name)
73
+ } catch (e) {
74
+ if (!quiet) console.error(`[content] no me pude anunciar en ${name}: ${e.message}`)
75
+ }
76
+ }
77
+ const first = !current.length && done.length
78
+ current = done
79
+ if (first && !quiet) console.log(`[content] anunciado como node de ${owner.slice(0, 16)} en ${done.length} proxio(s)`)
80
+ }
81
+
82
+ publishAll()
83
+ // Re-publicar al reconectar: el token cambia y el anuncio viejo apunta a una
84
+ // conexión que ya no existe. Sin esto, un corte de red deja al node listado y
85
+ // mudo, que es peor que no estar listado.
86
+ const offToken = client.on('token', () => { current = []; publishAll() })
87
+ const timer = setInterval(publishAll, intervalMs)
88
+ timer.unref?.()
89
+
90
+ return {
91
+ channels: () => [...current],
92
+ close () {
93
+ stopped = true
94
+ clearInterval(timer)
95
+ try { offToken?.() } catch (_) {}
96
+ for (const name of current) { try { client.unpublish(name) } catch (_) {} }
97
+ current = []
98
+ }
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Lado del que BUSCA: los tokens de los nodes de un dueño que están en línea, en
104
+ * todos los proxios conocidos, sin repetidos.
105
+ *
106
+ * Lo que devuelve son **candidatos, no autoridades**: cualquiera puede publicarse
107
+ * en el canal de cualquier dueño (el nombre del canal no es un secreto). Quien
108
+ * pregunte tiene que comprobar las dos cosas de siempre — que el node presenta un
109
+ * certificado que encadena a ese `ownerId`, y que los bytes que entrega hashean al
110
+ * `cid` pedido. Con esas dos, un impostor en la lista no consigue nada más que
111
+ * gastar un intento.
112
+ *
113
+ * @param {{ client: any, owner: string }} opts
114
+ * @returns {Promise<string[]>} tokens (direcciones en el proxy)
115
+ */
116
+ export async function findNodes ({ client, owner }) {
117
+ if (!client || !owner) throw new Error('findNodes: faltan client u owner')
118
+ const known = Array.isArray(client.knownNodes) && client.knownNodes.length
119
+ ? client.knownNodes
120
+ : (client.node ? [client.node] : [])
121
+ const out = new Set()
122
+ for (const nodeId of known) {
123
+ try {
124
+ for (const token of await client.list(channelFor(nodeId, owner))) out.add(token)
125
+ } catch (_) { /* un proxio que no contesta no invalida al otro */ }
126
+ }
127
+ // El propio que pregunta puede estar en la lista (una PWA que también es node).
128
+ if (client.token) out.delete(client.token)
129
+ return [...out]
130
+ }
131
+
132
+ export default { startAnnounce, findNodes, channelFor, REPUBLISH_MS }