supcoflow 0.0.0-stage → 0.1.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 +82 -2
- package/dist/api.js +133 -0
- package/dist/argumentos.js +43 -0
- package/dist/cli.js +76 -0
- package/dist/comandos/describir.js +106 -0
- package/dist/comandos/login.js +254 -0
- package/dist/comandos/logout.js +96 -0
- package/dist/comandos/quien-soy.js +50 -0
- package/dist/contrato.js +38 -0
- package/dist/entorno.js +152 -0
- package/dist/guardado/archivo.js +63 -0
- package/dist/guardado/index.js +258 -0
- package/dist/guardado/llavero.js +110 -0
- package/dist/guardado/repo.js +35 -0
- package/dist/sondeo.js +47 -0
- package/package.json +11 -4
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { FalloDeLaTerminal, resultadoDeFallo, SALIDA, taparSecretos } from "../contrato.js";
|
|
2
|
+
import { PLAZO_MINIMO_MS, RESERVA_PARA_CERRAR_MS, sondearUnTramo, TRAMO_MAXIMO_MS } from "../sondeo.js";
|
|
3
|
+
const TECHO_DE_UN_PASO_MS = 5_000;
|
|
4
|
+
const VOLVER_A_EMPEZAR = "Volvé a empezar con `supcoflow login --start`.";
|
|
5
|
+
const fallo = (codigo, error, message, extra = {}) => ({
|
|
6
|
+
codigo, json: { ok: false, error, message, ...extra }, texto: message,
|
|
7
|
+
});
|
|
8
|
+
// Un error crudo del sistema (fs, el llavero) puede traer cualquier cosa: se tapa antes de mostrarlo.
|
|
9
|
+
const motivo = (e) => e instanceof FalloDeLaTerminal ? e.message : taparSecretos(e instanceof Error ? e.message : String(e));
|
|
10
|
+
// Las claves que un login o logout anterior no pudo revocar (Review Focus 14). Nunca lanza: es
|
|
11
|
+
// limpieza de lo de antes y no puede trabar un login nuevo.
|
|
12
|
+
export async function reintentarPorRevocar(d, limite) {
|
|
13
|
+
const origen = d.api.origin;
|
|
14
|
+
const noSe = () => {
|
|
15
|
+
d.avisar("No pude leer entera la lista de claves por revocar (el llavero no respondió); la reintento la próxima vez.");
|
|
16
|
+
};
|
|
17
|
+
let lectura;
|
|
18
|
+
try {
|
|
19
|
+
lectura = await d.guardado.leerPorRevocar(origen);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
noSe();
|
|
23
|
+
return { revocadas: 0, quedan: null };
|
|
24
|
+
}
|
|
25
|
+
// «Cuántas quedan» sólo es un número si se pudo comprobar entero (Review Focus 15): con un
|
|
26
|
+
// almacén que no respondió puede haber claves que no se vieron, y decir 0 sería mentir.
|
|
27
|
+
let sabido = lectura.completa;
|
|
28
|
+
let revocadas = 0;
|
|
29
|
+
for (const clave of lectura.claves) {
|
|
30
|
+
const queda = limite - d.ahora();
|
|
31
|
+
if (queda < PLAZO_MINIMO_MS)
|
|
32
|
+
break;
|
|
33
|
+
try {
|
|
34
|
+
await d.revocarPropia(d.api, clave, Math.min(TECHO_DE_UN_PASO_MS, queda));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
continue; // sigue anotada; el próximo login o logout la reintenta
|
|
38
|
+
}
|
|
39
|
+
revocadas++;
|
|
40
|
+
try {
|
|
41
|
+
if (!(await d.guardado.quitarPorRevocar(origen, clave)).completo)
|
|
42
|
+
sabido = false;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
sabido = false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!sabido) {
|
|
49
|
+
noSe();
|
|
50
|
+
return { revocadas, quedan: null };
|
|
51
|
+
}
|
|
52
|
+
return { revocadas, quedan: lectura.claves.length - revocadas };
|
|
53
|
+
}
|
|
54
|
+
export async function loginStart(d, flags) {
|
|
55
|
+
const origen = d.api.origin;
|
|
56
|
+
if (flags.dryRun) {
|
|
57
|
+
const haria = `pedir un código de dispositivo a ${origen}`;
|
|
58
|
+
return { codigo: SALIDA.ok, json: { ok: true, dry_run: true, haria }, texto: `Con --dry-run no hago nada; sin él, iría a ${haria}.` };
|
|
59
|
+
}
|
|
60
|
+
await reintentarPorRevocar(d, d.ahora() + 15_000);
|
|
61
|
+
let c;
|
|
62
|
+
try {
|
|
63
|
+
c = await d.pedirCodigo(d.api, d.maquina);
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
return resultadoDeFallo(e);
|
|
67
|
+
}
|
|
68
|
+
// Sólo el pendiente, en su propia cuenta: la clave vigente sigue sirviendo hasta que la nueva
|
|
69
|
+
// quede guardada (Review Focus 7), así un login rechazado o abandonado no deja a nadie sin sesión.
|
|
70
|
+
const pendiente = {
|
|
71
|
+
deviceCode: c.deviceCode, userCode: c.userCode, verificationUriComplete: c.verificationUriComplete,
|
|
72
|
+
venceEn: d.ahora() + c.expiresIn * 1000, intervaloS: c.interval, ultimoSondeoEn: null,
|
|
73
|
+
};
|
|
74
|
+
try {
|
|
75
|
+
await d.guardado.guardarPendiente(origen, pendiente);
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
return fallo(SALIDA.inesperado, e instanceof FalloDeLaTerminal ? e.error : "no_se_pudo_guardar", `No pude guardar el inicio de sesión en curso: ${motivo(e)}`);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
codigo: SALIDA.esperandoAUnaPersona,
|
|
82
|
+
json: {
|
|
83
|
+
ok: false, esperando: "persona", verification_uri: c.verificationUri,
|
|
84
|
+
verification_uri_complete: c.verificationUriComplete, user_code: c.userCode, expires_in: c.expiresIn,
|
|
85
|
+
},
|
|
86
|
+
texto: `Abrí ${c.verificationUriComplete} y confirmá el código ${c.userCode}. Después corré \`supcoflow login --wait\`.`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
export async function loginWait(d) {
|
|
90
|
+
const origen = d.api.origin;
|
|
91
|
+
const inicio = d.ahora();
|
|
92
|
+
const tramo = d.tramoMs ?? TRAMO_MAXIMO_MS;
|
|
93
|
+
const limite = inicio + tramo;
|
|
94
|
+
// La proporción sólo pesa con el tramo corto de las pruebas; con 90 s la reserva es la entera.
|
|
95
|
+
const finDeSondeos = limite - Math.min(RESERVA_PARA_CERRAR_MS, tramo / 3);
|
|
96
|
+
const techo = () => Math.max(0, Math.min(TECHO_DE_UN_PASO_MS, limite - d.ahora()));
|
|
97
|
+
const p = await d.guardado.leerPendiente(origen);
|
|
98
|
+
if (p === null) {
|
|
99
|
+
return fallo(SALIDA.entradaInvalida, "sin_login_en_curso", "No hay un inicio de sesión en curso: empezá con `supcoflow login --start`.");
|
|
100
|
+
}
|
|
101
|
+
// La anterior se lee ANTES de sondear, así no consume la reserva del cierre. Se pide por
|
|
102
|
+
// `leerClaveCompleta` para saber de paso si SUPCOFLOW_TOKEN está definida (sin costo: la variable
|
|
103
|
+
// se devuelve sin tocar los almacenes); en ese caso la anterior es la GUARDADA, nunca la del
|
|
104
|
+
// entorno, que no es de este login y no se revoca acá.
|
|
105
|
+
const vigente = await d.guardado.leerClaveCompleta(origen);
|
|
106
|
+
const conVariable = vigente.valor?.fuente === "env";
|
|
107
|
+
const lectura = conVariable ? await d.guardado.leerClaveGuardadaCompleta(origen) : vigente;
|
|
108
|
+
// Un llavero que no contestó no es «no había clave» (Review Focus 12 y 14): seguir guardaría la
|
|
109
|
+
// nueva encima de la anterior sin revocarla, viva y olvidada. Se corta antes de canjear el código
|
|
110
|
+
// y sin borrar el pendiente, así el mismo `--wait` sirve cuando el llavero vuelva.
|
|
111
|
+
if (!lectura.completa) {
|
|
112
|
+
const salida = conVariable
|
|
113
|
+
? "desbloqueá el llavero y volvé a llamar `supcoflow login --wait`."
|
|
114
|
+
: "desbloqueá el llavero y volvé a llamar `supcoflow login --wait` (o, sin login, exportá SUPCOFLOW_TOKEN con una clave vigente).";
|
|
115
|
+
return fallo(SALIDA.inesperado, "no_pude_leer_la_clave", `No pude leer tu clave guardada (el llavero no respondió), y sin saber cuál es no la puedo reemplazar sin dejarla viva: ${salida}`);
|
|
116
|
+
}
|
|
117
|
+
const anterior = lectura.valor;
|
|
118
|
+
let fin;
|
|
119
|
+
try {
|
|
120
|
+
fin = await sondearUnTramo(p, {
|
|
121
|
+
ahora: d.ahora, dormir: d.dormir, finDeSondeos,
|
|
122
|
+
canjear: (deviceCode, plazoMs) => d.canjear(d.api, deviceCode, plazoMs),
|
|
123
|
+
persistir: (q, techoMs) => d.guardado.guardarPendiente(origen, q, techoMs),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
// El progreso de los sondeos anteriores ya quedó guardado: el próximo `--wait` sigue de ahí.
|
|
128
|
+
return resultadoDeFallo(e);
|
|
129
|
+
}
|
|
130
|
+
switch (fin.tipo) {
|
|
131
|
+
case "sigue":
|
|
132
|
+
return {
|
|
133
|
+
codigo: SALIDA.esperandoAUnaPersona,
|
|
134
|
+
json: { ok: false, esperando: "persona", user_code: fin.pendiente.userCode, verification_uri_complete: fin.pendiente.verificationUriComplete },
|
|
135
|
+
texto: `Todavía no se confirmó. Abrí ${fin.pendiente.verificationUriComplete} y confirmá el código ${fin.pendiente.userCode}; después volvé a correr \`supcoflow login --wait\`.`,
|
|
136
|
+
};
|
|
137
|
+
case "token": {
|
|
138
|
+
const r = await reemplazarClave(d, { clave: fin.clave, equipo: fin.equipo }, anterior?.clave ?? null, limite);
|
|
139
|
+
// Va acá y no adentro de `reemplazarClave` porque es acá donde se supo, antes de sondear, sin
|
|
140
|
+
// gastar reserva: preguntarlo al final costaría otra lectura lenta del guardado.
|
|
141
|
+
if (conVariable && r.json.guardada_en !== undefined) {
|
|
142
|
+
d.avisar("SUPCOFLOW_TOKEN está definida y tiene prioridad sobre la clave que acabo de guardar.");
|
|
143
|
+
}
|
|
144
|
+
return r;
|
|
145
|
+
}
|
|
146
|
+
case "rechazado":
|
|
147
|
+
await d.guardado.borrarPendiente(origen, techo());
|
|
148
|
+
return fallo(SALIDA.haceFaltaUnaPersona, "acceso_rechazado", "Rechazaron el acceso desde la consola; tu sesión anterior, si había, sigue igual.");
|
|
149
|
+
case "vencido":
|
|
150
|
+
await d.guardado.borrarPendiente(origen, techo());
|
|
151
|
+
return fallo(SALIDA.faltaLogin, "codigo_vencido", `El código venció. ${VOLVER_A_EMPEZAR}`);
|
|
152
|
+
case "invalido":
|
|
153
|
+
await d.guardado.borrarPendiente(origen, techo());
|
|
154
|
+
return fallo(SALIDA.faltaLogin, "codigo_invalido", `El código ya no vale. ${VOLVER_A_EMPEZAR}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// El login de una persona frente a la terminal: los mismos dos pasos, encadenados. Sin TTY (o con
|
|
158
|
+
// `--json`) no pide código: nadie iba a ver la URL, y un pedido que nadie aprueba sólo gasta el tope
|
|
159
|
+
// de pedidos por IP del motor. A un asistente le corresponde el camino partido.
|
|
160
|
+
const PAUSA_ENTRE_TRAMOS_MS = 1_000;
|
|
161
|
+
export async function loginInteractivo(d, flags) {
|
|
162
|
+
if (!d.esTty || flags.json) {
|
|
163
|
+
return fallo(SALIDA.entradaInvalida, "hace_falta_tty", "Sin terminal interactiva usá `supcoflow login --start --json` y después `supcoflow login --wait --json`.");
|
|
164
|
+
}
|
|
165
|
+
const inicio = await loginStart(d, flags);
|
|
166
|
+
if (inicio.codigo !== SALIDA.esperandoAUnaPersona)
|
|
167
|
+
return inicio;
|
|
168
|
+
// El texto de `--start` le dice a la persona que corra `--wait`; acá eso lo hace el comando solo.
|
|
169
|
+
const { verification_uri_complete: url, user_code: codigo } = inicio.json;
|
|
170
|
+
d.mostrar(`Abrí ${String(url)} y confirmá el código ${String(codigo)}. Espero acá…`);
|
|
171
|
+
// Cada tramo termina por su cuenta (≤ 90 s) y el código vence a los pocos minutos, así que el
|
|
172
|
+
// bucle tiene fin aunque la persona nunca conteste: termina en `codigo_vencido`.
|
|
173
|
+
let r;
|
|
174
|
+
do {
|
|
175
|
+
r = await loginWait(d);
|
|
176
|
+
// Un `loginWait` que devuelve 5 sin haber sondeado (el intervalo era más largo que la ventana
|
|
177
|
+
// que quedaba) volvería a girar sin esperar nada: este segundo evita quemar CPU en el vacío.
|
|
178
|
+
if (r.codigo === SALIDA.esperandoAUnaPersona)
|
|
179
|
+
await d.dormir(PAUSA_ENTRE_TRAMOS_MS);
|
|
180
|
+
} while (r.codigo === SALIDA.esperandoAUnaPersona);
|
|
181
|
+
return r;
|
|
182
|
+
}
|
|
183
|
+
// Por qué este orden (guardar → revocar → anotar → borrar el pendiente), Review Focus 13 y 14:
|
|
184
|
+
// la nueva se guarda primero porque es la única copia; la anterior se revoca recién con la nueva a
|
|
185
|
+
// salvo (si no, un fallo al guardar dejaba a la persona sin ninguna); si no se pudo revocar, se anota
|
|
186
|
+
// para no olvidarla viva; y el borrado del pendiente va último y no lanza, porque ubicado antes era el
|
|
187
|
+
// que podía impedir revocar la anterior, y un pendiente viejo es inofensivo.
|
|
188
|
+
export async function reemplazarClave(d, nueva, anterior, limite) {
|
|
189
|
+
const origen = d.api.origin;
|
|
190
|
+
const queda = () => limite - d.ahora();
|
|
191
|
+
const techo = () => Math.max(0, Math.min(TECHO_DE_UN_PASO_MS, queda()));
|
|
192
|
+
// 1. Guardar la nueva. Con el techo agotado el guardado salta el llavero y escribe el archivo, que
|
|
193
|
+
// es local: la clave se guarda igual aunque el tramo esté por vencer.
|
|
194
|
+
let guardadaEn;
|
|
195
|
+
try {
|
|
196
|
+
guardadaEn = (await d.guardado.guardarClave(origen, nueva.clave, techo())).donde;
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
// Una clave que nadie guardó no puede quedar viva: se revoca, aunque haga falta un plazo mínimo
|
|
200
|
+
// más allá del tramo, porque nadie más la va a poder revocar.
|
|
201
|
+
let revocada = true;
|
|
202
|
+
try {
|
|
203
|
+
await d.revocarPropia(d.api, nueva.clave, Math.min(TECHO_DE_UN_PASO_MS, Math.max(PLAZO_MINIMO_MS, queda())));
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
revocada = false;
|
|
207
|
+
}
|
|
208
|
+
const despues = revocada
|
|
209
|
+
? "La revoqué para que no quede viva; tu sesión anterior, si había, sigue como estaba."
|
|
210
|
+
: "Tampoco pude revocarla: revocala en la consola, en «Accesos desde la terminal».";
|
|
211
|
+
return fallo(SALIDA.inesperado, "no_se_pudo_guardar", `No pude guardar la clave nueva (${motivo(e)}). ${despues}`);
|
|
212
|
+
}
|
|
213
|
+
// 2. Revocar la anterior, o anotarla.
|
|
214
|
+
let anteriorRevocada = null;
|
|
215
|
+
let quedaPorRevocar = false;
|
|
216
|
+
if (anterior !== null && anterior !== nueva.clave) {
|
|
217
|
+
anteriorRevocada = false;
|
|
218
|
+
if (queda() >= PLAZO_MINIMO_MS) {
|
|
219
|
+
try {
|
|
220
|
+
await d.revocarPropia(d.api, anterior, Math.min(TECHO_DE_UN_PASO_MS, queda()));
|
|
221
|
+
anteriorRevocada = true;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// cae a anotarla
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (!anteriorRevocada) {
|
|
228
|
+
try {
|
|
229
|
+
await d.guardado.anotarPorRevocar(origen, anterior, techo());
|
|
230
|
+
quedaPorRevocar = true;
|
|
231
|
+
d.avisar("No pude revocar tu clave anterior; quedó anotada y la reintento en el próximo login o logout.");
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
const message = "No pude revocar ni anotar tu clave anterior: revocala en la consola, en «Accesos desde la terminal».";
|
|
235
|
+
d.avisar(message);
|
|
236
|
+
if (queda() >= PLAZO_MINIMO_MS)
|
|
237
|
+
await d.guardado.borrarPendiente(origen, techo());
|
|
238
|
+
// La nueva SÍ quedó guardada y anda: el json lo dice para que nadie vuelva a hacer login.
|
|
239
|
+
return fallo(SALIDA.inesperado, "clave_anterior_sin_revocar", message, {
|
|
240
|
+
equipo: nueva.equipo, guardada_en: guardadaEn, anterior_revocada: false, queda_por_revocar: false,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// 3. Borrar el pendiente; su `false` no cambia nada.
|
|
246
|
+
if (queda() >= PLAZO_MINIMO_MS)
|
|
247
|
+
await d.guardado.borrarPendiente(origen, techo());
|
|
248
|
+
const donde = guardadaEn === "llavero" ? "el llavero del sistema" : "el archivo de credenciales";
|
|
249
|
+
return {
|
|
250
|
+
codigo: SALIDA.ok,
|
|
251
|
+
json: { ok: true, equipo: nueva.equipo, guardada_en: guardadaEn, anterior_revocada: anteriorRevocada, queda_por_revocar: quedaPorRevocar },
|
|
252
|
+
texto: `Sesión iniciada en el equipo ${nueva.equipo}; la clave quedó en ${donde}.`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// `supcoflow logout`: cierra UNA credencial, la que se está usando, y nunca olvida una clave viva
|
|
2
|
+
// (Review Focus 9 y 14). Borrar sin revocar dejaría la clave sirviendo en el motor y sin nadie que
|
|
3
|
+
// la recuerde: por eso, si el motor no confirma, no se borra nada y el comando sale 1.
|
|
4
|
+
import { FalloDeLaTerminal, SALIDA, taparSecretos } from "../contrato.js";
|
|
5
|
+
import { reintentarPorRevocar } from "./login.js";
|
|
6
|
+
const motivo = (e) => e instanceof FalloDeLaTerminal ? e.message : taparSecretos(e instanceof Error ? e.message : String(e));
|
|
7
|
+
export async function comandoLogout(d, flags) {
|
|
8
|
+
const origen = d.api.origin;
|
|
9
|
+
// Sin nada legible y un almacén que no contestó, no se sabe si hay sesión: decir «no había»
|
|
10
|
+
// dejaría creer que no queda ninguna clave viva (Review Focus 12).
|
|
11
|
+
const noPudeLeer = (extra = {}) => {
|
|
12
|
+
const message = "No pude leer la clave guardada (el llavero no respondió); no cerré nada. Probá de nuevo.";
|
|
13
|
+
return { codigo: SALIDA.inesperado, json: { ok: false, error: "no_pude_leer_la_clave", message, ...extra }, texto: message };
|
|
14
|
+
};
|
|
15
|
+
if (flags.dryRun) {
|
|
16
|
+
const lectura = await d.guardado.leerClaveCompleta(origen);
|
|
17
|
+
const vigente = lectura.valor;
|
|
18
|
+
if (vigente === null && !lectura.completa)
|
|
19
|
+
return noPudeLeer({ dry_run: true });
|
|
20
|
+
if (vigente === null) {
|
|
21
|
+
return { codigo: SALIDA.ok, json: { ok: true, dry_run: true, habia_sesion: false }, texto: "No hay sesión iniciada: no haría nada." };
|
|
22
|
+
}
|
|
23
|
+
const haria = `revocar la clave de ${vigente.fuente} en ${origen}` + (vigente.fuente !== "env" ? " y borrarla" : "");
|
|
24
|
+
return { codigo: SALIDA.ok, json: { ok: true, dry_run: true, haria }, texto: `Con --dry-run no hago nada; sin él, iría a ${haria}.` };
|
|
25
|
+
}
|
|
26
|
+
// Lo pendiente de antes va primero: un logout es justo cuando la persona espera que no quede
|
|
27
|
+
// ninguna clave suya viva, y es la última oportunidad que tiene la terminal de revocarlas.
|
|
28
|
+
const { quedan } = await reintentarPorRevocar(d, d.ahora() + 15_000);
|
|
29
|
+
// `null` es «no lo pude comprobar» y se dice así: un 0 haría creer que no queda nada vivo.
|
|
30
|
+
if (quedan === null) {
|
|
31
|
+
d.avisar("No pude comprobar cuántas claves quedan por revocar; las que haya siguen anotadas y las reintento en el próximo login o logout.");
|
|
32
|
+
}
|
|
33
|
+
else if (quedan > 0) {
|
|
34
|
+
d.avisar(`Quedan ${quedan} clave(s) anterior(es) sin revocar; siguen anotadas y las reintento en el próximo login o logout.`);
|
|
35
|
+
}
|
|
36
|
+
const lectura = await d.guardado.leerClaveCompleta(origen);
|
|
37
|
+
const vigente = lectura.valor;
|
|
38
|
+
if (vigente === null && !lectura.completa)
|
|
39
|
+
return noPudeLeer({ quedan_por_revocar: quedan });
|
|
40
|
+
const conPendientes = (json) => ({ ...json, quedan_por_revocar: quedan, ...(lectura.completa ? {} : { lectura_completa: false }) });
|
|
41
|
+
if (vigente === null) {
|
|
42
|
+
await d.guardado.borrarPendiente(origen);
|
|
43
|
+
return { codigo: SALIDA.ok, json: conPendientes({ ok: true, habia_sesion: false }), texto: "No había sesión iniciada." };
|
|
44
|
+
}
|
|
45
|
+
let revocada;
|
|
46
|
+
try {
|
|
47
|
+
revocada = (await d.revocarPropia(d.api, vigente.clave)) === "revocada";
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
const cual = vigente.fuente === "env" ? "la clave de SUPCOFLOW_TOKEN sigue viva" : "la clave sigue guardada";
|
|
51
|
+
const message = `No pude revocar la clave, así que no cerré la sesión (${motivo(e)}); ${cual}. Probá de nuevo.`;
|
|
52
|
+
const error = e instanceof FalloDeLaTerminal ? e.error : "inesperado";
|
|
53
|
+
return { codigo: SALIDA.inesperado, json: conPendientes({ ok: false, error, message }), texto: message };
|
|
54
|
+
}
|
|
55
|
+
// La variable no es nuestra: se revoca la clave que trae, pero lo guardado es OTRA credencial,
|
|
56
|
+
// que sólo se cierra revocándola a ella. Borrarla acá la dejaría viva y olvidada.
|
|
57
|
+
if (vigente.fuente === "env") {
|
|
58
|
+
d.avisar("Revoqué la clave de SUPCOFLOW_TOKEN; sacala de tu entorno.");
|
|
59
|
+
// `undefined` = no se pudo leer: no se afirma que no haya, el json lleva `null` en vez de mentir.
|
|
60
|
+
let guardada;
|
|
61
|
+
try {
|
|
62
|
+
const l = await d.guardado.leerClaveGuardadaCompleta(origen);
|
|
63
|
+
guardada = l.valor ?? (l.completa ? null : undefined);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
guardada = undefined;
|
|
67
|
+
}
|
|
68
|
+
if (guardada) {
|
|
69
|
+
d.avisar(`Sigue guardada otra clave en ${guardada.fuente}; para cerrarla, corré \`supcoflow logout\` sin SUPCOFLOW_TOKEN.`);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
codigo: SALIDA.ok,
|
|
73
|
+
json: conPendientes({ ok: true, revocada, fuente: "env", queda_clave_guardada: guardada === undefined ? null : guardada !== null }),
|
|
74
|
+
texto: "Revoqué la clave de SUPCOFLOW_TOKEN.",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// Con la lectura incompleta se borra sólo la copia que se leyó y se acaba de revocar: la del
|
|
78
|
+
// almacén que no contestó puede ser otra clave, viva, y borrarla sin revocarla la olvidaría.
|
|
79
|
+
if (!lectura.completa) {
|
|
80
|
+
const otro = vigente.fuente === "archivo" ? "el llavero del sistema" : "el archivo de credenciales";
|
|
81
|
+
d.avisar(`Cierro la clave de ${vigente.fuente}, pero no pude leer ${otro}: puede quedar otra copia guardada ahí; cuando responda, volvé a correr \`supcoflow logout\`.`);
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
if (lectura.completa)
|
|
85
|
+
await d.guardado.borrarClave(origen);
|
|
86
|
+
else
|
|
87
|
+
await d.guardado.borrarClaveDe(origen, vigente.fuente);
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
// La clave ya no sirve en el motor, pero quedó escrita: el comando no puede decir que terminó bien.
|
|
91
|
+
const message = `La clave quedó revocada pero no pude borrarla: ${motivo(e)}`;
|
|
92
|
+
return { codigo: SALIDA.inesperado, json: conPendientes({ ok: false, error: "no_se_pudo_borrar", message }), texto: message };
|
|
93
|
+
}
|
|
94
|
+
await d.guardado.borrarPendiente(origen);
|
|
95
|
+
return { codigo: SALIDA.ok, json: conPendientes({ ok: true, revocada }), texto: "Sesión cerrada." };
|
|
96
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { FalloDeLaTerminal, resultadoDeFallo, SALIDA } from "../contrato.js";
|
|
2
|
+
export async function comandoQuienSoy(d) {
|
|
3
|
+
const origen = d.api.origin;
|
|
4
|
+
const lectura = await d.guardado.leerClaveCompleta(origen);
|
|
5
|
+
const vigente = lectura.valor;
|
|
6
|
+
// «Iniciá sesión» con la clave guardada en un llavero que no contestó invita a un login que la
|
|
7
|
+
// pisaría: no es «sin sesión», es «no pude mirar» (Review Focus 12).
|
|
8
|
+
if (vigente === null && !lectura.completa) {
|
|
9
|
+
const message = "No pude leer la clave guardada (el llavero no respondió). Desbloquealo y probá de nuevo.";
|
|
10
|
+
return { codigo: SALIDA.inesperado, json: { ok: false, error: "llavero_no_responde", message }, texto: message };
|
|
11
|
+
}
|
|
12
|
+
if (vigente === null) {
|
|
13
|
+
const message = "Iniciá sesión con `supcoflow login`.";
|
|
14
|
+
return { codigo: SALIDA.faltaLogin, json: { ok: false, error: "sin_sesion", message }, texto: message };
|
|
15
|
+
}
|
|
16
|
+
if (!lectura.completa) {
|
|
17
|
+
const otro = vigente.fuente === "archivo" ? "el llavero del sistema" : "el archivo de credenciales";
|
|
18
|
+
d.avisar(`Uso la clave de ${vigente.fuente}, pero no pude leer ${otro}: puede haber guardada otra más nueva.`);
|
|
19
|
+
}
|
|
20
|
+
// Leer no cuesta nada y avisa de algo que la persona no vería de otro modo: claves viejas vivas.
|
|
21
|
+
try {
|
|
22
|
+
const { claves, completa } = await d.guardado.leerPorRevocar(origen);
|
|
23
|
+
if (claves.length > 0) {
|
|
24
|
+
d.avisar(`Hay ${claves.length} clave(s) anterior(es) anotadas para revocar; se reintenta en el próximo login o logout.`);
|
|
25
|
+
}
|
|
26
|
+
if (!completa)
|
|
27
|
+
d.avisar("No pude leer entera la lista de claves por revocar.");
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
d.avisar("No pude leer entera la lista de claves por revocar.");
|
|
31
|
+
}
|
|
32
|
+
let yo;
|
|
33
|
+
try {
|
|
34
|
+
yo = await d.quienSoy(d.api, vigente.clave);
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
// Con la variable definida, «iniciá sesión de nuevo» no arregla nada: la variable sigue
|
|
38
|
+
// ganándole a la clave nueva. Hay que decir cuál es la que no vale.
|
|
39
|
+
if (e instanceof FalloDeLaTerminal && e.codigo === SALIDA.faltaLogin && vigente.fuente === "env") {
|
|
40
|
+
return resultadoDeFallo(new FalloDeLaTerminal(e.codigo, e.error, "La clave de SUPCOFLOW_TOKEN no vale: sacala de tu entorno o reemplazala por una vigente."));
|
|
41
|
+
}
|
|
42
|
+
return resultadoDeFallo(e);
|
|
43
|
+
}
|
|
44
|
+
const quien = yo.usuario?.login ?? yo.usuario?.email ?? yo.via;
|
|
45
|
+
return {
|
|
46
|
+
codigo: SALIDA.ok,
|
|
47
|
+
json: { ok: true, via: yo.via, equipo: yo.equipo, usuario: yo.usuario, proyecto: yo.proyecto, fuente: vigente.fuente },
|
|
48
|
+
texto: `${quien} en el equipo ${yo.equipo} (clave en ${vigente.fuente})`,
|
|
49
|
+
};
|
|
50
|
+
}
|
package/dist/contrato.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// El contrato de salida de la terminal (§13). Quien la llama es, muchas veces, un asistente
|
|
2
|
+
// que lee el código de salida y la ÚLTIMA línea de stdout: por eso un resultado es siempre
|
|
3
|
+
// UNA línea de JSON, y un 5 no es un error sino «volvé a llamarme cuando la persona termine».
|
|
4
|
+
export const SALIDA = {
|
|
5
|
+
ok: 0,
|
|
6
|
+
inesperado: 1,
|
|
7
|
+
faltaLogin: 2,
|
|
8
|
+
entradaInvalida: 3,
|
|
9
|
+
haceFaltaUnaPersona: 4,
|
|
10
|
+
esperandoAUnaPersona: 5,
|
|
11
|
+
topeAlcanzado: 6,
|
|
12
|
+
};
|
|
13
|
+
export class FalloDeLaTerminal extends Error {
|
|
14
|
+
codigo;
|
|
15
|
+
error;
|
|
16
|
+
constructor(codigo, error, message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.codigo = codigo;
|
|
19
|
+
this.error = error;
|
|
20
|
+
this.name = "FalloDeLaTerminal";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// JSON.stringify escapa los saltos de línea de los valores, así que la salida cabe en una línea.
|
|
24
|
+
export function emitir(r, modoJson, out) {
|
|
25
|
+
out(modoJson ? JSON.stringify(r.json) + "\n" : r.texto + "\n");
|
|
26
|
+
}
|
|
27
|
+
// Un mensaje de error de afuera (fetch, el sistema) podría traer la clave o el device_code
|
|
28
|
+
// tal cual; la clave se tapa ANTES que los 64 hex porque los contiene.
|
|
29
|
+
export function taparSecretos(s) {
|
|
30
|
+
return s.replace(/scf_[0-9a-f]{16}_[0-9a-f]{64}/g, "scf_…").replace(/\b[0-9a-f]{64}\b/g, "[device_code]");
|
|
31
|
+
}
|
|
32
|
+
export function resultadoDeFallo(e) {
|
|
33
|
+
if (e instanceof FalloDeLaTerminal) {
|
|
34
|
+
return { codigo: e.codigo, json: { ok: false, error: e.error, message: e.message }, texto: e.message };
|
|
35
|
+
}
|
|
36
|
+
const message = taparSecretos(e instanceof Error ? e.message : String(e));
|
|
37
|
+
return { codigo: SALIDA.inesperado, json: { ok: false, error: "inesperado", message }, texto: message };
|
|
38
|
+
}
|
package/dist/entorno.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Lo único que toca el mundo de verdad: el proceso, el disco, el llavero, la red y el reloj. Todo lo
|
|
2
|
+
// demás recibe estas piezas inyectadas y se prueba con dobles; acá sólo se enchufan.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { chmodSync, closeSync, fchmodSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeSync } from "node:fs";
|
|
5
|
+
import { homedir, hostname } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { canjear, pedirCodigo, quienSoy, revocarPropia, urlDeLaApi } from "./api.js";
|
|
8
|
+
import { almacenDeArchivo } from "./guardado/archivo.js";
|
|
9
|
+
import { crearGuardado } from "./guardado/index.js";
|
|
10
|
+
import { almacenDeLlavero } from "./guardado/llavero.js";
|
|
11
|
+
import { TRAMO_MAXIMO_MS } from "./sondeo.js";
|
|
12
|
+
// El `.tmp` se crea en exclusiva (`wx`: falla si quedó algo, enlace incluido, en vez de escribir a
|
|
13
|
+
// través de él) y el `fchmod` va aparte porque el modo de `open` pasa por la umask del proceso.
|
|
14
|
+
// `rmSync` sobre un enlace borra el enlace, nunca su destino.
|
|
15
|
+
export const fsReal = {
|
|
16
|
+
leer(p) {
|
|
17
|
+
try {
|
|
18
|
+
return readFileSync(p, "utf8");
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
if (e.code === "ENOENT")
|
|
22
|
+
return null;
|
|
23
|
+
throw e;
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
crearDir: (p, modo) => void mkdirSync(p, { recursive: true, mode: modo }),
|
|
27
|
+
chmod: (p, modo) => chmodSync(p, modo),
|
|
28
|
+
borrarSiExiste: (p) => rmSync(p, { force: true }),
|
|
29
|
+
escribirExclusivo(p, s, modo) {
|
|
30
|
+
const fd = openSync(p, "wx", modo);
|
|
31
|
+
try {
|
|
32
|
+
fchmodSync(fd, modo);
|
|
33
|
+
writeSync(fd, s);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
closeSync(fd);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
renombrar: (a, b) => renameSync(a, b),
|
|
40
|
+
// Un enlace roto que no se puede resolver es un FALLO: el control del repo no puede saber adónde
|
|
41
|
+
// apunta, y escribir sin saberlo es justo lo que el control existe para impedir.
|
|
42
|
+
realpath(p) {
|
|
43
|
+
try {
|
|
44
|
+
return realpathSync(p);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error(`No puedo resolver la ruta real de ${p} (¿un enlace roto?); no guardo nada ahí.`);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
// `lstat` y no `existsSync`: éste sigue el enlace y ante uno roto dice «no existe», y el control
|
|
51
|
+
// del repo pasaba de largo, resolvía al padre y daba una ruta «real» que no era por donde se iba a
|
|
52
|
+
// escribir (Review Focus 6). Con `lstat` el enlace existe, `realpath` no lo resuelve y falla cerrado.
|
|
53
|
+
existe(p) {
|
|
54
|
+
try {
|
|
55
|
+
lstatSync(p);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
// Para hablar con `security`/`secret-tool`. stderr se captura sólo como señal (¿dijo algo?: es lo
|
|
64
|
+
// que separa «no encontrado» de «sin D-Bus» en `secret-tool`), recortado, y nunca se muestra ni se
|
|
65
|
+
// registra: podría traer algo del secreto. Al techo el hijo se mata con SIGKILL (un llavero bloqueado no atiende un SIGTERM) y se
|
|
66
|
+
// resuelve sin esperar a que muera: un `close` que no llega no puede colgar el comando (Review Focus 2).
|
|
67
|
+
// Un entorno mínimo para el hijo: con `process.env` entero, SUPCOFLOW_TOKEN (y cualquier otro
|
|
68
|
+
// secreto del shell) llegaría a `security`/`secret-tool`, que no lo necesitan. Las variables de D-Bus
|
|
69
|
+
// y XDG sí: sin ellas `secret-tool` no encuentra el bus del Secret Service.
|
|
70
|
+
const VARIABLES_DEL_HIJO = ["PATH", "HOME", "USER", "LANG", "DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR", "DISPLAY", "XDG_CONFIG_HOME"];
|
|
71
|
+
function entornoDelHijo() {
|
|
72
|
+
const r = {};
|
|
73
|
+
for (const k of VARIABLES_DEL_HIJO) {
|
|
74
|
+
const v = process.env[k];
|
|
75
|
+
if (v !== undefined)
|
|
76
|
+
r[k] = v;
|
|
77
|
+
}
|
|
78
|
+
return r;
|
|
79
|
+
}
|
|
80
|
+
const MAXIMO_DE_STDERR = 2_048;
|
|
81
|
+
export const correrReal = (bin, args, stdin, techoMs) => new Promise((resolve) => {
|
|
82
|
+
let listo = false;
|
|
83
|
+
let stdout = "";
|
|
84
|
+
let stderr = "";
|
|
85
|
+
const terminar = (r) => {
|
|
86
|
+
if (listo)
|
|
87
|
+
return;
|
|
88
|
+
listo = true;
|
|
89
|
+
clearTimeout(reloj);
|
|
90
|
+
resolve({ ...r, stdout, stderr });
|
|
91
|
+
};
|
|
92
|
+
const hijo = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"], env: entornoDelHijo() });
|
|
93
|
+
const reloj = setTimeout(() => {
|
|
94
|
+
hijo.kill("SIGKILL");
|
|
95
|
+
terminar({ codigo: null, vencido: true });
|
|
96
|
+
}, techoMs);
|
|
97
|
+
hijo.on("error", (e) => {
|
|
98
|
+
terminar({ codigo: null, vencido: false, ausente: e.code === "ENOENT" ? true : undefined });
|
|
99
|
+
});
|
|
100
|
+
hijo.stdout.on("data", (b) => (stdout += b.toString("utf8")));
|
|
101
|
+
// Se lee siempre (un pipe lleno que nadie vacía colgaría al hijo) pero se guarda poco.
|
|
102
|
+
hijo.stderr.on("data", (b) => {
|
|
103
|
+
if (stderr.length < MAXIMO_DE_STDERR)
|
|
104
|
+
stderr = (stderr + b.toString("utf8")).slice(0, MAXIMO_DE_STDERR);
|
|
105
|
+
});
|
|
106
|
+
hijo.on("close", (codigo) => terminar({ codigo, vencido: false }));
|
|
107
|
+
// Un hijo que muere antes de leer deja un EPIPE en stdin; sin este handler tumbaría el proceso.
|
|
108
|
+
hijo.stdin.on("error", () => { });
|
|
109
|
+
hijo.stdin.end(stdin ?? undefined);
|
|
110
|
+
});
|
|
111
|
+
const LOCALES = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
112
|
+
export function entornoReal(env = process.env) {
|
|
113
|
+
const api = urlDeLaApi(env);
|
|
114
|
+
// Perillas de PRUEBA, no del producto: sólo valen contra un motor en la propia máquina. Contra
|
|
115
|
+
// producción se ignoran, así una variable olvidada en el shell no saca la clave del llavero ni
|
|
116
|
+
// acorta los tramos de nadie.
|
|
117
|
+
const local = LOCALES.has(api.hostname);
|
|
118
|
+
const sinLlavero = local && env.SUPCOFLOW_SIN_LLAVERO === "1";
|
|
119
|
+
const tramo = Number(env.SUPCOFLOW_TRAMO_MS);
|
|
120
|
+
const tramoMs = local && Number.isInteger(tramo) && tramo > 0 && tramo <= TRAMO_MAXIMO_MS ? tramo : undefined;
|
|
121
|
+
const avisar = (s) => void process.stderr.write(s + "\n");
|
|
122
|
+
// `||` y no `??`: un XDG_CONFIG_HOME vacío daría una ruta RELATIVA, que cae donde esté parado el
|
|
123
|
+
// comando; la especificación de XDG lo trata como no definido.
|
|
124
|
+
const rutaDelArchivo = join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "supcoflow", "credenciales.json");
|
|
125
|
+
const llavero = sinLlavero ? null : almacenDeLlavero(process.platform, correrReal);
|
|
126
|
+
const guardado = crearGuardado({
|
|
127
|
+
env, llavero, archivo: almacenDeArchivo(rutaDelArchivo, fsReal), rutaDelArchivo, fs: fsReal, ahora: Date.now, avisar,
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
env,
|
|
131
|
+
api,
|
|
132
|
+
esTty: process.stdin.isTTY === true && process.stdout.isTTY === true,
|
|
133
|
+
mostrar: avisar,
|
|
134
|
+
avisar,
|
|
135
|
+
rutaDelArchivo,
|
|
136
|
+
llavero,
|
|
137
|
+
guardado,
|
|
138
|
+
login: {
|
|
139
|
+
api,
|
|
140
|
+
guardado,
|
|
141
|
+
maquina: hostname(),
|
|
142
|
+
ahora: Date.now,
|
|
143
|
+
dormir: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
144
|
+
pedirCodigo: (a, maquina) => pedirCodigo(a, maquina, fetch),
|
|
145
|
+
canjear: (a, deviceCode, plazoMs) => canjear(a, deviceCode, fetch, plazoMs),
|
|
146
|
+
revocarPropia: (a, clave, plazoMs) => revocarPropia(a, clave, fetch, plazoMs),
|
|
147
|
+
avisar,
|
|
148
|
+
tramoMs,
|
|
149
|
+
},
|
|
150
|
+
quienSoy: (a, clave) => quienSoy(a, clave, fetch),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// El archivo de credenciales, para cuando no hay llavero: un JSON `{ "<cuenta>": Registro }`.
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export function esRegistro(v) {
|
|
4
|
+
return typeof v === "object" && v !== null
|
|
5
|
+
&& typeof v.valor === "string" && typeof v.escritoEn === "number";
|
|
6
|
+
}
|
|
7
|
+
export function almacenDeArchivo(ruta, fs) {
|
|
8
|
+
const dir = path.dirname(ruta);
|
|
9
|
+
const temporal = ruta + ".tmp";
|
|
10
|
+
// Un archivo que no se entiende es un FALLO, no un vacío: tratarlo como vacío y reescribirlo
|
|
11
|
+
// perdería claves por revocar que quizás siguen ahí.
|
|
12
|
+
function todo() {
|
|
13
|
+
const crudo = fs.leer(ruta);
|
|
14
|
+
if (crudo === null)
|
|
15
|
+
return {};
|
|
16
|
+
let v;
|
|
17
|
+
try {
|
|
18
|
+
v = JSON.parse(crudo);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
v = undefined;
|
|
22
|
+
}
|
|
23
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) {
|
|
24
|
+
throw new Error(`No entiendo el archivo de credenciales ${ruta}; borralo y volvé a iniciar sesión.`);
|
|
25
|
+
}
|
|
26
|
+
return v;
|
|
27
|
+
}
|
|
28
|
+
// El `mkdir` no corrige los permisos de una carpeta que ya existía: por eso el `chmod` aparte.
|
|
29
|
+
// El `.tmp` viejo (o un enlace dejado ahí) se borra y el nuevo se crea en exclusiva, así nunca se
|
|
30
|
+
// escribe a través de un enlace ni se hereda un `0644`; el `rename` es atómico y, si `ruta` era un
|
|
31
|
+
// enlace, reemplaza el enlace y no su destino (Review Focus 6).
|
|
32
|
+
function persistir(t) {
|
|
33
|
+
fs.crearDir(dir, 0o700);
|
|
34
|
+
fs.chmod(dir, 0o700);
|
|
35
|
+
fs.borrarSiExiste(temporal);
|
|
36
|
+
fs.escribirExclusivo(temporal, JSON.stringify(t, null, 2) + "\n", 0o600);
|
|
37
|
+
fs.renombrar(temporal, ruta);
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
nombre: "archivo",
|
|
41
|
+
async leer(cuenta) {
|
|
42
|
+
const t = todo();
|
|
43
|
+
if (!Object.hasOwn(t, cuenta))
|
|
44
|
+
return null;
|
|
45
|
+
const r = t[cuenta];
|
|
46
|
+
if (!esRegistro(r))
|
|
47
|
+
throw new Error(`No entiendo el archivo de credenciales ${ruta}; borralo y volvé a iniciar sesión.`);
|
|
48
|
+
return { valor: r.valor, escritoEn: r.escritoEn };
|
|
49
|
+
},
|
|
50
|
+
async escribir(cuenta, r) {
|
|
51
|
+
const t = todo();
|
|
52
|
+
t[cuenta] = { valor: r.valor, escritoEn: r.escritoEn };
|
|
53
|
+
persistir(t);
|
|
54
|
+
},
|
|
55
|
+
async borrar(cuenta) {
|
|
56
|
+
const t = todo();
|
|
57
|
+
if (!Object.hasOwn(t, cuenta))
|
|
58
|
+
return;
|
|
59
|
+
delete t[cuenta];
|
|
60
|
+
persistir(t);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|