@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.
@@ -0,0 +1,158 @@
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 arrive = () => {}
88
+ const ready = new Promise((resolve) => { arrive = 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
+ let expired = false
96
+ const deadline = setTimeout(() => {
97
+ expired = true
98
+ log(`[vault] no answer in ${Math.round(firstWaitMs / 1000)}s: starting with the local config and restarting when it arrives`)
99
+ arrive(null)
100
+ }, firstWaitMs)
101
+ deadline.unref?.()
102
+
103
+ ;(async () => {
104
+ const { waitForSecrets } = await import('@dotrino/vault/service')
105
+ const { applyEnv, watchEnv } = await import('@dotrino/vault/env')
106
+ const secrets = await waitForSecrets({
107
+ dir,
108
+ ns: NS,
109
+ onRetry: (e, delay) => log(`[vault] no config yet (${e.message}); retrying in ${Math.round(delay / 1000)}s`)
110
+ })
111
+ if (stopped) return
112
+
113
+ const { injected, overridden } = applyEnv(secrets)
114
+ log(`[vault] applied ${injected.length} value(s) from the vault to the environment`)
115
+ if (overridden.length) log(`[vault] these overrode the machine environment: ${overridden.join(', ')}`)
116
+ clearTimeout(deadline)
117
+ arrive(secrets)
118
+ onSecrets?.(secrets)
119
+
120
+ // LLEGÓ TARDE: el node ya arrancó, y arrancó con lo que había — o sea, con el
121
+ // almacén equivocado si la configuración dice otro. `watchEnv` no sirve aquí
122
+ // porque solo reacciona a CAMBIOS, y esto es la primera llegada: sin esto, un node
123
+ // que arrancó antes que su bóveda se queda en local para siempre, con la
124
+ // configuración correcta puesta en el entorno y sin usarla. Se sale, y el
125
+ // supervisor lo levanta ya con todo.
126
+ if (expired) {
127
+ log('[vault] config arrived after startup: restarting to pick it up')
128
+ setTimeout(() => process.exit(0), 300)
129
+ return
130
+ }
131
+
132
+ // Sin `onUpdate`, `watchEnv` sale del proceso él mismo (con el código que
133
+ // corresponda: 0 si cambió la configuración, 1 si revocaron a este agente, para
134
+ // que un supervisor que lo relance no gire en silencio). Es lo que queremos, así
135
+ // que solo se le pasa `onUpdate` si la app quiere hacer otra cosa.
136
+ watcher = await watchEnv({
137
+ dir,
138
+ ns: NS,
139
+ ...(onChange
140
+ ? {
141
+ onUpdate: ({ reason }) => {
142
+ log(`[vault] ${reason === 'revoked' ? 'this device was revoked' : 'new config arrived'}`)
143
+ onChange()
144
+ }
145
+ }
146
+ : {})
147
+ })
148
+ })().catch((e) => { log(`[vault] could not read the config: ${e.message}`); clearTimeout(deadline); arrive(null) })
149
+
150
+ return {
151
+ enabled: true,
152
+ /** Resuelve con la configuración, o con `null` si venció el plazo o falló. */
153
+ ready,
154
+ close () { stopped = true; clearTimeout(deadline); try { watcher?.close?.() } catch (_) {} }
155
+ }
156
+ }
157
+
158
+ export default { NS, serviceDir, isEnrolled, enrollToVault, startVaultConfig }