@dotrino/content 0.3.13

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/agent.js ADDED
@@ -0,0 +1,111 @@
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 { startPublicFetch } from './fetch.js'
27
+ import { createOps } from './ops.js'
28
+
29
+ /** Carpeta de datos del enlace (NO es la de los blobs: el enlace es del aparato). */
30
+ export const linkDir = () => process.env.DOTRINO_CONTENT_LINK_DIR || dataDir('dotrino-content')
31
+
32
+ /** ¿Está este node enlazado a un vault? (sin enlace, Fase 1: solo loopback) */
33
+ export const isLinked = (dir = linkDir()) => {
34
+ const link = loadLink(dir)
35
+ return !!(link?.device?.privateJwk && link?.cert && link?.iss)
36
+ }
37
+
38
+ /**
39
+ * Arranca el plano de control del node.
40
+ *
41
+ * @param {{
42
+ * node?: import('./node.js').ContentNode,
43
+ * dir?: string, proxyUrl?: string, version?: string|null,
44
+ * client?: any, quiet?: boolean, onRevoked?: () => void, announce?: boolean
45
+ * }} opts
46
+ * client: transporte inyectado — SOLO para pruebas (bus en memoria). En
47
+ * producción lo levanta el middleware con el proxy del ecosistema.
48
+ * announce: publicarse en el canal del dueño para que un TERCERO con el enlace
49
+ * pueda encontrar este node (§3.1). Encendido por defecto; se apaga con `false`
50
+ * en un node que solo quieras usar desde tus propios aparatos.
51
+ * @returns {Promise<{ machine: string, machineId: string, owner: string, close: () => void }>}
52
+ */
53
+ export async function startContentAgent ({
54
+ node, dir = linkDir(), proxyUrl, version = null, client, quiet = false, onRevoked, announce = true
55
+ } = {}) {
56
+ if (!node) throw new Error('startContentAgent: node is required')
57
+ const link = loadLink(dir)
58
+ if (!link?.iss) {
59
+ throw new Error('this node is not linked to a vault. Run first: dotrino-content enroll <code>')
60
+ }
61
+
62
+ // El node representa a la maestra que lo certificó: su huella es el `ownerId`.
63
+ // Se resuelve ANTES de abrir el transporte, para que la primera sesión que entre
64
+ // ya encuentre el despachador armado.
65
+ const owner = await pubkeyId(link.iss)
66
+ node.owner = owner
67
+ const dispatch = createOps(node, { owner, version })
68
+
69
+ const agent = await startRemoteAgent({
70
+ dir,
71
+ proxyUrl,
72
+ client,
73
+ quiet,
74
+ onRevoked,
75
+ onSession: (session) => {
76
+ session.on('message', (msg) => {
77
+ dispatch(msg)
78
+ .then((reply) => session.send(reply))
79
+ .catch((e) => session.send({ rid: msg?.rid, ok: false, code: 'failed', error: e.message }))
80
+ })
81
+ }
82
+ })
83
+
84
+ if (!quiet) console.log(`[content] control plane ready · owner ${owner.slice(0, 16)}`)
85
+
86
+ // ANUNCIO (§3.1): sin esto el node es administrable por sus dueños pero
87
+ // INVISIBLE para quien recibe un enlace — la referencia nombra al dueño, y hay
88
+ // que poder pasar de ahí a «qué nodes suyos están vivos». Reusa la conexión del
89
+ // middleware: nunca se abre una segunda.
90
+ const beacon = announce && agent.client
91
+ ? startAnnounce({ client: agent.client, owner, quiet })
92
+ : null
93
+ // LECTURA PÚBLICA por la red (§16): un tercero con el enlace pide el `cid` en un
94
+ // mensaje suelto y recibe solo lo marcado público. Sin esto, anunciarse no sirve
95
+ // de nada: el enlace nombraría a un node que no contesta a nadie.
96
+ const fetcher = agent.client ? startPublicFetch({ client: agent.client, node, quiet }) : null
97
+
98
+ return {
99
+ ...agent,
100
+ owner,
101
+ channels: () => beacon?.channels() || [],
102
+ served: () => fetcher?.served() || 0,
103
+ close () {
104
+ try { fetcher?.close() } catch (_) {}
105
+ try { beacon?.close() } catch (_) {}
106
+ agent.close()
107
+ }
108
+ }
109
+ }
110
+
111
+ export default { startContentAgent, isLinked, linkDir }
@@ -0,0 +1,138 @@
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
+ import { channelFor } from '../lib/public.js'
34
+
35
+ /** Nombre del canal de un dueño dentro de un proxio concreto (vive en la lib: el cliente del tercero lo necesita igual). */
36
+ export { channelFor }
37
+
38
+ /** Cada cuánto se re-publica (el proxio caduca las entradas de canal). */
39
+ export const REPUBLISH_MS = 4 * 60 * 1000
40
+
41
+ /**
42
+ * Publica este node en el canal de su dueño, en todos los proxios conocidos, y lo
43
+ * mantiene publicado. Best-effort a propósito: si el proxy está caído, el node
44
+ * sigue funcionando —sirve en local y atiende a los aparatos del acta— y el
45
+ * siguiente tic lo vuelve a intentar. Un anuncio perdido no rompe nada; lo único
46
+ * que pasa es que un tercero no lo encuentra hasta que vuelva.
47
+ *
48
+ * @param {{ client: any, owner: string, quiet?: boolean, intervalMs?: number }} opts
49
+ * client: el `WebSocketProxyClient` YA conectado del agente (`ra.client`) — no se
50
+ * abre otro: dos conexiones del mismo aparato son dos identidades de transporte.
51
+ * @returns {{ channels: () => string[], close: () => void }}
52
+ */
53
+ export function startAnnounce ({ client, owner, quiet = false, intervalMs = REPUBLISH_MS }) {
54
+ if (!client) throw new Error('startAnnounce: client is required')
55
+ if (!owner) throw new Error('startAnnounce: owner is required')
56
+
57
+ let stopped = false
58
+ let current = []
59
+
60
+ /** Los proxios donde hay que anunciarse: el que nos atiende y los que conoce. */
61
+ const targets = () => {
62
+ const known = Array.isArray(client.knownNodes) ? client.knownNodes : []
63
+ return known.length ? known : (client.node ? [client.node] : [])
64
+ }
65
+
66
+ async function publishAll () {
67
+ if (stopped) return
68
+ const names = targets().map((n) => channelFor(n, owner))
69
+ if (!names.length) return // aún sin `connected`: el próximo tic
70
+ const done = []
71
+ for (const name of names) {
72
+ try {
73
+ await client.publish(name, { app: 'content', owner })
74
+ done.push(name)
75
+ } catch (e) {
76
+ if (!quiet) console.error(`[content] could not announce on ${name}: ${e.message}`)
77
+ }
78
+ }
79
+ // Se avisa cuando CAMBIA en cuántos proxios está anunciado, no solo la primera vez.
80
+ // Antes solo hablaba al pasar de 0 a algo, así que un arranque que solo alcanzó uno de
81
+ // los dos decía «en 1 proxio(s)» y se callaba para siempre — aunque el tic siguiente ya
82
+ // estuviera en los dos. Leyendo ese log parecía una federación rota que no lo estaba.
83
+ const changed = done.length !== current.length
84
+ current = done
85
+ if (changed && !quiet) console.log(`[content] announced as node for ${owner.slice(0, 16)} on ${done.length} proxio(s) · token ${client.token}`)
86
+ }
87
+
88
+ publishAll()
89
+ // Re-publicar al reconectar: el token cambia y el anuncio viejo apunta a una
90
+ // conexión que ya no existe. Sin esto, un corte de red deja al node listado y
91
+ // mudo, que es peor que no estar listado.
92
+ const offToken = client.on('token', () => { current = []; publishAll() })
93
+ const timer = setInterval(publishAll, intervalMs)
94
+ timer.unref?.()
95
+
96
+ return {
97
+ channels: () => [...current],
98
+ close () {
99
+ stopped = true
100
+ clearInterval(timer)
101
+ try { offToken?.() } catch (_) {}
102
+ for (const name of current) { try { client.unpublish(name) } catch (_) {} }
103
+ current = []
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Lado del que BUSCA: los tokens de los nodes de un dueño que están en línea, en
110
+ * todos los proxios conocidos, sin repetidos.
111
+ *
112
+ * Lo que devuelve son **candidatos, no autoridades**: cualquiera puede publicarse
113
+ * en el canal de cualquier dueño (el nombre del canal no es un secreto). Quien
114
+ * pregunte tiene que comprobar las dos cosas de siempre — que el node presenta un
115
+ * certificado que encadena a ese `ownerId`, y que los bytes que entrega hashean al
116
+ * `cid` pedido. Con esas dos, un impostor en la lista no consigue nada más que
117
+ * gastar un intento.
118
+ *
119
+ * @param {{ client: any, owner: string }} opts
120
+ * @returns {Promise<string[]>} tokens (direcciones en el proxy)
121
+ */
122
+ export async function findNodes ({ client, owner }) {
123
+ if (!client || !owner) throw new Error('findNodes: client and owner are required')
124
+ const known = Array.isArray(client.knownNodes) && client.knownNodes.length
125
+ ? client.knownNodes
126
+ : (client.node ? [client.node] : [])
127
+ const out = new Set()
128
+ for (const nodeId of known) {
129
+ try {
130
+ for (const token of await client.list(channelFor(nodeId, owner))) out.add(token)
131
+ } catch (_) { /* un proxio que no contesta no invalida al otro */ }
132
+ }
133
+ // El propio que pregunta puede estar en la lista (una PWA que también es node).
134
+ if (client.token) out.delete(client.token)
135
+ return [...out]
136
+ }
137
+
138
+ export default { startAnnounce, findNodes, channelFor, REPUBLISH_MS }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * El almacén con BUCKET detrás (DISENO.md §15).
3
+ *
4
+ * Misma interfaz que `BlobStore` —`put`, `read`, `sizeOf`, `remove`— para que ni la API
5
+ * local, ni el plano de control, ni el puerto de vistas previas se enteren de dónde
6
+ * salen los bytes. Lo que cambia es de dónde salen:
7
+ *
8
+ * disco (caché) → si está, sale de aquí, y así es como se sirve casi siempre
9
+ * bucket → si no, se jala mientras se sirve y se deja la copia
10
+ *
11
+ * Tres cosas que no son detalles:
12
+ *
13
+ * · **Se escribe primero en local y se responde.** La subida al bucket va detrás, y
14
+ * hasta que el bucket confirma, ese blob NO es desalojable (`remote`, §15.11). Un
15
+ * pendiente que no logra subir se reintenta y se reporta; no se olvida.
16
+ * · **Público y privado son buckets distintos** y con credenciales distintas (§15.1).
17
+ * Cuál toca lo dice el ACL del blob, y quien lo sabe es el índice — por eso `put` y
18
+ * `upload` reciben `isPublic` en vez de adivinarlo.
19
+ * · **Una lectura parcial NO puebla la caché.** Cachear trozos sueltos dejaría
20
+ * agujeros que luego parecen un blob completo.
21
+ */
22
+ import { createWriteStream } from 'node:fs'
23
+ import { mkdir, rename, rm } from 'node:fs/promises'
24
+ import path from 'node:path'
25
+ import { Readable } from 'node:stream'
26
+ import { pipeline } from 'node:stream/promises'
27
+ import { PassThrough } from 'node:stream'
28
+ import { BlobStore, isValidCid } from './blobstore.js'
29
+ import { S3Bucket, S3Error } from './s3.js'
30
+
31
+ /** Lo que se le pone a un objeto público: es inmutable por construcción (§15.2). */
32
+ const IMMUTABLE = 'public, max-age=31536000, immutable'
33
+
34
+ /** Del `cid` a la clave del objeto. Sin prefijos: el bucket ya separa público de privado. */
35
+ const keyOf = (cid) => {
36
+ if (!isValidCid(cid)) throw new Error(`invalid cid: ${cid}`)
37
+ return cid
38
+ }
39
+
40
+ export class S3BlobStore {
41
+ /**
42
+ * @param {object} o
43
+ * @param {string} o.root directorio de la CACHÉ (el mismo árbol `blobs/` de siempre)
44
+ * @param {S3Bucket} o.priv bucket privado (obligatorio: es donde va todo lo cifrado)
45
+ * @param {S3Bucket|null} [o.pub] bucket público; sin él, lo público viaja por la red (§15.3)
46
+ * @param {(m:string)=>void} [o.log]
47
+ */
48
+ constructor ({ root, priv, pub = null, log = () => {} }) {
49
+ if (!priv) throw new Error('S3BlobStore: the private bucket is missing')
50
+ this.cache = new BlobStore(root)
51
+ this.priv = priv
52
+ this.pub = pub
53
+ this.log = log
54
+ /** Hay una segunda copia detrás: el GC puede desalojar en vez de destruir (§15.11). */
55
+ this.backed = true
56
+ }
57
+
58
+ async init () {
59
+ await this.cache.init()
60
+ return this
61
+ }
62
+
63
+ /** El bucket que le toca a un blob según su ACL. */
64
+ bucketFor (isPublic) {
65
+ if (!isPublic) return this.priv
66
+ if (!this.pub) throw new Error('this node has no public bucket (CONTENT_S3_BUCKET_PUBLIC)')
67
+ return this.pub
68
+ }
69
+
70
+ /** La URL pública de un blob, o `null` si este node no publica por bucket. */
71
+ urlFor (cid, baseUrl) {
72
+ return baseUrl ? `${String(baseUrl).replace(/\/+$/, '')}/${keyOf(cid)}` : null
73
+ }
74
+
75
+ pathFor (cid) { return this.cache.pathFor(cid) }
76
+
77
+ /** Guarda en la caché. La subida al bucket la lanza el node después (`upload`). */
78
+ async put (readable, opts) {
79
+ return this.cache.put(readable, opts)
80
+ }
81
+
82
+ /**
83
+ * Sube al bucket que le toca y **confirma**. Quien llama es el único que puede marcar
84
+ * `remote`, y solo con lo que devuelve esto: marcar al lanzar la subida es
85
+ * exactamente el error que deja perder contenido.
86
+ *
87
+ * @param {string} cid
88
+ * @param {{ size: number, mime?: string, public?: boolean }} meta
89
+ */
90
+ async upload (cid, { size, mime, public: isPublic = false }) {
91
+ const bucket = this.bucketFor(isPublic)
92
+ const body = this.cache.read(cid)
93
+ await bucket.put(keyOf(cid), Readable.toWeb(body), {
94
+ sha256: cid.slice('sha256-'.length),
95
+ size,
96
+ contentType: mime || 'application/octet-stream',
97
+ // Solo lo público lleva cabecera de caché: es lo único que sirve un CDN.
98
+ ...(isPublic ? { cacheControl: IMMUTABLE } : {})
99
+ })
100
+ return { cid, remote: true }
101
+ }
102
+
103
+ /** ¿Está en la caché de esta máquina? (no pregunta al bucket: eso cuesta una petición) */
104
+ async sizeOf (cid) {
105
+ return this.cache.sizeOf(cid)
106
+ }
107
+
108
+ /**
109
+ * Lee. Si está en la caché sale de ahí; si no, se jala del bucket **mientras se
110
+ * sirve** y se deja la copia — salvo que sea una lectura parcial.
111
+ *
112
+ * Devuelve un stream, como el de disco, para que quien llama no cambie.
113
+ *
114
+ * @param {string} cid
115
+ * @param {{ start: number, end: number }} [range] inclusivo
116
+ * @param {{ public?: boolean }} [opts] de qué bucket, si hay que ir a buscarlo
117
+ */
118
+ read (cid, range, { public: isPublic = false } = {}) {
119
+ const out = new PassThrough()
120
+ this.cache.sizeOf(cid).then(async (size) => {
121
+ if (size !== null) return pipeline(this.cache.read(cid, range), out)
122
+
123
+ const bucket = this.bucketFor(isPublic)
124
+ const { body } = await bucket.get(keyOf(cid), range || null)
125
+ const src = Readable.fromWeb(/** @type {any} */ (body))
126
+
127
+ // Parcial: se sirve y se olvida. Guardar un trozo dejaría en la caché algo que
128
+ // parece el blob entero y no lo es.
129
+ if (range) { src.pipe(out); return }
130
+
131
+ // Entero: el mismo stream va a DOS destinos —quien pidió y el disco—, y solo al
132
+ // terminar bien se pone en su sitio. Si la descarga se corta, no queda medio blob
133
+ // con el nombre bueno, que luego se serviría como si estuviera completo.
134
+ const dest = this.cache.pathFor(cid)
135
+ const tmp = dest + '.dl'
136
+ await mkdir(path.dirname(dest), { recursive: true })
137
+ const disk = createWriteStream(tmp)
138
+ src.pipe(out)
139
+ src.pipe(disk)
140
+ disk.on('finish', () => { rename(tmp, dest).catch(() => rm(tmp, { force: true })) })
141
+ src.on('error', () => { disk.destroy(); rm(tmp, { force: true }).catch(() => {}) })
142
+ }).catch((e) => out.destroy(e))
143
+ return out
144
+ }
145
+
146
+ /**
147
+ * Los primeros `n` bytes. Es lo que necesita el puerto público para comprobar el tipo
148
+ * REAL de una imagen por sus bytes mágicos (§7.2) — y existe en la interfaz porque
149
+ * con bucket detrás no hay ninguna ruta de disco que abrir.
150
+ */
151
+ async readHead (cid, n, { public: isPublic = false } = {}) {
152
+ const local = await this.cache.sizeOf(cid)
153
+ if (local !== null) return this.cache.readHead(cid, n)
154
+ return this.bucketFor(isPublic).head(keyOf(cid), n)
155
+ }
156
+
157
+ /** Suelta la copia local y deja la del bucket (desalojar, §15.11). */
158
+ async evict (cid) {
159
+ await this.cache.remove(cid)
160
+ }
161
+
162
+ /** Borra de verdad: de la caché y del bucket. Caducar y despublicar pasan por aquí. */
163
+ async remove (cid, { public: isPublic = null } = {}) {
164
+ await this.cache.remove(cid)
165
+ // Sin saber su ACL se borra de los dos: es idempotente y borrar lo que no está no
166
+ // es un error. Más vale una petición de más que dejar un objeto huérfano pagando.
167
+ const buckets = isPublic === null ? [this.priv, this.pub] : [this.bucketFor(isPublic)]
168
+ for (const b of buckets) {
169
+ if (!b) continue
170
+ try { await b.remove(keyOf(cid)) } catch (e) {
171
+ if (!(e instanceof S3Error) || e.status !== 404) throw e
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ export default { S3BlobStore }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Almacén de blobs direccionado por contenido (Fase 1, DISENO.md §3).
3
+ *
4
+ * - `cid = sha256-<hex>` (prefijo de algoritmo → extensible a blake3 después).
5
+ * Se usa SHA-256 de `node:crypto` porque no requiere dependencias nativas
6
+ * (el `.npmrc` del ecosistema bloquea los build scripts de npm).
7
+ * - Disco: `blobs/<aa>/<bb>/<cid>` (sharding por los 4 primeros hex del hash).
8
+ * - Escritura por streaming: se hashea MIENTRAS se escribe a un tmp y al final
9
+ * se renombra al path definitivo (dedup gratis: si ya existe, se descarta).
10
+ */
11
+ import { createHash, randomBytes } from 'node:crypto'
12
+ import { createWriteStream, createReadStream } from 'node:fs'
13
+ import { mkdir, open, rename, rm, stat } from 'node:fs/promises'
14
+ import { pipeline } from 'node:stream/promises'
15
+ import { Transform } from 'node:stream'
16
+ import path from 'node:path'
17
+
18
+ const CID_RE = /^sha256-[0-9a-f]{64}$/
19
+
20
+ export function isValidCid (cid) {
21
+ return typeof cid === 'string' && CID_RE.test(cid)
22
+ }
23
+
24
+ export class BlobStore {
25
+ /** @param {string} root directorio raíz de datos (contiene blobs/ y tmp/) */
26
+ constructor (root) {
27
+ this.root = root
28
+ this.blobsDir = path.join(root, 'blobs')
29
+ this.tmpDir = path.join(root, 'tmp')
30
+ /**
31
+ * ¿Hay una segunda copia detrás (un bucket)? Aquí no: el disco es la única
32
+ * copia, así que soltar un blob es destruirlo y el GC se comporta como
33
+ * siempre (DISENO.md §15.11).
34
+ */
35
+ this.backed = false
36
+ }
37
+
38
+ /** Prepara los directorios. Devuelve `this` para poder encadenar, como el resto. */
39
+ async init () {
40
+ await mkdir(this.blobsDir, { recursive: true })
41
+ await mkdir(this.tmpDir, { recursive: true })
42
+ return this
43
+ }
44
+
45
+ /** Path en disco de un cid (no comprueba existencia). */
46
+ pathFor (cid) {
47
+ if (!isValidCid(cid)) throw new Error(`invalid cid: ${cid}`)
48
+ const hex = cid.slice('sha256-'.length)
49
+ return path.join(this.blobsDir, hex.slice(0, 2), hex.slice(2, 4), cid)
50
+ }
51
+
52
+ /** ¿Existe el blob en disco? → size o null. */
53
+ async sizeOf (cid) {
54
+ try {
55
+ const st = await stat(this.pathFor(cid))
56
+ return st.size
57
+ } catch {
58
+ return null
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Guarda un stream hasheando al vuelo.
64
+ * @param {import('node:stream').Readable} readable
65
+ * @param {{ maxBytes?: number }} [opts] límite duro de tamaño (corta el stream)
66
+ * @returns {Promise<{ cid: string, size: number, existed: boolean }>}
67
+ */
68
+ async put (readable, opts = {}) {
69
+ const tmp = path.join(this.tmpDir, `up-${randomBytes(8).toString('hex')}`)
70
+ const hash = createHash('sha256')
71
+ let size = 0
72
+ const meter = new Transform({
73
+ transform (chunk, _enc, cb) {
74
+ size += chunk.length
75
+ if (opts.maxBytes && size > opts.maxBytes) {
76
+ cb(Object.assign(new Error('blob too large'), { code: 'ETOOBIG' }))
77
+ return
78
+ }
79
+ hash.update(chunk)
80
+ cb(null, chunk)
81
+ }
82
+ })
83
+ try {
84
+ await pipeline(readable, meter, createWriteStream(tmp))
85
+ const cid = `sha256-${hash.digest('hex')}`
86
+ const dest = this.pathFor(cid)
87
+ if (await this.sizeOf(cid) !== null) {
88
+ // dedup: ya lo teníamos, descartar el tmp
89
+ await rm(tmp, { force: true })
90
+ return { cid, size, existed: true }
91
+ }
92
+ await mkdir(path.dirname(dest), { recursive: true })
93
+ await rename(tmp, dest)
94
+ return { cid, size, existed: false }
95
+ } catch (err) {
96
+ await rm(tmp, { force: true })
97
+ throw err
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Stream de lectura, con rango opcional [start, end] inclusivo.
103
+ * @returns {import('node:fs').ReadStream}
104
+ */
105
+ read (cid, range) {
106
+ const p = this.pathFor(cid)
107
+ return range
108
+ ? createReadStream(p, { start: range.start, end: range.end })
109
+ : createReadStream(p)
110
+ }
111
+
112
+ /**
113
+ * Los primeros `n` bytes de un blob. Existe para que el puerto público compruebe el
114
+ * tipo REAL de una imagen por sus bytes mágicos (§7.2) **sin abrir el archivo por su
115
+ * ruta**: con un bucket detrás no hay ruta que abrir, así que olfatear tiene que ser
116
+ * una operación del almacén y no de quien lo usa.
117
+ */
118
+ async readHead (cid, n) {
119
+ const fh = await open(this.pathFor(cid), 'r')
120
+ try {
121
+ const buf = Buffer.alloc(n)
122
+ const { bytesRead } = await fh.read(buf, 0, n, 0)
123
+ return buf.subarray(0, bytesRead)
124
+ } finally { await fh.close() }
125
+ }
126
+
127
+ async remove (cid) {
128
+ await rm(this.pathFor(cid), { force: true })
129
+ }
130
+
131
+ /**
132
+ * Suelta la copia LOCAL y deja la de atrás. Sin bucket detrás no hay copia de
133
+ * atrás, así que esto es exactamente `remove` — y por eso el node comprueba
134
+ * `backed` antes de llamarlo: con este backend, desalojar es destruir.
135
+ */
136
+ async evict (cid) {
137
+ return this.remove(cid)
138
+ }
139
+ }