@dotrino/vault 0.48.0 → 0.49.1

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.
@@ -31,13 +31,18 @@ function help () {
31
31
  console.log(`dotrino-env — credenciales del vault en vez del .env
32
32
 
33
33
  enroll --vault <bóveda> --ns <ns> [--code <invitación>] [--dir <dir>]
34
+ (--vault SOLO hace falta aquí, y para usarlo si el mismo <ns> está en dos bóvedas)
34
35
  Registra ESTE servicio contra el vault (una sola vez).
35
36
  Antes, en el vault: dotrino-vault pair --service <ns>
36
37
  Si no pasas --code (alias --qr), se pide por consola (también acepta stdin).
37
38
 
38
39
  status servicios enrolados en esta máquina
39
- check [--ns <ns>] pide los secretos al vault y lista sus NOMBRES (nunca los valores)
40
- run --vault <bóveda> [--ns <ns>] -- <cmd> [args…]
40
+ check [--ns <ns>] [--public]
41
+ pide los secretos al vault y lista sus NOMBRES (nunca los valores)
42
+ con --public no pide aprobación: solo mira las públicas
43
+ run [--vault <bóveda>] [--ns <ns>] [--public] -- <cmd> [args…]
44
+ --public trae SOLO las variables públicas. No pide aprobación en el teléfono:
45
+ la aprobación es para las claves privadas, y una pública está en claro.
41
46
  ssh-agent [--ns <ns>] [--socket <ruta>]
42
47
  agente SSH con las llaves (SSH_KEY_* del cajón) solo en memoria;
43
48
  pide el cajón al arrancar. Pon SSH_AUTH_SOCK en la ruta que imprime.
@@ -82,9 +87,15 @@ async function readInvite () {
82
87
  * `--vault` y no `--profile`: `dotrino-vault --profile` ya significa otra cosa y los dos
83
88
  * comandos se usan seguidos.
84
89
  */
85
- function etiquetaBoveda () {
90
+ /**
91
+ * La etiqueta de bóveda, si la hay. AL ENROLAR es obligatoria (`exigida: true`): ahí se está
92
+ * creando el directorio y hay que decir de quién es. Para USARLO no: el `ns` se busca entre
93
+ * las bóvedas enroladas y solo hace falta elegir si está en más de una (dueño, 2026-09-01).
94
+ */
95
+ function etiquetaBoveda ({ exigida = false } = {}) {
86
96
  const v = resolveVaultLabel(flag('vault'))
87
97
  if (!v) {
98
+ if (!exigida) return null
88
99
  console.error('falta --vault <nombre> (o DOTRINO_ENV_VAULT)')
89
100
  console.error('')
90
101
  console.error(' Dice DE QUÉ BÓVEDA es este cajón. Es una etiqueta tuya, de esta máquina:')
@@ -99,8 +110,8 @@ function etiquetaBoveda () {
99
110
  async function cmdEnroll () {
100
111
  const ns = flag('ns')
101
112
  if (!ns) { console.error('falta --ns <ns> (el mismo del `dotrino-vault pair --service <ns>`)'); process.exit(2) }
102
- const perfil = etiquetaBoveda()
103
- const dir = flag('dir') || serviceDir(bov, ns)
113
+ const boveda = etiquetaBoveda({ exigida: true })
114
+ const dir = flag('dir') || serviceDir(boveda, ns)
104
115
  const qr = parseInvite((flag('qr') || flag('code')) || await readInvite())
105
116
 
106
117
  console.log('\nEnrolando el servicio "%s" contra el vault…', ns)
@@ -170,9 +181,13 @@ async function cmdCheck () {
170
181
  // `fetchSecrets` y no `loadEnv`: listar NO debe tener efectos secundarios. Con
171
182
  // `loadEnv` esto inyectaría el bundle en el entorno del propio `check`, que es
172
183
  // justo lo que un comando de diagnóstico no tiene por qué hacer.
173
- const secrets = await fetchSecrets({ dir: serviceDir(etiquetaBoveda(), ns), ns })
184
+ // `--public` también aquí: ver QUÉ hay no debería costarle una aprobación al dueño. Sin
185
+ // él se piden todas, que es lo que hace falta para comprobar de verdad un arranque.
186
+ const publicOnly = argv.includes('--public')
187
+ const secrets = await fetchSecrets({ dir: serviceDir(etiquetaBoveda(), ns), ns, publicOnly })
174
188
  const keys = Object.keys(secrets)
175
- console.log('ns "%s": %d secreto(s)%s', ns, keys.length, keys.length ? ':' : '')
189
+ console.log('ns "%s": %d %s%s', ns, keys.length,
190
+ publicOnly ? 'variable(s) pública(s)' : 'secreto(s)', keys.length ? ':' : '')
176
191
  for (const k of keys) console.log(' ' + k) // NUNCA los valores
177
192
  // Delata el `.env` rancio: qué claves de esta máquina el vault pisaría.
178
193
  const clashing = keys.filter((k) => k in process.env && process.env[k] !== String(secrets[k]))
@@ -184,7 +199,7 @@ async function cmdCheck () {
184
199
  async function cmdRun () {
185
200
  const sep = argv.indexOf('--')
186
201
  const cmd = sep >= 0 ? argv.slice(sep + 1) : []
187
- if (!cmd.length) { console.error('uso: dotrino-env run --vault <bóveda> [--ns <ns>] -- <cmd> [args…]'); process.exit(2) }
202
+ if (!cmd.length) { console.error('uso: dotrino-env run [--vault <bóveda>] [--ns <ns>] [--public] -- <cmd> [args…]'); process.exit(2) }
188
203
  const onPending = () => console.error('[dotrino-env] waiting for approval on your phone…')
189
204
  // POR QUÉ SE REINTENTA, SIEMPRE. Sin esto la espera es muda: la bóveda contestaba, la
190
205
  // respuesta se caía por un motivo concreto —un aparato sin sobre en su cajón, por
@@ -192,7 +207,12 @@ async function cmdRun () {
192
207
  // dueño hubiera aprobado cada una. Un reintento que no dice su causa es un fallback: le
193
208
  // pone cara de espera a un error. (Ver CLAUDE.md, «nada de repliegues».)
194
209
  const onRetry = (e, ms) => console.error('[dotrino-env] failed: %s — retrying in %ds', e.message, Math.round(ms / 1000))
195
- const { injected, overridden } = await loadEnv({ ns: flag('ns'), vault: etiquetaBoveda(), onPending, onRetry })
210
+ // `--public`: SOLO las variables públicas, y por eso no hace sonar el teléfono de nadie.
211
+ // La aprobación existe para soltar claves privadas; una pública está guardada en claro y
212
+ // ya la ve quien administra. Es lo que quiere un arranque que solo necesita configuración
213
+ // —una URL, un puerto— y no tiene por qué despertar al dueño.
214
+ const publicOnly = argv.includes('--public')
215
+ const { injected, overridden } = await loadEnv({ ns: flag('ns'), vault: etiquetaBoveda(), onPending, onRetry, publicOnly })
196
216
  console.error('[dotrino-env] %d valor(es) en el entorno de %s%s', injected.length, cmd[0],
197
217
  overridden.length ? ` (pisados: ${overridden.join(', ')})` : '')
198
218
  const child = spawn(cmd[0], cmd.slice(1), { stdio: 'inherit', env: process.env })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.48.0",
3
+ "version": "0.49.1",
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/env.js CHANGED
@@ -53,11 +53,26 @@ export const isValidVaultLabel = (p) => typeof p === 'string' && /^[a-z0-9-]{1,3
53
53
  /** Directorio de la identidad del servicio `ns` bajo la etiqueta de bóveda `vault`. */
54
54
  export function serviceDir (vault, ns) {
55
55
  if (process.env.DOTRINO_ENV_DIR) return process.env.DOTRINO_ENV_DIR
56
- if (!isValidVaultLabel(vault)) {
57
- throw new Error('missing vault label: pass --vault <nombre> (use [a-z0-9-]{1,32}). It is a label of YOUR machine: it says which vault this drawer came from')
58
- }
59
56
  if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "myapp")')
60
- return path.join(serviceRoot(), vault, ns)
57
+ if (isValidVaultLabel(vault)) return path.join(serviceRoot(), vault, ns)
58
+ // SIN `--vault`: SE BUSCA, Y SOLO SE EXIGE SI HAY EMPATE (dueño, 2026-09-01).
59
+ //
60
+ // La etiqueta se hizo obligatoria para que dos bóvedas pudieran tener un cajón que se
61
+ // llame igual sin pisarse. Eso lo arregla la RUTA, no la bandera: con un solo enrolamiento
62
+ // de ese `ns` no hay nada que desambiguar y pedirla es fricción sin nada a cambio.
63
+ //
64
+ // Con dos, en cambio, elegir por el usuario sería adivinar cuál de sus cuentas usar — así
65
+ // que se para y se dice cuáles hay. Eso es lo contrario de un repliegue: no se resuelve
66
+ // una ambigüedad a la brava, se señala.
67
+ const donde = listEnrolled().filter((e) => !e.legacy && e.ns === ns)
68
+ if (donde.length === 1) return path.join(serviceRoot(), donde[0].vault, ns)
69
+ if (donde.length > 1) {
70
+ throw new Error(`"${ns}" is enrolled in more than one vault (${donde.map((e) => e.vault).join(', ')}): pick one with --vault <label>`)
71
+ }
72
+ const otros = listEnrolled().filter((e) => !e.legacy)
73
+ throw new Error(otros.length
74
+ ? `"${ns}" is not enrolled on this machine (there is: ${otros.map((e) => `${e.vault}/${e.ns}`).join(', ')})`
75
+ : `"${ns}" is not enrolled on this machine: run \`dotrino-env enroll --vault <label> --ns ${ns}\` first`)
61
76
  }
62
77
 
63
78
  /** La etiqueta de bóveda: `--vault`, o `DOTRINO_ENV_VAULT`. */
@@ -139,18 +154,21 @@ let lastApplied = null
139
154
  * @param {boolean} [opts.wait] `true` (default) = si el vault no está, ESPERA (reintenta) en vez de fallar.
140
155
  * @param {string[]} [opts.required] Claves que deben venir; si falta alguna, lanza.
141
156
  * @param {(e:Error, ms:number)=>void} [opts.onRetry]
157
+ * @param {boolean} [opts.publicOnly] Solo las variables PÚBLICAS. No hay claves privadas de
158
+ * por medio, así que la bóveda no pide aprobación: sirve para un arranque que solo
159
+ * necesita configuración (una URL, un puerto) y no debe despertar a nadie.
142
160
  * @returns {Promise<{ns, secrets, injected:string[], overridden:string[], skipped:string[]}>}
143
161
  * `overridden` = las que YA tenían otro valor en el entorno y el vault pisó. Es
144
162
  * el dato que delata un `.env` rancio, así que se reporta en vez de callarse.
145
163
  */
146
- export async function loadEnv ({ ns, vault, dir, override, wait = true, required = [], onRetry, onPending } = {}) {
164
+ export async function loadEnv ({ ns, vault, dir, override, wait = true, required = [], onRetry, onPending, publicOnly = false } = {}) {
147
165
  if (override === undefined) override = overrideByDefault()
148
166
  ns = resolveNs(ns)
149
- // La ETIQUETA DE BÓVEDA es obligatoria salvo que se diga el directorio a pelo: es lo que
150
- // hace que dos bóvedas puedan tener un cajón con el mismo nombre sin pisarse.
167
+ // La etiqueta de bóveda es OPCIONAL: `serviceDir` busca el `ns` entre las que hay y solo
168
+ // la exige si está enrolado en más de una (ahí sí hay que elegir, y elige el dueño).
151
169
  dir = dir || serviceDir(resolveVaultLabel(vault), ns)
152
170
  const load = wait ? waitForSecrets : fetchSecrets
153
- const secrets = await load({ dir, ns, onRetry, onPending })
171
+ const secrets = await load({ dir, ns, onRetry, onPending, publicOnly })
154
172
 
155
173
  const missing = required.filter((k) => !(k in secrets))
156
174
  if (missing.length) {
package/src/service.js CHANGED
@@ -421,7 +421,7 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
421
421
  * Renueva el cert automáticamente si está por vencer (best-effort).
422
422
  * @returns {Promise<Record<string,string>>} secretos KEY→valor
423
423
  */
424
- export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000, onPending, onCert, approvalTimeoutMs = APPROVAL_TIMEOUT_MS } = {}) {
424
+ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000, onPending, onCert, approvalTimeoutMs = APPROVAL_TIMEOUT_MS, publicOnly = false } = {}) {
425
425
  let saved = null
426
426
  if (dir) saved = readServiceIdentity(dir)
427
427
  // Sin `dir`: la identidad viene entera por parámetros — es el caso de un agente
@@ -499,7 +499,9 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
499
499
  }
500
500
 
501
501
  const eph = await makeEphemeralKey()
502
- const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now() }
502
+ // `publicOnly` VIAJA FIRMADO (va dentro de `data`), así que nadie lo puede quitar ni
503
+ // poner por el camino. Quien decide qué se manda es la bóveda; esto solo es la petición.
504
+ const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now(), ...(publicOnly ? { publicOnly: true } : {}) }
503
505
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
504
506
  const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
505
507
  client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
@@ -1022,11 +1024,11 @@ export function isFinal (e) {
1022
1024
  return /not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch|denied)/.test(e?.message || '')
1023
1025
  }
1024
1026
 
1025
- export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending, onCert } = {}) {
1027
+ export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending, onCert, publicOnly = false } = {}) {
1026
1028
  let delay = retryMs
1027
1029
  for (;;) {
1028
1030
  try {
1029
- return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending, onCert })
1031
+ return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending, onCert, publicOnly })
1030
1032
  } catch (e) {
1031
1033
  if (isFinal(e)) throw e
1032
1034
  try { onRetry?.(e, delay) } catch (_) {}