@dotrino/vaultd 0.132.0 → 0.134.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.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * deviceInfo.js — QUÉ APARATO SOY, dicho igual en todos los comandos del ecosistema.
3
+ *
4
+ * Regla del dueño (2026-09-29): todo comando que se enrola en una bóveda tiene un `info`
5
+ * que enseña la información del aparato, **sobre todo su id**. El id (`AB12-CD34`) es lo que
6
+ * sale en `dotrino-vault members` y lo que pide `dotrino-vault caps <ID>`: sin verlo aquí no
7
+ * hay forma de saber qué fila del acta es esta máquina. `dotrino-env status` enseñaba la
8
+ * llave de la bóveda en crudo (`{"key_ops":["verify"],…`) y ningún id.
9
+ *
10
+ * Una sola pieza para todos: `dotrino-env`, los agentes de remote-agent (terminal, ia) y lo
11
+ * que venga. Local y sin red: lee el enlace que ya está en el disco, así que responde con
12
+ * la bóveda apagada, que es justo cuando más falta hace saber quién eres.
13
+ *
14
+ * Sirve cualquier enlace con la forma común `{ device: { publickey }, cert, iss, proxy }`
15
+ * (el de remote-agent y el de un servicio de dotrino-env).
16
+ */
17
+ import { pubkeyId } from '@dotrino/identity/capabilities'
18
+
19
+ /** El id corto de una llave, como lo enseña la bóveda: `AB12-CD34`. */
20
+ export async function shortId (publickey) {
21
+ return (await pubkeyId(publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
22
+ }
23
+
24
+ /**
25
+ * La información del aparato a partir de su enlace.
26
+ * @param {{ device?: { publickey?: string }, cert?: object, iss?: string, proxy?: string }} link
27
+ * @param {Record<string, any>} [extra] lo propio de cada comando (nombre, versión, carpeta…).
28
+ */
29
+ export async function deviceInfo (link, extra = {}) {
30
+ const pub = link?.device?.publickey
31
+ if (!pub) throw Object.assign(new Error('this link has no device key: enroll first'), { code: 'not-enrolled' })
32
+ const cert = link.cert || null
33
+ return {
34
+ id: await shortId(pub),
35
+ vault: link.iss ? await shortId(link.iss) : null,
36
+ scope: Array.isArray(cert?.scope) ? cert.scope : [],
37
+ // El papel se describe por el acta a la que se ató; uno del modelo viejo, por su fecha,
38
+ // porque eso sí importa: es lo único que todavía caduca.
39
+ record: typeof cert?.seq === 'number' ? cert.seq : null,
40
+ legacyExpires: typeof cert?.seq !== 'number' && typeof cert?.exp === 'number' ? cert.exp : null,
41
+ proxy: link.proxy || null,
42
+ publickey: pub,
43
+ ...extra
44
+ }
45
+ }
46
+
47
+ const LABELS = {
48
+ id: 'Aparato', vault: 'Bóveda', kind: 'Tipo', name: 'Nombre', ns: 'Cajón', version: 'Versión',
49
+ scope: 'Permisos', record: 'Papel', dir: 'Enlace', proxy: 'Proxio'
50
+ }
51
+
52
+ /**
53
+ * El texto que imprime `info`. El id va primero y con la pista de dónde buscarlo: es lo que
54
+ * se viene a mirar.
55
+ * @param {Awaited<ReturnType<typeof deviceInfo>>} info
56
+ */
57
+ export function formatDeviceInfo (info) {
58
+ const rows = []
59
+ const put = (k, v) => { if (v != null && v !== '') rows.push([LABELS[k] || k, v]) }
60
+ put('id', `${info.id} (así sale en \`dotrino-vault members\`)`)
61
+ put('vault', info.vault)
62
+ put('kind', info.kind); put('name', info.name); put('ns', info.ns); put('version', info.version)
63
+ put('scope', info.scope?.length ? info.scope.join(', ') : null)
64
+ put('record', info.record != null
65
+ ? `acta #${info.record}`
66
+ : (info.legacyExpires ? `modelo viejo, vence ${new Date(info.legacyExpires).toISOString().slice(0, 10)}` : null))
67
+ put('dir', info.dir); put('proxy', info.proxy)
68
+ const w = Math.max(...rows.map(([k]) => k.length))
69
+ return rows.map(([k, v]) => `${(k + ':').padEnd(w + 2)}${v}`).join('\n')
70
+ }
@@ -512,6 +512,75 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
512
512
  } finally { client.close() }
513
513
  }
514
514
 
515
+ /**
516
+ * GUARDA VARIABLES EN EL CAJÓN DE ESTE SERVICIO (dueño, 2026-09-29: «para facilitar la
517
+ * migración de ENVs a Dotrino»). Es lo que hay detrás de `dotrino-env import`.
518
+ *
519
+ * Los valores se sellan AQUÍ, en la máquina del servicio, para quien diga el acta; la
520
+ * bóveda recibe sobres que no abre. La lista de destinatarios la firma la bóveda y se
521
+ * comprueba (`verifyResponder`): si el proxio pudiera cambiarla por sus llaves, abriría las
522
+ * privadas al pasar.
523
+ *
524
+ * Lo que decide la bóveda, no este cliente:
525
+ * · si hay quien apruebe, se pide aprobación siempre, y al aprobarse puede EDITAR;
526
+ * · si nadie puede aprobar, solo AÑADE lo que falta (una que ya exista falla con `exists`);
527
+ * · BORRAR no existe por aquí.
528
+ *
529
+ * @param {object} o
530
+ * @param {Array<{ key:string, value:string, public?:boolean }>} o.vars
531
+ * @param {string} [o.dir] identidad de `dotrino-env` (`service-identity.json`); o, en su
532
+ * lugar, la identidad por parámetros (`ns`, `proxyUrl`, `masterPubkey`, `device`, `cert`),
533
+ * que es como la trae un agente de remote-agent.
534
+ * @returns {Promise<{ keys: string[], pending: string|null, approval: boolean }>}
535
+ * `pending`: el id del pedido de aprobación (se guarda al aprobar); `keys`: lo guardado ya.
536
+ */
537
+ export async function storeVars ({ dir, ns, proxyUrl, masterPubkey, device, cert, vars, timeoutMs = 30000 } = {}) {
538
+ const saved = dir ? readServiceIdentity(dir) : null
539
+ ns = ns || saved?.ns
540
+ proxyUrl = proxyUrl || saved?.proxy
541
+ masterPubkey = masterPubkey || saved?.iss
542
+ device = device || saved?.device
543
+ cert = cert || saved?.cert
544
+ if (!isValidSecretsNs(ns)) throw new Error('invalid ns')
545
+ if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
546
+ if (!Array.isArray(vars) || !vars.length) throw new Error('storeVars: no variables to store')
547
+ for (const v of vars) {
548
+ if (typeof v?.key !== 'string' || !v.key) throw new Error('storeVars: every variable needs a key')
549
+ if (typeof v.value !== 'string' || !v.value) throw Object.assign(new Error(`storeVars: "${v.key}" has no value`), { code: 'novalue' })
550
+ }
551
+
552
+ const client = await freshClient(proxyUrl, 20000, masterPubkey)
553
+ try {
554
+ await identifyAsService(client, device)
555
+ const ask = async (data) => {
556
+ const signed = { ...data, ns, publickey: device.publickey, ts: Date.now() }
557
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data: signed })
558
+ const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
559
+ client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data: signed, signature, cert })
560
+ const res = await pending
561
+ if (res.type === MSG.ERROR) throw vaultError(res)
562
+ await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
563
+ if (res.body?.op !== data.op.replace(/^store$/, 'store.result') || res.body?.ns !== ns) throw new Error('the vault answered something else')
564
+ return res.body
565
+ }
566
+
567
+ const { recipients, approval } = await ask({ op: 'store.recipients' })
568
+ const { buildSealedVar, authorFromDeviceKey } = await import('./admin.js')
569
+ const author = authorFromDeviceKey(device)
570
+ const items = []
571
+ for (const v of vars) {
572
+ const sealed = await buildSealedVar({
573
+ recipients: v.public ? recipients.public : recipients.private,
574
+ owner: `ns:${ns}`, key: v.key, value: v.value, author
575
+ })
576
+ items.push({ key: v.key, sealed, public: !!v.public })
577
+ }
578
+ const nonce = [...crypto.getRandomValues(new Uint8Array(16))].map((x) => x.toString(16).padStart(2, '0')).join('')
579
+ const r = await ask({ op: 'store', items, nonce })
580
+ return { keys: r.keys || [], pending: r.pending || null, approval: !!approval }
581
+ } finally { client.close() }
582
+ }
583
+
515
584
  /**
516
585
  * Pide los secretos del ns al vault (una petición puntual; lanza si falla).
517
586
  * Usa la identidad persistida por `enrollService` salvo que se pase explícita.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.132.0",
3
+ "version": "0.134.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": {
package/src/ctl.js CHANGED
@@ -1971,7 +1971,7 @@ function help () {
1971
1971
  console.log(`dotrino-vault — control del certificador personal
1972
1972
 
1973
1973
  tui interfaz de terminal a pantalla completa (bóvedas, pares, secretos)
1974
- status estado del servicio + fingerprint
1974
+ status | info estado del servicio + fingerprint + el id de cada perfil
1975
1975
  pair [--save <f>] inicia un emparejamiento (QR + espera); --save escribe la invitación (.dpair)
1976
1976
  pair --kms <config.json>
1977
1977
  el sitio que se cree (--adopt o --new-account) NACE con su clave
@@ -2178,7 +2178,10 @@ export async function runCtl (argv) {
2178
2178
  case 'profile': return cmdProfile(rest)
2179
2179
  case 'unlock': return cmdUnlock()
2180
2180
  case 'lock': return cmdLock()
2181
- case 'status': return cmdStatus()
2181
+ // `info` es el comando común de todo el ecosistema para «qué aparato soy» (regla del
2182
+ // dueño, 2026-09-29); en la bóveda eso es su estado, que ya enseña el id de cada perfil.
2183
+ case 'status':
2184
+ case 'info': return cmdStatus()
2182
2185
  case 'pair': return cmdPair(rest)
2183
2186
  case 'join': return cmdJoin(rest)
2184
2187
  case 'pending': return cmdPending()
package/src/vault.js CHANGED
@@ -150,6 +150,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
150
150
  * permisos como los demás, y se quita quitándolo — sin acordarse de un registro escondido
151
151
  * en una máquina.
152
152
  */
153
+ /** ¿Hay en el acta alguien que pueda aprobar? Sin nadie, no se pide aprobación. */
154
+ function hasApprover (record) {
155
+ return !!record?.members?.some((m) => Acta.memberCan(record, m.pub, 'approve'))
156
+ }
157
+
153
158
  function needsApproval (pub, record) {
154
159
  if (!record) return true // sin acta no se decide que sí: se pide permiso
155
160
  // SIN NADIE QUE APRUEBE, NO SE NECESITA APROBACIÓN (dueño, 2026-09-24: «la regla es, si
@@ -1423,6 +1428,80 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1423
1428
  }
1424
1429
  }
1425
1430
 
1431
+ /**
1432
+ * UN SERVICIO GUARDA VARIABLES EN SU PROPIO CAJÓN (dueño, 2026-09-29: «para facilitar la
1433
+ * migración de ENVs a Dotrino»). Es lo que usa `dotrino-env import`: el `.env` que ya
1434
+ * existe en la máquina del servicio sube tal cual, sin copiarlo a mano en la consola.
1435
+ *
1436
+ * Va por el canal del servicio —el mismo que lee el cajón— y con SUS fronteras: el cert
1437
+ * con `vault:secrets:<ns>` y el CN del acta. Así un servicio solo escribe en su cajón y
1438
+ * nunca en el de otro. La guarda la hace la MISMA pieza que la consola (`varsDesk`): la
1439
+ * firma del autor, las envolturas, la espera de aprobación. Aquí no hay otra puerta.
1440
+ *
1441
+ * Los límites (dueño, 2026-09-29):
1442
+ * · si hay alguien que aprueba, SIEMPRE se pide aprobación —`unattended` no exime— y con
1443
+ * el sí puede también EDITAR lo que ya existe;
1444
+ * · si nadie puede aprobar, no se pide y solo puede AÑADIR lo que falta;
1445
+ * · BORRAR, nunca: eso es de la consola y del CLI de la bóveda.
1446
+ *
1447
+ * Dos operaciones: `store.recipients` (para quién se sella: solo públicas, nada que ocultar)
1448
+ * y `store` (los sobres ya hechos; la bóveda no los abre).
1449
+ */
1450
+ const storeNonces = new Map()
1451
+ async function handleVarStore (from, p) {
1452
+ const ns = p.data?.ns
1453
+ const op = p.data?.op
1454
+ if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: `${op}: invalid namespace` })
1455
+ const chk = await verifyChain({
1456
+ data: p.data, signature: p.signature, cert: p.cert,
1457
+ expectedScope: secretsScope(ns), ...(await contextoActa()), revoked: await revocationSet()
1458
+ })
1459
+ if (!chk.ok) return denyChain(from, chk, p, op)
1460
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1461
+ if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
1462
+ audit('rejected', { what: op, ns, reason: record ? 'cn' : 'sin-acta' })
1463
+ return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
1464
+ }
1465
+ const deviceId = await deviceIdOf(chk.device).catch(() => null)
1466
+ // FIRMADA COMO TODO LO QUE SIRVE LA BÓVEDA (con la llave de sellado que nombra el acta, y
1467
+ // el acta al lado): `store.recipients` dice PARA QUIÉN se sellan los valores, y si el
1468
+ // proxio pudiera cambiar esa lista por sus llaves, abriría las privadas al pasar.
1469
+ const answer = async (body) => reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
1470
+ try {
1471
+ if (op === 'store.recipients') {
1472
+ return await answer({
1473
+ op: 'store.recipients', ns, ts: Date.now(),
1474
+ recipients: { public: await varsDesk.recipients({ ns, public: true }), private: await varsDesk.recipients({ ns, public: false }) },
1475
+ // Para que quien importa sepa de antemano qué va a pasar con lo que ya existe.
1476
+ approval: hasApprover(record)
1477
+ })
1478
+ }
1479
+ // CAMBIA ESTADO: un solo uso. La frescura (±5 min) no basta — repetir el mismo sobre
1480
+ // dentro de esa ventana volvería a timbrar el teléfono de quien aprueba.
1481
+ const nonce = p.data?.nonce
1482
+ if (typeof nonce !== 'string' || nonce.length < 16) return reply(from, { type: MSG.ERROR, code: 'no-nonce', error: 'store: missing single-use nonce' })
1483
+ const now = Date.now()
1484
+ for (const [n, exp] of storeNonces) if (exp <= now) storeNonces.delete(n)
1485
+ if (storeNonces.has(nonce)) { audit('rejected', { what: 'store', ns, reason: 'replay' }); return reply(from, { type: MSG.ERROR, code: 'replay', error: 'store: nonce already used' }) }
1486
+ storeNonces.set(nonce, now + 10 * 60 * 1000)
1487
+
1488
+ const items = p.data?.items
1489
+ if (!Array.isArray(items) || !items.length) return reply(from, { type: MSG.ERROR, error: 'store: needs `items`, each with its key and its sealed envelope' })
1490
+ const result = await varsDesk.setMany({ ns, items, caller: chk.device, by: deviceId, fromService: true })
1491
+ if (result?.pending) {
1492
+ audit('store.pending', { device: deviceId, ns, id: result.pending, count: items.length })
1493
+ log(`[vault] ${ns}: ${deviceId || '????-????'} wants to store ${items.length} variable(s) — waiting for approval (${result.pending})`)
1494
+ } else {
1495
+ audit('store', { device: deviceId, ns, keys: result?.keys || [] })
1496
+ log(`[vault] ${ns}: ${deviceId || '????-????'} stored ${(result?.keys || []).length} variable(s) (nobody can approve: add only)`)
1497
+ await notifyMembers('vars', { by: deviceId, keys: result?.keys || [], ns })
1498
+ }
1499
+ return await answer({ op: 'store.result', ns, ts: Date.now(), keys: result?.keys || [], pending: result?.pending || null })
1500
+ } catch (e) {
1501
+ return reply(from, { type: MSG.ERROR, ...(e.code ? { code: e.code } : {}), error: `${op}: ${e.message}` })
1502
+ }
1503
+ }
1504
+
1426
1505
  async function handleSecrets (from, p) {
1427
1506
  if (!isFresh(p.data)) { audit('rejected', { what: 'secrets', reason: 'stale' }); return staleReply(from) }
1428
1507
  // REGISTRAR LA LLAVE DE CIFRADO de un servicio ya enrolado. Va por aquí, y no por
@@ -1433,6 +1512,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1433
1512
  // da acceso a nada por sí solo. Quien firma esta petición ya tiene la llave de firma
1434
1513
  // del servicio y su cert, o sea que ya lee ese namespace. No hay escalada.
1435
1514
  if (p.data?.op === 'enckey') return handleEncKey(from, p)
1515
+ if (p.data?.op === 'store.recipients' || p.data?.op === 'store') return handleVarStore(from, p)
1436
1516
  if (['approvals', 'approve', 'deny', 'grants', 'grant-revoke'].includes(p.data?.op)) return handleApproval(from, p)
1437
1517
  const ns = p.data?.ns
1438
1518
  if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
@@ -2323,8 +2403,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2323
2403
  // PÚBLICA Y PRIVADA VAN IGUAL (dueño, 2026-09-02: «la única diferencia es si se
2324
2404
  // despachan o no, son políticas; dales el mismo tratamiento de seguridad»). La
2325
2405
  // marca solo decide si se entrega sin aprobación.
2406
+ // Si esto va a pedir aprobación, al aprobarse puede EDITAR; si no, solo añadir.
2407
+ const ask = needsApproval(caller, await refreshActa())
2326
2408
  const apply = async () => {
2327
- await checkAuthor(owner, key, sealed, caller)
2409
+ await checkAuthor(owner, key, sealed, caller, { allowOverwrite: ask })
2328
2410
  await checkRecipients(owner, sealed.wraps, !!isPublic)
2329
2411
  await secrets.putSealed(owner, key, sealed, { by: who, public: isPublic })
2330
2412
  audit('secret.set', { ns: ns || null, key, sealed: true }); scheduleNotice(ns)
@@ -2332,9 +2414,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2332
2414
  }
2333
2415
  // Comprobado ANTES de dejarlo en espera: un sobre malo falla al guardarlo, no cinco
2334
2416
  // minutos después en la pantalla de quien aprueba. `apply` lo repite al aprobar.
2335
- await checkAuthor(owner, key, sealed, caller)
2417
+ await checkAuthor(owner, key, sealed, caller, { allowOverwrite: ask })
2336
2418
  await checkRecipients(owner, sealed.wraps, !!isPublic)
2337
- const pending = await holdWrite({ caller, ns, pub, keys: [key], apply })
2419
+ const pending = await holdWrite({ caller, ns, pub, keys: [key], apply, ask })
2338
2420
  if (pending) return { ok: true, key, pending }
2339
2421
  await apply()
2340
2422
  return { ok: true, key }
@@ -2362,7 +2444,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2362
2444
  * Los NOMBRES también viajan dentro del sobre —no solo los valores—: el proxy
2363
2445
  * transporta y no tiene por qué aprender cómo se llama la configuración de un servicio.
2364
2446
  */
2365
- async setMany ({ ns, pub, items, caller = null, by: who = null }) {
2447
+ async setMany ({ ns, pub, items, caller = null, by: who = null, fromService = false }) {
2366
2448
  // CADA VARIABLE VIENE EN SU PROPIO SOBRE, ya hecho. La bóveda no abre ninguno: solo
2367
2449
  // comprueba quién los firma y a quién envuelven, igual que en `set`. Aquí estaba el
2368
2450
  // `enc` que las traía todas juntas selladas AL PERFIL, y abrirlo era lo que obligaba
@@ -2370,16 +2452,24 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2370
2452
  if (!Array.isArray(items) || !items.length) throw new Error('var.setMany: no variables came')
2371
2453
  const owner = ns ? `ns:${ns}` : `dev:${pub}`
2372
2454
  const keys = []
2455
+ // ¿SE PIDE APROBACIÓN? Desde la mesa del admin, lo de siempre (`needsApproval`: quien
2456
+ // tiene `unattended` escribe solo). Desde el propio SERVICIO que guarda en su cajón, se
2457
+ // pide SIEMPRE que haya quien apruebe, tenga o no `unattended` (dueño, 2026-09-29:
2458
+ // «el unattended también pediría aprobación»): `unattended` es para llevarse claves al
2459
+ // arrancar, no para reescribir su propia configuración. Sin nadie que apruebe no se
2460
+ // pide —la regla de siempre— y entonces solo puede añadir.
2461
+ const record = await refreshActa()
2462
+ const ask = fromService ? hasApprover(record) : needsApproval(caller, record)
2373
2463
  for (const it of items) {
2374
2464
  if (!it?.key || !it?.sealed) throw new Error('var.setMany: each variable needs its key and its sealed envelope')
2375
- await checkAuthor(owner, it.key, it.sealed, caller)
2465
+ await checkAuthor(owner, it.key, it.sealed, caller, { allowOverwrite: ask })
2376
2466
  await checkRecipients(owner, it.sealed.wraps, !!it.public)
2377
2467
  }
2378
2468
  // Se comprueban TODAS antes de escribir NINGUNA: media carga aplicada es una
2379
2469
  // configuración que nadie quiso, y el servicio se reinicia con ella.
2380
2470
  const apply = async () => {
2381
2471
  for (const it of items) {
2382
- await checkAuthor(owner, it.key, it.sealed, caller)
2472
+ await checkAuthor(owner, it.key, it.sealed, caller, { allowOverwrite: ask })
2383
2473
  await checkRecipients(owner, it.sealed.wraps, !!it.public)
2384
2474
  }
2385
2475
  for (const it of items) {
@@ -2389,7 +2479,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2389
2479
  audit('secret.setMany', { ns: ns || null, keys }); scheduleNotice(ns)
2390
2480
  await settleDebts(owner)
2391
2481
  }
2392
- const pending = await holdWrite({ caller, ns, pub, keys: items.map((it) => it.key), apply })
2482
+ const pending = await holdWrite({ caller, ns, pub, keys: items.map((it) => it.key), apply, ask })
2393
2483
  if (pending) return { ok: true, keys: [], pending }
2394
2484
  await apply()
2395
2485
  return { ok: true, keys }
@@ -2410,8 +2500,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2410
2500
  *
2411
2501
  * Devuelve el id del pedido si la escritura quedó en espera, o `null` si se puede hacer ya.
2412
2502
  */
2413
- async function holdWrite ({ caller, ns, pub, keys, apply }) {
2414
- if (!needsApproval(caller, await refreshActa())) return null
2503
+ async function holdWrite ({ caller, ns, pub, keys, apply, ask }) {
2504
+ if (!(ask ?? needsApproval(caller, await refreshActa()))) return null
2415
2505
  // Quien llama ya comprobó el sobre; `apply` lo vuelve a comprobar al aprobar, porque el
2416
2506
  // acta pudo cambiar en medio.
2417
2507
  const target = ns || await deviceIdOf(pub).catch(() => null) || '?'
@@ -3062,7 +3152,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3062
3152
  * relevo: alguien con `admin` colando un sobre que fabricó un tercero, sin que ninguna de
3063
3153
  * las dos comprobaciones lo note.
3064
3154
  */
3065
- async function checkAuthor (owner, key, sealed, caller) {
3155
+ async function checkAuthor (owner, key, sealed, caller, { allowOverwrite = false } = {}) {
3066
3156
  const a = sealed?.author
3067
3157
  if (!a || typeof a.pub !== 'string' || typeof a.sig !== 'string' || typeof a.ts !== 'number') {
3068
3158
  throw new Error('var.set: the envelope must say WHO made it (`author: { pub, sig, ts }`)')
@@ -3088,12 +3178,17 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3088
3178
  // un sobre existente; solamente el admin y el vault editan sobres, los otros pueden
3089
3179
  // crear los que faltan nomás»).
3090
3180
  //
3181
+ // SALVO QUE ALGUIEN LO APRUEBE (dueño, 2026-09-29: «si requiere aprobación hagamos que
3182
+ // pueda también editar; si no hay approval, solamente agregar — la aprobación nos da el
3183
+ // nivel de seguridad aceptable»). Un sí de quien aprueba es justo la decisión humana que
3184
+ // faltaba para reemplazar; sin él, sigue siendo solo rellenar.
3185
+ //
3091
3186
  // Es la misma regla que `putWrap` («solo añade, nunca pisa») y por el mismo motivo: un
3092
3187
  // servicio que pudiera reemplazar un sobre podría dejar sin leer a otro miembro con uno
3093
3188
  // basura — denegación de servicio disfrazada de escritura. Rellenar lo que falta no
3094
3189
  // quita nada a nadie; reemplazar sí, y eso es de quien administra.
3095
- if (m.cn && secrets.has?.(owner, key)) {
3096
- throw new Error(`var.set: "${m.cn}" cannot overwrite "${key}" — a service only fills in what is missing; replacing is for who administers`)
3190
+ if (m.cn && !allowOverwrite && secrets.has?.(owner, key)) {
3191
+ throw Object.assign(new Error(`var.set: "${m.cn}" cannot overwrite "${key}" — without an approval a service only fills in what is missing; replacing is for who administers`), { code: 'exists' })
3097
3192
  }
3098
3193
  const cuerpo = authorBody(owner, key, sealed.e, a.ts)
3099
3194
  if (!(await verifyDeviceSig({ publickey: a.pub, data: cuerpo, signature: a.sig }))) {