@dotrino/vaultd 0.131.5 → 0.132.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
@@ -239,10 +239,11 @@ dotrino-vault caps <ID> ±permiso # cambia lo que puede un dispositivo (+firma
239
239
  dotrino-vault revoke <nonce> # revoca un dispositivo (le ordena autoborrarse)
240
240
  dotrino-vault activity [n] # bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
241
241
  dotrino-vault pair --service <ns> # empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
242
- dotrino-vault pair --scope <lista> # los PERMISOS del cert: sign,read,store,secrets:<ns>. Sin esto, sign,read,store.
242
+ dotrino-vault pair --scope <lista> # los PERMISOS con los que entra (cualquiera de caps: firma,lee,guarda,administra,aprueba…
243
+ # y secrets:<ns>). Entra al acta ya con ellos, sin otra acta. Sin esto, firma,lee,guarda.
243
244
  dotrino-vault caps <ID> +permiso # ese aparato pide tu aprobación (teléfono) al recibir claves; pair --approval al enrolar
244
245
  # Se combina con --service: `--service eco --scope sign` = un bot que firma
245
- # como aparato del acta y lee SOLO su cajón. `admin` no se empareja (caps).
246
+ # como aparato del acta y lee SOLO su cajón.
246
247
  dotrino-vault secret set <ns> <CLAVE> <valor> # variable del SCOPE: la comparten todos los
247
248
  # aparatos que sirven ese namespace
248
249
  dotrino-vault secret set <ns> CLAVE=valor CLAVE2=valor2 … # VARIAS de una vez (un solo aviso)
package/lib/src/enroll.js CHANGED
@@ -30,7 +30,7 @@
30
30
  * `docs/pairing-protocol.md`).
31
31
  */
32
32
  import { verifyDeviceSig, pubkeyId, commitCode } from '@dotrino/identity/capabilities'
33
- import { verifyContinuity, canSeal } from '@dotrino/identity/acta'
33
+ import { verifyContinuity, canSeal, DEVICE_CAPS } from '@dotrino/identity/acta'
34
34
 
35
35
  /** Un token de emparejamiento vale 5 min. */
36
36
  export const PAIRING_TTL_MS = 5 * 60 * 1000
@@ -159,8 +159,24 @@ export function createEnrollDesk ({
159
159
  * · `account` → cómo se llama la cuenta de la bóveda, para nombrarla en el
160
160
  * aviso. Es ORIENTATIVO (un nombre que puso su dueño); la
161
161
  * identidad de verdad de la cuenta es `iss`.
162
+ * · `caps` → LOS PERMISOS con los que entra al acta, elegidos por quien
163
+ * empareja (dueño, 2026-09-26: «al emparejar debería escoger los
164
+ * permisos, caso contrario estoy obligado a hacer dos actas»). Sin
165
+ * esto el aparato entraba con lo que da el scope y cualquier otro
166
+ * permiso —`admin`, `approve`…— costaba una segunda acta. Se quedan
167
+ * AQUÍ, en la sesión de la bóveda: NO viajan en el QR, así que la
168
+ * invitación que circula no concede nada; lo concede quien teclea
169
+ * el código, igual que hacía `caps` después.
162
170
  */
163
- async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
171
+ async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '', caps = null } = {}) {
172
+ if (caps != null) {
173
+ if (!Array.isArray(caps)) throw Object.assign(new Error('caps must be a list'), { code: 'bad-caps' })
174
+ const cn = scopeToCn(scope)
175
+ const bad = caps.filter((c) => !(DEVICE_CAPS.includes(c) || (c === 'secrets' && cn)))
176
+ if (bad.length) throw Object.assign(new Error('unknown permission(s): ' + bad.join(', ')), { code: 'bad-caps' })
177
+ if (!caps.length) throw Object.assign(new Error('the pairing grants no permission'), { code: 'empty-scope' })
178
+ caps = [...new Set(caps)]
179
+ }
164
180
  pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
165
181
  const acct = String(account || '').slice(0, 40)
166
182
  // INVITACIÓN CORTA: si sabemos cómo alcanzarnos, el QR lleva solo eso y el
@@ -176,12 +192,12 @@ export function createEnrollDesk ({
176
192
  const conn = typeof connToken === 'function' ? await connToken() : connToken
177
193
  if (conn) {
178
194
  const sn = randToken(8)
179
- pending.set(sn, { token: sn, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
195
+ pending.set(sn, { token: sn, exp: Date.now() + PAIRING_TTL_MS, scope, caps, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
180
196
  return { token: sn, qr: { v: 2, conn, sn, m: mode, proxy }, expiresInMs: PAIRING_TTL_MS }
181
197
  }
182
198
  const token = randToken(PAIR_TOKEN_BYTES)
183
199
  const sn = randToken(PAIR_TOKEN_BYTES)
184
- pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
200
+ pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, caps, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
185
201
  return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
186
202
  }
187
203
 
@@ -364,8 +380,10 @@ export function createEnrollDesk ({
364
380
  // PERMISOS, no tipos (2026-08-22): las capacidades son las del scope ENTERO. Un
365
381
  // cajón (`secrets:<ns>`) suma `secrets` y fija el CN; no borra lo demás — un bot
366
382
  // con `sign,secrets:eco` firma como aparato del acta Y lee solo su cajón.
383
+ // Si quien emparejó ELIGIÓ los permisos (`startPairing({ caps })`), entran esos y en
384
+ // esta misma acta; si no, los que da el scope.
367
385
  const cn = scopeToCn(pend.scope)
368
- const caps = [...new Set([...scopeToCaps(pend.scope), ...(cn ? ['secrets'] : [])])]
386
+ const caps = [...new Set([...(pend.caps || scopeToCaps(pend.scope)), ...(cn ? ['secrets'] : [])])]
369
387
  if (!caps.length) {
370
388
  throw Object.assign(new Error('the pairing scope grants no permission: no certificate was issued'), { code: 'empty-scope' })
371
389
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.131.5",
3
+ "version": "0.132.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -22,11 +22,11 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@dotrino/compat": "0.4.0",
25
- "@dotrino/identity": "0.101.0",
25
+ "@dotrino/identity": "0.105.0",
26
26
  "@dotrino/opaque": "0.1.0",
27
27
  "@dotrino/passmanager": "0.17.0",
28
28
  "@dotrino/proxy-client": "0.24.0",
29
- "@dotrino/roadmap": "0.3.7",
29
+ "@dotrino/roadmap": "0.3.19",
30
30
  "@dotrino/store": "0.11.0",
31
31
  "@dotrino/update": "0.2.2",
32
32
  "ws": "^8.18.0"
package/src/ctl.js CHANGED
@@ -209,16 +209,6 @@ async function cmdPair (args = []) {
209
209
  console.error('uso: dotrino-vault pair --service <ns> (ns en minúsculas, p.ej. proxy)'); process.exit(2)
210
210
  }
211
211
  }
212
- // --scope <lista>: los PERMISOS del cert, y nada más. No hay tipos de aparato
213
- // (2026-08-22, dueño): un aparato es un aparato y lo que puede hacer. Sin --scope,
214
- // el juego de siempre (sign,read,store); `--service <ns>` es el atajo de
215
- // `secrets:<ns>`, y los dos se combinan (`--service eco --scope sign` = un bot que
216
- // firma como aparato del acta y lee solo su cajón). `admin` no se empareja: se
217
- // concede desde el PC (`caps <ID> +administra`).
218
- // (Aquí vivía `--approval`. Ya no hace falta: PEDIR APROBACIÓN ES EL DEFECTO, y lo
219
- // contrario —`unattended`— no se empareja, por el mismo motivo que `admin`: un QR que
220
- // circula no puede conceder «llévate mis claves privadas sin preguntar». Se concede
221
- // después y a mano con `caps <ID> +desatendido`.)
222
212
  // --name <n>: CÓMO SE VA A LLAMAR el aparato que entre, decidido aquí y antes de nada.
223
213
  // Sin esto el nombre lo ponía el propio aparato, que por defecto usa el apodo del
224
214
  // PERFIL: acababas con varios dispositivos llamados igual que tú, y para distinguirlos
@@ -267,32 +257,28 @@ async function cmdPair (args = []) {
267
257
  console.error('No se creó ninguna cuenta.'); process.exit(1)
268
258
  }
269
259
  }
260
+ // --scope <lista>: LOS PERMISOS con los que entra, cualquiera del acta (dueño,
261
+ // 2026-09-26: al emparejar se eligen, para no tener que sellar otra acta después). Van
262
+ // a la bóveda, no al QR: los concede quien teclea el código aquí. `secrets:<ns>` es el
263
+ // cajón (lo mismo que `--service <ns>`).
270
264
  const scIdx = args.indexOf('--scope')
271
265
  let scope = null
266
+ let pairCaps = null
272
267
  if (scIdx >= 0) {
273
268
  const raw = args[scIdx + 1]
274
- if (!raw || raw.startsWith('-')) { console.error('uso: dotrino-vault pair --scope sign,read,store,secrets:<ns>'); process.exit(2) }
275
- const ALIAS = { firma: 'sign', lee: 'read', guarda: 'store', contrasenas: 'passwords', 'contraseñas': 'passwords' }
269
+ if (!raw || raw.startsWith('-')) { console.error('uso: dotrino-vault pair --scope firma,lee,guarda,administra,aprueba,contrasenas,secrets:<ns>'); process.exit(2) }
276
270
  scope = []
271
+ pairCaps = []
277
272
  for (const tok of raw.split(',').map((t) => t.trim()).filter(Boolean)) {
278
- const t = ALIAS[tok] || tok
279
- if (t === 'admin' || t === 'administra') { console.error('`admin` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +administra'); process.exit(2) }
280
- if (t === 'approve' || t === 'aprueba') { console.error('`approve` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +aprueba'); process.exit(2) }
281
- if (t === 'sealer' || t === 'sella') { console.error('`sella` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +sella'); process.exit(2) }
282
- if (t === 'sign' || t === 'read' || t === 'store') { scope.push('vault:' + t); continue }
283
- // Un REPLICADOR nace acotado: solo esto, ni firma ni lee ni guarda. Se empareja
284
- // —al contrario que `sella`— porque se despliega en una máquina sin teclado y el
285
- // permiso no concede nada que haya que pensarse dos veces: reparte sobres cerrados.
286
- if (t === 'replica' || t === 'replicador') { scope.push('vault:replica'); continue }
287
- // El gestor de contraseñas SÍ se empareja con su permiso puesto: es lo único que
288
- // va a hacer ese aparato, y pedirlo en dos pasos era el paso que nadie daba.
289
- if (t === 'passwords') { scope.push('vault:passwords'); continue }
290
- const m = /^secrets:([a-z0-9-]{1,32})$/.exec(t)
273
+ const m = /^secrets:([a-z0-9-]{1,32})$/.exec(tok)
291
274
  if (m) { scope.push('vault:secrets:' + m[1]); continue }
292
- console.error('permiso desconocido: %s (sign | read | store | contrasenas | secrets:<ns>)', tok); process.exit(2)
275
+ const cap = CAP_BY_WORD[tok.toLowerCase()]
276
+ if (!cap) { console.error('permiso desconocido: %s (%s | secrets:<ns>)', tok, Object.keys(CAP_BY_WORD).join(' | ')); process.exit(2) }
277
+ pairCaps.push(cap)
293
278
  }
294
279
  if (service) scope.push('vault:secrets:' + service)
295
280
  scope = [...new Set(scope)]
281
+ pairCaps = [...new Set(pairCaps)]
296
282
  }
297
283
  // `--new-account [nombre]`: la otra respuesta a «¿a qué cuenta entra?». En vez de
298
284
  // meter el dispositivo en una cuenta que ya vive aquí, se ESTRENA una (vacía) y
@@ -340,7 +326,7 @@ async function cmdPair (args = []) {
340
326
 
341
327
  // La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
342
328
  // se empareja el dispositivo.
343
- writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(admin ? { admin: true } : {}), ...(label ? { label } : {}) })
329
+ writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope?.length ? { scope } : {}), ...(pairCaps?.length ? { caps: pairCaps } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(admin ? { admin: true } : {}), ...(label ? { label } : {}) })
344
330
  sendSignal(s.pid, 'SIGUSR1')
345
331
 
346
332
  let pair = null
@@ -348,6 +334,7 @@ async function cmdPair (args = []) {
348
334
  await sleep(100)
349
335
  const p = ipcRead(pairFile, null)
350
336
  assertOpen(p) // el candado se contesta por el mismo archivo, para no dejar esperando
337
+ if (p?.error) { console.error('No se abrió el emparejamiento: %s', p.error); process.exit(1) }
351
338
  if (p?.expiresAt > Date.now()) { pair = p; break }
352
339
  }
353
340
  if (!pair) { console.error('No se recibió respuesta del daemon para el emparejamiento.'); process.exit(1) }
@@ -2001,8 +1988,9 @@ function help () {
2001
1988
  El QR no lleva nada: el permiso se aplica al aprobar el código aquí
2002
1989
  pair --quiet escupe SOLO la invitación (una línea) y termina: sin QR y sin
2003
1990
  esperar. Para desplegar (un contenedor no mira un QR en pantalla)
2004
- pair --scope <lista> los PERMISOS del cert: sign,read,store,contrasenas,replica,secrets:<ns> (sin esto: sign,read,store;
2005
- se combina con --service: --service eco --scope sign = bot que firma y lee su cajón)
1991
+ pair --scope <lista> los PERMISOS con los que entra, cualquiera de los de caps (firma,lee,guarda,administra,
1992
+ aprueba,contrasenas,passkeys,sella,desatendido,replica) y secrets:<ns>. Sin esto: firma,lee,guarda.
1993
+ Entra al acta ya con ellos. Se combina con --service: --service eco --scope firma
2006
1994
  secret set <ns> <CLAVE> <valor> variable del scope <ns>: la comparten TODOS los
2007
1995
  aparatos del perfil que sirven ese namespace
2008
1996
  secret set <ns> CLAVE=valor CLAVE2=valor2 …
package/src/daemon.js CHANGED
@@ -22,6 +22,8 @@ import { dataDir } from './paths.js'
22
22
  // EL CANAL LOCAL VA CIFRADO: por aquí pasan la contraseña, los valores y las invitaciones.
23
23
  import { ipcRead, ipcWrite, migrateIpcDir } from './ipc.js'
24
24
  import { parseInvite } from '../lib/src/invite.js'
25
+ import { scopeToCaps, scopeToCn } from '../lib/src/enroll.js'
26
+ import { capScope } from '@dotrino/identity/acta'
25
27
  import { watchBinary } from './selfupdate.js'
26
28
  import { latestRelease, isNewer, CHECK_EVERY_MS, isUserInstall, installUserRelease } from './update.js'
27
29
  import { VERSION } from './version.js'
@@ -156,19 +158,6 @@ export async function runDaemon () {
156
158
 
157
159
  // --- SIGUSR1: iniciar emparejamiento ---
158
160
  const pairFile = path.join(dir, 'pair.json')
159
- /**
160
- * Lo pidió `pair --admin`: el aparato que entre por ESTA invitación podrá administrar.
161
- *
162
- * Sigue en pie la regla de que **ningún QR concede administración**: el QR no lleva
163
- * nada. Lo que hay es una nota LOCAL de esta bóveda, y el permiso se aplica en el
164
- * mismo gesto que ya era la puerta —aprobar con el código tecleado aquí—, exactamente
165
- * igual que `--approval`. Es el mismo `caps <ID> +administra` que harías a mano un
166
- * segundo después, sin tener que ir a buscar el ID.
167
- *
168
- * Existe por el contenedor: allí cada paso cuesta un `docker exec`, y el primer aparato
169
- * de una bóveda recién desplegada es SIEMPRE la consola.
170
- */
171
- let pendingAdmin = false
172
161
  const pairReqFile = path.join(dir, 'pair-request.json')
173
162
  async function handlePairingRequest () {
174
163
  try {
@@ -217,13 +206,34 @@ export async function runDaemon () {
217
206
  ipcWrite(pairFile, { v: 2, at: Date.now(), error: 'scope not allowed: ' + asked.filter((x) => !ALLOWED(x)).join(',') })
218
207
  return console.error('[vault] pairing refused: scope not allowed (%s)', asked.join(','))
219
208
  }
220
- const scope = asked?.length
209
+ const base = asked?.length
221
210
  ? [...new Set(asked)]
222
211
  : isService ? ['vault:secrets:' + pairReq.service] : ['vault:sign', 'vault:read', 'vault:store']
212
+ // LOS PERMISOS DEL ACTA se eligen al emparejar (dueño, 2026-09-26: «caso contrario
213
+ // estoy obligado a hacer dos actas»). Entran TODOS en el acta con la que el aparato
214
+ // entra —`admin` y `approve` incluidos: los concede quien teclea el código, igual
215
+ // que `caps` después, y el QR no lleva nada—, y el certificado lleva solo los que
216
+ // `ALLOWED` deja firmar. Decide el acta, no el papel (0.131.6).
217
+ //
218
+ // `pair --admin` es el mismo gesto: antes emparejaba y sellaba OTRA acta para sumar
219
+ // `admin`; ahora se suma aquí.
220
+ let caps = Array.isArray(pairReq?.caps) ? pairReq.caps.filter((x) => typeof x === 'string') : null
221
+ if (pairReq?.admin) caps = [...(caps || scopeToCaps(base)), 'admin']
222
+ let scope = base
223
+ if (caps) {
224
+ caps = [...new Set(caps)]
225
+ const cn = scopeToCn(base)
226
+ const fromCaps = caps.map((c) => capScope(c, cn)).filter((x) => x && ALLOWED(x))
227
+ // Sin `--scope` el papel sale de los permisos; con él, se suman.
228
+ scope = [...new Set([...(asked?.length || isService ? base : []), ...fromCaps])]
229
+ if (!scope.length) {
230
+ ipcWrite(pairFile, { v: 2, at: Date.now(), error: 'pick at least one of: sign, read, store, passwords, replica', code: 'empty-scope' })
231
+ return console.error('[vault] pairing refused: no permission the certificate can carry (%s)', caps.join(','))
232
+ }
233
+ }
223
234
  // La etiqueta por defecto de un servicio es su ns a secas: la lista ya marca [servicio «ns»],
224
235
  // y el prefijo «service:» confundía (el cajón se llama claude, no service:claude).
225
236
  const label = pairReq?.label || (isService ? pairReq.service : 'cli')
226
- pendingAdmin = !!pairReq?.admin
227
237
  // `profile`/`profileName`: la CUENTA del vault a la que entra el dispositivo.
228
238
  // Con varias bóvedas en el mismo daemon, el QR sale de UNA y quien empareja
229
239
  // tiene que verlo (lo muestran la TUI y `dotrino-vault pair`). El nombre viaja
@@ -237,7 +247,7 @@ export async function runDaemon () {
237
247
  // perfil de esta bóveda tiene que haber nacido para eso (`--adopt`
238
248
  // crea uno vacío), o no habría dónde meterla.
239
249
  const mode = pairReq?.mode === 'adopt' ? 'adopt' : 'join'
240
- const { qr, expiresInMs } = await vault.startPairing({ scope, label, ttlMs: DEVICE_TTL_MS, mode, account: profileName })
250
+ const { qr, expiresInMs } = await vault.startPairing({ scope, label, ttlMs: DEVICE_TTL_MS, mode, account: profileName, ...(caps ? { caps } : {}) })
241
251
  ipcWrite(pairFile, { v: 2, at: Date.now(), qr, expiresAt: Date.now() + expiresInMs, profile: profileId, profileName })
242
252
  // El token es un secreto efímero: no debe quedar en disco más allá de su
243
253
  // vida. Se borra al VENCER (aquí) y al APROBARSE (abajo, consumido).
@@ -248,6 +258,9 @@ export async function runDaemon () {
248
258
  }, expiresInMs + 1000).unref?.()
249
259
  console.log('[vault] pairing started (valid for %d min)', expiresInMs / 60000)
250
260
  } catch (e) {
261
+ // Se contesta por el archivo que espera quien lo pidió: si no, se queda mirando una
262
+ // pantalla vacía hasta que se agota el tiempo (p. ej. un permiso que no existe).
263
+ ipcWrite(pairFile, { v: 2, at: Date.now(), error: e.message, code: e.code || 'PAIR_FAILED' })
251
264
  console.error('[vault] could not start pairing:', e.message)
252
265
  }
253
266
  }
@@ -592,18 +605,6 @@ export async function runDaemon () {
592
605
  const vault = targetOf(appr)
593
606
  if (!vault) throw Object.assign(new Error('profile locked'), { code: 'PROFILE_LOCKED' })
594
607
  const r = await vault.approveDevice(appr.code); rm(pendingEnrollFile); rm(pairFile)
595
- // `pair --admin`: se le SUMA `admin` a lo que ya tiene, no se le reescriben los
596
- // permisos — el aparato acaba de entrar con el scope que pidió la invitación.
597
- if (pendingAdmin && r?.deviceId) {
598
- try {
599
- const rec = await vault.profileMembers()
600
- const m = (rec?.members || []).find((x) => x.id === r.deviceId)
601
- if (!m?.pub) throw new Error('the device is not in the record yet')
602
- await vault.setCaps(m.pub, [...new Set([...(m.caps || []), 'admin'])])
603
- console.log('[vault] the new device can ADMINISTER this account (console)')
604
- } catch (e) { console.error('[vault] could not grant admin: %s', e.message) }
605
- }
606
- pendingAdmin = false
607
608
  console.log('[vault] approved %s', r.deviceId)
608
609
  answer({ ok: true, deviceId: r.deviceId || null })
609
610
  } catch (e) {
@@ -627,7 +628,24 @@ export async function runDaemon () {
627
628
  } catch (e) { console.error('[vault] could not rename the device:', e.message) }
628
629
  }
629
630
  const capsReq = readJsonSafe(path.join(dir, 'caps-request.json'))
630
- if (capsReq?.pub && Array.isArray(capsReq.caps)) {
631
+ // VARIOS APARATOS, UNA ACTA (el borrador de la TUI). Se contesta con el `id` de la
632
+ // petición, para que quien espera sepa si se firmó de verdad.
633
+ if (Array.isArray(capsReq?.changes)) {
634
+ rm(path.join(dir, 'caps-request.json'))
635
+ const answer = (extra) => ipcWrite(path.join(dir, 'caps.json'), { v: 1, at: Date.now(), req: capsReq.id || null, ...extra })
636
+ try {
637
+ const vault = targetOf(capsReq)
638
+ if (!vault) throw Object.assign(new Error('profile locked'), { code: 'PROFILE_LOCKED' })
639
+ const changes = capsReq.changes.filter((c) => typeof c?.pub === 'string' && Array.isArray(c.caps))
640
+ if (changes.length !== capsReq.changes.length) throw Object.assign(new Error('malformed change list'), { code: 'BAD_CHANGES' })
641
+ await vault.setCapsMany(changes)
642
+ console.log('[vault] permissions updated for %d device(s) in one record', changes.length)
643
+ answer({ ok: true, count: changes.length })
644
+ } catch (e) {
645
+ console.error('[vault] could not change permissions:', e.message)
646
+ answer({ ok: false, error: e.message, code: e.code || 'CAPS_FAILED' })
647
+ }
648
+ } else if (capsReq?.pub && Array.isArray(capsReq.caps)) {
631
649
  rm(path.join(dir, 'caps-request.json'))
632
650
  try {
633
651
  await targetOf(capsReq)?.setCaps(capsReq.pub, capsReq.caps)
package/src/tui/app.js CHANGED
@@ -307,6 +307,13 @@ function deviceRows (st, t) {
307
307
  if (!devices.length) {
308
308
  rows.push({ text: t.muted(i.noDevices), sel: false })
309
309
  }
310
+ // PERMISOS SIN GUARDAR en uno o varios aparatos: se dice ARRIBA y se marca cada uno con
311
+ // `*`, porque G los firma todos juntos y hay que ver qué entra antes de pulsarlo.
312
+ const borrador = draftChanges(st)
313
+ const conCambios = new Set(borrador.map((c) => c.pub))
314
+ if (borrador.length) {
315
+ rows.unshift({ text: t.bold(' ' + i.devicesDraft(borrador.length)), sel: false }, { text: '', sel: false })
316
+ }
310
317
  for (const d of devices) {
311
318
  const label = d.label || t.muted(i.noLabel)
312
319
  const vars = devVarsOf(st, d.sub).length
@@ -339,7 +346,8 @@ function deviceRows (st, t) {
339
346
  : d.isMaster
340
347
  ? t.muted(i.thisVault)
341
348
  : t.muted('scope:' + shortScope(d.scope)) + ' ' + t.muted('exp:' + fmtExp(d.exp))
342
- rows.push({ text: ` ${t.bold(d.deviceId)} ${label}${desde} ${status}${extra}`, sel: true, meta: d })
349
+ const marca = conCambios.has(d.sub) ? t.bold('*') : ' '
350
+ rows.push({ text: `${marca}${t.bold(d.deviceId)} ${label}${desde} ${status}${extra}`, sel: true, meta: d })
343
351
  }
344
352
  const revoked = st.devices?.revoked || []
345
353
  if (revoked.length) {
@@ -462,27 +470,23 @@ const CAPS_ORDER = [...ORDEN.filter((c) => DEVICE_CAPS.includes(c)),
462
470
  */
463
471
  const capsForMember = (member) => (member?.cn ? ['secrets', ...CAPS_ORDER] : CAPS_ORDER)
464
472
 
465
- function capsRows (st, t) {
466
- const i = L(st)
467
- const target = st.capsFor
468
- if (!target) return [{ text: t.muted(i.loading), sel: false }]
469
- const member = (st.members || []).find((m) => m.pub === target.pub)
470
- if (!member) return [{ text: t.muted(i.capsNoMember), sel: false }]
473
+ /** Con lo que entra un aparato si no eliges otra cosa: lo mismo que `pair` sin `--scope`. */
474
+ const PAIR_DEFAULT_CAPS = ['sign', 'read', 'store']
471
475
 
472
- // EL BORRADOR manda sobre lo que hay en el acta: es lo que estás a punto de dejar.
473
- const real = new Set(member.caps || [])
474
- const has = st.capsDraft?.pub === member.pub ? new Set(st.capsDraft.caps) : real
475
-
476
- const rows = [
477
- { text: ' ' + t.bold(i.capsFor(target.deviceId, member.label || '')), sel: false },
478
- { text: '', sel: false }
479
- ]
476
+ /**
477
+ * La lista de casillas de permisos, la MISMA para editar un aparato y para elegir con qué
478
+ * entra uno nuevo: una pantalla que dice lo mismo de dos maneras acaba diciendo dos cosas.
479
+ * `real` es contra qué se marca el `*` (lo que hay en el acta); al emparejar no hay acta
480
+ * todavía y se pasa `has`, así que no sale ninguna marca.
481
+ */
482
+ function capChecklist (i, t, { cn = null, has, real = has }) {
483
+ const rows = []
480
484
  let tocados = 0
481
- for (const cap of capsForMember(member)) {
485
+ for (const cap of capsForMember({ cn })) {
482
486
  const mark = has.has(cap) ? '[x]' : '[ ]'
483
487
  // El nombre de `secrets` lleva el cajón dentro: «las claves de proxy» dice qué abre,
484
488
  // «lee sus claves» te deja preguntándote cuáles.
485
- const name = typeof i.capName[cap] === 'function' ? i.capName[cap](member.cn) : i.capName[cap]
489
+ const name = typeof i.capName[cap] === 'function' ? i.capName[cap](cn) : i.capName[cap]
486
490
  // LO QUE CAMBIA SE SEÑALA. Sin esto el borrador es indistinguible del acta y no sabes
487
491
  // qué vas a firmar — que es justo lo que hace peligroso acumular cambios.
488
492
  const cambia = has.has(cap) !== real.has(cap)
@@ -491,17 +495,119 @@ function capsRows (st, t) {
491
495
  // así que no hay que repetirlo al lado. Lo que hace va debajo.
492
496
  const line = ` ${mark}${cambia ? t.bold('*') : ' '} ${cap === 'admin' ? t.bold(name) : name}`
493
497
  rows.push({ text: line, sel: true, meta: { cap } })
494
- const hint = typeof i.capHint[cap] === 'function' ? i.capHint[cap](member.cn) : i.capHint[cap]
498
+ const hint = typeof i.capHint[cap] === 'function' ? i.capHint[cap](cn) : i.capHint[cap]
495
499
  rows.push({ text: t.muted(' ' + hint), sel: false })
496
500
  }
497
- rows.push({ text: '', sel: false })
498
- rows.push({
499
- text: tocados ? ' ' + t.bold(i.capsPending(tocados)) : t.muted(' ' + i.capsApplyHint),
500
- sel: false
501
- })
501
+ return { rows, tocados }
502
+ }
503
+
504
+ /**
505
+ * EL BORRADOR ES DE TODA LA BÓVEDA, no de un aparato (dueño, 2026-09-26: «hay que
506
+ * extenderlo a guardar después de cambiar permisos de varios dispositivos, con esto me
507
+ * evito más actas inútiles»). `st.capsDrafts` guarda, por llave, la lista que QUEDARÍA;
508
+ * un aparato que vuelve a coincidir con el acta sale del borrador.
509
+ */
510
+ const sameCaps = (a, b) => { const x = new Set(a || []); const y = new Set(b || []); return x.size === y.size && [...x].every((c) => y.has(c)) }
511
+ const draftCapsOf = (st, member) => st.capsDrafts?.[member.pub] || member.caps || []
512
+ function setCapsDraft (st, member, caps) {
513
+ const drafts = { ...(st.capsDrafts || {}) }
514
+ if (sameCaps(caps, member.caps)) delete drafts[member.pub]
515
+ else drafts[member.pub] = [...caps]
516
+ st.capsDrafts = drafts
517
+ }
518
+ /** Lo que se firmaría ahora: un cambio por aparato que sigue en el acta y difiere de ella. */
519
+ function draftChanges (st) {
520
+ const out = []
521
+ for (const [pub, caps] of Object.entries(st.capsDrafts || {})) {
522
+ const member = (st.members || []).find((m) => m.pub === pub)
523
+ if (!member || sameCaps(caps, member.caps)) continue
524
+ const real = new Set(member.caps || []); const has = new Set(caps)
525
+ const n = [...new Set([...has, ...real])].filter((c) => has.has(c) !== real.has(c)).length
526
+ out.push({ pub, caps: [...caps], member, n })
527
+ }
528
+ return out
529
+ }
530
+
531
+ /**
532
+ * CON CAMBIOS SIN GUARDAR, IRSE SE PREGUNTA. Tirarlos callando es perder trabajo sin
533
+ * decirlo, y es justo lo que un borrador no debe hacer. Sin cambios, se va sin preguntar:
534
+ * confirmar por nada es ruido.
535
+ */
536
+ function unlessDrafts (st, fn) {
537
+ const n = draftChanges(st).length
538
+ if (!n) return fn()
539
+ setConfirm(st, { text: L(st).capsDiscardAll(n), onYes: async () => { st.capsDrafts = {}; await fn() } })
540
+ }
541
+
542
+ /**
543
+ * GUARDAR: UNA sola acta con todo lo que hayas tocado, en uno o en varios aparatos.
544
+ * Administrar se PREGUNTA, y se pregunta AL GUARDAR: es cuando pasa a ser verdad.
545
+ */
546
+ async function saveCapsDrafts (term, st) {
547
+ const i = L(st)
548
+ const changes = draftChanges(st)
549
+ if (!changes.length) { flash(st, i.capsNothing); return }
550
+ const guardar = async () => {
551
+ const r = await guard(term, st, i.applyingCaps, () => vc.setDevicesCaps(changes.map(({ pub, caps }) => ({ pub, caps })), activeId(st)))
552
+ if (!r.ok) return
553
+ applyDump(st, r.v)
554
+ st.capsDrafts = {}
555
+ await refreshMembers(term, st)
556
+ flash(st, i.capsSavedMany(changes.reduce((a, c) => a + c.n, 0), changes.length))
557
+ }
558
+ const nuevosAdmin = changes.filter((c) => c.caps.includes('admin') && !(c.member.caps || []).includes('admin'))
559
+ if (nuevosAdmin.length) {
560
+ setConfirm(st, { text: i.confirmAdmin(nuevosAdmin.map((c) => c.member.id || deviceIdOf(st, c.pub)).join(', ')), onYes: guardar })
561
+ return
562
+ }
563
+ await guardar()
564
+ }
565
+
566
+ function capsRows (st, t) {
567
+ const i = L(st)
568
+ const target = st.capsFor
569
+ if (!target) return [{ text: t.muted(i.loading), sel: false }]
570
+ const member = (st.members || []).find((m) => m.pub === target.pub)
571
+ if (!member) return [{ text: t.muted(i.capsNoMember), sel: false }]
572
+
573
+ // EL BORRADOR manda sobre lo que hay en el acta: es lo que estás a punto de dejar.
574
+ const real = new Set(member.caps || [])
575
+ const has = new Set(draftCapsOf(st, member))
576
+ const { rows: lista, tocados } = capChecklist(i, t, { cn: member.cn, has, real })
577
+ const rows = [
578
+ { text: ' ' + t.bold(i.capsFor(target.deviceId, member.label || '')), sel: false },
579
+ { text: '', sel: false },
580
+ ...lista,
581
+ { text: '', sel: false }
582
+ ]
583
+ // Lo que hay pendiente en OTROS aparatos también se dice aquí: G los guarda juntos.
584
+ const otros = draftChanges(st).filter((c) => c.pub !== member.pub)
585
+ if (tocados) rows.push({ text: ' ' + t.bold(i.capsPending(tocados)), sel: false })
586
+ if (otros.length) rows.push({ text: ' ' + t.bold(i.capsPendingOthers(otros.length)), sel: false })
587
+ if (!tocados && !otros.length) rows.push({ text: t.muted(' ' + i.capsApplyHint), sel: false })
502
588
  return rows
503
589
  }
504
590
 
591
+ /**
592
+ * PERMISOS CON LOS QUE ENTRA el aparato que se va a emparejar (dueño, 2026-09-26: «al
593
+ * emparejar debería escoger los permisos, caso contrario estoy obligado a hacer dos
594
+ * actas»). Se eligen ANTES del QR y viajan a la bóveda, no al QR: el aparato entra al acta
595
+ * ya con ellos, en la misma que lo admite.
596
+ */
597
+ function pairCapsRows (st, t) {
598
+ const i = L(st)
599
+ const pc = st.pairCaps
600
+ if (!pc) return [{ text: t.muted(i.loading), sel: false }]
601
+ const { rows: lista } = capChecklist(i, t, { cn: pc.service || null, has: new Set(pc.caps) })
602
+ return [
603
+ { text: ' ' + t.bold(pc.service ? i.pairCapsService(pc.service) : i.pairCapsFor(pc.label || '')), sel: false },
604
+ { text: '', sel: false },
605
+ ...lista,
606
+ { text: '', sel: false },
607
+ { text: t.muted(' ' + i.pairCapsHint), sel: false }
608
+ ]
609
+ }
610
+
505
611
  /** Las variables por SCOPE: las que comparten todos los aparatos que sirven ese ns. */
506
612
  function secretRows (st, t) {
507
613
  const i = L(st)
@@ -756,7 +862,7 @@ async function refreshAll (term, st, api = vc) {
756
862
  // Cerrada: no se pide su contenido, y tampoco se enseña un error por mirarla desde
757
863
  // fuera. Lo que hubiera cargado se suelta, para no dejar en pantalla lo de antes.
758
864
  if (activeLocked(st)) {
759
- st.devices = null; st.secrets = null; st.members = []; st.me = undefined
865
+ st.devices = null; st.secrets = null; st.members = []; st.me = undefined; st.capsDrafts = {}
760
866
  return
761
867
  }
762
868
  const r = await guard(term, st, L(st).loading, () => api.snapshot(activeId(st)))
@@ -1085,10 +1191,10 @@ async function onKeyDevices (term, st, key) {
1085
1191
  })
1086
1192
  } else if (ch === 'c' && cur?.sub) {
1087
1193
  st.capsFor = { pub: cur.sub, deviceId: cur.deviceId }
1088
- // Borrador limpio al entrar: arrastrar el de otro aparato sería firmar lo que no viste.
1089
- st.capsDraft = null
1090
1194
  st.sel.caps = 0
1091
- await refreshMembers(term, st)
1195
+ // Con cambios a medias NO se recarga el acta: el borrador se compara contra ella y
1196
+ // traer otra por debajo cambiaría qué marca el `*` sin que lo vieras.
1197
+ if (!draftChanges(st).length) await refreshMembers(term, st)
1092
1198
  st.screen = 'caps'
1093
1199
  } else if (ch === 'e' && cur?.sub) {
1094
1200
  // Variables de ESTE aparato. Solo un servicio las lee (es el único que pide su
@@ -1099,8 +1205,11 @@ async function onKeyDevices (term, st, key) {
1099
1205
  st.sel.devvars = 0
1100
1206
  await refreshSecrets(term, st)
1101
1207
  st.screen = 'devvars'
1208
+ } else if (ch === 'g') {
1209
+ // Guarda los permisos que dejaste a medias en uno o varios aparatos, en UNA acta.
1210
+ await saveCapsDrafts(term, st)
1102
1211
  } else if (key.name === 'f5') {
1103
- await refreshDevices(term, st)
1212
+ unlessDrafts(st, () => refreshDevices(term, st))
1104
1213
  }
1105
1214
  return true
1106
1215
  }
@@ -1235,13 +1344,12 @@ function beginPairingNamed (term, st, profile, service = null, after = null) {
1235
1344
  hint: i.nameDeviceHint,
1236
1345
  onSubmit: async (nombre) => {
1237
1346
  st.input = null
1238
- const ok = await beginPairing(term, st, profile, service, (nombre || '').trim().slice(0, 60) || null)
1239
- // `after` es para quien tenga que limpiar si el emparejamiento no llega a abrirse
1240
- // (la cuenta que nació para él). Va aquí porque con el nombre de por medio ya no se
1241
- // puede mirar el valor de retorno desde fuera.
1242
- if (after) await after(ok)
1347
+ // Después del nombre, LOS PERMISOS: el aparato entra al acta ya con ellos. `after` es
1348
+ // para quien tenga que limpiar si el emparejamiento no llega a abrirse (la cuenta
1349
+ // que nació para él); lo llama la pantalla de permisos, al generar o al cancelar.
1350
+ choosePairCaps(st, { profile, service, label: (nombre || '').trim().slice(0, 60) || null, after })
1243
1351
  },
1244
- onCancel: () => { st.input = null }
1352
+ onCancel: async () => { st.input = null; if (after) await after(false) }
1245
1353
  })
1246
1354
  return true
1247
1355
  }
@@ -1250,8 +1358,8 @@ function beginPairingNamed (term, st, profile, service = null, after = null) {
1250
1358
  * Abre el emparejamiento contra `profile` y salta a la pantalla del QR. Con `service`,
1251
1359
  * el QR es el de un SERVICIO de ese namespace (cert limitado a sus variables).
1252
1360
  */
1253
- async function beginPairing (term, st, profile, service = null, label = null) {
1254
- const r = await guard(term, st, L(st).startingPairing, () => vc.startPairing({ profile, ...(service ? { service } : {}), ...(label ? { label } : {}) }))
1361
+ async function beginPairing (term, st, profile, service = null, label = null, caps = null) {
1362
+ const r = await guard(term, st, L(st).startingPairing, () => vc.startPairing({ profile, ...(service ? { service } : {}), ...(label ? { label } : {}), ...(caps ? { caps } : {}) }))
1255
1363
  // `service` se pega al estado porque el daemon no lo devuelve: la pantalla del QR
1256
1364
  // tiene que poder decir qué se está entregando, que no es lo mismo un aparato tuyo
1257
1365
  // que una máquina que solo va a leer la configuración del proxy.
@@ -1260,81 +1368,96 @@ async function beginPairing (term, st, profile, service = null, label = null) {
1260
1368
  }
1261
1369
 
1262
1370
  /**
1263
- * UN BORRADOR QUE SE GUARDA O SE DESCARTA (dueño, 2026-09-01).
1371
+ * UN BORRADOR QUE SE GUARDA O SE DESCARTA (dueño, 2026-09-01), y que abarca VARIOS
1372
+ * APARATOS (dueño, 2026-09-26).
1264
1373
  *
1265
1374
  * Antes cada Enter sellaba un acta. Cambiar cuatro permisos eran cuatro actas, cuatro
1266
1375
  * avisos a todos los aparatos y cuatro renovaciones de certificado — absurdo, y encima
1267
1376
  * dejaba la cuenta pasando por estados intermedios que nadie quiso (un aparato con
1268
- * `admin` pero todavía sin `read`, por ejemplo).
1269
- *
1270
- * Ahora Enter mueve un borrador LOCAL —nada firmado, nada avisado— y `G` lo aplica de
1271
- * golpe. `applyChanges` ya tomaba una LISTA de cambios y producía UNA acta: el borrador es
1272
- * esa lista, sin firmar, hasta que confirmas.
1377
+ * `admin` pero todavía sin `read`, por ejemplo). Y lo mismo pasaba entre aparatos: cada
1378
+ * uno que tocabas era otra acta.
1273
1379
  *
1274
- * Lo que se cambia va marcado con `*` y contado abajo: un borrador que no se distingue del
1275
- * acta es peor que no tenerlo, porque firmas sin saber qué.
1380
+ * Ahora Enter mueve un borrador LOCAL —nada firmado, nada avisado—, Esc vuelve a la lista
1381
+ * SIN tirarlo (para seguir con otro aparato) y `G` lo aplica de golpe, todos los aparatos
1382
+ * en UNA acta. Lo que se cambia va marcado con `*` y contado: un borrador que no se
1383
+ * distingue del acta es peor que no tenerlo, porque firmas sin saber qué.
1276
1384
  */
1277
1385
  async function onKeyCaps (term, st, key) {
1278
- const i = L(st)
1279
1386
  const rows = capsRows(st, term.t)
1280
1387
  const sels = rows.filter((r) => r.sel).map((r) => r.meta)
1281
1388
  moveSel(st, key, 'caps', sels.length)
1282
1389
  const cur = sels[Math.min(st.sel.caps || 0, sels.length - 1)]
1283
1390
  const ch = key.name === 'char' ? key.ch.toLowerCase() : null
1284
1391
 
1392
+ const volver = () => { st.screen = 'devices'; st.capsFor = null }
1285
1393
  const member = (st.members || []).find((m) => m.pub === st.capsFor?.pub)
1286
1394
  if (!member) {
1287
- if (key.name === 'escape' || ch === 'b') { st.screen = 'devices'; st.capsFor = null }
1288
- return true
1289
- }
1290
- const real = new Set(member.caps || [])
1291
- if (st.capsDraft?.pub !== member.pub) st.capsDraft = { pub: member.pub, caps: [...real] }
1292
- const draft = new Set(st.capsDraft.caps)
1293
- const sucio = [...new Set([...draft, ...real])].some((c) => draft.has(c) !== real.has(c))
1294
-
1295
- const salir = () => { st.screen = 'devices'; st.capsFor = null; st.capsDraft = null }
1296
- if (key.name === 'escape' || ch === 'b') {
1297
- // SALIR CON CAMBIOS SIN GUARDAR SE PREGUNTA. Tirarlos callando es perder trabajo sin
1298
- // decirlo, y es justo lo que un borrador no debe hacer.
1299
- if (sucio) setConfirm(st, { text: i.capsDiscard, onYes: async () => { salir() } })
1300
- else salir()
1395
+ if (key.name === 'escape' || ch === 'b') volver()
1301
1396
  return true
1302
1397
  }
1398
+ // Volver a la lista NO tira el borrador: es como se pasa al siguiente aparato.
1399
+ if (key.name === 'escape' || ch === 'b') { volver(); return true }
1303
1400
  if (key.name === 'f5') {
1304
- // F5 PREGUNTA IGUAL QUE ESC. Refrescar trae el acta de nuevo y el borrador deja de
1305
- // tener con qué compararse, así que hay que tirarlo — pero tirarlo callando es perder
1306
- // trabajo sin decirlo, que es lo mismo que Esc ya no hace.
1307
- const refrescar = async () => { st.capsDraft = null; await refreshMembers(term, st) }
1308
- if (sucio) setConfirm(st, { text: i.capsDiscard, onYes: refrescar })
1309
- else await refrescar()
1401
+ // F5 trae el acta de nuevo y el borrador deja de tener con qué compararse: se pregunta
1402
+ // antes de tirarlo, igual que al salir de la bóveda.
1403
+ unlessDrafts(st, async () => { st.capsDrafts = {}; await refreshMembers(term, st) })
1310
1404
  return true
1311
1405
  }
1406
+ if (ch === 'g') { await saveCapsDrafts(term, st); return true }
1407
+
1408
+ if ((key.name !== 'enter' && ch !== ' ') || !cur) return true
1409
+ // Enter solo mueve el BORRADOR: no firma, no avisa, no se puede equivocar caro.
1410
+ const draft = new Set(draftCapsOf(st, member))
1411
+ if (draft.has(cur.cap)) draft.delete(cur.cap); else draft.add(cur.cap)
1412
+ setCapsDraft(st, member, [...draft])
1413
+ return true
1414
+ }
1415
+
1416
+ /**
1417
+ * Elegir los permisos del aparato que se va a emparejar. Enter marca, G genera el QR con
1418
+ * ellos, Esc cancela (y quien abrió esto limpia lo suyo con `after(false)`).
1419
+ */
1420
+ async function onKeyPairCaps (term, st, key) {
1421
+ const i = L(st)
1422
+ const pc = st.pairCaps
1423
+ const rows = pairCapsRows(st, term.t)
1424
+ const sels = rows.filter((r) => r.sel).map((r) => r.meta)
1425
+ moveSel(st, key, 'paircaps', sels.length)
1426
+ const cur = sels[Math.min(st.sel.paircaps || 0, sels.length - 1)]
1427
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
1428
+ if (!pc) { st.screen = 'devices'; return true }
1312
1429
 
1313
- // GUARDAR: UNA sola acta con todo lo que hayas tocado.
1430
+ if (key.name === 'escape' || ch === 'b') {
1431
+ st.pairCaps = null; st.screen = 'devices'
1432
+ if (pc.after) await pc.after(false)
1433
+ return true
1434
+ }
1314
1435
  if (ch === 'g') {
1315
- if (!sucio) { flash(st, i.capsNothing); return true }
1316
- const cuantos = [...new Set([...draft, ...real])].filter((c) => draft.has(c) !== real.has(c)).length
1317
- const guardar = async () => {
1318
- const r = await guard(term, st, i.applyingCaps, () => vc.setDeviceCaps(member.pub, [...draft], activeId(st)))
1319
- if (!r.ok) return
1320
- applyDump(st, r.v)
1321
- st.capsDraft = null
1322
- await refreshMembers(term, st)
1323
- flash(st, i.capsSaved(cuantos))
1324
- }
1325
- // Administrar se PREGUNTA, y se pregunta AL GUARDAR: es cuando pasa a ser verdad.
1326
- if (draft.has('admin') && !real.has('admin')) {
1327
- setConfirm(st, { text: i.confirmAdmin(st.capsFor.deviceId), onYes: guardar })
1328
- return true
1436
+ if (!pc.caps.length) { flash(st, i.pairCapsNone, 'warn'); return true }
1437
+ const go = async () => {
1438
+ st.pairCaps = null
1439
+ const ok = await beginPairing(term, st, pc.profile, pc.service, pc.label, pc.caps)
1440
+ if (!ok) st.screen = 'devices'
1441
+ if (pc.after) await pc.after(ok)
1329
1442
  }
1330
- await guardar()
1443
+ // Administrar se pregunta también aquí: es el mismo permiso, dado de otra manera.
1444
+ if (pc.caps.includes('admin')) { setConfirm(st, { text: i.confirmAdmin(i.pairCapsNewDevice), onYes: go }); return true }
1445
+ await go()
1331
1446
  return true
1332
1447
  }
1333
-
1334
1448
  if ((key.name !== 'enter' && ch !== ' ') || !cur) return true
1335
- // Enter solo mueve el BORRADOR: no firma, no avisa, no se puede equivocar caro.
1336
- if (draft.has(cur.cap)) draft.delete(cur.cap); else draft.add(cur.cap)
1337
- st.capsDraft = { pub: member.pub, caps: [...draft] }
1449
+ const set = new Set(pc.caps)
1450
+ if (set.has(cur.cap)) set.delete(cur.cap); else set.add(cur.cap)
1451
+ st.pairCaps = { ...pc, caps: [...set] }
1452
+ return true
1453
+ }
1454
+
1455
+ /** Abre la pantalla de permisos del emparejamiento; el QR sale al pulsar G. */
1456
+ function choosePairCaps (st, { profile, service = null, label = null, after = null }) {
1457
+ st.pairCaps = { profile, service, label, after, caps: service ? ['secrets'] : [...PAIR_DEFAULT_CAPS] }
1458
+ st.sel.paircaps = 0
1459
+ st.scroll.paircaps = { value: 0 }
1460
+ st.screen = 'paircaps'
1338
1461
  return true
1339
1462
  }
1340
1463
 
@@ -1363,7 +1486,7 @@ async function onKeyPairMode (term, st, key) {
1363
1486
  st.input = null
1364
1487
  const ns = String(raw || '').trim().toLowerCase()
1365
1488
  if (!/^[a-z0-9-]{1,32}$/.test(ns)) { flash(st, i.serviceNsBad, 'danger'); return }
1366
- await beginPairing(term, st, activeId(st), ns)
1489
+ choosePairCaps(st, { profile: activeId(st), service: ns })
1367
1490
  },
1368
1491
  onCancel: () => { st.input = null }
1369
1492
  })
@@ -1967,6 +2090,7 @@ const helpSegs = (i, screen, st = {}) => {
1967
2090
  logins: i.helpLogins,
1968
2091
  pairing: i.helpPairing,
1969
2092
  pairmode: i.helpPairMode,
2093
+ paircaps: i.helpPairCaps,
1970
2094
  join: i.helpJoin,
1971
2095
  me: i.helpMe,
1972
2096
  caps: i.helpCaps,
@@ -1982,6 +2106,7 @@ const helpSegs = (i, screen, st = {}) => {
1982
2106
  const lg = (st.logins || [])[Math.min(st.sel?.logins || 0, Math.max(0, (st.logins || []).length - 1))]
1983
2107
  return segs({
1984
2108
  pending: !!st.pending,
2109
+ drafts: draftChanges(st).length > 0,
1985
2110
  hasDevices: (st.devices?.issued || []).length > 0,
1986
2111
  isService: !!cur?.cn,
1987
2112
  hasSecrets: Object.keys(st.secrets?.ns || {}).length > 0,
@@ -1996,6 +2121,7 @@ const title = (i, screen) => ({
1996
2121
  profiles: i.titleProfiles,
1997
2122
  pairing: i.titlePairing,
1998
2123
  pairmode: i.titlePairMode,
2124
+ paircaps: i.titlePairCaps,
1999
2125
  join: i.titleJoin,
2000
2126
  caps: i.titleCaps,
2001
2127
  devvars: i.titleDevVars
@@ -2114,6 +2240,7 @@ function render (term, st) {
2114
2240
  else if (st.screen === 'caps') body = renderList(capsRows(st, t), st.sel.caps || 0, contentH, cols, t, scrollRef)
2115
2241
  else if (st.screen === 'devvars') body = renderList(devVarRows(st, t), st.sel.devvars || 0, contentH, cols, t, scrollRef)
2116
2242
  else if (st.screen === 'pairmode') body = renderList(pairModeRows(st, t), st.sel.pairmode, contentH, cols, t, scrollRef)
2243
+ else if (st.screen === 'paircaps') body = renderList(pairCapsRows(st, t), st.sel.paircaps || 0, contentH, cols, t, scrollRef)
2117
2244
  else if (st.screen === 'pairing') {
2118
2245
  const pb = pairingBody(st, t, cols, contentH)
2119
2246
  body = scrollBody(pb, contentH, scrollRef)
@@ -2196,7 +2323,9 @@ export async function runTui () {
2196
2323
  const st = {
2197
2324
  screen: 'profiles', // se arranca en la lista de bóvedas: hay que ENTRAR a una
2198
2325
  lang: loadLang(), // es/en — se conmuta con `l` y se recuerda en prefs.json
2199
- sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0, devvars: 0, logins: 0 },
2326
+ sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0, paircaps: 0, devvars: 0, logins: 0 },
2327
+ // Permisos tocados y sin guardar, por llave del aparato (ver `setCapsDraft`).
2328
+ capsDrafts: {},
2200
2329
  // Las bóvedas que ha abierto ESTA sesión, para volver a cerrarlas al salir.
2201
2330
  unlockedHere: new Set(),
2202
2331
  // Su contraseña, SOLO en memoria y SOLO mientras la TUI esté abierta: sirve para
@@ -2254,7 +2383,7 @@ export async function runTui () {
2254
2383
  // input/confirm se AWAITan: serializa las ops contra el daemon (ver onInputKey).
2255
2384
  // Ctrl-C dentro de un modal lo CANCELA (no sale); fuera de un modal, sale.
2256
2385
  if (st.input) { await onInputKey(st, key); continue }
2257
- if (st.confirm) { await onConfirmKey(st, key); continue }
2386
+ if (st.confirm) { await onConfirmKey(st, key); if (st.quit) running = false; continue }
2258
2387
  if (key.name === 'ctrl-c') { running = false; continue }
2259
2388
 
2260
2389
  // TECLEAR ES USO. El candado se cierra a los 5 min de no usarse, y hasta ahora
@@ -2265,8 +2394,12 @@ export async function runTui () {
2265
2394
  seguirAqui(st)
2266
2395
 
2267
2396
  const ch = key.name === 'char' ? key.ch.toLowerCase() : null
2268
- // 'q' global sale.
2269
- if (ch === 'q') { running = false; continue }
2397
+ // 'q' global sale — preguntando antes si hay permisos sin guardar.
2398
+ if (ch === 'q') {
2399
+ if (draftChanges(st).length) unlessDrafts(st, () => { st.quit = true })
2400
+ else running = false
2401
+ continue
2402
+ }
2270
2403
  // 'l' global: idioma es⇄en en cualquier pantalla (por eso el candado es 'c').
2271
2404
  if (ch === 'l') { toggleLang(st); continue }
2272
2405
  // ←→ cambia entre las pestañas de la bóveda entrada (Dispositivos/Scopes).
@@ -2282,7 +2415,9 @@ export async function runTui () {
2282
2415
  // Esc/'b' desde una pestaña vuelve a la lista de bóvedas (salir de la bóveda
2283
2416
  // entrada). La pantalla de emparejamiento maneja su propio Esc (va a Dispositivos).
2284
2417
  if ((key.name === 'escape' || ch === 'b') && INNER_TABS.includes(st.screen)) {
2285
- st.screen = 'profiles'; continue
2418
+ // El borrador es de ESTA bóveda: al salir de ella se guarda o se tira.
2419
+ unlessDrafts(st, () => { st.capsDrafts = {}; st.screen = 'profiles' })
2420
+ continue
2286
2421
  }
2287
2422
 
2288
2423
  if (st.screen === 'profiles') running = await onKeyProfiles(term, st, key)
@@ -2293,6 +2428,7 @@ export async function runTui () {
2293
2428
  else if (st.screen === 'caps') running = await onKeyCaps(term, st, key)
2294
2429
  else if (st.screen === 'devvars') running = await onKeyDevVars(term, st, key)
2295
2430
  else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
2431
+ else if (st.screen === 'paircaps') running = await onKeyPairCaps(term, st, key)
2296
2432
  else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
2297
2433
  else if (st.screen === 'join') running = await onKeyJoin(term, st, key)
2298
2434
  }
@@ -2309,4 +2445,4 @@ export async function runTui () {
2309
2445
  }
2310
2446
 
2311
2447
  // Solo para pruebas headless (render sin terminal real). No usar en runtime.
2312
- export const __test = { render, onKeySecrets, loginRows, onKeyLogins, refreshLogins, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, wrapHelp, wrapWords, joinBody, onKeyJoin, promptJoin, onKeyProfiles, onInputKey, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
2448
+ export const __test = { render, pairCapsRows, onKeyPairCaps, onKeyDevices, draftChanges, saveCapsDrafts, onKeySecrets, loginRows, onKeyLogins, refreshLogins, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, wrapHelp, wrapWords, joinBody, onKeyJoin, promptJoin, onKeyProfiles, onInputKey, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
package/src/tui/i18n.js CHANGED
@@ -78,11 +78,21 @@ const es = {
78
78
  titleCaps: 'Permisos del dispositivo',
79
79
  capsFor: (id, name) => `Permisos de ${id}${name ? ' · ' + name : ''}`,
80
80
  capsNoMember: ' (este dispositivo ya no está en el perfil)',
81
- capsApplyHint: 'Enter marca o desmarca · G guarda los cambios · Esc sale',
82
- capsPending: (n) => `${n} cambio(s) sin guardar (*) · G para guardarlos todos de una vez · Esc para descartarlos`,
83
- capsSaved: (n) => `${n} cambio(s) guardados de una vez`,
81
+ capsApplyHint: 'Enter marca o desmarca · G guarda · Esc vuelve a la lista sin perder lo marcado',
82
+ capsPending: (n) => `${n} cambio(s) sin guardar en este aparato (*) · G guarda todo en una sola acta`,
83
+ capsPendingOthers: (n) => `Y hay cambios en ${n} aparato(s) más: G los guarda juntos`,
84
+ capsSavedMany: (n, d) => `${n} cambio(s) en ${d} aparato(s), guardados en una sola acta`,
84
85
  capsNothing: 'No has cambiado nada',
85
- capsDiscard: '¿Descartar los cambios sin guardar?',
86
+ capsDiscardAll: (n) => `¿Descartar los permisos sin guardar de ${n} aparato(s)?`,
87
+ devicesDraft: (n) => `${n} aparato(s) con permisos sin guardar (*) · G los guarda en una sola acta`,
88
+ // PERMISOS AL EMPAREJAR: se eligen antes del código y el aparato entra ya con ellos.
89
+ titlePairCaps: 'Qué podrá hacer',
90
+ pairCapsFor: (name) => `Permisos con los que entra ${name ? '«' + name + '»' : 'el aparato nuevo'}`,
91
+ pairCapsService: (ns) => `Permisos con los que entra el servicio «${ns}»`,
92
+ pairCapsHint: 'Enter marca o desmarca · G genera el código · Esc cancela. Entra ya con estos: no hace falta cambiarlos después.',
93
+ pairCapsNone: 'Marca al menos un permiso',
94
+ pairCapsNewDevice: 'el aparato nuevo',
95
+ helpPairCaps: ['↑↓', 'Enter marcar', 'G generar código', 'Esc cancelar', 'l English', 'q salir'],
86
96
  // EL NOMBRE DICE EL ACTO, NO LA CONSECUENCIA (dueño, 2026-09-02: «que digan exactamente
87
97
  // lo que hacen y no lo que implican»). «Sellar el acta» y «Administrar el perfil» eran
88
98
  // lo segundo: hay que saberse el modelo para traducirlos. Lo que hacen es admitir
@@ -361,8 +371,9 @@ const es = {
361
371
  // Anunciar teclas muertas confunde y además las quema para otros usos.
362
372
  // `e variables` solo en un SERVICIO: es el único que las lee, así que en un teléfono
363
373
  // era una tecla que solo sabía decir que no.
364
- helpDevices: ({ pending, hasDevices, isService } = {}) => [
374
+ helpDevices: ({ pending, hasDevices, isService, drafts } = {}) => [
365
375
  '←→ pestaña', '↑↓', 'p emparejar',
376
+ ...(drafts ? ['g guardar permisos'] : []),
366
377
  ...(pending ? ['a aprobar', 'x rechazar'] : []),
367
378
  ...(hasDevices ? ['r renombrar', 'c permisos'] : []),
368
379
  ...(isService ? ['e variables'] : []),
@@ -486,11 +497,21 @@ const en = {
486
497
  titleCaps: 'Device permissions',
487
498
  capsFor: (id, name) => `Permissions for ${id}${name ? ' · ' + name : ''}`,
488
499
  capsNoMember: ' (this device is no longer in the record)',
489
- capsApplyHint: 'Enter ticks or unticks · G saves · Esc leaves',
490
- capsPending: (n) => `${n} unsaved change(s) (*) · G to save them in ONE record · Esc to discard`,
491
- capsSaved: (n) => `${n} change(s) saved in one go`,
500
+ capsApplyHint: 'Enter ticks or unticks · G saves · Esc goes back to the list keeping your ticks',
501
+ capsPending: (n) => `${n} unsaved change(s) on this device (*) · G saves everything in ONE record`,
502
+ capsPendingOthers: (n) => `And ${n} more device(s) have changes: G saves them together`,
503
+ capsSavedMany: (n, d) => `${n} change(s) on ${d} device(s), saved in one record`,
492
504
  capsNothing: 'You have not changed anything',
493
- capsDiscard: 'Discard the unsaved changes?',
505
+ capsDiscardAll: (n) => `Discard the unsaved permissions of ${n} device(s)?`,
506
+ devicesDraft: (n) => `${n} device(s) with unsaved permissions (*) · G saves them in one record`,
507
+ // PERMISSIONS WHEN PAIRING: chosen before the code; the device joins already with them.
508
+ titlePairCaps: 'What it will be able to do',
509
+ pairCapsFor: (name) => `Permissions ${name ? '“' + name + '”' : 'the new device'} joins with`,
510
+ pairCapsService: (ns) => `Permissions the “${ns}” service joins with`,
511
+ pairCapsHint: 'Enter ticks or unticks · G creates the code · Esc cancels. It joins with these: no need to change them afterwards.',
512
+ pairCapsNone: 'Tick at least one permission',
513
+ pairCapsNewDevice: 'the new device',
514
+ helpPairCaps: ['↑↓', 'Enter tick', 'G create code', 'Esc cancel', 'l Español', 'q quit'],
494
515
  // El título es el NOMBRE del permiso, el mismo que se teclea. Ver la nota en el bloque
495
516
  // español: describirlo dejaba una lista donde no se sabía qué permiso era cuál.
496
517
  capName: {
@@ -725,8 +746,9 @@ const en = {
725
746
  helpConfirm: 'y confirm · n/Esc cancel',
726
747
 
727
748
  helpProfiles: ['↑↓', 'Enter open', 'p pair', 'j join', 'n new', 'r rename', 'd delete', 'c password', 'x drop-password', 'u unlock', 'k lock', 'l Español', 'q quit'],
728
- helpDevices: ({ pending, hasDevices, isService } = {}) => [
749
+ helpDevices: ({ pending, hasDevices, isService, drafts } = {}) => [
729
750
  '←→ tab', '↑↓', 'p pair',
751
+ ...(drafts ? ['g save permissions'] : []),
730
752
  ...(pending ? ['a approve', 'x reject'] : []),
731
753
  ...(hasDevices ? ['r rename', 'c permissions'] : []),
732
754
  ...(isService ? ['e variables'] : []),
package/src/vault.js CHANGED
@@ -1577,15 +1577,22 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1577
1577
 
1578
1578
  /**
1579
1579
  * PEDIDOS DE APROBACIÓN (aparatos marcados con `approval on`). Entran por `vault.secrets` con
1580
- * `op: approvals | approve | deny`, firmados por un aparato con `vault:approve` — que,
1581
- * como `admin`, no se empareja: se concede a mano (`caps <ID> +aprueba`). El acta tiene
1582
- * que decirlo también, para que quitar el permiso surta efecto en el acto.
1580
+ * `op: approvals | approve | deny`, firmados por un aparato al que el ACTA le da `approve`
1581
+ * — que, como `admin`, no se empareja: se concede a mano (`caps <ID> +aprueba`).
1582
+ *
1583
+ * DECIDE EL ACTA, NO EL PAPEL (dueño, 2026-09-26). El papel solo prueba que el aparato es
1584
+ * de esta bóveda y no está revocado; por eso no se le pide `vault:approve`. Pedírselo
1585
+ * contestaba `unauthorized: scope` a un teléfono SIN el permiso, el teléfono lo tomaba por
1586
+ * un papel atrasado, pedía renovarlo, y renovar firma con la maestra: con la bóveda
1587
+ * cerrada le llegaba «vault locked», que no era la causa. Ahora le llega `acta`, que sí lo
1588
+ * es. Y a uno CON el permiso ya no le hace falta renovar para aprobar: funciona con la
1589
+ * bóveda cerrada, como todos los aparatos.
1583
1590
  */
1584
1591
  async function handleApproval (from, p) {
1585
1592
  const op = p.data?.op
1586
1593
  const chk = await verifyChain({
1587
1594
  data: p.data, signature: p.signature, cert: p.cert,
1588
- expectedScope: SCOPE.APPROVE, ...(await contextoActa()), revoked: await revocationSet()
1595
+ ...(await contextoActa()), revoked: await revocationSet()
1589
1596
  })
1590
1597
  if (!chk.ok) return denyChain(from, chk, p, 'approval')
1591
1598
  const record = (await identity.profileActa?.().catch(() => null))?.acta
@@ -3494,6 +3501,41 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3494
3501
  return secrets.openBundle(ns, devicePub, adminKey)
3495
3502
  }
3496
3503
 
3504
+ /**
3505
+ * CAMBIA LOS PERMISOS DE UNO O VARIOS APARATOS EN UNA SOLA ACTA. `changes`: `[{ pub, caps }]`,
3506
+ * cada `caps` la lista COMPLETA de ese aparato.
3507
+ */
3508
+ async function setCapsMany (changes) {
3509
+ if (!Array.isArray(changes) || !changes.length) throw Object.assign(new Error('no changes to save'), { code: 'NO_CHANGES' })
3510
+ if (typeof identity.setCapsMany !== 'function') {
3511
+ throw Object.assign(new Error('this build\'s @dotrino/identity cannot change several devices at once (needs setCapsMany)'), { code: 'caps-many-unavailable' })
3512
+ }
3513
+ const r = await identity.setCapsMany(changes)
3514
+ // ¿SE QUEDÓ ALGUNO POR EL CAMINO? El acta tiene una lista CERRADA de permisos y
3515
+ // `cleanCaps` descarta los que no conoce — correcto al recibir un acta ajena, y
3516
+ // pésimo aquí: conceder `+sella` con una versión del pilar que no sabe qué es
3517
+ // devolvía «Listo», resellaba el acta y no concedía nada. Costó una tarde en un
3518
+ // contenedor, porque la imagen traía la versión de npm y el árbol local otra.
3519
+ try {
3520
+ const members = (await identity.profileMembers()).members
3521
+ for (const { pub, caps } of changes) {
3522
+ const quedaron = new Set(members.find((m) => m.pub === pub)?.caps || [])
3523
+ const perdidos = caps.filter((c) => !quedaron.has(c))
3524
+ if (perdidos.length) {
3525
+ log(`[vault] WARNING these permissions were DROPPED: ${perdidos.join(', ')} — this build's @dotrino/identity does not know them (record unchanged for those)`)
3526
+ }
3527
+ }
3528
+ } catch (_) { /* comprobar es un extra: si falla, no rompe el cambio */ }
3529
+ const devices = await Promise.all(changes.map(async ({ pub, caps }) => ({ deviceId: await deviceIdOf(pub).catch(() => null), caps })))
3530
+ for (const d of devices) audit('caps', { device: d.deviceId, caps: d.caps })
3531
+ // El acta acaba de cambiar QUIÉN debe tener envoltura de qué: se repasa antes de
3532
+ // avisar, para que el aviso salga con el llavero ya al día.
3533
+ await refreshWraps('caps')
3534
+ // UN aviso: un aparato solo (la forma de siempre) o la lista, si fueron varios.
3535
+ await notifyMembers('caps', devices.length === 1 ? devices[0] : { devices })
3536
+ return r
3537
+ }
3538
+
3497
3539
  /** El miembro del acta con esa llave, o `null` (también si la bóveda todavía no tiene acta). */
3498
3540
  async function memberOf (pub) {
3499
3541
  const record = (await identity.profileActa?.().catch(() => null))?.acta
@@ -3835,27 +3877,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3835
3877
  secretRecipients: (owner) => secrets.recipientsIn(owner),
3836
3878
  // ¿Es ESTA bóveda la que sella el acta? Lo usa el freno de borrado (D12).
3837
3879
  isMaster: () => identity.isMaster(),
3838
- setCaps: async (pub, caps) => {
3839
- const r = await identity.setCaps(pub, caps)
3840
- // ¿SE QUEDÓ ALGUNO POR EL CAMINO? El acta tiene una lista CERRADA de permisos y
3841
- // `cleanCaps` descarta los que no conoce — correcto al recibir un acta ajena, y
3842
- // pésimo aquí: conceder `+sella` con una versión del pilar que no sabe qué es
3843
- // devolvía «Listo», resellaba el acta y no concedía nada. Costó una tarde en un
3844
- // contenedor, porque la imagen traía la versión de npm y el árbol local otra.
3845
- try {
3846
- const quedaron = new Set((await identity.profileMembers()).members.find((m) => m.pub === pub)?.caps || [])
3847
- const perdidos = caps.filter((c) => !quedaron.has(c))
3848
- if (perdidos.length) {
3849
- log(`[vault] WARNING these permissions were DROPPED: ${perdidos.join(', ')} — this build's @dotrino/identity does not know them (record unchanged for those)`)
3850
- }
3851
- } catch (_) { /* comprobar es un extra: si falla, no rompe el cambio */ }
3852
- audit('caps', { device: await deviceIdOf(pub).catch(() => null), caps })
3853
- // El acta acaba de cambiar QUIÉN debe tener envoltura de qué: se repasa antes de
3854
- // avisar, para que el aviso salga con el llavero ya al día.
3855
- await refreshWraps('caps')
3856
- await notifyMembers('caps', { deviceId: await deviceIdOf(pub).catch(() => null), caps })
3857
- return r
3858
- },
3880
+ setCaps: async (pub, caps) => setCapsMany([{ pub, caps }]),
3881
+ // VARIOS APARATOS EN UNA SOLA ACTA (dueño, 2026-09-26): el borrador de la TUI se sella
3882
+ // de golpe, con UN repaso del llavero y UN aviso, en vez de uno por aparato.
3883
+ setCapsMany,
3859
3884
  // Renombrar un dispositivo: es un nombre para el humano (no toca permisos ni llaves),
3860
3885
  // pero pasa por el acta y se avisa, para que el cambio no sea invisible en el resto.
3861
3886
  setLabel: async (pub, label) => {
@@ -46,6 +46,7 @@ const F = {
46
46
  revokeReq: 'revoke-request.json',
47
47
  labelReq: 'label-request.json',
48
48
  capsReq: 'caps-request.json',
49
+ capsRes: 'caps.json',
49
50
  secretReq: 'secret-request.json',
50
51
  profileReq: 'profile-request.json',
51
52
  meReq: 'me-request.json',
@@ -462,6 +463,26 @@ export async function setDeviceCaps (pub, caps, profile) {
462
463
  return listDevices(profile)
463
464
  }
464
465
 
466
+ /**
467
+ * Cambia los permisos de VARIOS dispositivos en UNA sola acta: `[{ pub, caps }]`, cada
468
+ * `caps` la lista completa de ese aparato. Es lo que guarda el borrador de la TUI cuando
469
+ * tocaste más de uno (dueño, 2026-09-26: «con esto me evito más actas inútiles»).
470
+ */
471
+ export async function setDevicesCaps (changes, profile) {
472
+ requireAlive()
473
+ if (!Array.isArray(changes) || !changes.length) throw coded('no changes to save', 'NO_CHANGES')
474
+ const since = Date.now()
475
+ const id = await writeReq(F.capsReq, { changes }, profile)
476
+ signalOrCleanup('SIGUSR2', [F.capsReq])
477
+ // SE ESPERA LA RESPUESTA: con varios aparatos a la vez, un «Guardado» que no lo era
478
+ // deja creer que se firmó lo que no.
479
+ const r = await waitFor(F.capsRes, { req: id, since, tries: 40 })
480
+ if (!r) throw coded('the daemon did not reply', 'NO_REPLY')
481
+ assertOpen(r)
482
+ if (r.ok === false) throw coded(r.error || 'could not change the permissions', r.code || 'CAPS_FAILED')
483
+ return listDevices(profile)
484
+ }
485
+
465
486
  /**
466
487
  * Quita un dispositivo por su llave `sub` (le ordena autoborrarse) y revuelca.
467
488
  * Se acepta un `nonce` suelto por compatibilidad, pero eso retira UN certificado:
@@ -731,15 +752,18 @@ export function pairUrl (qr) {
731
752
  return { url: inviteUrl(qr), code, payload: JSON.stringify(qr), b64: code }
732
753
  }
733
754
 
734
- export async function startPairing ({ profile, service, label } = {}) {
755
+ export async function startPairing ({ profile, service, label, caps } = {}) {
735
756
  requireAlive()
736
757
  rm(F.pair); rm(F.pending)
737
- await writeReq(F.pairReq, { ...(service ? { service } : {}), ...(label ? { label } : {}) }, profile)
758
+ // `caps`: los permisos con los que el aparato entra al acta. Van en la MISMA acta que lo
759
+ // admite, así que no hace falta otra para dárselos después.
760
+ await writeReq(F.pairReq, { ...(service ? { service } : {}), ...(label ? { label } : {}), ...(Array.isArray(caps) ? { caps } : {}) }, profile)
738
761
  signalOrCleanup('SIGUSR1', [F.pairReq])
739
762
  for (let i = 0; i < 50; i++) {
740
763
  await sleep(100)
741
764
  const pr = read(F.pair, null)
742
765
  assertOpen(pr)
766
+ if (pr?.error) throw coded(pr.error, pr.code || 'PAIR_FAILED')
743
767
  if (pr?.expiresAt > Date.now()) {
744
768
  const { url, payload, code } = pairUrl(pr.qr)
745
769
  // `profile`/`profileName`: DE QUÉ CUENTA del vault sale este QR. El vault