@dotrino/vaultd 0.131.6 → 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 +3 -2
- package/lib/src/enroll.js +23 -5
- package/package.json +3 -3
- package/src/ctl.js +17 -29
- package/src/daemon.js +47 -29
- package/src/tui/app.js +228 -92
- package/src/tui/i18n.js +32 -10
- package/src/vault.js +39 -21
- package/src/vaultControl.js +26 -2
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
2005
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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(
|
|
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](
|
|
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](
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1241
|
-
|
|
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
|
-
*
|
|
1275
|
-
*
|
|
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')
|
|
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
|
|
1305
|
-
//
|
|
1306
|
-
|
|
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
|
-
|
|
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 (!
|
|
1316
|
-
const
|
|
1317
|
-
|
|
1318
|
-
const
|
|
1319
|
-
if (!
|
|
1320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1336
|
-
if (
|
|
1337
|
-
st.
|
|
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
|
-
|
|
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') {
|
|
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
|
-
|
|
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
|
|
82
|
-
capsPending: (n) => `${n} cambio(s) sin guardar (*) · G
|
|
83
|
-
|
|
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
|
-
|
|
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
|
|
490
|
-
capsPending: (n) => `${n} unsaved change(s) (*) · G
|
|
491
|
-
|
|
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
|
-
|
|
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
|
@@ -3501,6 +3501,41 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3501
3501
|
return secrets.openBundle(ns, devicePub, adminKey)
|
|
3502
3502
|
}
|
|
3503
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
|
+
|
|
3504
3539
|
/** El miembro del acta con esa llave, o `null` (también si la bóveda todavía no tiene acta). */
|
|
3505
3540
|
async function memberOf (pub) {
|
|
3506
3541
|
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
@@ -3842,27 +3877,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3842
3877
|
secretRecipients: (owner) => secrets.recipientsIn(owner),
|
|
3843
3878
|
// ¿Es ESTA bóveda la que sella el acta? Lo usa el freno de borrado (D12).
|
|
3844
3879
|
isMaster: () => identity.isMaster(),
|
|
3845
|
-
setCaps: async (pub, caps) => {
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
// pésimo aquí: conceder `+sella` con una versión del pilar que no sabe qué es
|
|
3850
|
-
// devolvía «Listo», resellaba el acta y no concedía nada. Costó una tarde en un
|
|
3851
|
-
// contenedor, porque la imagen traía la versión de npm y el árbol local otra.
|
|
3852
|
-
try {
|
|
3853
|
-
const quedaron = new Set((await identity.profileMembers()).members.find((m) => m.pub === pub)?.caps || [])
|
|
3854
|
-
const perdidos = caps.filter((c) => !quedaron.has(c))
|
|
3855
|
-
if (perdidos.length) {
|
|
3856
|
-
log(`[vault] WARNING these permissions were DROPPED: ${perdidos.join(', ')} — this build's @dotrino/identity does not know them (record unchanged for those)`)
|
|
3857
|
-
}
|
|
3858
|
-
} catch (_) { /* comprobar es un extra: si falla, no rompe el cambio */ }
|
|
3859
|
-
audit('caps', { device: await deviceIdOf(pub).catch(() => null), caps })
|
|
3860
|
-
// El acta acaba de cambiar QUIÉN debe tener envoltura de qué: se repasa antes de
|
|
3861
|
-
// avisar, para que el aviso salga con el llavero ya al día.
|
|
3862
|
-
await refreshWraps('caps')
|
|
3863
|
-
await notifyMembers('caps', { deviceId: await deviceIdOf(pub).catch(() => null), caps })
|
|
3864
|
-
return r
|
|
3865
|
-
},
|
|
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,
|
|
3866
3884
|
// Renombrar un dispositivo: es un nombre para el humano (no toca permisos ni llaves),
|
|
3867
3885
|
// pero pasa por el acta y se avisa, para que el cambio no sea invisible en el resto.
|
|
3868
3886
|
setLabel: async (pub, label) => {
|
package/src/vaultControl.js
CHANGED
|
@@ -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
|
-
|
|
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
|