@dotrino/vault 0.13.0 → 0.15.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,61 @@ 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
+ ### Un agente tiene UNA identidad, y se la da el vault
101
+
102
+ Un **aparato** puede llevar varios perfiles, y hasta meter su propia cuenta al vault
103
+ por adopción: llega con una historia que conservar. Un **agente** no es eso. Es un
104
+ servicio: su identidad se la cede el vault y no hay caso en que quiera empujar la
105
+ suya hacia arriba. De ahí tres reglas, que el paquete aplica solas:
106
+
107
+ - **No adopta, nunca.** La invitación declara su modo (`join` / `adopt`); si viene
108
+ abierta para adoptar, `enrollService` la **rechaza al pegarla**, sin salir a la red.
109
+ La intención `join` va además firmada dentro de la petición, para que nadie en el
110
+ medio la convierta en otra cosa.
111
+ - **No acumula.** Enrolar de nuevo **reemplaza** la identidad anterior, que deja de
112
+ existir en ese agente. No es un error a desbloquear con `--force`: es la forma de
113
+ **rotar** la identidad de un agente comprometido. Se avisa por `onReplace` qué se
114
+ descarta.
115
+ - **Un agente, un `ns`.** Varios agentes pueden convivir en una máquina (un
116
+ directorio por namespace); lo que no existe es un agente que sea varios.
117
+
118
+ > ⚠ Si esa llave es además la **identidad de red** del servicio —el caso del proxio,
119
+ > cuyo id de nodo se deriva de ella— reemplazarla le cambia el nombre en la red: las
120
+ > instancias y citas vivas dejan de resolver y los peers que lo tenían pineado lo
121
+ > rechazan hasta re-pinearlo. Es a propósito: así se echa a un nodo comprometido.
122
+
123
+ ### Precedencia: el vault MANDA
124
+
125
+ Los valores del vault **pisan** los del `.env` y los del entorno. El vault no
126
+ reemplaza al `.env` —que sigue siendo lo que arranca una máquina sin enrolar— pero
127
+ sí tiene la última palabra sobre las claves que administra.
128
+
129
+ Esa es la pieza que hace barata la **rotación**: cambias el valor en un solo lugar y
130
+ ningún `.env` viejo olvidado en un VPS puede seguir ganando. Con la precedencia al
131
+ revés (como estaba hasta la 0.14.0) rotar exigía además ir a limpiar cada copia
132
+ rancia —el trabajo que se quería evitar— y, peor, el servicio arrancaba con la llave
133
+ vieja **sin decir nada**.
134
+
135
+ Lo que sí se dice en voz alta: al arrancar se listan las claves que el vault tuvo que
136
+ pisar. Es la señal de que en esa máquina quedó un `.env` por limpiar.
137
+
138
+ ```bash
139
+ DOTRINO_ENV_OVERRIDE=0 node server.js # escotilla: por esta corrida, gana el entorno
140
+ dotrino-env check # dice qué claves pisaría en esta máquina
141
+ ```
142
+
100
143
  ### API `@dotrino/vault/env`
101
144
 
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` )
145
+ - `loadEnv({ ns?, dir?, override?, wait?, required?, onRetry? }) → { ns, secrets, injected, overridden, skipped }`
146
+ (por defecto **pisa** lo que ya esté en el entorno; `override: false` invierte la regla).
147
+ `overridden` son las claves que tenían otro valor y el vault reemplazó.
148
+ - `applyEnv(secrets, override?) → { injected, overridden, skipped }` — vuelca un bundle
149
+ ya obtenido, sin pedirlo. Para servicios que **no pueden bloquear su arranque**
150
+ esperando al vault y lo aplican cuando llega (el caso del proxy: el vault le habla
151
+ *por* el proxy, así que esperarlo sería un abrazo mortal).
104
152
  - `serviceDir(ns)`, `serviceRoot()`, `listEnrolled()`, `resolveNs(ns?)`
105
- - Entorno: `DOTRINO_NS` · `DOTRINO_ENV_DIR` · `DOTRINO_ENV_HOME` · `DOTRINO_ENV_QUIET`
153
+ - Entorno: `DOTRINO_NS` · `DOTRINO_ENV_DIR` · `DOTRINO_ENV_HOME` · `DOTRINO_ENV_QUIET` ·
154
+ `DOTRINO_ENV_OVERRIDE`
106
155
  - CLI: `dotrino-env enroll|status|check|run` (`check` lista **nombres** de secretos, nunca valores)
107
156
 
108
157
  Bajo el capó es `@dotrino/vault/service` (`enrollService` / `waitForSecrets`): petición
@@ -16,12 +16,16 @@ 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 }
24
- const has = (name) => argv.includes('--' + name)
25
29
 
26
30
  function help () {
27
31
  console.log(`dotrino-env — credenciales del vault en vez del .env
@@ -40,7 +44,11 @@ En tu código:
40
44
  import '@dotrino/vault/config' // ns por DOTRINO_NS
41
45
  import { loadEnv } from '@dotrino/vault/env'; await loadEnv({ ns: '<ns>' })
42
46
 
43
- Entorno: DOTRINO_NS · DOTRINO_ENV_DIR · DOTRINO_ENV_HOME · DOTRINO_ENV_QUIET`)
47
+ El vault MANDA: sus valores pisan los del .env y los del entorno. Para una
48
+ corrida suelta sin que pise nada: DOTRINO_ENV_OVERRIDE=0
49
+
50
+ Entorno: DOTRINO_NS · DOTRINO_ENV_DIR · DOTRINO_ENV_HOME · DOTRINO_ENV_QUIET
51
+ DOTRINO_ENV_OVERRIDE`)
44
52
  }
45
53
 
46
54
  /**
@@ -66,9 +74,6 @@ async function cmdEnroll () {
66
74
  const ns = flag('ns')
67
75
  if (!ns) { console.error('falta --ns <ns> (el mismo del `dotrino-vault pair --service <ns>`)'); process.exit(2) }
68
76
  const dir = flag('dir') || serviceDir(ns)
69
- if (readServiceIdentity(dir) && !has('force')) {
70
- console.error('ya hay un servicio enrolado en %s (usa --force para re-enrolar)', dir); process.exit(2)
71
- }
72
77
  const qr = parseInvite(flag('qr') || await readInvite())
73
78
 
74
79
  console.log('\nEnrolando el servicio "%s" contra el vault…', ns)
@@ -77,6 +82,17 @@ async function cmdEnroll () {
77
82
  ns,
78
83
  dir,
79
84
  label: flag('label') || 'servicio:' + ns,
85
+ // Un agente tiene UNA identidad y se la da el vault: re-enrolar REEMPLAZA,
86
+ // no acumula. Antes había una reja (`--force`) que hacía de esto un error a
87
+ // desbloquear; sobra, porque no existe la alternativa de "quedarse con las
88
+ // dos". Lo que sí corresponde es que se vea qué se está tirando.
89
+ onReplace: (prev) => {
90
+ console.log('\n ⚠ Este agente YA tenía identidad (dispositivo %s, del %s).',
91
+ prev.deviceId, new Date(prev.enrolledAt).toISOString().slice(0, 10))
92
+ console.log(' Se DESCARTA: el vault le cede una nueva y la anterior deja de existir aquí.')
93
+ console.log(' Si esa llave era además la identidad de red del servicio (el caso del')
94
+ console.log(' proxio), su id de nodo cambia y sus peers lo rechazan hasta re-pinearlo.\n')
95
+ },
80
96
  onCode: ({ deviceId, code }) => {
81
97
  console.log('\n Dispositivo: %s', deviceId)
82
98
  console.log(' APRUEBA en el vault tipeando este código:\n')
@@ -106,17 +122,27 @@ function cmdStatus () {
106
122
 
107
123
  async function cmdCheck () {
108
124
  const ns = resolveNs(flag('ns'))
109
- const { secrets } = await loadEnv({ ns, wait: false })
125
+ // `fetchSecrets` y no `loadEnv`: listar NO debe tener efectos secundarios. Con
126
+ // `loadEnv` esto inyectaría el bundle en el entorno del propio `check`, que es
127
+ // justo lo que un comando de diagnóstico no tiene por qué hacer.
128
+ const secrets = await fetchSecrets({ dir: serviceDir(ns), ns })
110
129
  const keys = Object.keys(secrets)
111
130
  console.log('ns "%s": %d secreto(s)%s', ns, keys.length, keys.length ? ':' : '')
112
131
  for (const k of keys) console.log(' ' + k) // NUNCA los valores
132
+ // Delata el `.env` rancio: qué claves de esta máquina el vault pisaría.
133
+ const chocan = keys.filter((k) => k in process.env && process.env[k] !== String(secrets[k]))
134
+ if (chocan.length) {
135
+ console.log('\nEl vault PISA estos valores del entorno de esta máquina:\n %s', chocan.join(', '))
136
+ }
113
137
  }
114
138
 
115
139
  async function cmdRun () {
116
140
  const sep = argv.indexOf('--')
117
141
  const cmd = sep >= 0 ? argv.slice(sep + 1) : []
118
142
  if (!cmd.length) { console.error('uso: dotrino-env run [--ns <ns>] -- <cmd> [args…]'); process.exit(2) }
119
- await loadEnv({ ns: flag('ns') })
143
+ const { injected, overridden } = await loadEnv({ ns: flag('ns') })
144
+ console.error('[dotrino-env] %d valor(es) en el entorno de %s%s', injected.length, cmd[0],
145
+ overridden.length ? ` (pisados: ${overridden.join(', ')})` : '')
120
146
  const child = spawn(cmd[0], cmd.slice(1), { stdio: 'inherit', env: process.env })
121
147
  child.on('exit', (code, signal) => process.exit(signal ? 1 : (code ?? 0)))
122
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.13.0",
3
+ "version": "0.15.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
  }
package/src/service.js CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  } from '@dotrino/identity/capabilities'
27
27
  import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
28
28
  import { makeEphemeralKey, openSealed } from './sealed.js'
29
+ import { parseInvite } from './invite.js'
29
30
 
30
31
  const IDENTITY_FILE = 'service-identity.json'
31
32
  const FRESH_WINDOW_MS = 5 * 60 * 1000
@@ -74,9 +75,36 @@ async function verificarHola (p, sn) {
74
75
  if (!(await verifyDeviceSig({ publickey: b.iss, data: b, signature: p.signature }))) {
75
76
  throw new Error('la respuesta de la bóveda no está bien firmada')
76
77
  }
78
+ // El modo también viene aquí, y aquí viene FIRMADO por la bóveda. Se comprueba
79
+ // de nuevo aunque ya se haya mirado el del QR: en la forma corta el QR es un
80
+ // código que pasó por manos ajenas, y esta es la primera vez que la bóveda
81
+ // dice de su puño y letra qué se propone hacer.
82
+ rechazarAdopcion(b.m)
77
83
  return b
78
84
  }
79
85
 
86
+ /**
87
+ * UN AGENTE NUNCA TRANSFIERE SU IDENTIDAD: el vault propone, el agente acepta.
88
+ *
89
+ * El emparejamiento tiene dos modos y los declara la bóveda en la invitación:
90
+ * `join` (el que se enrola entra a la cuenta de la bóveda) y `adopt` (la bóveda
91
+ * se queda con la cuenta que trae el aparato). El segundo existe para APARATOS,
92
+ * que llegan con una cuenta propia y una historia que conservar.
93
+ *
94
+ * Un agente no tiene nada de eso: es un servicio, su identidad se la da el vault
95
+ * y no hay caso en que quiera empujar la suya hacia arriba. Así que este camino
96
+ * no se negocia, se rechaza — y se rechaza ACÁ, cuando el humano pega la
97
+ * invitación, en vez de dejar que el viaje termine en un «intent-mismatch» del
98
+ * otro lado que no le explica nada a nadie.
99
+ */
100
+ function rechazarAdopcion (modo) {
101
+ if (modo !== 'adopt') return
102
+ throw new Error(
103
+ 'esta invitación se abrió para ADOPTAR la cuenta del aparato, y un agente no transfiere su identidad: ' +
104
+ 'la suya se la cede el vault. Abre el emparejamiento sin `--adopt` (`dotrino-vault pair --service <ns>`).'
105
+ )
106
+ }
107
+
80
108
  /**
81
109
  * Canjea la cita del QR y devuelve la instancia a la que apunta.
82
110
  *
@@ -153,26 +181,68 @@ function writeServiceIdentity (dir, obj) {
153
181
  }
154
182
 
155
183
  /**
156
- * Enrola ESTE servicio contra el vault (una sola vez; persiste la identidad).
157
- * En el vault se corre antes `dotrino-vault pair --service <ns>`; el QR/payload
158
- * de ese comando es el `qr` de aquí. Muestra un código por `onCode` (o stdout):
159
- * el dueño lo tipea en el vault (`dotrino-vault approve <código>`).
184
+ * Enrola ESTE servicio contra el vault y persiste su identidad.
185
+ *
186
+ * UN AGENTE TIENE UNA SOLA IDENTIDAD, Y SE LA DA EL VAULT. A diferencia de un
187
+ * aparato —que puede llevar varios perfiles y hasta meter su cuenta al vault por
188
+ * adopción—, un agente no acumula identidades ni transfiere la suya: se enrola,
189
+ * el vault le cede una (llave propia + cert de la maestra) y **la anterior, si
190
+ * había, se descarta**. No hay fusión ni convivencia, y no hace falta: un agente
191
+ * es un servicio, no una persona; no tiene por qué "ser varios".
192
+ *
193
+ * Enrolar dos veces, entonces, no es un error a bloquear sino un REEMPLAZO — que
194
+ * es además la forma de rotar la identidad de un agente comprometido. Lo que sí
195
+ * hace falta es que se vea: se avisa por `onReplace` qué identidad se tira.
196
+ *
197
+ * En el vault se corre antes `dotrino-vault pair --service <ns>`; la invitación
198
+ * que imprime ese comando es el `qr` de aquí (en cualquiera de sus formas).
199
+ * Muestra un código por `onCode`: el dueño lo tipea en el vault
200
+ * (`dotrino-vault approve <código>`).
160
201
  *
161
202
  * @param {Object} opts
162
- * @param {{v:number, iss:string, proxy:string, token:string, sn:string}|string} opts.qr QR v2 (objeto o JSON string).
203
+ * @param {object|string} opts.qr La invitación: objeto, URL del QR o código pegado.
163
204
  * @param {string} opts.ns Namespace de secretos del servicio (el mismo del pair).
164
205
  * @param {string} opts.dir Dónde persistir `service-identity.json`.
165
206
  * @param {string} [opts.label]
166
207
  * @param {(c:{deviceId:string, code:string})=>void} [opts.onCode]
167
- * @returns {Promise<{device, cert, iss:string}>}
208
+ * @param {(prev:{ns:string, enrolledAt:number, deviceId:string})=>void} [opts.onReplace]
209
+ * Se llama ANTES de enrolar si ya había una identidad: la que va a descartarse.
210
+ * @returns {Promise<{device, cert, iss:string, replaced:object|null}>}
168
211
  */
169
- export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeoutMs = 180000 } = {}) {
170
- if (typeof qr === 'string') { try { qr = JSON.parse(qr) } catch (_) { throw new Error('qr inválido: no es JSON') } }
212
+ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, approveTimeoutMs = 180000 } = {}) {
213
+ // `parseInvite` y NO `JSON.parse`: el vault no imprime JSON desde hace rato.
214
+ // `dotrino-vault pair --service` emite la URL del QR y el código compacto
215
+ // (`c…`/`t…`, ver invite.js), así que un `JSON.parse` fallaba SIEMPRE con
216
+ // «qr inválido: no es JSON» y el enrolamiento de un servicio era imposible por
217
+ // este camino. Lo tapaba que el único servicio enrolado del ecosistema lo hizo
218
+ // cuando el formato todavía era JSON. `parseInvite` acepta todas las formas,
219
+ // incluida la vieja, así que esto entiende cualquier invitación.
220
+ if (typeof qr === 'string') {
221
+ const o = parseInvite(qr)
222
+ if (!o) throw new Error('eso no parece una invitación del vault (pega la salida de `dotrino-vault pair --service <ns>`)')
223
+ qr = o
224
+ }
171
225
  if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('qr inválido: falta la bóveda o el nonce')
226
+ rechazarAdopcion(qr.m)
172
227
  if (!isValidSecretsNs(ns)) throw new Error('ns inválido (usa [a-z0-9-]{1,32}, p.ej. "proxy")')
173
228
  if (!dir) throw new Error('falta dir (dónde persistir la identidad del servicio)')
174
229
  label = label || 'servicio:' + ns
175
230
 
231
+ // La identidad que va a quedar descartada. Se avisa antes de tocar nada: para
232
+ // el proxy, por ejemplo, esta llave es además su identidad de red, así que
233
+ // reemplazarla le cambia el id de nodo y sus peers dejan de reconocerlo hasta
234
+ // que se re-pineen a mano.
235
+ const anterior = readServiceIdentity(dir)
236
+ let replaced = null
237
+ if (anterior?.device?.publickey) {
238
+ replaced = {
239
+ ns: anterior.ns,
240
+ enrolledAt: anterior.enrolledAt,
241
+ deviceId: (await pubkeyId(anterior.device.publickey)).slice(0, 8).toUpperCase()
242
+ }
243
+ try { onReplace?.(replaced) } catch (_) {}
244
+ }
245
+
176
246
  const client = await freshClient(qr.proxy || 'wss://proxy.dotrino.com')
177
247
  // QR CORTO: se le pregunta a la bóveda quién es, punto a punto, presentando el `sn`.
178
248
  if (!qr.iss) {
@@ -201,7 +271,11 @@ export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeou
201
271
  // El COMPROMISO del código (nunca el código): la bóveda lo recompone con lo que
202
272
  // tipeas y solo entonces firma el cert → aprobar exige haber leído esta pantalla.
203
273
  const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
204
- const data = { op: 'enroll', dpub: device.publickey, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now() }
274
+ // `intent: 'join'` EXPLÍCITO. La bóveda lo compara con el modo que abrió y,
275
+ // si falta, asume `join` — pero un agente no debe apoyarse en un default
276
+ // para algo que decide de quién es la cuenta. Yendo dentro de `data`, viaja
277
+ // firmado: nadie en el medio puede convertirlo en una adopción.
278
+ const data = { op: 'enroll', intent: 'join', dpub: device.publickey, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now() }
205
279
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
206
280
 
207
281
  const enrolled = new Promise((resolve, reject) => {
@@ -225,8 +299,10 @@ export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeou
225
299
  if (!v.ok) throw new Error('cert inválido: ' + v.reason)
226
300
  if (res.cert.iss !== qr.iss) throw new Error('cert firmado por una maestra distinta a la del QR')
227
301
 
302
+ // Reemplazo, no acumulación: el archivo se sobrescribe entero y la identidad
303
+ // anterior deja de existir en este agente.
228
304
  writeServiceIdentity(dir, { v: 1, ns, iss: qr.iss, proxy: qr.proxy, device, cert: res.cert, enrolledAt: Date.now() })
229
- return { device, cert: res.cert, iss: qr.iss }
305
+ return { device, cert: res.cert, iss: qr.iss, replaced }
230
306
  } finally { client.close() }
231
307
  }
232
308