@dotrino/vaultd 0.7.3 → 0.7.5

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
@@ -103,9 +103,12 @@ los dispositivos/variables que estás viendo:
103
103
  **entrar** a uno — lo activa si no lo estaba). Ahí también creas una bóveda
104
104
  nueva, renombras, borras y pones/quitas/usas la contraseña (candado).
105
105
  2. Al entrar caes en sus **pestañas horizontales**, que cambias con `←→`:
106
- - **Dispositivos (pares):** verlos, **emparejar** uno nuevo (muestra el QR y la
107
- URL, y espera a que se conecte), **aprobar** con el código que muestra el
108
- dispositivo, **rechazar** y **revocar**.
106
+ - **Dispositivos (pares):** verlos, **emparejar** uno nuevo, **aprobar** con el
107
+ código que muestra el dispositivo, **rechazar** y **revocar**. Al emparejar, la
108
+ bóveda **pregunta primero a qué cuenta entra el dispositivo** —a esta, o a una
109
+ cuenta nueva que se estrena para él— y recién después muestra el QR, que además
110
+ dice de qué cuenta salió. (En la CLI: `dotrino-vault pair --new-account
111
+ [nombre]`.)
109
112
  - **Scopes y variables (secretos):** ver los scopes y sus variables (nunca los
110
113
  valores), **agregar** una variable (con su scope) y **quitar** una variable o
111
114
  un scope entero.
package/lib/src/enroll.js CHANGED
@@ -108,13 +108,29 @@ export function createEnrollDesk ({
108
108
  const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] no se pudo responder:', e.message) } }
109
109
  const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
110
110
 
111
- /** Inicia un emparejamiento: token + nonce de sesión. NO firma nada todavía. */
112
- function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '' } = {}) {
111
+ /**
112
+ * Inicia un emparejamiento: token + nonce de sesión. NO firma nada todavía.
113
+ *
114
+ * `mode` y `account` son LO QUE LA BÓVEDA DECLARA que va a pasar, y viajan en el QR
115
+ * para que el aparato pueda **decirlo antes de hacerlo** en vez de emparejar a
116
+ * ciegas (decisión V9 de `docs/vinculacion-de-cuentas.md`: pregunta el vault, el
117
+ * dispositivo muestra el proceso y sus consecuencias):
118
+ *
119
+ * · `mode: 'join'` → el dispositivo estrena una cuenta suya y entra a la de la
120
+ * bóveda. Es lo único que existe hoy.
121
+ * · `mode: 'adopt'` → la bóveda se quedaría con la cuenta que trae el aparato
122
+ * (camino A). Reservado: todavía no hay protocolo.
123
+ * · `account` → cómo se llama la cuenta de la bóveda, para nombrarla en el
124
+ * aviso. Es ORIENTATIVO (un nombre que puso su dueño); la
125
+ * identidad de verdad de la cuenta es `iss`.
126
+ */
127
+ function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
113
128
  pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
114
129
  const token = randToken()
115
130
  const sn = randToken()
116
- pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, state: 'AWAITING_ENROLL' })
117
- return { token, qr: { v: 2, iss, proxy, token, sn }, expiresInMs: PAIRING_TTL_MS }
131
+ const acct = String(account || '').slice(0, 40)
132
+ pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
133
+ return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
118
134
  }
119
135
 
120
136
  function stopPairing (token) { pending.delete(token) }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
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
@@ -153,6 +153,20 @@ async function cmdPair (args = []) {
153
153
  console.error('uso: dotrino-vault pair --service <ns> (ns en minúsculas, p.ej. proxy)'); process.exit(2)
154
154
  }
155
155
  }
156
+ // `--new-account [nombre]`: la otra respuesta a «¿a qué cuenta entra?». En vez de
157
+ // meter el dispositivo en una cuenta que ya vive aquí, se ESTRENA una (vacía) y
158
+ // entra a ella; las demás no se tocan. En la TUI esto es una pregunta con sus
159
+ // opciones; en la CLI es una bandera, para que siga sirviendo en un script.
160
+ const naIdx = args.findIndex((a) => a === '--new-account')
161
+ if (naIdx >= 0) {
162
+ const next = args[naIdx + 1]
163
+ const name = (next && !next.startsWith('-')) ? next : `cuenta ${new Date().toISOString().slice(0, 10)}`
164
+ const d = await profileRequest('add', { name })
165
+ if (d.error) { console.error('%s', d.error); process.exit(1) }
166
+ if (!d.id) { console.error('El daemon no dijo qué cuenta creó.'); process.exit(1) }
167
+ PROFILE = d.id // el emparejamiento y los comandos siguientes apuntan a ELLA
168
+ console.log('Cuenta nueva: %s (%s)', name, d.id)
169
+ }
156
170
  // La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
157
171
  // se empareja el dispositivo.
158
172
  writeReq('pair-request.json', service ? { service } : {})
@@ -539,6 +553,9 @@ function help () {
539
553
  tui interfaz de terminal a pantalla completa (bóvedas, pares, secretos)
540
554
  status estado del servicio + fingerprint
541
555
  pair [--save <f>] inicia un emparejamiento (QR + espera); --save escribe la invitación (.dpair)
556
+ pair --new-account [nombre]
557
+ estrena una cuenta VACÍA en este vault y mete ahí al dispositivo
558
+ (sin la bandera entra a la cuenta activa, o a la de --profile)
542
559
  pair --service <ns> empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
543
560
  secret set <ns> <CLAVE> <valor> guarda un secreto para el servicio <ns>
544
561
  secret rm <ns> <CLAVE> borra un secreto
package/src/daemon.js CHANGED
@@ -117,11 +117,13 @@ export async function runDaemon () {
117
117
  const isService = typeof pairReq?.service === 'string' && pairReq.service
118
118
  const scope = isService ? ['vault:secrets:' + pairReq.service] : ['vault:sign', 'vault:read', 'vault:store']
119
119
  const label = pairReq?.label || (isService ? 'servicio:' + pairReq.service : 'cli')
120
- const { qr, expiresInMs } = vault.startPairing({ scope, label, ttlMs: DEVICE_TTL_MS })
121
120
  // `profile`/`profileName`: la CUENTA del vault a la que entra el dispositivo.
122
121
  // Con varias bóvedas en el mismo daemon, el QR sale de UNA y quien empareja
123
- // tiene que verlo (lo muestran la TUI y `dotrino-vault pair`).
122
+ // tiene que verlo (lo muestran la TUI y `dotrino-vault pair`). El nombre viaja
123
+ // TAMBIÉN dentro del QR (`acct`) para que el dispositivo pueda anunciar qué va
124
+ // a pasar antes de hacerlo (V9 de docs/vinculacion-de-cuentas.md).
124
125
  const profileName = mgr.profiles.get(profileId)?.name || ''
126
+ const { qr, expiresInMs } = vault.startPairing({ scope, label, ttlMs: DEVICE_TTL_MS, mode: 'join', account: profileName })
125
127
  writeJson(pairFile, { v: 2, qr, expiresAt: Date.now() + expiresInMs, profile: profileId, profileName })
126
128
  // El token es un secreto efímero: no debe quedar en disco más allá de su
127
129
  // vida. Se borra al VENCER (aquí) y al APROBARSE (abajo, consumido).
@@ -156,7 +158,9 @@ export async function runDaemon () {
156
158
  const ref = () => mgr.resolve(req.profile || mgr.currentId())
157
159
  switch (req.op) {
158
160
  case 'list': return {} // el volcado de perfiles ya se hace abajo
159
- case 'add': { const p = await mgr.add(req.name); return { done: `perfil creado: ${p.name || p.id}` } }
161
+ // `id`: quien la crea necesita saber CUÁL quedó, no adivinar por nombre (dos
162
+ // cuentas pueden llamarse igual). Lo usa «emparejar en una cuenta nueva».
163
+ case 'add': { const p = await mgr.add(req.name); return { done: `perfil creado: ${p.name || p.id}`, id: p.id } }
160
164
  case 'rm': { const r = await mgr.remove(req.profile); return { done: `perfil borrado: ${r.name || r.id}` } }
161
165
  case 'rename': { const p = mgr.profiles.rename(ref(), req.name); return { done: `perfil renombrado: ${p.name}` } }
162
166
  case 'use': { const p = mgr.profiles.setCurrent(ref()); return { done: `perfil activo: ${p.name || p.id}` } }
@@ -215,7 +219,9 @@ export async function runDaemon () {
215
219
  rm(profileReqFile) // lleva la contraseña: fuera del disco cuanto antes
216
220
  let extra = {}
217
221
  try { extra = await handleProfileRequest(preq) }
218
- catch (e) { extra = { error: e.message }; console.error('[vault] perfil: %s', e.message) }
222
+ // `code`: la TUI es bilingüe y traduce por código (un freno como el D12 tiene
223
+ // que leerse en el idioma de quien lo lee, no en el del daemon).
224
+ catch (e) { extra = { error: e.message, ...(e.code ? { code: e.code } : {}) }; console.error('[vault] perfil: %s', e.message) }
219
225
  dumpProfiles(extra)
220
226
  } else {
221
227
  dumpProfiles()
package/src/manager.js CHANGED
@@ -13,6 +13,28 @@ import { openProfiles } from './profiles.js'
13
13
  import { installNodeGlobals } from './node-globals.js'
14
14
  import { dataDir, ensureDir } from './paths.js'
15
15
 
16
+ /**
17
+ * D12 (`docs/acta-de-perfil.md`): la bóveda **no** borra una cuenta que ella manda si
18
+ * quedan otros miembros — antes tiene que pasarle el acta a un dispositivo conectado.
19
+ * Es D6 ("perder el master es perder la cuenta") leído al derecho: con más miembros,
20
+ * el que borra no la pierde solo para él.
21
+ *
22
+ * Pura a propósito (recibe el veredicto ya calculado) para poder probar la regla sin
23
+ * levantar un vault entero.
24
+ */
25
+ export function assertCanRemove ({ isMaster, memberCount, name = '' }) {
26
+ if (!isMaster || memberCount <= 1) return true
27
+ const otros = memberCount - 1
28
+ const e = new Error(
29
+ `la cuenta "${name}" la manda esta bóveda y tiene ${otros} dispositivo(s) más: ` +
30
+ 'pásale primero el mando a uno que esté conectado. Si la borras así, se quedan ' +
31
+ 'con su llave y sin nadie que pueda volver a firmar el acta.'
32
+ )
33
+ e.code = 'MASTER_WITH_MEMBERS'
34
+ e.members = memberCount
35
+ throw e
36
+ }
37
+
16
38
  export async function startVaultManager ({ root = dataDir(), proxyUrl, log = console.log, onEnrollChallenge } = {}) {
17
39
  ensureDir(root)
18
40
  // El keypair de transporte del proxy-client es del PROCESO, no de la identidad:
@@ -76,6 +98,21 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
76
98
  /** Borra el perfil: cierra su conexión y elimina su maestra y sus datos. */
77
99
  async remove (ref) {
78
100
  const id = profiles.resolve(ref)
101
+ // FRENO D12 (acta-de-perfil.md): si esta bóveda MANDA la cuenta y quedan otros
102
+ // miembros, borrarla los deja con su llave y sin nadie que pueda volver a sellar
103
+ // el acta: la cuenta muere para todos, en silencio. Primero se le pasa el mando
104
+ // a un dispositivo conectado. (En el dispositivo no hay tal freno: allí borrar
105
+ // se lleva su llave y su copia, y la cuenta sigue viva donde vive el master.)
106
+ const v = running.get(id)
107
+ if (v) {
108
+ const [soyMaster, acta] = await Promise.all([
109
+ v.isMaster().catch(() => false),
110
+ v.profileMembers().catch(() => ({ members: [] }))
111
+ ])
112
+ assertCanRemove({ isMaster: soyMaster, memberCount: (acta?.members || []).length, name: profiles.get(id)?.name || id })
113
+ } else {
114
+ log('[vault] perfil %s no está abierto: se borra sin poder comprobar su acta', id)
115
+ }
79
116
  const res = profiles.remove(id) // valida: no es el único, no está bloqueado
80
117
  try { running.get(id)?.close() } catch (_) {}
81
118
  running.delete(id)
package/src/tui/app.js CHANGED
@@ -50,7 +50,8 @@ function humanErr (e, st) {
50
50
  NO_REPLY: t.errNoReply,
51
51
  NOT_APPLIED: t.errNotApplied,
52
52
  NOT_DELETED: t.errNotDeleted,
53
- PAIR_FAILED: t.errPairFailed
53
+ PAIR_FAILED: t.errPairFailed,
54
+ MASTER_WITH_MEMBERS: t.errMasterWithMembers
54
55
  }
55
56
  return byCode[e?.code] || e?.message || String(e)
56
57
  }
@@ -174,6 +175,30 @@ function deviceRows (st, t) {
174
175
  return rows
175
176
  }
176
177
 
178
+ /**
179
+ * LA PREGUNTA DEL EMPAREJAMIENTO. La decisión es del vault (es quien lo inicia) y
180
+ * este daemon puede tener varias cuentas: antes de mostrar el QR hay que decir a
181
+ * cuál entra el dispositivo. Hoy se responde con las dos formas que existen —una
182
+ * cuenta que ya vive aquí, o una nueva que se estrena para él—; la tercera
183
+ * («adoptar la que trae el aparato») necesita el protocolo de adopción y se
184
+ * muestra desactivada para no prometer lo que todavía no hace
185
+ * (docs/vinculacion-de-cuentas.md §5).
186
+ */
187
+ function pairModeRows (st, t) {
188
+ const i = L(st)
189
+ const ap = activeProfile(st)
190
+ const rows = [{ text: t.muted(' ' + i.pairModeIntro), sel: false }, { text: '', sel: false }]
191
+ rows.push({ text: ` ${t.bold(i.pairModeHere(ap?.name || ap?.id || '—'))}`, sel: true, meta: { mode: 'here' } })
192
+ rows.push({ text: t.muted(' ' + i.pairModeHereHint), sel: false })
193
+ rows.push({ text: '', sel: false })
194
+ rows.push({ text: ` ${t.bold(i.pairModeNew)}`, sel: true, meta: { mode: 'new' } })
195
+ rows.push({ text: t.muted(' ' + i.pairModeNewHint), sel: false })
196
+ rows.push({ text: '', sel: false })
197
+ rows.push({ text: ' ' + t.muted(i.pairModeAdopt), sel: false })
198
+ rows.push({ text: t.muted(' (' + i.pairModeAdoptSoon + ')'), sel: false })
199
+ return rows
200
+ }
201
+
177
202
  function secretRows (st, t) {
178
203
  const i = L(st)
179
204
  const ns = st.secrets || {}
@@ -381,9 +406,10 @@ async function onKeyDevices (term, st, key) {
381
406
  const cur = sels[Math.min(st.sel.devices, sels.length - 1)]
382
407
  const ch = key.name === 'char' ? key.ch.toLowerCase() : null
383
408
 
384
- if (ch === 'p') { // pair
385
- const r = await guard(term, st, i.startingPairing, () => vc.startPairing({ profile: activeId(st) }))
386
- if (r.ok) { st.pairing = r.v; st.pending = null; st.screen = 'pairing' }
409
+ if (ch === 'p') { // pair → primero LA PREGUNTA (a qué cuenta entra), luego el QR
410
+ st.sel.pairmode = 0
411
+ st.scroll.pairmode = { value: 0 }
412
+ st.screen = 'pairmode'
387
413
  } else if (ch === 'a') { // aprobar el pendiente
388
414
  if (!st.pending) { flash(st, i.noPending, 'warn'); return true }
389
415
  promptApprove(term, st)
@@ -407,6 +433,50 @@ async function onKeyDevices (term, st, key) {
407
433
  return true
408
434
  }
409
435
 
436
+ /** Abre el emparejamiento contra `profile` y salta a la pantalla del QR. */
437
+ async function beginPairing (term, st, profile) {
438
+ const r = await guard(term, st, L(st).startingPairing, () => vc.startPairing({ profile }))
439
+ if (r.ok) { st.pairing = r.v; st.pending = null; st.screen = 'pairing' }
440
+ return r.ok
441
+ }
442
+
443
+ async function onKeyPairMode (term, st, key) {
444
+ const i = L(st)
445
+ const rows = pairModeRows(st, term.t)
446
+ const sels = rows.filter((r) => r.sel).map((r) => r.meta)
447
+ moveSel(st, key, 'pairmode', sels.length)
448
+ const cur = sels[Math.min(st.sel.pairmode, sels.length - 1)]
449
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
450
+
451
+ if (key.name === 'escape' || ch === 'b') { st.screen = 'devices'; return true }
452
+ if (key.name !== 'enter' || !cur) return true
453
+
454
+ if (cur.mode === 'here') { await beginPairing(term, st, activeId(st)); return true }
455
+
456
+ // Cuenta nueva: se crea aquí, se ACTIVA (así aprobar/rechazar y las listas miran
457
+ // a la misma que el QR) y recién entonces se abre el emparejamiento contra ella.
458
+ setInput(st, {
459
+ label: i.newAccountLabel,
460
+ hint: i.newAccountHint,
461
+ onSubmit: async (name) => {
462
+ st.input = null
463
+ const nombre = name.trim()
464
+ if (!nombre) { flash(st, i.nameEmpty, 'danger'); return }
465
+ const r = await guard(term, st, i.creatingVault, () => vc.addProfile(nombre))
466
+ if (!r.ok) return
467
+ const nuevo = r.v?.id || (r.v?.profiles || []).find((p) => p.name === nombre)?.id
468
+ if (!nuevo) { flash(st, i.errNoReply, 'danger'); return }
469
+ const u = await guard(term, st, i.switchingVault, () => vc.useProfile(nuevo))
470
+ if (!u.ok) return
471
+ await refreshAll(term, st)
472
+ flash(st, i.accountCreated(nombre))
473
+ await beginPairing(term, st, nuevo)
474
+ },
475
+ onCancel: () => { st.input = null }
476
+ })
477
+ return true
478
+ }
479
+
410
480
  function promptApprove (term, st) {
411
481
  const i = L(st)
412
482
  setInput(st, {
@@ -554,10 +624,15 @@ const helpSegs = (i, screen) => ({
554
624
  profiles: i.helpProfiles,
555
625
  devices: i.helpDevices,
556
626
  secrets: i.helpSecrets,
557
- pairing: i.helpPairing
627
+ pairing: i.helpPairing,
628
+ pairmode: i.helpPairMode
558
629
  })[screen] || []
559
630
 
560
- const title = (i, screen) => (screen === 'profiles' ? i.titleProfiles : screen === 'pairing' ? i.titlePairing : '')
631
+ const title = (i, screen) => ({
632
+ profiles: i.titleProfiles,
633
+ pairing: i.titlePairing,
634
+ pairmode: i.titlePairMode
635
+ })[screen] || ''
561
636
 
562
637
  /** Barra de pestañas horizontal (Dispositivos | Scopes y variables) de la bóveda entrada. */
563
638
  function renderTabs (st, t) {
@@ -639,6 +714,7 @@ function render (term, st) {
639
714
  if (st.screen === 'profiles') body = renderList(profileRows(st, t), st.sel.profiles, contentH, cols, t, scrollRef)
640
715
  else if (st.screen === 'devices') body = renderList(deviceRows(st, t), st.sel.devices, contentH, cols, t, scrollRef)
641
716
  else if (st.screen === 'secrets') body = renderList(secretRows(st, t), st.sel.secrets, contentH, cols, t, scrollRef)
717
+ else if (st.screen === 'pairmode') body = renderList(pairModeRows(st, t), st.sel.pairmode, contentH, cols, t, scrollRef)
642
718
  else if (st.screen === 'pairing') {
643
719
  const pb = pairingBody(st, t, cols, contentH)
644
720
  body = pb.slice(0, contentH)
@@ -724,7 +800,7 @@ export async function runTui () {
724
800
  const st = {
725
801
  screen: 'profiles', // se arranca en la lista de bóvedas: hay que ENTRAR a una
726
802
  lang: loadLang(), // es/en — se conmuta con `l` y se recuerda en prefs.json
727
- sel: { profiles: 0, devices: 0, secrets: 0 },
803
+ sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0 },
728
804
  scroll: {},
729
805
  profiles: null,
730
806
  devices: null,
@@ -788,6 +864,7 @@ export async function runTui () {
788
864
  if (st.screen === 'profiles') running = await onKeyProfiles(term, st, key)
789
865
  else if (st.screen === 'devices') running = await onKeyDevices(term, st, key)
790
866
  else if (st.screen === 'secrets') running = await onKeySecrets(term, st, key)
867
+ else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
791
868
  else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
792
869
  }
793
870
  } finally {
@@ -796,4 +873,4 @@ export async function runTui () {
796
873
  }
797
874
 
798
875
  // Solo para pruebas headless (render sin terminal real). No usar en runtime.
799
- export const __test = { render, profileRows, deviceRows, secretRows, pairingBody, fitHelp, toggleLang }
876
+ export const __test = { render, profileRows, deviceRows, secretRows, pairModeRows, pairingBody, fitHelp, toggleLang }
package/src/tui/i18n.js CHANGED
@@ -37,6 +37,7 @@ const es = {
37
37
  tabsHint: ' (←→ cambiar)',
38
38
  titleProfiles: 'Bóvedas',
39
39
  titlePairing: 'Emparejar un dispositivo',
40
+ titlePairMode: 'Emparejar: ¿a qué cuenta entra?',
40
41
 
41
42
  // bóvedas (perfiles)
42
43
  noPassword: 'sin clave',
@@ -122,7 +123,17 @@ const es = {
122
123
  savingVar: 'Guardando variable…',
123
124
  varSaved: (ns, key) => `Guardado ${ns}/${key}`,
124
125
 
125
- // emparejamiento
126
+ // emparejamiento — la PREGUNTA es del vault, que es quien lo inicia
127
+ pairModeIntro: 'Un dispositivo puede entrar a una cuenta que ya vive aquí, o estrenar una.',
128
+ pairModeHere: (name) => `Entrar a esta cuenta: ${name}`,
129
+ pairModeHereHint: 'el dispositivo pasa a ver y firmar lo de esta cuenta',
130
+ pairModeNew: 'Estrenar una cuenta nueva en este vault',
131
+ pairModeNewHint: 'se crea aquí, vacía, y el dispositivo entra a ELLA (las otras no se tocan)',
132
+ pairModeAdopt: 'Adoptar la cuenta que trae el dispositivo',
133
+ pairModeAdoptSoon: 'todavía no: el dispositivo aún no sabe entregar la suya',
134
+ newAccountLabel: 'Nombre de la cuenta nueva',
135
+ newAccountHint: 'nace vacía; el dispositivo será su primer invitado',
136
+ accountCreated: (name) => `Cuenta creada: ${name}`,
126
137
  pairAccount: (name) => `Cuenta que se comparte: ${name}`,
127
138
  pairValid: (min) => `Válido ~${min} min. Escanéalo o abre la URL en el dispositivo.`,
128
139
  pairUrl: 'URL: ',
@@ -144,6 +155,7 @@ const es = {
144
155
  helpDevices: ['←→ pestaña', '↑↓', 'p emparejar', 'a aprobar', 'x rechazar', 'v revocar', 'r refrescar', 'Esc bóvedas', 'l English', 'q salir'],
145
156
  helpSecrets: ['←→ pestaña', '↑↓', 'n nueva variable', 'x quitar (variable/scope)', 'r refrescar', 'Esc bóvedas', 'l English', 'q salir'],
146
157
  helpPairing: ['a aprobar', 'x rechazar', 'r reiniciar', 'Esc atrás', 'l English'],
158
+ helpPairMode: ['↑↓', 'Enter elegir', 'Esc atrás', 'l English', 'q salir'],
147
159
 
148
160
  // pantalla "daemon caído"
149
161
  downTitle: 'El daemon del vault no está corriendo.',
@@ -167,7 +179,8 @@ const es = {
167
179
  errNoReply: 'El daemon no respondió.',
168
180
  errNotApplied: 'El daemon no aplicó el cambio (revisa los logs del servicio).',
169
181
  errNotDeleted: 'El daemon no borró la variable (revisa los logs del servicio).',
170
- errPairFailed: 'El daemon no inició el emparejamiento.'
182
+ errPairFailed: 'El daemon no inició el emparejamiento.',
183
+ errMasterWithMembers: 'Esta cuenta la manda esta bóveda y tiene otros dispositivos: pásale primero el mando a uno que esté conectado. Si la borras así, se quedan con su llave y sin nadie que pueda volver a firmar el acta.'
171
184
  }
172
185
 
173
186
  // ---------------------------------- inglés ----------------------------------
@@ -190,6 +203,7 @@ const en = {
190
203
  tabsHint: ' (←→ switch)',
191
204
  titleProfiles: 'Vaults',
192
205
  titlePairing: 'Pair a device',
206
+ titlePairMode: 'Pairing: which account does it join?',
193
207
 
194
208
  noPassword: 'no password',
195
209
  locked: '🔒 locked',
@@ -272,6 +286,16 @@ const en = {
272
286
  savingVar: 'Saving variable…',
273
287
  varSaved: (ns, key) => `Saved ${ns}/${key}`,
274
288
 
289
+ pairModeIntro: 'A device can join an account that already lives here, or start a new one.',
290
+ pairModeHere: (name) => `Join this account: ${name}`,
291
+ pairModeHereHint: 'the device gets to see and sign for this account',
292
+ pairModeNew: 'Start a new account in this vault',
293
+ pairModeNewHint: 'created here, empty, and the device joins THAT one (the others are untouched)',
294
+ pairModeAdopt: 'Adopt the account the device brings',
295
+ pairModeAdoptSoon: 'not yet: the device cannot hand its own over',
296
+ newAccountLabel: 'Name of the new account',
297
+ newAccountHint: 'born empty; the device will be its first guest',
298
+ accountCreated: (name) => `Account created: ${name}`,
275
299
  pairAccount: (name) => `Account being shared: ${name}`,
276
300
  pairValid: (min) => `Valid ~${min} min. Scan it or open the URL on the device.`,
277
301
  pairUrl: 'URL: ',
@@ -288,6 +312,7 @@ const en = {
288
312
  helpDevices: ['←→ tab', '↑↓', 'p pair', 'a approve', 'x reject', 'v revoke', 'r refresh', 'Esc vaults', 'l Español', 'q quit'],
289
313
  helpSecrets: ['←→ tab', '↑↓', 'n new variable', 'x remove (variable/scope)', 'r refresh', 'Esc vaults', 'l Español', 'q quit'],
290
314
  helpPairing: ['a approve', 'x reject', 'r restart', 'Esc back', 'l Español'],
315
+ helpPairMode: ['↑↓', 'Enter choose', 'Esc back', 'l Español', 'q quit'],
291
316
 
292
317
  downTitle: 'The vault daemon is not running.',
293
318
  downBody1: 'The TUI gives orders to the daemon (the keeper of your key). Without it',
@@ -309,7 +334,8 @@ const en = {
309
334
  errNoReply: 'The daemon did not answer.',
310
335
  errNotApplied: 'The daemon did not apply the change (check the service logs).',
311
336
  errNotDeleted: 'The daemon did not delete the variable (check the service logs).',
312
- errPairFailed: 'The daemon did not start the pairing.'
337
+ errPairFailed: 'The daemon did not start the pairing.',
338
+ errMasterWithMembers: 'This vault is in charge of this account and it has other devices: hand the lead over to one that is online first. If you delete it like this, they keep their key with nobody able to sign the record again.'
313
339
  }
314
340
 
315
341
  // --------------------------- selección y persistencia -----------------------
package/src/vault.js CHANGED
@@ -296,6 +296,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
296
296
  // Acta del perfil (quién es del perfil y qué puede cada uno): lo que muestran
297
297
  // `dotrino-vault members` y la consola de vault.dotrino.com.
298
298
  profileMembers: () => identity.profileMembers(),
299
+ // ¿Es ESTA bóveda la que sella el acta? Lo usa el freno de borrado (D12).
300
+ isMaster: () => identity.isMaster(),
299
301
  setCaps: (pub, caps) => identity.setCaps(pub, caps),
300
302
  revokeDevice: (nonce) => desk.revoke(nonce),
301
303
  close () { try { client.close() } catch (_) {} identity.destroy() }
@@ -149,7 +149,7 @@ async function profileOp (op, { profile, name, password } = {}) {
149
149
  signalOrCleanup('SIGUSR2', [F.profileReq])
150
150
  const d = await waitFor(F.profilesList)
151
151
  if (!d) throw coded('el daemon no respondió', 'NO_REPLY')
152
- if (d.error) throw new Error(d.error)
152
+ if (d.error) throw coded(d.error, d.code) // p.ej. MASTER_WITH_MEMBERS (freno D12)
153
153
  return d // { profiles:[{id,name,protected,locked,current,fingerprint,iss,createdAt}], current, done? }
154
154
  }
155
155