@dotrino/vaultd 0.98.0 → 0.105.2

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/src/vault.js CHANGED
@@ -26,7 +26,7 @@ import { startSealersPublisher } from './sealers.js'
26
26
  import { assertKeyOwnsDir } from './keyowner.js'
27
27
  import { openStore } from './store.js'
28
28
  import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
29
- import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP } from './secretsStore.js'
29
+ import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_OWNER } from './secretsStore.js'
30
30
  // `PENDING_TTL_MS` se usa abajo, al esperar la firma del aprobador: sin importarlo, esa
31
31
  // espera reventaba con un ReferenceError y la aprobación del mostrador de contraseñas no
32
32
  // llegaba a existir. Solo se veía por ese camino —el único que lo usa—, y no había prueba
@@ -36,7 +36,9 @@ import { makeSealer } from './sealer.js'
36
36
  import { openSealKeys } from './sealKey.js'
37
37
  import { openCommKey, COMM_CN, COMM_CAPS } from './commKey.js'
38
38
  import { seal } from '../lib/src/sealed.js'
39
+ import { localSocketPath, socketDir } from '../lib/src/localdesk.js'
39
40
  import { dataDir, ensureDir } from './paths.js'
41
+ import net from 'node:net'
40
42
  import { atRestFor, kekFor, migrateFile, encryptText, decryptText } from './atrest.js'
41
43
  import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
42
44
 
@@ -321,15 +323,26 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
321
323
  * Así que una respuesta que no quepa no se manda: se sustituye por un error, que sí cabe.
322
324
  * Una bóveda que dice «no cabe» se arregla; una muda no se puede ni diagnosticar.
323
325
  */
326
+ /**
327
+ * QUIÉN CONTESTA A QUIÉN. Por defecto el proxio; un remitente LOCAL (el socket de esta
328
+ * máquina) se apunta aquí con su propia forma de contestar.
329
+ *
330
+ * Que haya dos transportes no cambia NADA de quién puede pedir qué: la autorización sale
331
+ * del certificado y del acta, igual por los dos caminos. Alcanzar el socket no autoriza
332
+ * nada — es la misma puerta, con el camino más corto.
333
+ */
334
+ const canales = new Map()
335
+
324
336
  const reply = (to, obj) => {
325
337
  try {
338
+ const enviar = canales.get(to) || ((o) => client.send(to, o))
326
339
  const bytes = Buffer.byteLength(JSON.stringify(obj))
327
340
  if (bytes > MAX_REPLY_BYTES) {
328
341
  log(`[vault] the reply to ${obj?.type} does not fit (${bytes} bytes > ${MAX_REPLY_BYTES}): sending an error instead of killing the connection`)
329
342
  audit('reply-too-big', { type: obj?.type || null, bytes })
330
- return client.send(to, { type: MSG.ERROR, error: `reply too big (${bytes} bytes): ask for less at a time` })
343
+ return enviar({ type: MSG.ERROR, error: `reply too big (${bytes} bytes): ask for less at a time` })
331
344
  }
332
- client.send(to, obj)
345
+ enviar(obj)
333
346
  } catch (e) { log('[vault] could not reply:', e.message) }
334
347
  }
335
348
 
@@ -610,6 +623,69 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
610
623
 
611
624
  // Store de hilos+aperturas (Fase 3): escrituras requieren vault:store; lecturas
612
625
  // aceptan vault:store o vault:read. Cada op va firmada por D + cert (cadena D←maestra).
626
+ /**
627
+ * LOS DATOS DEL PERFIL (`docs/datos-del-perfil.md`). Tres métodos y ninguno más:
628
+ *
629
+ * · `profilePut` — guarda un dato. Privado = sobre ya sellado; público = en claro.
630
+ * · `profileBundle` — los sobres de ESTE aparato, para que arme su perfil.
631
+ * · `profilePublic` — lo público en claro, que puede pedir cualquiera.
632
+ *
633
+ * Lo que NO se comprueba aquí: el candado. Un sobre sellado no lo puede leer ni fabricar
634
+ * la bóveda, así que guardarlo no es una decisión suya y no necesita su maestra. Ese era
635
+ * el motivo real por el que editar el perfil exigía abrirla, y es el síntoma que abrió
636
+ * todo esto: se cerraba sola a los cinco minutos y editar dejaba de funcionar.
637
+ */
638
+ const PROFILE_METHODS = new Set(['profilePut', 'profileBundle', 'profilePublic', 'profileRecipients'])
639
+
640
+ async function handleProfile (from, p, d) {
641
+ // LO PÚBLICO NO PIDE NADA. Es lo que ve quien pregunta desde fuera y no tiene ninguna
642
+ // llave tuya: pedir un papel para leerlo sería contradecir lo que significa «público».
643
+ // Va firmado, así que quien lo recibe comprueba que es tuyo sin fiarse de nadie.
644
+ if (d.method === 'profilePublic') {
645
+ return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: secrets.profilePublic() })
646
+ }
647
+ // A QUIÉN HAY QUE ENVOLVERLE. Lo pide el aparato ANTES de sellar, porque sellar lo hace
648
+ // él: envolver solo necesita públicas, así que puede hacerlo un navegador sin que
649
+ // ninguna privada ande suelta. Sale de aquí y no de una lista suya para que conceder
650
+ // `firma` a un aparato nuevo baste — sin que nadie tenga que acordarse de nada.
651
+ if (d.method === 'profileRecipients') {
652
+ const members = await recipientsOf(PROFILE_OWNER)
653
+ return reply(from, {
654
+ type: MSG.STORE_RESULT,
655
+ method: d.method,
656
+ result: { recoveryPub: secrets.recoveryPub(), members: members.map((m) => ({ pub: m.pub, encPub: m.encPub })) }
657
+ })
658
+ }
659
+
660
+ const revoked = await revocationSet()
661
+ // `vault:sign` — hablar por ti. No `vault:store`, que es guardar cosas.
662
+ const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.SIGN, ...(await contextoActa()), revoked })
663
+ if (!chk.ok) return denyChain(from, chk, p, 'profile')
664
+ if (!await actaAllows(from, chk, SCOPE.SIGN, 'profile')) return
665
+
666
+ try {
667
+ if (d.method === 'profileBundle') {
668
+ const b = secrets.profileBundleFor(chk.device)
669
+ return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: b })
670
+ }
671
+ // profilePut
672
+ const { key, cls, sobre, value } = d.args || {}
673
+ const quien = await deviceIdOf(chk.device).catch(() => null)
674
+ let r
675
+ if (cls === 'public') {
676
+ r = await secrets.profilePutPublic(key, value, { by: quien })
677
+ } else {
678
+ r = await secrets.putSealed(PROFILE_OWNER, key, sobre, { by: quien })
679
+ }
680
+ audit('profile.put', { key, cls: cls || 'private', by: quien, gen: r?.gen ?? null })
681
+ // Y a los demás aparatos, que si no se enteran al arrancar y vuelve la divergencia.
682
+ await notifyMembers('profile', { key, by: quien })
683
+ return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: r })
684
+ } catch (e) {
685
+ return reply(from, { type: MSG.ERROR, error: 'profile: ' + e.message })
686
+ }
687
+ }
688
+
613
689
  async function handleStore (from, p) {
614
690
  const d = p.data
615
691
  // `Object.hasOwn` y no `threads.methods[d.method]`: con la comprobación laxa,
@@ -619,9 +695,19 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
619
695
  return reply(from, { type: MSG.ERROR, error: 'store: invalid method' })
620
696
  }
621
697
  if (!isFresh(d)) return staleReply(from)
622
- // CANDADO del perfil (contraseña opcional): solo frena EDITAR el perfil. Un
623
- // dispositivo enrolado puede seguir firmando, leyendo y guardando contenido;
624
- // lo que no puede es reescribir quién sos mientras el perfil está bloqueado.
698
+ // EL PERFIL VA POR OTRO CAMINO, y por eso se atiende antes que nada
699
+ // (`docs/datos-del-perfil.md`). Dos diferencias, y las dos son el arreglo:
700
+ //
701
+ // · **No hace falta abrir la bóveda.** Lo que llega es un sobre ya sellado y
702
+ // firmado, que ella no puede leer ni falsificar: aceptarlo no es una decisión
703
+ // suya. El candado estaba ahí porque guardaba el perfil en claro; ya no.
704
+ // · **Lo gatea `firma`, no `guarda`.** Escribir tu identidad es hablar por ti, no
705
+ // guardar un archivo en tu bóveda (dueño, 2026-09-03). El proxio tiene `guarda`
706
+ // para lo suyo y no debe poder cambiar quién eres.
707
+ if (PROFILE_METHODS.has(d.method)) return await handleProfile(from, p, d)
708
+ // CANDADO del perfil (contraseña opcional): solo frena EDITAR el perfil heredado
709
+ // (`profileSet`, el `me` plano). Un dispositivo enrolado puede seguir firmando,
710
+ // leyendo y guardando contenido.
625
711
  if (PROFILE_EDIT_METHODS.has(d.method) && isLocked()) {
626
712
  audit('rejected', { what: 'store', method: d.method, reason: 'locked' })
627
713
  return reply(from, { type: MSG.ERROR, error: 'profile locked: unlock it on the vault machine (dotrino-vault unlock) to edit it' })
@@ -981,7 +1067,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
981
1067
  if (!publicOnly && needsApproval(chk.device, record)) {
982
1068
  const deviceId = await deviceIdOf(chk.device).catch(() => null)
983
1069
  const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
984
- const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek })
1070
+ // `from` SE GUARDA: la respuesta llega más tarde —cuando alguien apruebe— y tiene que
1071
+ // volver POR DONDE VINO. Si la pregunta entró por el mostrador local, mandarla por el
1072
+ // proxio la deja en el vacío: quien preguntó está escuchando en el socket, no ahí.
1073
+ // Lo cazó el E2E de aprobación, que se quedó esperando cinco minutos.
1074
+ const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek, from })
985
1075
  audit('secrets.pending', { device: deviceId, ns, id: pend.id })
986
1076
  log(`[vault] ${ns}: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
987
1077
  const body = { op: 'secrets.pending', ns, id: pend.id, exp: pend.exp, ts: Date.now() }
@@ -1093,8 +1183,12 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1093
1183
  if (op === 'deny') {
1094
1184
  audit('secrets.denied', { device: pend.deviceId, ns: pend.ns, id, by })
1095
1185
  log(`[vault] ${pend.ns}: request of ${pend.deviceId} DENIED by ${by}`)
1096
- try { client.sendByPubkey(pend.device, { type: MSG.ERROR, error: `unauthorized: denied — the "${pend.ns}" request was denied from ${by}` }) }
1097
- catch (e) { log(`[vault] ${pend.ns}: could not tell ${pend.deviceId} it was denied: ${e.message}`) }
1186
+ try {
1187
+ const err = { type: MSG.ERROR, error: `unauthorized: denied — the "${pend.ns}" request was denied from ${by}` }
1188
+ const porLocal = pend.from && canales.get(pend.from)
1189
+ if (porLocal) porLocal(err)
1190
+ else client.sendByPubkey(pend.device, err)
1191
+ } catch (e) { log(`[vault] ${pend.ns}: could not tell ${pend.deviceId} it was denied: ${e.message}`) }
1098
1192
  return answer({ op: 'deny.result', id, ok: true })
1099
1193
  }
1100
1194
  if (op !== 'approve') return reply(from, { type: MSG.ERROR, error: 'approval: unknown op' })
@@ -1108,8 +1202,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1108
1202
  // EL FALLO NO SE TRAGA. Tragarlo era lo peor de este mostrador: la bóveda apuntaba
1109
1203
  // «aprobado», el que pedía no recibía nada, y no quedaba una sola línea que mirar —
1110
1204
  // así que el dueño aprobaba una y otra vez creyendo que el teléfono no llegaba.
1111
- try { client.sendByPubkey(pend.device, { type: MSG.SECRETS_RESULT, ...res }) }
1112
- catch (e) { log(`[vault] ${pend.ns}: approved, but the reply did not reach ${pend.deviceId}: ${e.message}`) }
1205
+ // Por el canal por el que preguntó. Si fue el proxio, `sendByPubkey`: así lo recoge al
1206
+ // volver aunque ahora esté apagado. Si fue el socket local, por el socket.
1207
+ try {
1208
+ const porLocal = pend.from && canales.get(pend.from)
1209
+ if (porLocal) porLocal({ type: MSG.SECRETS_RESULT, ...res })
1210
+ else client.sendByPubkey(pend.device, { type: MSG.SECRETS_RESULT, ...res })
1211
+ } catch (e) { log(`[vault] ${pend.ns}: approved, but the reply did not reach ${pend.deviceId}: ${e.message}`) }
1113
1212
  return answer({ op: 'approve.result', id, ok: true })
1114
1213
  }
1115
1214
 
@@ -1144,7 +1243,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1144
1243
  } catch (e) { log('[vault] could not notify approvers:', e.message) }
1145
1244
  }
1146
1245
 
1147
- client.on('message', async (from, payload) => {
1246
+ client.on('message', (from, payload) => onMessage(from, payload))
1247
+
1248
+ // EL MOSTRADOR LOCAL, siempre que se pueda. Si no se puede abrir (un sistema sin sockets
1249
+ // de archivo, un directorio de solo lectura) se dice y se sigue: los servicios usan el
1250
+ // proxio como hasta ahora. Lo que NO se hace es callarlo — un atajo que no está y nadie
1251
+ // lo dice es media hora buscando por qué algo va lento.
1252
+ // El socket se nombra por la LLAVE de esta bóveda: una máquina puede tener varias, y un
1253
+ // servicio solo conoce la maestra que lleva pineada. Así los dos lados calculan la misma
1254
+ // ruta sin mirar ningún índice.
1255
+ if (identity.me?.publickey) {
1256
+ abrirMostradorLocal(localSocketPath((await pubkeyId(identity.me.publickey)).slice(0, 16)))
1257
+ }
1258
+
1259
+ async function onMessage (from, payload) {
1148
1260
  if (!payload || typeof payload !== 'object') return
1149
1261
  try {
1150
1262
  if (payload.type === MSG.HELLO) return desk.handleHello(from, payload)
@@ -1165,7 +1277,60 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1165
1277
  } catch (e) {
1166
1278
  reply(from, { type: MSG.ERROR, error: e.message })
1167
1279
  }
1168
- })
1280
+ }
1281
+
1282
+ /**
1283
+ * EL MOSTRADOR LOCAL: el mismo protocolo, sin salir de la máquina.
1284
+ *
1285
+ * Un servicio en ESTA máquina hablaba con la bóveda dando la vuelta por
1286
+ * `proxy.dotrino.com`: sale a internet, vuelve, y si el proxio tiene un mal momento el
1287
+ * arranque se retrasa cinco segundos por nada. Absurdo cuando los dos procesos comparten
1288
+ * disco (dueño, 2026-09-03: «estando en la misma máquina debería ser inmediato»).
1289
+ *
1290
+ * Es EL MISMO PROTOCOLO y el mismo enrutador: llega `{type, data, signature, cert}` y se
1291
+ * comprueba igual, contra el certificado y contra el acta. **Alcanzar el socket no
1292
+ * autoriza nada** — es la misma puerta por un camino más corto, no una puerta de atrás.
1293
+ * Por eso no hay un solo `if` de «viene de local» en ningún handler, y no debe haberlo.
1294
+ *
1295
+ * Y no es un puerto: es un socket de archivo, en modo 0600 y bajo el directorio del
1296
+ * usuario. No se alcanza desde otra máquina ni desde otro usuario, así que la promesa
1297
+ * del `docker-compose` —«la bóveda no escucha nada»— sigue siendo cierta donde importa.
1298
+ */
1299
+ function abrirMostradorLocal (socketPath) {
1300
+ let n = 0
1301
+ const server = net.createServer((sock) => {
1302
+ const id = 'local:' + (++n)
1303
+ let buf = ''
1304
+ const enviar = (obj) => { try { sock.write(JSON.stringify(obj) + '\n') } catch (_) {} }
1305
+ canales.set(id, enviar)
1306
+ sock.on('data', (chunk) => {
1307
+ buf += chunk
1308
+ // UN TOPE, y por lo mismo que en las respuestas: sin él, quien se conecte puede
1309
+ // hacer crecer este buffer hasta tumbar la bóveda mandando y sin saltos de línea.
1310
+ if (buf.length > MAX_REPLY_BYTES) { buf = ''; enviar({ type: MSG.ERROR, error: 'request too big' }); return }
1311
+ let i
1312
+ while ((i = buf.indexOf('\n')) >= 0) {
1313
+ const linea = buf.slice(0, i); buf = buf.slice(i + 1)
1314
+ if (!linea.trim()) continue
1315
+ let p = null
1316
+ try { p = JSON.parse(linea) } catch (_) { enviar({ type: MSG.ERROR, error: 'malformed request' }); continue }
1317
+ onMessage(id, p).catch((e) => enviar({ type: MSG.ERROR, error: e.message }))
1318
+ }
1319
+ })
1320
+ const cerrar = () => canales.delete(id)
1321
+ sock.on('close', cerrar)
1322
+ sock.on('error', cerrar)
1323
+ })
1324
+ server.on('error', (e) => log('[vault] the local desk could not open (' + e.message + '): services will use the proxy'))
1325
+ try { fs.mkdirSync(socketDir(), { recursive: true, mode: 0o700 }) } catch (_) {}
1326
+ try { fs.rmSync(socketPath, { force: true }) } catch (_) {}
1327
+ server.listen(socketPath, () => {
1328
+ try { fs.chmodSync(socketPath, 0o600) } catch (_) {}
1329
+ log('[vault] local desk at ' + socketPath + ' (same machine, no round trip)')
1330
+ })
1331
+ server.unref?.()
1332
+ return server
1333
+ }
1169
1334
 
1170
1335
  /**
1171
1336
  * AVISO DE OTRA BÓVEDA de la misma cuenta (multivault). Trae el acta nueva y aquí se
@@ -1897,6 +2062,19 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1897
2062
  // abrirse desde un navegador. Lo que cambia es que «pública» ya sí quiere decir algo
1898
2063
  // para quien administra — puede leerla— además de que se despacha sin aprobación.
1899
2064
  const admins = isPublic ? await adminDevices() : []
2065
+ // EL PERFIL SE LE ENVUELVE A QUIEN PUEDE HABLAR POR TI (`docs/datos-del-perfil.md`).
2066
+ //
2067
+ // Va explícito y antes que nada porque si no cae en la rama de «cajón sin dueño» y
2068
+ // acabaría envuelto solo para quien ADMINISTRA — que no es lo que se decidió: leer y
2069
+ // escribir tu identidad es `firma`, y no todos los que firman administran. Un teléfono
2070
+ // normal firma y no administra, y tiene que ver tu perfil.
2071
+ if (owner === PROFILE_OWNER) {
2072
+ const record = (await identity.profileActa?.().catch(() => null))?.acta || null
2073
+ const firman = (record?.members || [])
2074
+ .filter((m) => m.encPub && Acta.memberCan(record, m.pub, 'sign'))
2075
+ .map((m) => ({ pub: m.pub, encPub: m.encPub }))
2076
+ return conCoselladores(dedup(firman), co)
2077
+ }
1900
2078
  if (owner.startsWith('ns:')) {
1901
2079
  const owned = await nsMembers(owner.slice(3))
1902
2080
  // Sin dueño (un cajón personal, o uno cuyo servicio ya no está) entra quien administra
@@ -2452,6 +2630,25 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2452
2630
  }
2453
2631
  async function deleteSecret (ns, key) { const ok = await secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); scheduleNotice(ns) } return ok }
2454
2632
 
2633
+ /**
2634
+ * RENOMBRAR una variable. Para el servicio que la lee es un cambio de configuración como
2635
+ * cualquier otro —la de antes desaparece y aparece otra—, así que se avisa igual: si no,
2636
+ * seguiría arrancando con el nombre viejo hasta el siguiente reinicio.
2637
+ */
2638
+ async function renameSecret (ns, from, to) {
2639
+ const r = await secrets.rename(`ns:${ns}`, from, to)
2640
+ if (r?.renamed) { audit('secret.rename', { ns, from, to }); scheduleNotice(ns) }
2641
+ return r
2642
+ }
2643
+ async function renameDeviceSecret (pub, from, to) {
2644
+ const r = await secrets.rename(`dev:${pub}`, from, to)
2645
+ if (r?.renamed) {
2646
+ audit('secret.rename', { device: await deviceIdOf(pub).catch(() => null), from, to, scope: 'device' })
2647
+ scheduleDeviceNotice(pub)
2648
+ }
2649
+ return r
2650
+ }
2651
+
2455
2652
  /**
2456
2653
  * CARGAR CONFIGURACIÓN ES UNA TRANSACCIÓN: muchas variables, UN aviso.
2457
2654
  *
@@ -2738,7 +2935,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2738
2935
  // El mostrador que atiende a la consola remota. Se expone para poder probar la
2739
2936
  // frontera de verdad (que el valor de una privada no salga ni dentro del sobre).
2740
2937
  vars: varsDesk,
2741
- setSecret, deleteSecret, listSecrets, setSecretVisibility, openSecrets,
2938
+ setSecret, deleteSecret, renameSecret, renameDeviceSecret, listSecrets, setSecretVisibility, openSecrets,
2742
2939
  // Sella un `secrets.json` v3 entero. Es el punto de no retorno del despliegue y
2743
2940
  // por eso es una operación con nombre propio, no algo que ocurra de refilón al
2744
2941
  // desbloquear: deja `secrets.json.v3.bak` para poder volver.
@@ -513,6 +513,30 @@ export function deleteSecret (ns, key, profile) {
513
513
  })
514
514
  }
515
515
 
516
+ /**
517
+ * Renombra una variable: el mismo sobre con otro nombre. No pide la frase — lo único que
518
+ * hay que rehacer es la firma, y de eso se encarga la llave de sellado.
519
+ *
520
+ * Se comprueban LAS DOS cosas: que la nueva esté y que la vieja ya no. Mirar solo una
521
+ * dejaría pasar una copia a medias, que es peor que un fallo.
522
+ */
523
+ export function renameSecret (ns, key, to, profile) {
524
+ return secretOp({ op: 'rename', ns, key, to }, profile, (out) => {
525
+ if (!has(out.ns[ns], to) || has(out.ns[ns], key)) {
526
+ throw coded('the daemon did not rename the variable (check the service logs)', 'NOT_RENAMED')
527
+ }
528
+ })
529
+ }
530
+
531
+ /** Lo mismo para una variable de UN aparato. */
532
+ export function renameDeviceSecret (pub, key, to, profile) {
533
+ return secretOp({ op: 'dev-rename', pub, key, to }, profile, (out) => {
534
+ if (!has(keysOf(out, pub), to) || has(keysOf(out, pub), key)) {
535
+ throw coded('the daemon did not rename the variable (check the service logs)', 'NOT_RENAMED')
536
+ }
537
+ })
538
+ }
539
+
516
540
  /**
517
541
  * Cambia SOLO quién puede ver el valor: `public` deja que la consola remota lo vea,
518
542
  * `private` lo encierra en esta máquina. No toca el valor (ni hace falta conocerlo).