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 +173 -0
- package/bin/cli.js +203 -0
- package/package.json +57 -0
- package/src/agent.js +104 -0
- package/src/announce.js +132 -0
- package/src/blobstore-s3.js +177 -0
- package/src/blobstore.js +139 -0
- package/src/db.js +237 -0
- package/src/index.js +3 -0
- package/src/node.js +243 -0
- package/src/ops.js +207 -0
- package/src/public.js +370 -0
- package/src/s3.js +257 -0
- package/src/server.js +142 -0
- package/src/storage.js +175 -0
- package/src/vaultEnv.js +144 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API HTTP local (Fase 1, DISENO.md §6). SOLO localhost: sin auth todavía
|
|
3
|
+
* (la auth por vault llega en Fase 2; la exposición al mundo en Fase 3).
|
|
4
|
+
*
|
|
5
|
+
* POST /c subir (streaming; Content-Type = mime; ?ttl=<ms>)
|
|
6
|
+
* GET /c/<cid> descargar/streamear (Range → 206; ETag = cid, immutable)
|
|
7
|
+
* HEAD /c/<cid> size/mime/etag sin cuerpo
|
|
8
|
+
* DELETE /c/<cid> borrar
|
|
9
|
+
* GET /list índice
|
|
10
|
+
* POST /pin/<cid> retención (evita GC) POST /unpin/<cid>
|
|
11
|
+
* POST /public/<cid> publicar (al bucket público si lo hay) POST /private/<cid>
|
|
12
|
+
* GET /stats uso de disco, nº blobs
|
|
13
|
+
*/
|
|
14
|
+
import http from 'node:http'
|
|
15
|
+
import { pipeline } from 'node:stream/promises'
|
|
16
|
+
import { isValidCid } from './node.js'
|
|
17
|
+
|
|
18
|
+
/** Parsea `Range: bytes=a-b|a-|-n` contra `size`. null = sin rango; false = inválido. */
|
|
19
|
+
export function parseRange (header, size) {
|
|
20
|
+
if (!header) return null
|
|
21
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
|
|
22
|
+
if (!m || (m[1] === '' && m[2] === '')) return false
|
|
23
|
+
let start, end
|
|
24
|
+
if (m[1] === '') { // sufijo: últimos n bytes
|
|
25
|
+
const n = Number(m[2])
|
|
26
|
+
if (n === 0) return false
|
|
27
|
+
start = Math.max(0, size - n)
|
|
28
|
+
end = size - 1
|
|
29
|
+
} else {
|
|
30
|
+
start = Number(m[1])
|
|
31
|
+
end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1)
|
|
32
|
+
}
|
|
33
|
+
if (start >= size || start > end) return false
|
|
34
|
+
return { start, end }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const json = (res, code, obj) => {
|
|
38
|
+
const body = JSON.stringify(obj)
|
|
39
|
+
res.writeHead(code, { 'content-type': 'application/json' }).end(body)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {import('./node.js').ContentNode} node
|
|
44
|
+
* @returns {http.Server}
|
|
45
|
+
*/
|
|
46
|
+
export function createServer (node) {
|
|
47
|
+
return http.createServer(async (req, res) => {
|
|
48
|
+
try {
|
|
49
|
+
await route(node, req, res)
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (res.headersSent) { res.destroy(); return }
|
|
52
|
+
if (err.code === 'ETOOBIG') return json(res, 413, { error: 'blob demasiado grande' })
|
|
53
|
+
if (err.code === 'ENOSPC') return json(res, 507, { error: 'cuota de disco excedida' })
|
|
54
|
+
json(res, 500, { error: err.message })
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function route (node, req, res) {
|
|
60
|
+
const url = new URL(req.url, 'http://localhost')
|
|
61
|
+
const [, top, cid] = url.pathname.split('/')
|
|
62
|
+
|
|
63
|
+
if (req.method === 'POST' && top === 'c' && !cid) {
|
|
64
|
+
const ttlMs = Number(url.searchParams.get('ttl')) || 0
|
|
65
|
+
const out = await node.put(req, {
|
|
66
|
+
mime: req.headers['content-type'] || 'application/octet-stream',
|
|
67
|
+
enc: url.searchParams.get('enc') === '1' ? 1 : 0,
|
|
68
|
+
ttl: ttlMs > 0 ? Date.now() + ttlMs : null
|
|
69
|
+
})
|
|
70
|
+
return json(res, out.existed ? 200 : 201, out)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (top === 'c' && cid) {
|
|
74
|
+
if (!isValidCid(cid)) return json(res, 400, { error: 'cid inválido' })
|
|
75
|
+
const meta = node.stat(cid)
|
|
76
|
+
if (!meta) return json(res, 404, { error: 'no existe' })
|
|
77
|
+
|
|
78
|
+
if (req.method === 'DELETE') {
|
|
79
|
+
await node.remove(cid)
|
|
80
|
+
return json(res, 200, { ok: true })
|
|
81
|
+
}
|
|
82
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
83
|
+
return json(res, 405, { error: 'método no permitido' })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const headers = {
|
|
87
|
+
'content-type': meta.mime,
|
|
88
|
+
etag: `"${cid}"`,
|
|
89
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
90
|
+
'accept-ranges': 'bytes'
|
|
91
|
+
}
|
|
92
|
+
if (req.headers['if-none-match'] === `"${cid}"`) {
|
|
93
|
+
res.writeHead(304, headers).end()
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
const range = parseRange(req.headers.range, meta.size)
|
|
97
|
+
if (range === false) {
|
|
98
|
+
res.writeHead(416, { 'content-range': `bytes */${meta.size}` }).end()
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
if (range) {
|
|
102
|
+
headers['content-range'] = `bytes ${range.start}-${range.end}/${meta.size}`
|
|
103
|
+
headers['content-length'] = range.end - range.start + 1
|
|
104
|
+
res.writeHead(206, headers)
|
|
105
|
+
} else {
|
|
106
|
+
headers['content-length'] = meta.size
|
|
107
|
+
res.writeHead(200, headers)
|
|
108
|
+
}
|
|
109
|
+
if (req.method === 'HEAD') return res.end()
|
|
110
|
+
await pipeline(node.read(cid, range ?? undefined), res)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (req.method === 'GET' && top === 'list') return json(res, 200, node.list())
|
|
115
|
+
if (req.method === 'GET' && top === 'stats') return json(res, 200, node.stats())
|
|
116
|
+
|
|
117
|
+
if (req.method === 'POST' && (top === 'pin' || top === 'unpin') && cid) {
|
|
118
|
+
if (!isValidCid(cid)) return json(res, 400, { error: 'cid inválido' })
|
|
119
|
+
const ok = node.pin(cid, top === 'pin')
|
|
120
|
+
return ok ? json(res, 200, { ok: true }) : json(res, 404, { error: 'no existe' })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// PUBLICAR / despublicar. Existía solo por el plano de control, y eso dejaba sin poder
|
|
124
|
+
// publicar a las herramientas de la propia máquina —que es donde vive el dueño—, y sin
|
|
125
|
+
// poder probar el camino público sin montar medio ecosistema.
|
|
126
|
+
//
|
|
127
|
+
// Con bucket detrás, marcar público MUEVE los bytes al bucket público, que es otro
|
|
128
|
+
// (§15.1): por eso pasa por `node.setAcl` y no por el índice a secas.
|
|
129
|
+
if (req.method === 'POST' && (top === 'public' || top === 'private') && cid) {
|
|
130
|
+
if (!isValidCid(cid)) return json(res, 400, { error: 'cid inválido' })
|
|
131
|
+
const meta = node.stat(cid)
|
|
132
|
+
if (!meta) return json(res, 404, { error: 'no existe' })
|
|
133
|
+
// Un blob CIFRADO no puede ser público, y se rechaza aquí además de en el índice:
|
|
134
|
+
// es la misma frontera del §7.2, y una frontera que solo se comprueba en un sitio
|
|
135
|
+
// acaba teniendo un camino que no pasa por ese sitio.
|
|
136
|
+
if (top === 'public' && meta.enc) return json(res, 400, { error: 'un blob cifrado no puede ser público' })
|
|
137
|
+
const ok = node.setAcl(cid, top === 'public' ? 'public' : null)
|
|
138
|
+
return ok ? json(res, 200, { ok: true, acl: top === 'public' ? 'public' : null }) : json(res, 404, { error: 'no existe' })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
json(res, 404, { error: 'ruta desconocida' })
|
|
142
|
+
}
|
package/src/storage.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Qué almacén usa este node, y las comprobaciones antes de fiarse de él
|
|
3
|
+
* (DISENO.md §15.14 y §15.15).
|
|
4
|
+
*
|
|
5
|
+
* Una variable pública manda —`CONTENT_STORAGE`— y las demás solo tienen sentido si
|
|
6
|
+
* nombra un proveedor. El valor nombra a QUIÉN está detrás (`r2`, `b2`, `hetzner`,
|
|
7
|
+
* `storj`, `s3`), no al protocolo: todos hablan S3 y usan el mismo backend, pero
|
|
8
|
+
* saberlo permite poner sus valores por omisión y, sobre todo, que quien mire la
|
|
9
|
+
* consola entienda lo que lee.
|
|
10
|
+
*
|
|
11
|
+
* **Nunca arranca a medias.** Si falta algo o una comprobación falla, se queda en
|
|
12
|
+
* `local` y lo DICE. Un almacén mal configurado que parece funcionar es la peor de las
|
|
13
|
+
* tres opciones: se descubre el día que hace falta lo que se creía guardado.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
16
|
+
import { BlobStore } from './blobstore.js'
|
|
17
|
+
import { S3BlobStore } from './blobstore-s3.js'
|
|
18
|
+
import { S3Bucket } from './s3.js'
|
|
19
|
+
|
|
20
|
+
/** Proveedores que hablan S3. Solo cambian el endpoint y algún valor por omisión. */
|
|
21
|
+
export const PROVIDERS = Object.freeze({
|
|
22
|
+
r2: { region: 'auto' },
|
|
23
|
+
b2: { region: 'us-west-004' },
|
|
24
|
+
hetzner: { region: 'eu-central' },
|
|
25
|
+
storj: { region: 'us-east-1' },
|
|
26
|
+
s3: { region: 'us-east-1' }
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/** La sonda del camino público: nueve bytes que se suben y se leen por el dominio. */
|
|
30
|
+
const PROBE = 'dotrino!'
|
|
31
|
+
|
|
32
|
+
/** Lo que hace falta para hablar con un bucket, más lo que hace falta para el público. */
|
|
33
|
+
const REQUIRED = ['CONTENT_S3_ENDPOINT', 'CONTENT_S3_BUCKET_PRIVATE', 'CONTENT_S3_KEY_ID', 'CONTENT_S3_SECRET']
|
|
34
|
+
const REQUIRED_PUBLIC = ['CONTENT_S3_PUBLIC_KEY_ID', 'CONTENT_S3_PUBLIC_SECRET', 'CONTENT_PUBLIC_BASE_URL']
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Lee la configuración del entorno (que la sirve el vault, §15.14).
|
|
38
|
+
* @param {Record<string,string|undefined>} [env]
|
|
39
|
+
* @returns {{ kind: string, provider: string|null, missing: string[], region: string,
|
|
40
|
+
* endpoint: string, priv: string, pub: string|null, baseUrl: string|null,
|
|
41
|
+
* keyId: string, secret: string, pubKeyId: string, pubSecret: string }}
|
|
42
|
+
*/
|
|
43
|
+
export function storageConfig (env = process.env) {
|
|
44
|
+
const kind = (env.CONTENT_STORAGE || 'local').trim().toLowerCase()
|
|
45
|
+
if (kind === 'local') return { kind: 'local', provider: null, missing: [] , region: '', endpoint: '', priv: '', pub: null, baseUrl: null, keyId: '', secret: '', pubKeyId: '', pubSecret: '' }
|
|
46
|
+
|
|
47
|
+
const provider = PROVIDERS[kind] ? kind : null
|
|
48
|
+
const missing = REQUIRED.filter((k) => !env[k])
|
|
49
|
+
|
|
50
|
+
// El bucket público es OPCIONAL a propósito: un node puede tener bucket solo para lo
|
|
51
|
+
// privado (durabilidad) y seguir sirviendo lo público por la red (§15.3). Pero si lo
|
|
52
|
+
// declara a medias, eso sí es un error — no se publica «casi».
|
|
53
|
+
const wantsPublic = !!env.CONTENT_S3_BUCKET_PUBLIC
|
|
54
|
+
if (wantsPublic) missing.push(...REQUIRED_PUBLIC.filter((k) => !env[k]))
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
kind,
|
|
58
|
+
provider,
|
|
59
|
+
missing,
|
|
60
|
+
region: env.CONTENT_S3_REGION || PROVIDERS[kind]?.region || 'us-east-1',
|
|
61
|
+
endpoint: env.CONTENT_S3_ENDPOINT || '',
|
|
62
|
+
priv: env.CONTENT_S3_BUCKET_PRIVATE || '',
|
|
63
|
+
pub: wantsPublic ? env.CONTENT_S3_BUCKET_PUBLIC : null,
|
|
64
|
+
baseUrl: env.CONTENT_PUBLIC_BASE_URL || null,
|
|
65
|
+
keyId: env.CONTENT_S3_KEY_ID || '',
|
|
66
|
+
secret: env.CONTENT_S3_SECRET || '',
|
|
67
|
+
pubKeyId: env.CONTENT_S3_PUBLIC_KEY_ID || '',
|
|
68
|
+
pubSecret: env.CONTENT_S3_PUBLIC_SECRET || ''
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Las tres comprobaciones del §15.15, las que se rompen SIN DAR ERROR.
|
|
74
|
+
*
|
|
75
|
+
* @param {ReturnType<typeof storageConfig>} cfg
|
|
76
|
+
* @param {{ priv: S3Bucket, pub: S3Bucket|null }} buckets
|
|
77
|
+
* @param {typeof fetch} [f]
|
|
78
|
+
* @returns {Promise<{ ok: boolean, fatal: string[], warn: string[] }>}
|
|
79
|
+
*/
|
|
80
|
+
export async function checkBuckets (cfg, { priv, pub }, f = fetch) {
|
|
81
|
+
const fatal = []
|
|
82
|
+
const warn = []
|
|
83
|
+
|
|
84
|
+
// 1. El mismo bucket para las dos cosas: lo privado acabaría en el que tiene dominio.
|
|
85
|
+
if (cfg.pub && cfg.pub === cfg.priv) {
|
|
86
|
+
fatal.push('el bucket privado y el público son el mismo: lo cifrado acabaría en el que tiene dominio')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 2. El privado NO puede responder sin credenciales. Se pide un objeto que no existe:
|
|
90
|
+
// un bucket cerrado contesta 400/401/403 (falta la firma o no autoriza), y uno
|
|
91
|
+
// ABIERTO contesta 404 (no está) — que es lo que delata que cualquiera puede leer
|
|
92
|
+
// los que sí están.
|
|
93
|
+
//
|
|
94
|
+
// LO QUE ESTO NO VE, y hay que decirlo: si alguien conecta un DOMINIO al bucket
|
|
95
|
+
// privado, este sondeo no se entera — pregunta al endpoint de S3, que sigue
|
|
96
|
+
// exigiendo firma aunque el bucket tenga dominio público. Contra eso no hay API:
|
|
97
|
+
// es responsabilidad de quien crea los buckets (§15.15).
|
|
98
|
+
try {
|
|
99
|
+
const r = await f(priv.urlFor('sha256-' + '0'.repeat(64)), { method: 'GET' })
|
|
100
|
+
if (r.status === 404 || r.ok) {
|
|
101
|
+
fatal.push(`el bucket privado «${cfg.priv}» responde sin credenciales (HTTP ${r.status}): está abierto al mundo`)
|
|
102
|
+
}
|
|
103
|
+
} catch (e) {
|
|
104
|
+
warn.push(`no se pudo comprobar si el bucket privado está cerrado: ${e.message}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 3. El dominio del público tiene que servir DE VERDAD, y eso no se deduce mirando
|
|
108
|
+
// una respuesta de error: se comprueba subiendo algo y leyéndolo por el dominio.
|
|
109
|
+
//
|
|
110
|
+
// Se intentó antes por las malas —«si el 404 viene en HTML, el dominio no es el
|
|
111
|
+
// bucket»— y se cayó sola: GitHub Pages contesta un 404 en HTML, pero R2 TAMBIÉN
|
|
112
|
+
// contesta el suyo en HTML. Dos cosas indistinguibles por su página de error.
|
|
113
|
+
//
|
|
114
|
+
// La sonda es un objeto de nueve bytes, siempre el mismo (su nombre es su hash, así
|
|
115
|
+
// que subirla dos veces no ensucia nada) y su presencia documenta que el enlace
|
|
116
|
+
// funciona. Cuesta dos peticiones al arrancar.
|
|
117
|
+
if (pub && cfg.baseUrl) {
|
|
118
|
+
try {
|
|
119
|
+
const bytes = Buffer.from(PROBE)
|
|
120
|
+
const cid = 'sha256-' + createHash('sha256').update(bytes).digest('hex')
|
|
121
|
+
await pub.put(cid, bytes, { sha256: cid.slice(7), size: bytes.length, contentType: 'text/plain' })
|
|
122
|
+
const r = await f(`${cfg.baseUrl.replace(/\/+$/, '')}/${cid}`, { method: 'GET' })
|
|
123
|
+
const cuerpo = r.ok ? (await r.text()).trim() : ''
|
|
124
|
+
if (cuerpo !== PROBE) {
|
|
125
|
+
warn.push(`${cfg.baseUrl} no sirve lo que hay en «${cfg.pub}» (HTTP ${r.status}): revisa que el dominio esté conectado AL BUCKET desde el panel`)
|
|
126
|
+
}
|
|
127
|
+
} catch (e) {
|
|
128
|
+
warn.push(`no se pudo comprobar el camino público: ${e.message}`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { ok: fatal.length === 0, fatal, warn }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Monta el almacén que toca. Devuelve SIEMPRE algo utilizable: si el bucket no está o
|
|
137
|
+
* no pasa las comprobaciones, devuelve el de disco y explica por qué.
|
|
138
|
+
*
|
|
139
|
+
* @param {{ dir: string, env?: any, log?: (m:string)=>void, check?: boolean, fetch?: typeof fetch }} o
|
|
140
|
+
*/
|
|
141
|
+
export async function openStore ({ dir, env = process.env, log = () => {}, check = true, fetch: f = fetch }) {
|
|
142
|
+
const cfg = storageConfig(env)
|
|
143
|
+
if (cfg.kind === 'local') return { store: await new BlobStore(dir).init(), cfg }
|
|
144
|
+
|
|
145
|
+
if (!cfg.provider) {
|
|
146
|
+
log(`[almacén] CONTENT_STORAGE=«${cfg.kind}» no es un proveedor conocido (${Object.keys(PROVIDERS).join(', ')}); sigo en local`)
|
|
147
|
+
return { store: await new BlobStore(dir).init(), cfg }
|
|
148
|
+
}
|
|
149
|
+
if (cfg.missing.length) {
|
|
150
|
+
log(`[almacén] ${cfg.kind} pedido pero faltan variables: ${cfg.missing.join(', ')}`)
|
|
151
|
+
log('[almacén] sigo en LOCAL: un almacén a medio configurar es peor que ninguno')
|
|
152
|
+
return { store: await new BlobStore(dir).init(), cfg }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const common = { endpoint: cfg.endpoint, region: cfg.region, fetch: f }
|
|
156
|
+
const priv = new S3Bucket({ ...common, bucket: cfg.priv, accessKeyId: cfg.keyId, secretAccessKey: cfg.secret })
|
|
157
|
+
const pub = cfg.pub
|
|
158
|
+
? new S3Bucket({ ...common, bucket: cfg.pub, accessKeyId: cfg.pubKeyId, secretAccessKey: cfg.pubSecret })
|
|
159
|
+
: null
|
|
160
|
+
|
|
161
|
+
if (check) {
|
|
162
|
+
const { ok, fatal, warn } = await checkBuckets(cfg, { priv, pub }, f)
|
|
163
|
+
for (const w of warn) log(`[almacén] ⚠ ${w}`)
|
|
164
|
+
if (!ok) {
|
|
165
|
+
for (const e of fatal) log(`[almacén] ✖ ${e}`)
|
|
166
|
+
log('[almacén] sigo en LOCAL hasta que eso se arregle')
|
|
167
|
+
return { store: await new BlobStore(dir).init(), cfg }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
log(`[almacén] ${cfg.kind}: privado «${cfg.priv}»${cfg.pub ? `, público «${cfg.pub}» en ${cfg.baseUrl}` : ' (sin bucket público: lo público viaja por la red)'}`)
|
|
172
|
+
return { store: await new S3BlobStore({ root: dir, priv, pub, log }).init(), cfg }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export default { storageConfig, checkBuckets, openStore, PROVIDERS }
|
package/src/vaultEnv.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* La CONFIGURACIÓN del node, servida por el vault (DISENO.md §15.14).
|
|
3
|
+
*
|
|
4
|
+
* Este node es un servicio del ecosistema como cualquier otro: no lleva su
|
|
5
|
+
* configuración en un `.env`, se la cede la bóveda del dueño **sellada para su llave**
|
|
6
|
+
* (`ns:content`). De ahí salen `CONTENT_STORAGE` y, si vale `s3`, el endpoint, los
|
|
7
|
+
* buckets y las credenciales.
|
|
8
|
+
*
|
|
9
|
+
* **No se reimplementa nada de eso**: lo resuelve `@dotrino/vault/service`, que es lo
|
|
10
|
+
* que usan los proxios. Aquí solo está el pegamento y una decisión propia — qué hacer
|
|
11
|
+
* cuando llega un cambio (abajo).
|
|
12
|
+
*
|
|
13
|
+
* Ojo con el enrolamiento, que tiene dos partes y hacen falta las dos:
|
|
14
|
+
*
|
|
15
|
+
* · **La llave de FIRMA** (`device`) — dice quién es este aparato.
|
|
16
|
+
* · **La llave de CIFRADO** (`enc`) — es a la que el vault le SELLA cada variable.
|
|
17
|
+
* Sin ella el aparato aparece en el acta pero se queda sin configuración, y no da
|
|
18
|
+
* error: simplemente no le llega nada. Por eso se enrola por este camino y no por
|
|
19
|
+
* el de `@dotrino/remote-agent`, que todavía no la crea.
|
|
20
|
+
*/
|
|
21
|
+
import fs from 'node:fs'
|
|
22
|
+
import path from 'node:path'
|
|
23
|
+
import { linkDir } from './agent.js'
|
|
24
|
+
|
|
25
|
+
/** El namespace de secretos de esta pieza. Es el mismo que el `pair --service`. */
|
|
26
|
+
export const NS = 'content'
|
|
27
|
+
|
|
28
|
+
/** Dónde vive la identidad de servicio (llave + cert). Junto al enlace del aparato. */
|
|
29
|
+
export const serviceDir = () =>
|
|
30
|
+
process.env.DOTRINO_CONTENT_VAULT_DIR || path.join(linkDir(), 'vault-service')
|
|
31
|
+
|
|
32
|
+
/** ¿Está este node enrolado a un vault? */
|
|
33
|
+
export const isEnrolled = (dir = serviceDir()) =>
|
|
34
|
+
fs.existsSync(path.join(dir, 'service-identity.json'))
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Enrola este node contra el vault del dueño con la invitación de
|
|
38
|
+
* `dotrino-vault pair --service content`.
|
|
39
|
+
*
|
|
40
|
+
* Deja además el enlace que espera el plano de control (`@dotrino/remote-agent`), con
|
|
41
|
+
* LA MISMA llave: un aparato, una identidad. Si se enrolara dos veces —una por cada
|
|
42
|
+
* plano— el acta tendría dos filas para la misma máquina y revocar una dejaría viva a
|
|
43
|
+
* la otra, que es exactamente lo que el modelo de revocación quiere evitar.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} qr la invitación (URL, código pegado o JSON)
|
|
46
|
+
* @param {{ dir?: string, onCode?: (c:{deviceId:string,code:string})=>void,
|
|
47
|
+
* onReplace?: (p:any)=>void, label?: string }} [opts]
|
|
48
|
+
*/
|
|
49
|
+
export async function enrollToVault (qr, { dir = serviceDir(), onCode, onReplace, label } = {}) {
|
|
50
|
+
const { enrollService } = await import('@dotrino/vault/service')
|
|
51
|
+
const res = await enrollService({ qr, ns: NS, dir, label: label || 'content', onCode, onReplace })
|
|
52
|
+
|
|
53
|
+
// El plano de control usa la misma identidad. `saveLink` es de remote-agent, y su
|
|
54
|
+
// formato es el que lee `startRemoteAgent` al arrancar.
|
|
55
|
+
const { saveLink } = await import('@dotrino/remote-agent/link')
|
|
56
|
+
saveLink(linkDir(), {
|
|
57
|
+
device: res.device,
|
|
58
|
+
cert: res.cert,
|
|
59
|
+
iss: res.iss,
|
|
60
|
+
proxy: res.cert?.proxy || 'wss://proxy.dotrino.com',
|
|
61
|
+
label: label || 'content',
|
|
62
|
+
at: Date.now()
|
|
63
|
+
})
|
|
64
|
+
return res
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Espera la configuración del vault y la vuelca en `process.env`.
|
|
69
|
+
*
|
|
70
|
+
* **Qué hace cuando cambia una variable: reiniciar.** Es lo mismo que decidió el
|
|
71
|
+
* proxio, y por la misma razón: una variable se rota casi siempre PORQUE SE FILTRÓ, y
|
|
72
|
+
* mientras el proceso siga vivo el valor viejo sigue en su memoria y sigue siendo el
|
|
73
|
+
* que usa. Aquí además es literal — el backend del almacén se construye al arrancar
|
|
74
|
+
* con las credenciales de entonces.
|
|
75
|
+
*
|
|
76
|
+
* Si el node no está enrolado no hace nada y se calla: sin vault corre en local, que
|
|
77
|
+
* es el modo normal de quien se lo autohospeda (§15.12).
|
|
78
|
+
*
|
|
79
|
+
* @param {{ dir?: string, onSecrets?: (s:any)=>void, log?: (m:string)=>void,
|
|
80
|
+
* onChange?: () => void, firstWaitMs?: number }} [opts]
|
|
81
|
+
*/
|
|
82
|
+
export function startVaultConfig ({ dir = serviceDir(), onSecrets, log = console.log, onChange, firstWaitMs = 20000 } = {}) {
|
|
83
|
+
if (!isEnrolled(dir)) return { enabled: false, ready: Promise.resolve(null), close () {} }
|
|
84
|
+
let stopped = false
|
|
85
|
+
let watcher = null
|
|
86
|
+
/** @type {(v:any)=>void} */
|
|
87
|
+
let llegaron = () => {}
|
|
88
|
+
const ready = new Promise((resolve) => { llegaron = resolve })
|
|
89
|
+
|
|
90
|
+
// La configuración decide QUÉ ALMACÉN usa este node, así que quien arranca la espera…
|
|
91
|
+
// pero con plazo. Si la bóveda está apagada, seguir esperando sería dejar al usuario
|
|
92
|
+
// sin su propio contenido local por una pieza que el ecosistema promete no exigir
|
|
93
|
+
// (`CLAUDE.md`: ninguna app puede requerir un daemon encendido). Al vencer el plazo se
|
|
94
|
+
// sigue en local, y cuando la configuración llegue, `watchEnv` reinicia con ella.
|
|
95
|
+
const plazo = setTimeout(() => {
|
|
96
|
+
log(`[vault] la bóveda no contestó en ${Math.round(firstWaitMs / 1000)}s: arranco con lo local y sigo esperando`)
|
|
97
|
+
llegaron(null)
|
|
98
|
+
}, firstWaitMs)
|
|
99
|
+
plazo.unref?.()
|
|
100
|
+
|
|
101
|
+
;(async () => {
|
|
102
|
+
const { waitForSecrets } = await import('@dotrino/vault/service')
|
|
103
|
+
const { applyEnv, watchEnv } = await import('@dotrino/vault/env')
|
|
104
|
+
const secrets = await waitForSecrets({
|
|
105
|
+
dir,
|
|
106
|
+
ns: NS,
|
|
107
|
+
onRetry: (e, delay) => log(`[vault] sin configuración todavía (${e.message}); reintento en ${Math.round(delay / 1000)}s`)
|
|
108
|
+
})
|
|
109
|
+
if (stopped) return
|
|
110
|
+
|
|
111
|
+
const { injected, overridden } = applyEnv(secrets)
|
|
112
|
+
log(`[vault] ${injected.length} valor(es) del vault aplicados al entorno`)
|
|
113
|
+
if (overridden.length) log(`[vault] pisaron el entorno de esta máquina: ${overridden.join(', ')}`)
|
|
114
|
+
clearTimeout(plazo)
|
|
115
|
+
llegaron(secrets)
|
|
116
|
+
onSecrets?.(secrets)
|
|
117
|
+
|
|
118
|
+
// Sin `onUpdate`, `watchEnv` sale del proceso él mismo (con el código que
|
|
119
|
+
// corresponda: 0 si cambió la configuración, 1 si revocaron a este agente, para
|
|
120
|
+
// que un supervisor que lo relance no gire en silencio). Es lo que queremos, así
|
|
121
|
+
// que solo se le pasa `onUpdate` si la app quiere hacer otra cosa.
|
|
122
|
+
watcher = await watchEnv({
|
|
123
|
+
dir,
|
|
124
|
+
ns: NS,
|
|
125
|
+
...(onChange
|
|
126
|
+
? {
|
|
127
|
+
onUpdate: ({ reason }) => {
|
|
128
|
+
log(`[vault] ${reason === 'revoked' ? 'este aparato fue revocado' : 'llegó configuración nueva'}`)
|
|
129
|
+
onChange()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
: {})
|
|
133
|
+
})
|
|
134
|
+
})().catch((e) => { log(`[vault] no se pudo leer la configuración: ${e.message}`); clearTimeout(plazo); llegaron(null) })
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
enabled: true,
|
|
138
|
+
/** Resuelve con la configuración, o con `null` si venció el plazo o falló. */
|
|
139
|
+
ready,
|
|
140
|
+
close () { stopped = true; clearTimeout(plazo); try { watcher?.close?.() } catch (_) {} }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export default { NS, serviceDir, isEnrolled, enrollToVault, startVaultConfig }
|