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/src/node.js ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * ContentNode = BlobStore (bytes) + Index (metadatos) + cuota/GC (Fase 1).
3
+ *
4
+ * Sigue sin saber de red: quien la pone es `agent.js` (Fase 2), que además le dice
5
+ * qué `owner` representa —la huella de la maestra del vault, la mitad izquierda de
6
+ * la referencia compartible `ownerId + cid` (DISENO.md §3)— y usa el `acl` para
7
+ * decidir qué puede salir del node cuando llegue el modo público (§7.2).
8
+ */
9
+ import { mkdir } from 'node:fs/promises'
10
+ import { BlobStore, isValidCid } from './blobstore.js'
11
+ import { Index } from './db.js'
12
+
13
+ export { isValidCid }
14
+
15
+ export class ContentNode {
16
+ /**
17
+ * @param {{ dir: string, maxBytes?: number, maxBlobBytes?: number, owner?: string|null,
18
+ * store?: any, log?: (m:string)=>void }} opts
19
+ * dir: raíz de datos · maxBytes: cuota total de disco (0 = sin límite)
20
+ * maxBlobBytes: tamaño máximo por blob (0 = sin límite)
21
+ * owner: `ownerId` de la maestra (lo pone el agente al enlazar; null sin vault)
22
+ * store: almacén ya montado (`storage.js`); por defecto, disco
23
+ */
24
+ constructor (opts) {
25
+ if (!opts?.dir) throw new Error('falta opts.dir')
26
+ this.dir = opts.dir
27
+ this.maxBytes = opts.maxBytes || 0
28
+ this.maxBlobBytes = opts.maxBlobBytes || 0
29
+ this.owner = opts.owner || null
30
+ // El almacén se puede INYECTAR (`storage.js` monta el de bucket cuando toca). Por
31
+ // defecto, disco: sin configuración, un node funciona con lo que hay en la máquina.
32
+ this.store = opts.store || new BlobStore(opts.dir)
33
+ this.index = null
34
+ /** Subidas al bucket en curso, por cid: para no lanzar dos veces la misma. */
35
+ this._uploading = new Map()
36
+ this.log = opts.log || (() => {})
37
+ }
38
+
39
+ async init () {
40
+ await mkdir(this.dir, { recursive: true })
41
+ await this.store.init()
42
+ this.index = new Index(this.dir)
43
+ return this
44
+ }
45
+
46
+ /**
47
+ * Sube un stream. Si la cuota no alcanza, intenta GC de no-pineados; si aun
48
+ * así no cabe, rechaza con code ENOSPC (no borra pineados jamás).
49
+ */
50
+ async put (readable, { mime = 'application/octet-stream', enc = 0, ttl = null, acl = null, meta = null } = {}) {
51
+ const { cid, size, existed } = await this.store.put(readable, {
52
+ maxBytes: this.maxBlobBytes || undefined
53
+ })
54
+ if (this.maxBytes && !existed) {
55
+ // La cuota es del DISCO, así que cuenta lo que está cacheado aquí, no el
56
+ // inventario: con bucket detrás, lo desalojado sigue existiendo (§15.11)
57
+ // pero ya no ocupa nada — contarlo dejaría la cuota excedida para siempre.
58
+ const over = (this.index.cachedBytes() + size) - this.maxBytes
59
+ if (over > 0 && this.gc({ needBytes: over }).freed < over) {
60
+ await this.store.remove(cid)
61
+ throw Object.assign(new Error('cuota de disco excedida'), { code: 'ENOSPC' })
62
+ }
63
+ }
64
+ this.index.upsert({ cid, size, mime, owner: this.owner, enc, acl, ttl, meta })
65
+ // Con bucket detrás, la subida va DESPUÉS de responder: quien sube no espera a la
66
+ // red. Hasta que el bucket confirme, este blob no es desalojable (§15.11).
67
+ if (!existed) this.backup(cid, { size, mime, public: acl === 'public' })
68
+ return { cid, size, mime, existed }
69
+ }
70
+
71
+ /** Metadatos de un blob (o null). */
72
+ stat (cid) {
73
+ return isValidCid(cid) ? this.index.get(cid) : null
74
+ }
75
+
76
+ /**
77
+ * Marca un blob como público o privado (`acl`). Es lo único que autoriza a que
78
+ * los bytes salgan del node cuando esté encendido el modo público (DISENO.md
79
+ * §7.2); sin `public` explícito, no sale.
80
+ */
81
+ setAcl (cid, acl) {
82
+ const ok = this.index.setAcl(cid, acl)
83
+ // Publicar mueve los bytes al bucket público, que es OTRO bucket (§15.1). Sin esto
84
+ // el blob quedaría marcado público y sin estar donde se sirve lo público.
85
+ if (ok && acl === 'public' && this.store.backed) {
86
+ const m = this.index.get(cid)
87
+ if (m && !m.enc) this.backup(cid, { size: m.size, mime: m.mime, public: true })
88
+ }
89
+ return ok
90
+ }
91
+
92
+ /**
93
+ * Sube un blob a su bucket y lo marca `remote` **solo cuando el bucket confirma**.
94
+ * Marcar al lanzar la subida es exactamente el error que deja perder contenido: un
95
+ * GC oportuno se llevaría la única copia que existe.
96
+ *
97
+ * No espera nadie a esto (se lanza y se olvida), pero un fallo se REPORTA: lo que no
98
+ * subió sigue siendo la cola pendiente del índice, y se reintenta al arrancar.
99
+ */
100
+ backup (cid, meta) {
101
+ if (!this.store.backed || this._uploading.has(cid)) return null
102
+ const p = this.store.upload(cid, meta)
103
+ .then(() => { this.index.setRemote(cid, true) })
104
+ .catch((e) => { this.log(`[almacén] no se pudo subir ${cid.slice(0, 14)}…: ${e.message}`) })
105
+ .finally(() => this._uploading.delete(cid))
106
+ this._uploading.set(cid, p)
107
+ return p
108
+ }
109
+
110
+ /**
111
+ * Reintenta lo que quedó sin subir. Se llama al arrancar: un reinicio a media subida
112
+ * no debe perder el pendiente, y por eso la cola es una consulta al índice y no una
113
+ * lista en memoria.
114
+ */
115
+ async backupPending (limit = 100) {
116
+ if (!this.store.backed) return { pending: 0 }
117
+ const rows = this.index.pendingUpload(limit)
118
+ for (const r of rows) {
119
+ const m = this.index.get(r.cid)
120
+ if (m) await this.backup(r.cid, { size: m.size, mime: m.mime, public: m.acl === 'public' })
121
+ }
122
+ return { pending: rows.length }
123
+ }
124
+
125
+ /**
126
+ * Metadatos de presentación (nombre/título/descripción). Es lo único con lo que
127
+ * el permalink público arma su tarjeta (DISENO.md §7.3): sin esto una vista
128
+ * previa solo puede decir el tipo y el tamaño.
129
+ */
130
+ setMeta (cid, meta) {
131
+ return this.index.setMeta(cid, meta)
132
+ }
133
+
134
+ /** Enlaza la miniatura pública de un blob (la genera la app, no el node). */
135
+ setThumbnail (cid, thumbnailCid) {
136
+ return this.index.setThumbnail(cid, thumbnailCid)
137
+ }
138
+
139
+ /** Índice de lo servible al mundo (público y en claro). */
140
+ listPublic (opts) {
141
+ return this.index.listPublic(opts)
142
+ }
143
+
144
+ /**
145
+ * ¿Puede este blob salir del node por el HTTP público? Dos condiciones, y las
146
+ * dos se comprueban aquí —en el único sitio por donde salen los bytes— aunque
147
+ * `ops.acl` ya impida marcar público lo cifrado: un índice traído de otra
148
+ * versión, o tocado a mano, no debe poder abrir una puerta.
149
+ * @returns {any|null} los metadatos si es servible, null si no.
150
+ */
151
+ publicStat (cid) {
152
+ const meta = this.stat(cid)
153
+ if (!meta || meta.acl !== 'public' || meta.enc) return null
154
+ return meta
155
+ }
156
+
157
+ /**
158
+ * ReadStream del blob; range = { start, end } inclusivo.
159
+ *
160
+ * Anota el acceso (`lastRead`), que es lo que ordena el desalojo de la caché
161
+ * (§15.11): sin esto, el GC tira lo más antiguo, y lo antiguo y muy pedido es
162
+ * justo lo que hay que conservar.
163
+ */
164
+ read (cid, range) {
165
+ this.index.touch(cid)
166
+ return this.store.read(cid, range)
167
+ }
168
+
169
+ async remove (cid) {
170
+ await this.store.remove(cid)
171
+ this.index.remove(cid)
172
+ }
173
+
174
+ list () {
175
+ return this.index.list()
176
+ }
177
+
178
+ pin (cid, pinned = true) {
179
+ return this.index.setPinned(cid, pinned)
180
+ }
181
+
182
+ stats () {
183
+ const cached = this.index.cachedBytes()
184
+ return {
185
+ blobs: this.index.count(),
186
+ // `bytes` es lo que ocupa el disco (lo que mira la cuota); `totalBytes` es
187
+ // el inventario entero, que con bucket es mayor porque incluye lo desalojado.
188
+ bytes: cached,
189
+ totalBytes: this.index.totalBytes(),
190
+ maxBytes: this.maxBytes || null,
191
+ backed: this.store.backed === true,
192
+ dir: this.dir
193
+ }
194
+ }
195
+
196
+ /**
197
+ * GC: borra vencidos (ttl) siempre; si `needBytes`, además desaloja
198
+ * no-pineados más viejos hasta liberar esa cantidad.
199
+ * Síncrono sobre el índice; el borrado de disco es fire-and-forget seguro
200
+ * (los bytes huérfanos se re-borran en el próximo GC vía índice… el índice
201
+ * es la fuente de verdad de qué existe).
202
+ */
203
+ /**
204
+ * Libera espacio. Hace DOS cosas distintas y conviene no confundirlas (§15.11):
205
+ *
206
+ * · **Caducar** (`ttl` vencido) es borrar de verdad: se va la fila, los bytes
207
+ * locales y —si hay bucket— también los de allí. Es lo que se pidió al subir.
208
+ * · **Desalojar** por cuota es tirar una copia caliente. Con bucket detrás solo
209
+ * toca lo que el bucket YA confirmó (`remote = 1`) y **la fila se queda** con
210
+ * `cached = 0`: si se fuera, el blob quedaría en el bucket sin ACL, sin dueño
211
+ * y sin tipo. Sin bucket no hay segunda copia, así que desalojar ES destruir
212
+ * y se comporta como hasta ahora.
213
+ */
214
+ gc ({ needBytes = 0, now = Date.now() } = {}) {
215
+ let freed = 0
216
+ const backed = this.store.backed === true
217
+
218
+ const destroy = ({ cid, size }) => {
219
+ this.index.remove(cid)
220
+ this.store.remove(cid).catch(() => {})
221
+ freed += size
222
+ }
223
+ const evict = ({ cid, size }) => {
224
+ if (!backed) return destroy({ cid, size })
225
+ this.store.evict(cid).catch(() => {})
226
+ this.index.setCached(cid, false)
227
+ freed += size
228
+ }
229
+
230
+ for (const b of this.index.expired(now)) destroy(b)
231
+ if (needBytes > freed) {
232
+ for (const b of this.index.evictable({ requireRemote: backed })) {
233
+ if (freed >= needBytes) break
234
+ evict(b)
235
+ }
236
+ }
237
+ return { freed }
238
+ }
239
+
240
+ close () {
241
+ this.index?.close()
242
+ }
243
+ }
package/src/ops.js ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * ops.js — las operaciones del PLANO DE CONTROL del node (DISENO.md §5.1 y §7).
3
+ *
4
+ * Es lo que tus propias apps pueden pedirle a este node a distancia: ver qué
5
+ * guarda, retener, soltar, borrar y marcar qué es público. Módulo PURO: no sabe de
6
+ * red, de sesiones ni de firmas — recibe un objeto ya descifrado y autorizado, y
7
+ * devuelve la respuesta. Así se prueba entero sin levantar nada, y quien autoriza
8
+ * (`agent.js`, vía `@dotrino/remote-agent`) queda en un solo sitio.
9
+ *
10
+ * SOBRE `put` Y `get`: existen, pero con un TOPE DURO de un mensaje (256 KB), y
11
+ * eso no es una limitación técnica que haya que levantar después — es la frontera
12
+ * (§7.1). El plano de control es el proxy del ecosistema: sus tramas son de 1 MB y
13
+ * su cola es de mensajes, no un almacén, y meter contenido por ahí sería usar la
14
+ * infraestructura de Dotrino como transporte, que es la regla dura 3. Por eso NO
15
+ * hay subida por partes: si algo no cabe en un mensaje, es que no es un mensaje.
16
+ *
17
+ * Lo que sí es un mensaje y por eso pasa: **un post** (un eco pesa cientos de
18
+ * bytes) y **una miniatura** (decenas de KB). Los originales suben en local por
19
+ * HTTP y, entre aparatos, por P2P — que es lo que este tope deja pendiente a
20
+ * propósito en vez de disimularlo con un troceado.
21
+ *
22
+ * El contrato de errores es `code`, no la frase: quien recibe compara `code`
23
+ * (`bad-request`, `not-found`, `unknown-op`, `failed`), que es lo estable. La frase
24
+ * es para un humano leyendo una bitácora.
25
+ */
26
+
27
+ /** Valores admitidos de ACL. Público es OPT-IN explícito; lo que no se dice, privado. */
28
+ export const ACL = Object.freeze({ PUBLIC: 'public', PRIVATE: 'private' })
29
+
30
+ /**
31
+ * Tope de lo que puede entrar o salir por el plano de control, en bytes crudos.
32
+ * En base64 dentro del sobre cifrado son ~350 KB, cómodos bajo la trama de 1 MB
33
+ * del proxy. **Es una frontera de diseño, no un parámetro a subir**: lo que no
34
+ * cabe aquí no es un mensaje y va por el otro camino.
35
+ */
36
+ export const CONTROL_PLANE_MAX_BYTES = 256 * 1024
37
+
38
+ import { Readable } from 'node:stream'
39
+ import { isValidCid } from './blobstore.js'
40
+
41
+ const ok = (rid, result) => ({ rid, ok: true, ...result })
42
+ const fail = (rid, code, error) => ({ rid, ok: false, code, error })
43
+
44
+ /**
45
+ * Metadatos de presentación, quedándose SOLO con los campos conocidos y
46
+ * recortados. Esto acaba en un HTML público (§7.3), así que no se guarda lo que
47
+ * llegue: ni campos de más ni textos sin fin.
48
+ * @returns {{name?:string,title?:string,description?:string}|null}
49
+ */
50
+ function cleanMeta (src) {
51
+ if (!src || typeof src !== 'object') return null
52
+ const out = Object.fromEntries(['name', 'title', 'description']
53
+ .map((k) => [k, typeof src[k] === 'string' ? src[k].trim().slice(0, 300) : null])
54
+ .filter(([, v]) => v))
55
+ return Object.keys(out).length ? out : null
56
+ }
57
+
58
+ /**
59
+ * Construye el despachador de operaciones de un node.
60
+ *
61
+ * @param {import('./node.js').ContentNode} node
62
+ * @param {{ owner?: string|null, version?: string }} [meta]
63
+ * owner: `ownerId` (huella de la maestra) que este node representa — es la mitad
64
+ * de la referencia compartible `ownerId + cid` (§3).
65
+ * @returns {(msg: any) => Promise<any>}
66
+ */
67
+ export function createOps (node, { owner = null, version = null } = {}) {
68
+ /** Operaciones que exigen un `cid` válido y que el blob exista. */
69
+ const withBlob = (fn) => async (msg) => {
70
+ const cid = typeof msg.cid === 'string' ? msg.cid : null
71
+ if (!cid) return fail(msg.rid, 'bad-request', 'cid is required')
72
+ const meta = node.stat(cid)
73
+ if (!meta) return fail(msg.rid, 'not-found', 'no such cid')
74
+ return fn(msg, cid, meta)
75
+ }
76
+
77
+ const handlers = {
78
+ /** Quién es este node: sirve de saludo y de comprobación de vida. */
79
+ hello: async (msg) => ok(msg.rid, {
80
+ owner, version, stats: node.stats(), maxBytes: CONTROL_PLANE_MAX_BYTES
81
+ }),
82
+
83
+ /**
84
+ * Guardar algo pequeño desde otro aparato tuyo. Los bytes llegan en base64
85
+ * dentro del sobre ya cifrado de la sesión, en UN mensaje: no hay subida por
86
+ * partes y no la va a haber (ver la cabecera del archivo).
87
+ *
88
+ * Que el aparato esté autorizado ya lo comprobó `verifyChain` antes de que
89
+ * esto se llame; aquí solo se comprueba la forma y el tamaño.
90
+ */
91
+ put: async (msg) => {
92
+ if (typeof msg.data !== 'string') return fail(msg.rid, 'bad-request', 'data (base64) is required')
93
+ let buf
94
+ try { buf = Buffer.from(msg.data, 'base64') } catch { return fail(msg.rid, 'bad-request', 'data is not valid base64') }
95
+ if (!buf.length) return fail(msg.rid, 'bad-request', 'empty payload')
96
+ if (buf.length > CONTROL_PLANE_MAX_BYTES) {
97
+ return fail(msg.rid, 'too-large',
98
+ `the control plane carries up to ${CONTROL_PLANE_MAX_BYTES} bytes; upload larger blobs locally or peer to peer`)
99
+ }
100
+ const enc = msg.enc ? 1 : 0
101
+ // Mismo cerrojo que en `acl`, y por lo mismo: decir que algo cifrado es
102
+ // público solo engaña a quien lo mire, porque nadie sin la llave lo lee.
103
+ const acl = msg.acl === ACL.PUBLIC && !enc ? ACL.PUBLIC : ACL.PRIVATE
104
+ const ttlMs = Number(msg.ttl) || 0
105
+ const out = await node.put(Readable.from(buf), {
106
+ mime: typeof msg.mime === 'string' ? msg.mime : 'application/octet-stream',
107
+ enc,
108
+ acl,
109
+ ttl: ttlMs > 0 ? Date.now() + ttlMs : null,
110
+ meta: cleanMeta(msg.meta)
111
+ })
112
+ return ok(msg.rid, { ...out, acl })
113
+ },
114
+
115
+ /**
116
+ * Leer algo pequeño de vuelta (otro aparato tuyo, o el mismo tras reinstalar).
117
+ * Mismo tope, y por la misma razón: esto es el plano de control.
118
+ */
119
+ get: withBlob(async (msg, cid, meta) => {
120
+ if (meta.size > CONTROL_PLANE_MAX_BYTES) {
121
+ return fail(msg.rid, 'too-large',
122
+ `${meta.size} bytes do not fit in the control plane; fetch it locally or peer to peer`)
123
+ }
124
+ const chunks = []
125
+ for await (const c of node.read(cid)) chunks.push(c)
126
+ return ok(msg.rid, {
127
+ cid, size: meta.size, mime: meta.mime, enc: meta.enc, data: Buffer.concat(chunks).toString('base64')
128
+ })
129
+ }),
130
+
131
+ list: async (msg) => ok(msg.rid, { blobs: node.list() }),
132
+
133
+ stats: async (msg) => ok(msg.rid, { stats: node.stats() }),
134
+
135
+ stat: withBlob(async (msg, cid, meta) => ok(msg.rid, { blob: meta })),
136
+
137
+ /** Retener: un blob pineado no lo borra el GC ni por cuota ni por ttl. */
138
+ pin: withBlob(async (msg, cid) => ok(msg.rid, { pinned: node.pin(cid, true) })),
139
+
140
+ unpin: withBlob(async (msg, cid) => ok(msg.rid, { pinned: !node.pin(cid, false) })),
141
+
142
+ remove: withBlob(async (msg, cid) => {
143
+ await node.remove(cid)
144
+ return ok(msg.rid, { removed: cid })
145
+ }),
146
+
147
+ /**
148
+ * Marcar un blob como público o privado. Es el interruptor que la Fase 3 mira
149
+ * antes de servir algo por HTTP: sin `public` explícito, no sale del node.
150
+ */
151
+ acl: withBlob(async (msg, cid) => {
152
+ const acl = msg.acl === ACL.PUBLIC ? ACL.PUBLIC : msg.acl === ACL.PRIVATE ? ACL.PRIVATE : null
153
+ if (!acl) return fail(msg.rid, 'bad-request', `acl must be "${ACL.PUBLIC}" or "${ACL.PRIVATE}"`)
154
+ const meta = node.stat(cid)
155
+ // Un blob cifrado no se puede marcar público: nadie sin la llave podría leerlo,
156
+ // así que decir que lo es solo engaña a quien lo mire.
157
+ if (acl === ACL.PUBLIC && meta.enc) return fail(msg.rid, 'bad-request', 'an encrypted blob cannot be public')
158
+ node.setAcl(cid, acl)
159
+ return ok(msg.rid, { cid, acl })
160
+ }),
161
+
162
+ /**
163
+ * Metadatos de PRESENTACIÓN (nombre, título, descripción). Es lo único con lo
164
+ * que la vista previa pública arma su tarjeta (§7.3): sin esto, una tarjeta
165
+ * solo puede decir el tipo y el tamaño. No forma parte del `cid` —dos nombres
166
+ * para los mismos bytes son el mismo blob—, por eso se pone aparte.
167
+ */
168
+ meta: withBlob(async (msg, cid) => {
169
+ if (msg.meta !== null && (!msg.meta || typeof msg.meta !== 'object')) {
170
+ return fail(msg.rid, 'bad-request', 'meta must be an object or null')
171
+ }
172
+ const clean = cleanMeta(msg.meta)
173
+ node.setMeta(cid, clean)
174
+ return ok(msg.rid, { cid, meta: clean })
175
+ }),
176
+
177
+ /**
178
+ * Enlaza la miniatura de un blob. La miniatura es OTRO blob (subido por la
179
+ * app) y tiene que ser pública por su cuenta: enlazarla no la publica.
180
+ */
181
+ thumb: withBlob(async (msg, cid) => {
182
+ const t = msg.thumbnailCid
183
+ if (t !== null && !isValidCid(t)) return fail(msg.rid, 'bad-request', 'thumbnailCid must be a valid cid or null')
184
+ if (t && !node.stat(t)) return fail(msg.rid, 'not-found', 'the thumbnail is not in this node')
185
+ node.setThumbnail(cid, t)
186
+ return ok(msg.rid, { cid, thumbnailCid: t })
187
+ }),
188
+
189
+ /** Recolección de vencidos (lo que el temporizador hace solo, a pedido). */
190
+ gc: async (msg) => ok(msg.rid, node.gc())
191
+ }
192
+
193
+ return async function dispatch (msg) {
194
+ if (!msg || typeof msg !== 'object' || typeof msg.op !== 'string') {
195
+ return fail(msg?.rid, 'bad-request', 'op is required')
196
+ }
197
+ const handler = handlers[msg.op]
198
+ if (!handler) return fail(msg.rid, 'unknown-op', `unknown op: ${msg.op}`)
199
+ try {
200
+ return await handler(msg)
201
+ } catch (e) {
202
+ return fail(msg.rid, e?.code === 'ENOSPC' ? 'no-space' : 'failed', e?.message || 'failed')
203
+ }
204
+ }
205
+ }
206
+
207
+ export default { createOps, ACL, CONTROL_PLANE_MAX_BYTES }