@dotrino/vault 0.48.0 → 0.49.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/bin/dotrino-env.js +20 -6
- package/package.json +1 -1
- package/src/env.js +26 -8
- package/src/service.js +6 -4
package/bin/dotrino-env.js
CHANGED
|
@@ -31,13 +31,16 @@ 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
40
|
check [--ns <ns>] pide los secretos al vault y lista sus NOMBRES (nunca los valores)
|
|
40
|
-
run --vault <bóveda> [--ns <ns>] -- <cmd> [args…]
|
|
41
|
+
run [--vault <bóveda>] [--ns <ns>] [--public] -- <cmd> [args…]
|
|
42
|
+
--public trae SOLO las variables públicas. No pide aprobación en el teléfono:
|
|
43
|
+
la aprobación es para las claves privadas, y una pública está en claro.
|
|
41
44
|
ssh-agent [--ns <ns>] [--socket <ruta>]
|
|
42
45
|
agente SSH con las llaves (SSH_KEY_* del cajón) solo en memoria;
|
|
43
46
|
pide el cajón al arrancar. Pon SSH_AUTH_SOCK en la ruta que imprime.
|
|
@@ -82,9 +85,15 @@ async function readInvite () {
|
|
|
82
85
|
* `--vault` y no `--profile`: `dotrino-vault --profile` ya significa otra cosa y los dos
|
|
83
86
|
* comandos se usan seguidos.
|
|
84
87
|
*/
|
|
85
|
-
|
|
88
|
+
/**
|
|
89
|
+
* La etiqueta de bóveda, si la hay. AL ENROLAR es obligatoria (`exigida: true`): ahí se está
|
|
90
|
+
* creando el directorio y hay que decir de quién es. Para USARLO no: el `ns` se busca entre
|
|
91
|
+
* las bóvedas enroladas y solo hace falta elegir si está en más de una (dueño, 2026-09-01).
|
|
92
|
+
*/
|
|
93
|
+
function etiquetaBoveda ({ exigida = false } = {}) {
|
|
86
94
|
const v = resolveVaultLabel(flag('vault'))
|
|
87
95
|
if (!v) {
|
|
96
|
+
if (!exigida) return null
|
|
88
97
|
console.error('falta --vault <nombre> (o DOTRINO_ENV_VAULT)')
|
|
89
98
|
console.error('')
|
|
90
99
|
console.error(' Dice DE QUÉ BÓVEDA es este cajón. Es una etiqueta tuya, de esta máquina:')
|
|
@@ -99,8 +108,8 @@ function etiquetaBoveda () {
|
|
|
99
108
|
async function cmdEnroll () {
|
|
100
109
|
const ns = flag('ns')
|
|
101
110
|
if (!ns) { console.error('falta --ns <ns> (el mismo del `dotrino-vault pair --service <ns>`)'); process.exit(2) }
|
|
102
|
-
const
|
|
103
|
-
const dir = flag('dir') || serviceDir(
|
|
111
|
+
const boveda = etiquetaBoveda({ exigida: true })
|
|
112
|
+
const dir = flag('dir') || serviceDir(boveda, ns)
|
|
104
113
|
const qr = parseInvite((flag('qr') || flag('code')) || await readInvite())
|
|
105
114
|
|
|
106
115
|
console.log('\nEnrolando el servicio "%s" contra el vault…', ns)
|
|
@@ -184,7 +193,7 @@ async function cmdCheck () {
|
|
|
184
193
|
async function cmdRun () {
|
|
185
194
|
const sep = argv.indexOf('--')
|
|
186
195
|
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) }
|
|
196
|
+
if (!cmd.length) { console.error('uso: dotrino-env run [--vault <bóveda>] [--ns <ns>] [--public] -- <cmd> [args…]'); process.exit(2) }
|
|
188
197
|
const onPending = () => console.error('[dotrino-env] waiting for approval on your phone…')
|
|
189
198
|
// POR QUÉ SE REINTENTA, SIEMPRE. Sin esto la espera es muda: la bóveda contestaba, la
|
|
190
199
|
// respuesta se caía por un motivo concreto —un aparato sin sobre en su cajón, por
|
|
@@ -192,7 +201,12 @@ async function cmdRun () {
|
|
|
192
201
|
// dueño hubiera aprobado cada una. Un reintento que no dice su causa es un fallback: le
|
|
193
202
|
// pone cara de espera a un error. (Ver CLAUDE.md, «nada de repliegues».)
|
|
194
203
|
const onRetry = (e, ms) => console.error('[dotrino-env] failed: %s — retrying in %ds', e.message, Math.round(ms / 1000))
|
|
195
|
-
|
|
204
|
+
// `--public`: SOLO las variables públicas, y por eso no hace sonar el teléfono de nadie.
|
|
205
|
+
// La aprobación existe para soltar claves privadas; una pública está guardada en claro y
|
|
206
|
+
// ya la ve quien administra. Es lo que quiere un arranque que solo necesita configuración
|
|
207
|
+
// —una URL, un puerto— y no tiene por qué despertar al dueño.
|
|
208
|
+
const publicOnly = argv.includes('--public')
|
|
209
|
+
const { injected, overridden } = await loadEnv({ ns: flag('ns'), vault: etiquetaBoveda(), onPending, onRetry, publicOnly })
|
|
196
210
|
console.error('[dotrino-env] %d valor(es) en el entorno de %s%s', injected.length, cmd[0],
|
|
197
211
|
overridden.length ? ` (pisados: ${overridden.join(', ')})` : '')
|
|
198
212
|
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.
|
|
3
|
+
"version": "0.49.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/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
|
|
150
|
-
//
|
|
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
|
-
|
|
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 (_) {}
|