@dotrino/vaultd 0.26.2 → 0.46.2
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 +145 -25
- package/bin/dotrino-vaultd.js +4 -4
- package/lib/README.md +13 -2
- package/lib/src/admin.js +92 -3
- package/lib/src/atrest.js +0 -0
- package/lib/src/config.js +1 -1
- package/lib/src/enroll.js +38 -25
- package/lib/src/env.js +37 -17
- package/lib/src/envtext.js +94 -0
- package/lib/src/index.js +4 -4
- package/lib/src/invite.js +8 -8
- package/lib/src/protocol.js +22 -0
- package/lib/src/service.js +497 -135
- package/package.json +11 -6
- package/src/ctl.js +639 -98
- package/src/daemon.js +321 -52
- package/src/manager.js +12 -6
- package/src/profiles.js +109 -13
- package/src/sealKey.js +80 -0
- package/src/sealer.js +170 -0
- package/src/secretsStore.js +881 -29
- package/src/store.js +3 -1
- package/src/transport.js +2 -2
- package/src/tui/app.js +625 -129
- package/src/tui/i18n.js +145 -24
- package/src/vault.js +972 -48
- package/src/vaultControl.js +286 -61
package/lib/src/env.js
CHANGED
|
@@ -32,7 +32,7 @@ export function serviceRoot () {
|
|
|
32
32
|
/** Directorio de la identidad del servicio `ns` (`DOTRINO_ENV_DIR` lo pisa). */
|
|
33
33
|
export function serviceDir (ns) {
|
|
34
34
|
if (process.env.DOTRINO_ENV_DIR) return process.env.DOTRINO_ENV_DIR
|
|
35
|
-
if (!isValidSecretsNs(ns)) throw new Error('ns
|
|
35
|
+
if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "myapp")')
|
|
36
36
|
return path.join(serviceRoot(), ns)
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -50,15 +50,15 @@ export function listEnrolled () {
|
|
|
50
50
|
export function resolveNs (ns) {
|
|
51
51
|
ns = ns || process.env.DOTRINO_NS
|
|
52
52
|
if (ns) {
|
|
53
|
-
if (!isValidSecretsNs(ns)) throw new Error('ns
|
|
53
|
+
if (!isValidSecretsNs(ns)) throw new Error('invalid ns: ' + ns)
|
|
54
54
|
return ns
|
|
55
55
|
}
|
|
56
56
|
const found = listEnrolled()
|
|
57
57
|
if (found.length === 1) return found[0]
|
|
58
58
|
if (found.length === 0) {
|
|
59
|
-
throw new Error('no
|
|
59
|
+
throw new Error('no service enrolled on this machine: run `npx dotrino-env enroll --ns <your-app>`')
|
|
60
60
|
}
|
|
61
|
-
throw new Error(`
|
|
61
|
+
throw new Error(`several services are enrolled (${found.join(', ')}): pick one with DOTRINO_NS=<ns> or loadEnv({ ns })`)
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|
|
@@ -81,6 +81,9 @@ function overrideByDefault () {
|
|
|
81
81
|
return process.env.DOTRINO_ENV_OVERRIDE !== '0'
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** El último bundle que `applyEnv` puso a correr en este proceso (ver `watchEnv`). */
|
|
85
|
+
let lastApplied = null
|
|
86
|
+
|
|
84
87
|
/**
|
|
85
88
|
* Trae los secretos del ns desde el vault y los pone en `process.env`.
|
|
86
89
|
*
|
|
@@ -104,7 +107,7 @@ export async function loadEnv ({ ns, dir, override, wait = true, required = [],
|
|
|
104
107
|
|
|
105
108
|
const missing = required.filter((k) => !(k in secrets))
|
|
106
109
|
if (missing.length) {
|
|
107
|
-
throw new Error(`
|
|
110
|
+
throw new Error(`missing secrets in ns "${ns}": ${missing.join(', ')} (add them with \`dotrino-vault secret set ${ns} <KEY> <value>\`)`)
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
return { ns, secrets, ...applyEnv(secrets, override) }
|
|
@@ -130,17 +133,21 @@ export async function loadEnv ({ ns, dir, override, wait = true, required = [],
|
|
|
130
133
|
* @returns {{injected:string[], overridden:string[], skipped:string[]}}
|
|
131
134
|
*/
|
|
132
135
|
export function applyEnv (secrets, override = overrideByDefault()) {
|
|
136
|
+
// Lo último que este proceso puso a correr. `watchEnv` lo toma como referencia para
|
|
137
|
+
// comparar, así que cualquier agente que use `loadEnv`/`applyEnv` —o sea, todos—
|
|
138
|
+
// queda protegido del aviso perdido sin cablear nada.
|
|
139
|
+
lastApplied = { ...(secrets || {}) }
|
|
133
140
|
const injected = []
|
|
134
141
|
const overridden = []
|
|
135
142
|
const skipped = []
|
|
136
143
|
for (const [k, v] of Object.entries(secrets || {})) {
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
if (!override &&
|
|
140
|
-
const
|
|
141
|
-
process.env[k] =
|
|
144
|
+
const previous = process.env[k]
|
|
145
|
+
const had = k in process.env
|
|
146
|
+
if (!override && had) { skipped.push(k); continue }
|
|
147
|
+
const value = String(v)
|
|
148
|
+
process.env[k] = value
|
|
142
149
|
injected.push(k)
|
|
143
|
-
if (
|
|
150
|
+
if (had && previous !== value) overridden.push(k)
|
|
144
151
|
}
|
|
145
152
|
return { injected, overridden, skipped }
|
|
146
153
|
}
|
|
@@ -169,10 +176,22 @@ export function applyEnv (secrets, override = overrideByDefault()) {
|
|
|
169
176
|
* el supervisor ya trae backoff y tope de intentos, que es justo lo que evita que
|
|
170
177
|
* una configuración rota se convierta en un ciclo.
|
|
171
178
|
*
|
|
179
|
+
* No depende solo del aviso: al conectar COMPARA su configuración con la de la bóveda
|
|
180
|
+
* (`watchSecretsChanges`), porque un agente incomunicado se pierde el aviso y antes se
|
|
181
|
+
* quedaba con lo viejo para siempre.
|
|
182
|
+
*
|
|
183
|
+
* OJO con lo que se compara: el bundle de la bóveda contra el bundle de la bóveda,
|
|
184
|
+
* nunca contra el `.env`. Recibir la configuración por primera vez —tarde, que es como
|
|
185
|
+
* la recibe el proxio— **no** es un cambio, y por eso esto no puede convertirse en un
|
|
186
|
+
* ciclo de reinicios.
|
|
187
|
+
*
|
|
172
188
|
* @param {Object} [opts]
|
|
173
189
|
* @param {string} [opts.ns]
|
|
174
190
|
* @param {string} [opts.dir]
|
|
175
|
-
* @param {
|
|
191
|
+
* @param {Record<string,string>} [opts.applied] Lo que este proceso tiene EN USO. Por
|
|
192
|
+
* defecto, lo último que pasó por `applyEnv` en este proceso — o sea, lo que puso a
|
|
193
|
+
* correr `loadEnv`.
|
|
194
|
+
* @param {(info:{ns:string, ts:number, reason:'changed'|'revoked', via?:'notice'|'reconcile'})=>void} [opts.onUpdate]
|
|
176
195
|
* Reemplaza la salida por defecto. Úsalo cuando terminar el proceso no sea una
|
|
177
196
|
* opción — el caso del proxio, cuyo reinicio corta el transporte de todos.
|
|
178
197
|
* @param {number} [opts.exitCode=0] Salida LIMPIA: systemd con `Restart=on-failure`
|
|
@@ -180,27 +199,28 @@ export function applyEnv (secrets, override = overrideByDefault()) {
|
|
|
180
199
|
* también. Se elige 0 porque salir a propósito no es un fallo.
|
|
181
200
|
* @returns {Promise<{stop:()=>void}>}
|
|
182
201
|
*/
|
|
183
|
-
export async function watchEnv ({ ns, dir, onUpdate, exitCode = 0, quiet = false, ...
|
|
202
|
+
export async function watchEnv ({ ns, dir, applied = lastApplied ?? undefined, onUpdate, exitCode = 0, quiet = false, ...rest } = {}) {
|
|
184
203
|
ns = resolveNs(ns)
|
|
185
204
|
dir = dir || serviceDir(ns)
|
|
186
205
|
const say = (m) => { if (!quiet) console.error(m) }
|
|
187
206
|
|
|
188
207
|
const exitNow = (reason) => {
|
|
189
208
|
say(`[dotrino-env] ${reason === 'revoked'
|
|
190
|
-
? '
|
|
191
|
-
: '
|
|
209
|
+
? 'the vault REVOKED this agent: exiting (it will not start again)'
|
|
210
|
+
: 'new config in the vault: exiting so the supervisor brings it back clean'}`)
|
|
192
211
|
process.exit(reason === 'revoked' ? 1 : exitCode)
|
|
193
212
|
}
|
|
194
213
|
|
|
195
214
|
return watchSecretsChanges({
|
|
196
215
|
dir,
|
|
197
216
|
ns,
|
|
217
|
+
applied,
|
|
198
218
|
log: say,
|
|
199
|
-
onChange: ({ ts }) => (onUpdate ? onUpdate({ ns, ts, reason: 'changed' }) : exitNow('changed')),
|
|
219
|
+
onChange: ({ ts, via }) => (onUpdate ? onUpdate({ ns, ts, reason: 'changed', via }) : exitNow('changed')),
|
|
200
220
|
// Un cert revocado sale con código de FALLO a propósito: si el supervisor lo
|
|
201
221
|
// levanta, va a morir otra vez al no poder leer sus secretos, y el contador de
|
|
202
222
|
// reinicios fallidos es lo que hace que se note en vez de girar en silencio.
|
|
203
223
|
onRevoked: () => (onUpdate ? onUpdate({ ns, ts: Date.now(), reason: 'revoked' }) : exitNow('revoked')),
|
|
204
|
-
...
|
|
224
|
+
...rest
|
|
205
225
|
})
|
|
206
226
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lector de `.env` — el formato en el que la gente YA tiene la configuración de un
|
|
3
|
+
* servicio, y por lo tanto la forma natural de cargarla entera de una vez.
|
|
4
|
+
*
|
|
5
|
+
* Existe aquí, en la lib pura, porque lo usan los tres sitios desde los que se cargan
|
|
6
|
+
* variables: el CLI (`secret import`), la TUI y la consola remota (pegar el bloque en
|
|
7
|
+
* la web). Tres lectores distintos serían tres formatos distintos.
|
|
8
|
+
*
|
|
9
|
+
* POR QUÉ ESTO IMPORTA MÁS DE LO QUE PARECE: cada variable guardada suelta es, para la
|
|
10
|
+
* bóveda, un cambio de configuración, y el servicio obedece el primero —sale y lo
|
|
11
|
+
* levanta su supervisor— mientras el dueño sigue tecleando las demás. Cargarlas juntas
|
|
12
|
+
* es lo que hace que el servicio se reinicie UNA vez, con todo puesto.
|
|
13
|
+
*
|
|
14
|
+
* Los errores salen como CÓDIGOS, no como frases: quien llama los traduce (el CLI en
|
|
15
|
+
* español, la consola en los dos idiomas).
|
|
16
|
+
*/
|
|
17
|
+
import { isValidVarKey } from './protocol.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `CLAVE=valor` — la clave no lleva espacios ni `=`; el valor puede llevar de todo. Se
|
|
21
|
+
* toleran los espacios alrededor del `=` porque un `.env` escrito a mano los trae, y
|
|
22
|
+
* rechazar un archivo entero por eso sería quisquilloso sin ganar nada.
|
|
23
|
+
*/
|
|
24
|
+
export const PAIR_RE = /^([^=\s]+)\s*=\s*([\s\S]*)$/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} text
|
|
28
|
+
* @returns {{items: Array<{op:'set', key:string, value:string}>,
|
|
29
|
+
* errors: Array<{code:'shape'|'dup'|'key'|'novalue'|'empty', line?:number, key?:string, first?:number}>}}
|
|
30
|
+
*/
|
|
31
|
+
export function parseEnvText (text) {
|
|
32
|
+
/** @type {Array<{op:'set', key:string, value:string}>} */
|
|
33
|
+
const items = []
|
|
34
|
+
/** @type {Array<{code:'shape'|'dup'|'key'|'novalue'|'empty', line?:number, key?:string, first?:number}>} */
|
|
35
|
+
const errors = []
|
|
36
|
+
const seen = new Map()
|
|
37
|
+
const lines = String(text || '').split(/\r?\n/)
|
|
38
|
+
|
|
39
|
+
lines.forEach((raw, idx) => {
|
|
40
|
+
const line = idx + 1
|
|
41
|
+
const trimmed = raw.trim()
|
|
42
|
+
// Línea vacía o comentario entero. Un `#` a MITAD de línea NO se corta: una
|
|
43
|
+
// contraseña puede llevarlo, y recortar el valor ahí lo estropea en silencio —que
|
|
44
|
+
// en un secreto significa un servicio que no levanta y nadie sabe por qué.
|
|
45
|
+
if (!trimmed || trimmed.startsWith('#')) return
|
|
46
|
+
const m = PAIR_RE.exec(trimmed.replace(/^export\s+/, ''))
|
|
47
|
+
if (!m) return errors.push({ code: 'shape', line })
|
|
48
|
+
|
|
49
|
+
const key = m[1]
|
|
50
|
+
const value = unquote(m[2].trim())
|
|
51
|
+
if (!isValidVarKey(key)) return errors.push({ code: 'key', line, key })
|
|
52
|
+
if (!value) return errors.push({ code: 'novalue', line, key })
|
|
53
|
+
// Repetida = casi siempre un pegado a medias. Adivinar cuál de las dos quería el
|
|
54
|
+
// dueño no es asunto de un lector de configuración.
|
|
55
|
+
if (seen.has(key)) return errors.push({ code: 'dup', line, key, first: seen.get(key) })
|
|
56
|
+
seen.set(key, line)
|
|
57
|
+
items.push({ op: 'set', key, value })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
if (!items.length && !errors.length) errors.push({ code: 'empty' })
|
|
61
|
+
return { items, errors }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Lo mismo, pero aceptando que todo venga en UNA línea (`K=v K2=v2`), que es lo que se
|
|
66
|
+
* puede escribir en un campo de una sola línea como el de la TUI. Un valor con espacios
|
|
67
|
+
* va entre comillas, igual que en la shell.
|
|
68
|
+
*/
|
|
69
|
+
export function parseEnvInput (text) {
|
|
70
|
+
const s = String(text || '')
|
|
71
|
+
return parseEnvText(/\r?\n/.test(s) ? s : tokenize(s).join('\n'))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Parte por espacios, pero no dentro de comillas. */
|
|
75
|
+
function tokenize (line) {
|
|
76
|
+
const out = []
|
|
77
|
+
let cur = ''
|
|
78
|
+
let quote = null
|
|
79
|
+
for (const ch of line) {
|
|
80
|
+
if (quote) { cur += ch; if (ch === quote) quote = null; continue }
|
|
81
|
+
if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue }
|
|
82
|
+
if (/\s/.test(ch)) { if (cur) { out.push(cur); cur = '' } ; continue }
|
|
83
|
+
cur += ch
|
|
84
|
+
}
|
|
85
|
+
if (cur) out.push(cur)
|
|
86
|
+
return out
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Quita las comillas de FUERA: un `.env` las usa cuando el valor lleva espacios. */
|
|
90
|
+
function unquote (v) {
|
|
91
|
+
const q = v[0]
|
|
92
|
+
if (v.length > 1 && (q === '"' || q === "'") && v.endsWith(q)) return v.slice(1, -1)
|
|
93
|
+
return v
|
|
94
|
+
}
|
package/lib/src/index.js
CHANGED
|
@@ -52,7 +52,7 @@ export { deviceIdOf }
|
|
|
52
52
|
*/
|
|
53
53
|
export async function startDeviceVault (identity, { proxyUrl, client: injectedClient } = {}) {
|
|
54
54
|
const iss = identity.me?.publickey
|
|
55
|
-
if (!iss) throw new Error('
|
|
55
|
+
if (!iss) throw new Error('no identity: create/unlock your identity before using this device as a vault')
|
|
56
56
|
const proxy = proxyUrl || 'wss://proxy.dotrino.com'
|
|
57
57
|
|
|
58
58
|
// ----- self-cert P ← P (para que este dispositivo pueda además actuar de cliente
|
|
@@ -133,7 +133,7 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
133
133
|
*/
|
|
134
134
|
async function handleRenew (from, p) {
|
|
135
135
|
const d = p?.data
|
|
136
|
-
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: '
|
|
136
|
+
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'invalid request' })
|
|
137
137
|
if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
|
|
138
138
|
return send(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
|
|
139
139
|
}
|
|
@@ -151,10 +151,10 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
|
|
|
151
151
|
// QUIEN consulta es una máquina ya revocada (reapareció), le re-emite el REVOKED firmado.
|
|
152
152
|
async function handleDevices (from, p) {
|
|
153
153
|
const d = p?.data
|
|
154
|
-
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: '
|
|
154
|
+
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'invalid request' })
|
|
155
155
|
if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) return
|
|
156
156
|
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, trustedIssuer: iss })
|
|
157
|
-
if (!chk.ok) return send(from, { type: MSG.ERROR, error: '
|
|
157
|
+
if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
158
158
|
const { issued, revoked, revokedCerts } = await identity.listDelegations()
|
|
159
159
|
const devices = await Promise.all((issued || []).map(async (x) => ({
|
|
160
160
|
deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null,
|
package/lib/src/invite.js
CHANGED
|
@@ -392,16 +392,16 @@ export function parseInvite (text) {
|
|
|
392
392
|
// el original.
|
|
393
393
|
const undoUrl = (s) => { try { return decodeURIComponent(s) } catch { return s } }
|
|
394
394
|
|
|
395
|
-
const
|
|
396
|
-
const
|
|
397
|
-
if (
|
|
398
|
-
if (
|
|
399
|
-
if (
|
|
400
|
-
if (
|
|
395
|
+
const tag = payload[0]
|
|
396
|
+
const rest = payload.slice(1)
|
|
397
|
+
if (tag === FMT_SHORT) { const o = shortDecode(rest); if (o) return o }
|
|
398
|
+
if (tag === FMT_COMPACT) { const o = compactDecode(rest); if (o) return o }
|
|
399
|
+
if (tag === FMT_JSON) { const o = parse(undoUrl(rest)) || parse(rest); if (o) return o }
|
|
400
|
+
if (tag === FMT_B64) { const s = b64urlDecodeStr(rest); const o = s && parse(s); if (o) return o }
|
|
401
401
|
|
|
402
402
|
// --- sin marca: formatos anteriores a la marca de formato (compatibilidad) ---
|
|
403
|
-
const
|
|
404
|
-
if (
|
|
403
|
+
const raw = undoUrl(payload)
|
|
404
|
+
if (raw.trimStart().startsWith('{')) return parse(raw)
|
|
405
405
|
const s = b64urlDecodeStr(payload)
|
|
406
406
|
return s ? parse(s) : null
|
|
407
407
|
}
|
package/lib/src/protocol.js
CHANGED
|
@@ -33,6 +33,11 @@ export const MSG = Object.freeze({
|
|
|
33
33
|
ACTA_SEALED: 'vault.acta.sealed', // dispositivo → vault: { acta, code }
|
|
34
34
|
ACTA_ADOPTED: 'vault.acta.adopted', // vault → dispositivo: { acta }
|
|
35
35
|
REVOKED: 'vault.revoked', // vault → dispositivo: { body:{op,sub,nonce,iat,exp}, signature }
|
|
36
|
+
// «¿sigo siendo de esta casa?» — la ÚNICA pregunta que se puede hacer SIN certificado:
|
|
37
|
+
// va firmada con la llave del propio aparato, que es lo que el acta nombra. Existe para
|
|
38
|
+
// el aparato que perdió su papel: sin ella no tiene forma de enterarse de que lo echaron.
|
|
39
|
+
CHECK: 'vault.check', // dispositivo → vault: { data:{op:'check',publickey,ts}, signature }
|
|
40
|
+
CHECKED: 'vault.checked', // vault → dispositivo: { in:boolean } — y si no, el REVOKED firmado
|
|
36
41
|
SIGN: 'vault.sign', // dispositivo → vault: { data, signature, cert }
|
|
37
42
|
SIGNED: 'vault.signed', // vault → dispositivo: { signature, publickey, device }
|
|
38
43
|
GET: 'vault.get', // dispositivo → vault: { data, signature, cert }
|
|
@@ -61,6 +66,16 @@ export const MSG = Object.freeze({
|
|
|
61
66
|
// Va FIRMADO por la maestra y el agente lo verifica contra su `iss` pineada: un
|
|
62
67
|
// aviso de reinicio sin autenticar ES un ataque de denegación.
|
|
63
68
|
SECRETS_CHANGED: 'vault.secrets.changed', // vault → servicio: { body:{op,ns,ts}, signature }
|
|
69
|
+
// REPARTIR LA LLAVE DE UN CAJÓN A UN MIEMBRO NUEVO. Lo hace el SERVICIO y no la
|
|
70
|
+
// bóveda, porque la bóveda no puede abrir la llave sin la frase y el servicio ya la
|
|
71
|
+
// tiene: re-envolverla no le añade ningún poder. Ver `docs/secretos-sellados.md` §8.11.
|
|
72
|
+
//
|
|
73
|
+
// El servicio NO se fía de lo que le manden: comprueba la firma de la maestra, comprueba
|
|
74
|
+
// el ACTA que viene dentro (también firmada) y saca de ahí la llave pública del
|
|
75
|
+
// destinatario. Así, ni siquiera una bóveda comprometida puede hacerle envolver la
|
|
76
|
+
// llave para alguien que la maestra no haya metido en su propio cajón.
|
|
77
|
+
REWRAP: 'vault.rewrap', // vault → servicio: { body:{op,owner,gen,wrap,target,acta,ts}, signature }
|
|
78
|
+
REWRAP_OK: 'vault.rewrap.ok', // servicio → vault: { data:{op,owner,gen,target,wrap,ts}, signature, cert }
|
|
64
79
|
// --- CONSOLA REMOTA (docs/consola-remota.md) — requiere cert `vault:admin` ---
|
|
65
80
|
// Un solo mensaje con `data.op`: pending · pair · approve · reject · revoke · audit.
|
|
66
81
|
// Admitir y expulsar, nada más: cambiar permisos, traspasar el mando y los secretos
|
|
@@ -93,3 +108,10 @@ export const SCOPE = Object.freeze({
|
|
|
93
108
|
export const SECRETS_SCOPE_PREFIX = 'vault:secrets:'
|
|
94
109
|
export const secretsScope = (ns) => SECRETS_SCOPE_PREFIX + ns
|
|
95
110
|
export const isValidSecretsNs = (ns) => typeof ns === 'string' && /^[a-z0-9-]{1,32}$/.test(ns)
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Nombre de una variable de entorno: `MAYUSCULAS_CON_GUION_BAJO`, hasta 64. Vive aquí
|
|
114
|
+
* —y no en el cajón que la guarda— porque la comprueban también la TUI, la consola
|
|
115
|
+
* remota y el lector de `.env`, y tres copias de una regla son tres reglas.
|
|
116
|
+
*/
|
|
117
|
+
export const isValidVarKey = (key) => typeof key === 'string' && /^[A-Z0-9_]{1,64}$/.test(key)
|