@dotrino/vault 0.13.0 → 0.14.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 CHANGED
@@ -97,12 +97,38 @@ Para procesos que no son Node, el CLI los inyecta en el entorno de un hijo:
97
97
  dotrino-env run --ns miapp -- ./mi-binario
98
98
  ```
99
99
 
100
+ ### Precedencia: el vault MANDA
101
+
102
+ Los valores del vault **pisan** los del `.env` y los del entorno. El vault no
103
+ reemplaza al `.env` —que sigue siendo lo que arranca una máquina sin enrolar— pero
104
+ sí tiene la última palabra sobre las claves que administra.
105
+
106
+ Esa es la pieza que hace barata la **rotación**: cambias el valor en un solo lugar y
107
+ ningún `.env` viejo olvidado en un VPS puede seguir ganando. Con la precedencia al
108
+ revés (como estaba hasta la 0.14.0) rotar exigía además ir a limpiar cada copia
109
+ rancia —el trabajo que se quería evitar— y, peor, el servicio arrancaba con la llave
110
+ vieja **sin decir nada**.
111
+
112
+ Lo que sí se dice en voz alta: al arrancar se listan las claves que el vault tuvo que
113
+ pisar. Es la señal de que en esa máquina quedó un `.env` por limpiar.
114
+
115
+ ```bash
116
+ DOTRINO_ENV_OVERRIDE=0 node server.js # escotilla: por esta corrida, gana el entorno
117
+ dotrino-env check # dice qué claves pisaría en esta máquina
118
+ ```
119
+
100
120
  ### API `@dotrino/vault/env`
101
121
 
102
- - `loadEnv({ ns?, dir?, override?, wait?, required?, onRetry? }) → { ns, secrets, injected, skipped }`
103
- (por defecto **no pisa** variables ya presentes en el entorno; `override: true` )
122
+ - `loadEnv({ ns?, dir?, override?, wait?, required?, onRetry? }) → { ns, secrets, injected, overridden, skipped }`
123
+ (por defecto **pisa** lo que ya esté en el entorno; `override: false` invierte la regla).
124
+ `overridden` son las claves que tenían otro valor y el vault reemplazó.
125
+ - `applyEnv(secrets, override?) → { injected, overridden, skipped }` — vuelca un bundle
126
+ ya obtenido, sin pedirlo. Para servicios que **no pueden bloquear su arranque**
127
+ esperando al vault y lo aplican cuando llega (el caso del proxy: el vault le habla
128
+ *por* el proxy, así que esperarlo sería un abrazo mortal).
104
129
  - `serviceDir(ns)`, `serviceRoot()`, `listEnrolled()`, `resolveNs(ns?)`
105
- - Entorno: `DOTRINO_NS` · `DOTRINO_ENV_DIR` · `DOTRINO_ENV_HOME` · `DOTRINO_ENV_QUIET`
130
+ - Entorno: `DOTRINO_NS` · `DOTRINO_ENV_DIR` · `DOTRINO_ENV_HOME` · `DOTRINO_ENV_QUIET` ·
131
+ `DOTRINO_ENV_OVERRIDE`
106
132
  - CLI: `dotrino-env enroll|status|check|run` (`check` lista **nombres** de secretos, nunca valores)
107
133
 
108
134
  Bajo el capó es `@dotrino/vault/service` (`enrollService` / `waitForSecrets`): petición
@@ -16,8 +16,13 @@ import fs from 'node:fs'
16
16
  import path from 'node:path'
17
17
  import readline from 'node:readline/promises'
18
18
  import { spawn } from 'node:child_process'
19
- import { enrollService, readServiceIdentity } from '../src/service.js'
19
+ import { enrollService, readServiceIdentity, fetchSecrets } from '../src/service.js'
20
20
  import { loadEnv, serviceDir, serviceRoot, listEnrolled, resolveNs } from '../src/env.js'
21
+ // Se usaba sin importarlo: `enroll` —el comando principal— moría con
22
+ // «sharedParseInvite is not defined» en cuanto tocaba una invitación. Nunca
23
+ // llegó a funcionar, y por eso el único servicio enrolado del ecosistema (el
24
+ // proxy) lo hizo con su propio `enroll-vault.js` en vez de con esta CLI.
25
+ import { parseInvite as sharedParseInvite } from '../src/invite.js'
21
26
 
22
27
  const argv = process.argv.slice(2)
23
28
  const flag = (name) => { const i = argv.indexOf('--' + name); return i >= 0 ? argv[i + 1] : undefined }
@@ -40,7 +45,11 @@ En tu código:
40
45
  import '@dotrino/vault/config' // ns por DOTRINO_NS
41
46
  import { loadEnv } from '@dotrino/vault/env'; await loadEnv({ ns: '<ns>' })
42
47
 
43
- Entorno: DOTRINO_NS · DOTRINO_ENV_DIR · DOTRINO_ENV_HOME · DOTRINO_ENV_QUIET`)
48
+ El vault MANDA: sus valores pisan los del .env y los del entorno. Para una
49
+ corrida suelta sin que pise nada: DOTRINO_ENV_OVERRIDE=0
50
+
51
+ Entorno: DOTRINO_NS · DOTRINO_ENV_DIR · DOTRINO_ENV_HOME · DOTRINO_ENV_QUIET
52
+ DOTRINO_ENV_OVERRIDE`)
44
53
  }
45
54
 
46
55
  /**
@@ -106,17 +115,27 @@ function cmdStatus () {
106
115
 
107
116
  async function cmdCheck () {
108
117
  const ns = resolveNs(flag('ns'))
109
- const { secrets } = await loadEnv({ ns, wait: false })
118
+ // `fetchSecrets` y no `loadEnv`: listar NO debe tener efectos secundarios. Con
119
+ // `loadEnv` esto inyectaría el bundle en el entorno del propio `check`, que es
120
+ // justo lo que un comando de diagnóstico no tiene por qué hacer.
121
+ const secrets = await fetchSecrets({ dir: serviceDir(ns), ns })
110
122
  const keys = Object.keys(secrets)
111
123
  console.log('ns "%s": %d secreto(s)%s', ns, keys.length, keys.length ? ':' : '')
112
124
  for (const k of keys) console.log(' ' + k) // NUNCA los valores
125
+ // Delata el `.env` rancio: qué claves de esta máquina el vault pisaría.
126
+ const chocan = keys.filter((k) => k in process.env && process.env[k] !== String(secrets[k]))
127
+ if (chocan.length) {
128
+ console.log('\nEl vault PISA estos valores del entorno de esta máquina:\n %s', chocan.join(', '))
129
+ }
113
130
  }
114
131
 
115
132
  async function cmdRun () {
116
133
  const sep = argv.indexOf('--')
117
134
  const cmd = sep >= 0 ? argv.slice(sep + 1) : []
118
135
  if (!cmd.length) { console.error('uso: dotrino-env run [--ns <ns>] -- <cmd> [args…]'); process.exit(2) }
119
- await loadEnv({ ns: flag('ns') })
136
+ const { injected, overridden } = await loadEnv({ ns: flag('ns') })
137
+ console.error('[dotrino-env] %d valor(es) en el entorno de %s%s', injected.length, cmd[0],
138
+ overridden.length ? ` (pisados: ${overridden.join(', ')})` : '')
120
139
  const child = spawn(cmd[0], cmd.slice(1), { stdio: 'inherit', env: process.env })
121
140
  child.on('exit', (code, signal) => process.exit(signal ? 1 : (code ?? 0)))
122
141
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/config.js CHANGED
@@ -8,19 +8,31 @@
8
8
  * secretos viejos ni vacíos. Un fallo NO transitorio (sin enrolar, cert revocado,
9
9
  * scope equivocado) sí aborta el proceso.
10
10
  *
11
+ * EL VAULT MANDA: lo que venga de aquí PISA lo que ya hubiera en el entorno
12
+ * (incluido un `.env` cargado antes). Ver `env.js` para el porqué.
13
+ *
11
14
  * Config por entorno:
12
- * DOTRINO_NS namespace de secretos (si no, el único enrolado en la máquina)
13
- * DOTRINO_ENV_DIR directorio de la identidad del servicio (si no, ~/.dotrino/service/<ns>)
14
- * DOTRINO_ENV_QUIET '1' para no imprimir la línea de arranque
15
+ * DOTRINO_NS namespace de secretos (si no, el único enrolado en la máquina)
16
+ * DOTRINO_ENV_DIR directorio de la identidad del servicio (si no, ~/.dotrino/service/<ns>)
17
+ * DOTRINO_ENV_QUIET '1' para no imprimir la línea de arranque
18
+ * DOTRINO_ENV_OVERRIDE '0' para NO pisar el entorno en esta corrida (depuración)
15
19
  */
16
20
  import { loadEnv } from './env.js'
17
21
 
18
22
  const quiet = process.env.DOTRINO_ENV_QUIET === '1'
19
23
 
20
- const { ns, injected } = await loadEnv({
24
+ const { ns, injected, overridden } = await loadEnv({
21
25
  onRetry: (e, ms) => {
22
26
  if (!quiet) console.error('[dotrino-env] vault no disponible (%s); reintentando en %ds…', e.message, Math.round(ms / 1000))
23
27
  }
24
28
  })
25
29
 
26
- if (!quiet) console.error('[dotrino-env] %d secreto(s) del ns "%s" cargados en process.env', injected.length, ns)
30
+ if (!quiet) {
31
+ console.error('[dotrino-env] %d valor(es) del ns "%s" cargados en process.env', injected.length, ns)
32
+ // Se dice en voz alta: que el vault haya tenido que pisar algo significa que
33
+ // en esta máquina hay un `.env` con valores viejos. Ganó el vault, pero el
34
+ // operador quiere enterarse — es la señal de que quedó basura por limpiar.
35
+ if (overridden.length) {
36
+ console.error('[dotrino-env] pisaron un valor previo del entorno: %s', overridden.join(', '))
37
+ }
38
+ }
package/src/env.js CHANGED
@@ -61,19 +61,42 @@ export function resolveNs (ns) {
61
61
  throw new Error(`hay varios servicios enrolados (${found.join(', ')}): elige uno con DOTRINO_NS=<ns> o loadEnv({ ns })`)
62
62
  }
63
63
 
64
+ /**
65
+ * EL VAULT MANDA: sus valores PISAN los del `.env` y los del entorno.
66
+ *
67
+ * No es el default de `dotenv` y es a propósito. El vault no viene a reemplazar
68
+ * al `.env` —que sigue ahí y sigue siendo el arranque de cualquier máquina sin
69
+ * enrolar—, viene a ser la ÚLTIMA palabra sobre las claves que administra. Esa
70
+ * es justamente la pieza que hace barata la rotación: se cambia el valor en un
71
+ * solo lugar y ningún `.env` viejo, olvidado en un VPS, puede seguir ganando.
72
+ * Con la precedencia al revés, rotar exigía además ir a limpiar cada copia
73
+ * rancia —que es exactamente el trabajo que se quería evitar—, y peor: el
74
+ * servicio arrancaba con la llave vieja SIN decir nada.
75
+ *
76
+ * Escotilla para depurar: `DOTRINO_ENV_OVERRIDE=0` devuelve la precedencia
77
+ * clásica (gana lo que ya está en el entorno) para una corrida suelta, sin
78
+ * tocar el vault ni el código.
79
+ */
80
+ function overrideByDefault () {
81
+ return process.env.DOTRINO_ENV_OVERRIDE !== '0'
82
+ }
83
+
64
84
  /**
65
85
  * Trae los secretos del ns desde el vault y los pone en `process.env`.
66
86
  *
67
87
  * @param {Object} [opts]
68
88
  * @param {string} [opts.ns] Namespace (por defecto: `DOTRINO_NS` o el único enrolado).
69
89
  * @param {string} [opts.dir] Dónde está `service-identity.json` (por defecto: `serviceDir(ns)`).
70
- * @param {boolean} [opts.override] `true` = pisa variables ya presentes en el entorno (default: no).
90
+ * @param {boolean} [opts.override] El vault pisa lo que ya esté en el entorno (default: SÍ, ver arriba).
71
91
  * @param {boolean} [opts.wait] `true` (default) = si el vault no está, ESPERA (reintenta) en vez de fallar.
72
92
  * @param {string[]} [opts.required] Claves que deben venir; si falta alguna, lanza.
73
93
  * @param {(e:Error, ms:number)=>void} [opts.onRetry]
74
- * @returns {Promise<{ns:string, secrets:Record<string,string>, injected:string[], skipped:string[]}>}
94
+ * @returns {Promise<{ns, secrets, injected:string[], overridden:string[], skipped:string[]}>}
95
+ * `overridden` = las que YA tenían otro valor en el entorno y el vault pisó. Es
96
+ * el dato que delata un `.env` rancio, así que se reporta en vez de callarse.
75
97
  */
76
- export async function loadEnv ({ ns, dir, override = false, wait = true, required = [], onRetry } = {}) {
98
+ export async function loadEnv ({ ns, dir, override, wait = true, required = [], onRetry } = {}) {
99
+ if (override === undefined) override = overrideByDefault()
77
100
  ns = resolveNs(ns)
78
101
  dir = dir || serviceDir(ns)
79
102
  const load = wait ? waitForSecrets : fetchSecrets
@@ -84,12 +107,31 @@ export async function loadEnv ({ ns, dir, override = false, wait = true, require
84
107
  throw new Error(`faltan secretos en el ns "${ns}": ${missing.join(', ')} (agrégalos con \`dotrino-vault secret set ${ns} <CLAVE> <valor>\`)`)
85
108
  }
86
109
 
110
+ return { ns, secrets, ...applyEnv(secrets, override) }
111
+ }
112
+
113
+ /**
114
+ * Vuelca un bundle de secretos en `process.env` y cuenta qué cambió.
115
+ *
116
+ * Separado de `loadEnv` porque un servicio que NO puede bloquear su arranque
117
+ * esperando al vault (el proxy: el vault le habla POR el proxy, así que
118
+ * esperarlo sería un abrazo mortal) igual necesita aplicar el bundle cuando
119
+ * llegue, tarde y por su cuenta.
120
+ *
121
+ * @returns {{injected:string[], overridden:string[], skipped:string[]}}
122
+ */
123
+ export function applyEnv (secrets, override = overrideByDefault()) {
87
124
  const injected = []
125
+ const overridden = []
88
126
  const skipped = []
89
- for (const [k, v] of Object.entries(secrets)) {
90
- if (!override && k in process.env) { skipped.push(k); continue }
91
- process.env[k] = String(v)
127
+ for (const [k, v] of Object.entries(secrets || {})) {
128
+ const previo = process.env[k]
129
+ const tenia = k in process.env
130
+ if (!override && tenia) { skipped.push(k); continue }
131
+ const valor = String(v)
132
+ process.env[k] = valor
92
133
  injected.push(k)
134
+ if (tenia && previo !== valor) overridden.push(k)
93
135
  }
94
- return { ns, secrets, injected, skipped }
136
+ return { injected, overridden, skipped }
95
137
  }