xonecode 0.35.0 → 0.36.3

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.
Files changed (93) hide show
  1. package/THIRD_PARTY_NOTICES.md +30 -0
  2. package/apps/web/dist/assets/{EditorDeFichero-CGZ6uZPa.js → EditorDeFichero-B9EyiuM6.js} +1 -1
  3. package/apps/web/dist/assets/index-C6MsC7A5.js +506 -0
  4. package/apps/web/dist/assets/index-DRNhZyYF.css +1 -0
  5. package/apps/web/dist/assets/{php-DTPkbJvq.js → php-DCP5mtTp.js} +1 -1
  6. package/apps/web/dist/assets/{ruby-D1wBOK7G.js → ruby-B41ayseU.js} +1 -1
  7. package/apps/web/dist/index.html +2 -2
  8. package/dist/agent/config/settingsEnDisco.js +30 -0
  9. package/dist/agent/config/settingsEnDisco.js.map +1 -1
  10. package/dist/agent/dispositivos/instalacionEnMaquina.js +13 -0
  11. package/dist/agent/dispositivos/instalacionEnMaquina.js.map +1 -1
  12. package/dist/agent/motores/trueforge/sesionTrueforge.js +39 -1
  13. package/dist/agent/motores/trueforge/sesionTrueforge.js.map +1 -1
  14. package/dist/agent/remoto/ngrokEnMaquina.js +113 -0
  15. package/dist/agent/remoto/ngrokEnMaquina.js.map +1 -0
  16. package/dist/agent/remoto/puenteWebSocket.js +175 -0
  17. package/dist/agent/remoto/puenteWebSocket.js.map +1 -0
  18. package/dist/agent/remoto/tunelNgrok.js +245 -0
  19. package/dist/agent/remoto/tunelNgrok.js.map +1 -0
  20. package/dist/core/dispositivos.js +53 -0
  21. package/dist/core/dispositivos.js.map +1 -1
  22. package/dist/core/idiomaDeLaRespuesta.js +38 -0
  23. package/dist/core/idiomaDeLaRespuesta.js.map +1 -0
  24. package/dist/core/panorama.js +93 -0
  25. package/dist/core/panorama.js.map +1 -0
  26. package/dist/core/ports.js +100 -0
  27. package/dist/core/ports.js.map +1 -1
  28. package/dist/core/remoto.js +117 -0
  29. package/dist/core/remoto.js.map +1 -0
  30. package/dist/core/settings.js +83 -0
  31. package/dist/core/settings.js.map +1 -1
  32. package/dist/core/tunel.js +92 -0
  33. package/dist/core/tunel.js.map +1 -0
  34. package/dist/web/servidor/arranque.js +597 -72
  35. package/dist/web/servidor/arranque.js.map +1 -1
  36. package/dist/web/servidor/consolaWeb.js +52 -2
  37. package/dist/web/servidor/consolaWeb.js.map +1 -1
  38. package/dist/web/servidor/puenteRemoto.js +272 -0
  39. package/dist/web/servidor/puenteRemoto.js.map +1 -0
  40. package/dist/web/servidor/transporte.js.map +1 -1
  41. package/dist/web/servidor/vestibulo.js +39 -1
  42. package/dist/web/servidor/vestibulo.js.map +1 -1
  43. package/node_modules/@xone/xonecode-remoto/dist/base64url.d.ts +2 -0
  44. package/node_modules/@xone/xonecode-remoto/dist/base64url.js +19 -0
  45. package/node_modules/@xone/xonecode-remoto/dist/cifrado.d.ts +25 -0
  46. package/node_modules/@xone/xonecode-remoto/dist/cifrado.js +75 -0
  47. package/node_modules/@xone/xonecode-remoto/dist/contenido.d.ts +121 -0
  48. package/node_modules/@xone/xonecode-remoto/dist/contenido.js +191 -0
  49. package/node_modules/@xone/xonecode-remoto/dist/index.d.ts +6 -0
  50. package/node_modules/@xone/xonecode-remoto/dist/index.js +6 -0
  51. package/node_modules/@xone/xonecode-remoto/dist/servidor/salas.d.ts +46 -0
  52. package/node_modules/@xone/xonecode-remoto/dist/servidor/salas.js +128 -0
  53. package/node_modules/@xone/xonecode-remoto/dist/servidor/servidor.d.ts +31 -0
  54. package/node_modules/@xone/xonecode-remoto/dist/servidor/servidor.js +198 -0
  55. package/node_modules/@xone/xonecode-remoto/dist/sobre.d.ts +53 -0
  56. package/node_modules/@xone/xonecode-remoto/dist/sobre.js +69 -0
  57. package/node_modules/@xone/xonecode-remoto/dist/url.d.ts +12 -0
  58. package/node_modules/@xone/xonecode-remoto/dist/url.js +35 -0
  59. package/node_modules/@xone/xonecode-remoto/dist/version.d.ts +2 -0
  60. package/node_modules/@xone/xonecode-remoto/dist/version.js +2 -0
  61. package/node_modules/@xone/xonecode-remoto/package.json +41 -0
  62. package/node_modules/@xone/xonecode-remoto/web/assets/index-Bfa2gK3J.css +1 -0
  63. package/node_modules/@xone/xonecode-remoto/web/assets/index-qBo9eRDN.js +77 -0
  64. package/node_modules/@xone/xonecode-remoto/web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  65. package/node_modules/@xone/xonecode-remoto/web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  66. package/node_modules/@xone/xonecode-remoto/web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  67. package/node_modules/@xone/xonecode-remoto/web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  68. package/node_modules/@xone/xonecode-remoto/web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  69. package/node_modules/@xone/xonecode-remoto/web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  70. package/node_modules/@xone/xonecode-remoto/web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  71. package/node_modules/@xone/xonecode-remoto/web/index.html +14 -0
  72. package/node_modules/ws/LICENSE +20 -0
  73. package/node_modules/ws/README.md +548 -0
  74. package/node_modules/ws/browser.js +8 -0
  75. package/node_modules/ws/index.js +22 -0
  76. package/node_modules/ws/lib/buffer-util.js +131 -0
  77. package/node_modules/ws/lib/constants.js +19 -0
  78. package/node_modules/ws/lib/event-target.js +292 -0
  79. package/node_modules/ws/lib/extension.js +203 -0
  80. package/node_modules/ws/lib/limiter.js +55 -0
  81. package/node_modules/ws/lib/permessage-deflate.js +530 -0
  82. package/node_modules/ws/lib/receiver.js +743 -0
  83. package/node_modules/ws/lib/sender.js +607 -0
  84. package/node_modules/ws/lib/stream.js +161 -0
  85. package/node_modules/ws/lib/subprotocol.js +62 -0
  86. package/node_modules/ws/lib/validation.js +152 -0
  87. package/node_modules/ws/lib/websocket-server.js +562 -0
  88. package/node_modules/ws/lib/websocket.js +1407 -0
  89. package/node_modules/ws/package.json +74 -0
  90. package/node_modules/ws/wrapper.mjs +21 -0
  91. package/package.json +6 -1
  92. package/apps/web/dist/assets/index-BBQH-l_3.css +0 -1
  93. package/apps/web/dist/assets/index-DqYXrrxo.js +0 -499
@@ -0,0 +1,46 @@
1
+ import { type SobreDelServidor } from "../index.js";
2
+ export interface Topes {
3
+ movilesPorSala: number;
4
+ bytesPorMensaje: number;
5
+ /** Ritmo sostenido de un MÓVIL (y de toda conexión antes de su primer mensaje). */
6
+ mensajesPorMinuto: number;
7
+ /**
8
+ * Ritmo sostenido de un ANFITRIÓN. xonecode manda una `sustitucion` cada 80 ms mientras el modelo
9
+ * escribe (unas 750 por minuto, hasta el doble con razonamiento): con el tope del móvil se cortaría.
10
+ */
11
+ mensajesPorMinutoAnfitrion: number;
12
+ graciaMs: number;
13
+ }
14
+ export declare const TOPES_POR_OMISION: Topes;
15
+ type Enviar = (sobre: SobreDelServidor) => void;
16
+ /** Salas en memoria: empareja y enruta. No sabe nada de lo que va dentro de `carga`. */
17
+ export declare class Salas {
18
+ private readonly topes;
19
+ private readonly ahora;
20
+ private readonly nuevoId;
21
+ private readonly salas;
22
+ constructor(topes: Topes, ahora?: () => number, nuevoId?: () => string);
23
+ /** Todo envío pasa por aquí: un socket roto no puede cortar un bucle ni dejar una sala sin borrar. */
24
+ private emitir;
25
+ existe(sala: string): boolean;
26
+ /**
27
+ * Supuesto: no hay prueba de identidad del anfitrión durante la gracia. El id de sala solo lo
28
+ * conocen el relé y quien tiene la URL de la sesión, que ya controla la sesión por diseño; el
29
+ * cifrado extremo a extremo impide leer o falsificar nada. Lo peor es una denegación de servicio
30
+ * durante la gracia.
31
+ */
32
+ abrir(sala: string, enviar: Enviar): boolean;
33
+ entrar(sala: string, enviar: Enviar): string | undefined;
34
+ salir(sala: string, id: string): void;
35
+ datoDelAnfitrion(sala: string, para: string | undefined, carga: string): void;
36
+ /** Solo de un móvil que SIGUE en la sala: uno de una sala ya cerrada no llega a quien reabra el mismo id. */
37
+ datoDelMovil(sala: string, id: string, carga: string): void;
38
+ /** Solo vale si `enviar` es el anfitrión actual: el cierre tardío de uno viejo no echa al nuevo. */
39
+ anfitrionCaido(sala: string, enviar: Enviar): void;
40
+ /** Cierre pedido por el anfitrión actual; con otro `enviar` no hace nada. */
41
+ cerrar(sala: string, enviar: Enviar): void;
42
+ private expulsar;
43
+ /** Cierra las salas cuyo anfitrión lleva caído más que la gracia. Se llama periódicamente. */
44
+ barrer(): void;
45
+ }
46
+ export {};
@@ -0,0 +1,128 @@
1
+ import { TOPE_DE_SOBRE } from "../index.js";
2
+ export const TOPES_POR_OMISION = {
3
+ movilesPorSala: 5,
4
+ bytesPorMensaje: TOPE_DE_SOBRE,
5
+ mensajesPorMinuto: 600,
6
+ mensajesPorMinutoAnfitrion: 6000,
7
+ graciaMs: 300_000,
8
+ };
9
+ /** Salas en memoria: empareja y enruta. No sabe nada de lo que va dentro de `carga`. */
10
+ export class Salas {
11
+ topes;
12
+ ahora;
13
+ nuevoId;
14
+ salas = new Map();
15
+ constructor(topes, ahora = Date.now, nuevoId = () => crypto.randomUUID()) {
16
+ this.topes = topes;
17
+ this.ahora = ahora;
18
+ this.nuevoId = nuevoId;
19
+ }
20
+ /** Todo envío pasa por aquí: un socket roto no puede cortar un bucle ni dejar una sala sin borrar. */
21
+ emitir(enviar, sobre) {
22
+ if (enviar === undefined)
23
+ return;
24
+ try {
25
+ enviar(sobre);
26
+ }
27
+ catch {
28
+ // se ignora: la conexión rota la limpia su propio cierre
29
+ }
30
+ }
31
+ existe(sala) {
32
+ return this.salas.has(sala);
33
+ }
34
+ /**
35
+ * Supuesto: no hay prueba de identidad del anfitrión durante la gracia. El id de sala solo lo
36
+ * conocen el relé y quien tiene la URL de la sesión, que ya controla la sesión por diseño; el
37
+ * cifrado extremo a extremo impide leer o falsificar nada. Lo peor es una denegación de servicio
38
+ * durante la gracia.
39
+ */
40
+ abrir(sala, enviar) {
41
+ const existente = this.salas.get(sala);
42
+ if (existente !== undefined && existente.anfitrion !== undefined) {
43
+ this.emitir(enviar, { t: "error", motivo: "sala-ocupada" });
44
+ return false;
45
+ }
46
+ const s = existente ?? { anfitrion: undefined, caidoDesde: undefined, moviles: new Map() };
47
+ s.anfitrion = enviar;
48
+ s.caidoDesde = undefined;
49
+ this.salas.set(sala, s);
50
+ this.emitir(enviar, { t: "abierta" });
51
+ if (s.moviles.size > 0) {
52
+ this.emitir(enviar, { t: "presencia", moviles: [...s.moviles.keys()] });
53
+ for (const movil of s.moviles.values())
54
+ this.emitir(movil, { t: "anfitrion", conectado: true });
55
+ }
56
+ return true;
57
+ }
58
+ entrar(sala, enviar) {
59
+ const s = this.salas.get(sala);
60
+ if (s === undefined) {
61
+ this.emitir(enviar, { t: "error", motivo: "sala-inexistente" });
62
+ return undefined;
63
+ }
64
+ if (s.moviles.size >= this.topes.movilesPorSala) {
65
+ this.emitir(enviar, { t: "error", motivo: "tope-de-moviles" });
66
+ return undefined;
67
+ }
68
+ const id = this.nuevoId();
69
+ s.moviles.set(id, enviar);
70
+ this.emitir(enviar, { t: "entrada", id });
71
+ this.emitir(enviar, { t: "anfitrion", conectado: s.anfitrion !== undefined });
72
+ this.emitir(s.anfitrion, { t: "presencia", moviles: [...s.moviles.keys()] });
73
+ return id;
74
+ }
75
+ salir(sala, id) {
76
+ const s = this.salas.get(sala);
77
+ if (s === undefined || !s.moviles.delete(id))
78
+ return;
79
+ this.emitir(s.anfitrion, { t: "presencia", moviles: [...s.moviles.keys()] });
80
+ }
81
+ datoDelAnfitrion(sala, para, carga) {
82
+ const s = this.salas.get(sala);
83
+ if (s === undefined)
84
+ return;
85
+ if (para !== undefined) {
86
+ this.emitir(s.moviles.get(para), { t: "dato", carga });
87
+ return;
88
+ }
89
+ for (const movil of s.moviles.values())
90
+ this.emitir(movil, { t: "dato", carga });
91
+ }
92
+ /** Solo de un móvil que SIGUE en la sala: uno de una sala ya cerrada no llega a quien reabra el mismo id. */
93
+ datoDelMovil(sala, id, carga) {
94
+ const s = this.salas.get(sala);
95
+ if (s === undefined || !s.moviles.has(id))
96
+ return;
97
+ this.emitir(s.anfitrion, { t: "dato", de: id, carga });
98
+ }
99
+ /** Solo vale si `enviar` es el anfitrión actual: el cierre tardío de uno viejo no echa al nuevo. */
100
+ anfitrionCaido(sala, enviar) {
101
+ const s = this.salas.get(sala);
102
+ if (s === undefined || s.anfitrion !== enviar)
103
+ return;
104
+ s.anfitrion = undefined;
105
+ s.caidoDesde = this.ahora();
106
+ for (const movil of s.moviles.values())
107
+ this.emitir(movil, { t: "anfitrion", conectado: false });
108
+ }
109
+ /** Cierre pedido por el anfitrión actual; con otro `enviar` no hace nada. */
110
+ cerrar(sala, enviar) {
111
+ const s = this.salas.get(sala);
112
+ if (s === undefined || s.anfitrion !== enviar)
113
+ return;
114
+ this.expulsar(sala, s);
115
+ }
116
+ expulsar(sala, s) {
117
+ for (const movil of s.moviles.values())
118
+ this.emitir(movil, { t: "cerrada" });
119
+ this.salas.delete(sala);
120
+ }
121
+ /** Cierra las salas cuyo anfitrión lleva caído más que la gracia. Se llama periódicamente. */
122
+ barrer() {
123
+ for (const [sala, s] of this.salas) {
124
+ if (s.caidoDesde !== undefined && this.ahora() - s.caidoDesde > this.topes.graciaMs)
125
+ this.expulsar(sala, s);
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,31 @@
1
+ import { type Topes } from "./salas.js";
2
+ export interface OpcionesDelServidor {
3
+ puerto: number;
4
+ /** Dirección de escucha; por omisión solo loopback. */
5
+ host?: string;
6
+ /** La web móvil; por omisión la que viaja en el paquete (`web/`, relativa a este módulo). */
7
+ carpetaWeb?: string;
8
+ topes?: Topes;
9
+ /** Periodo del barrido y del ping de latido; por omisión 10 s. */
10
+ latidoMs?: number;
11
+ /** Plazo para el primer `abrir`/`entrar` válido; por omisión `PLAZO_PRIMER_MENSAJE_MS`. */
12
+ plazoPrimerMensajeMs?: number;
13
+ /** El reloj (ms) del ritmo y de la gracia de las salas; por omisión `Date.now`. Solo para los tests. */
14
+ ahora?: () => number;
15
+ }
16
+ /**
17
+ * Un cubo de fichas por conexión: cabe un minuto de ritmo y se rellena a ese ritmo, sin ventanas
18
+ * fijas que dejen pasar el doble a caballo del cambio de minuto. El tope lo da el papel de la
19
+ * conexión en cada mensaje, así que al pasar a anfitrión se rellena a su ritmo.
20
+ */
21
+ export declare function crearCubo(ahora: () => number, inicial: number): {
22
+ /** Gasta una ficha si la hay; `false` es que se ha pasado del ritmo. */
23
+ gastar(porMinuto: number): boolean;
24
+ };
25
+ export declare const PLAZO_PRIMER_MENSAJE_MS = 10000;
26
+ export interface ServidorEnMarcha {
27
+ urlHttp: string;
28
+ urlWs: string;
29
+ cerrar(): Promise<void>;
30
+ }
31
+ export declare function arrancarServidor(opciones: OpcionesDelServidor): Promise<ServidorEnMarcha>;
@@ -0,0 +1,198 @@
1
+ import { createServer } from "node:http";
2
+ import { readFile } from "node:fs/promises";
3
+ import { extname, join, normalize } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { WebSocketServer } from "ws";
6
+ import { FORMA_DE_SALA, VERSION_DEL_PROTOCOLO, interpretarSobre } from "../index.js";
7
+ import { Salas, TOPES_POR_OMISION } from "./salas.js";
8
+ /** `dist/servidor/` y `src/servidor/` están a la misma profundidad de `web/`, que `npm run build` copia de la web. */
9
+ const CARPETA_WEB_DEL_PAQUETE = fileURLToPath(new URL("../../web/", import.meta.url));
10
+ /**
11
+ * Un cubo de fichas por conexión: cabe un minuto de ritmo y se rellena a ese ritmo, sin ventanas
12
+ * fijas que dejen pasar el doble a caballo del cambio de minuto. El tope lo da el papel de la
13
+ * conexión en cada mensaje, así que al pasar a anfitrión se rellena a su ritmo.
14
+ */
15
+ export function crearCubo(ahora, inicial) {
16
+ let fichas = inicial;
17
+ let desde = ahora();
18
+ return {
19
+ /** Gasta una ficha si la hay; `false` es que se ha pasado del ritmo. */
20
+ gastar(porMinuto) {
21
+ const t = ahora();
22
+ fichas = Math.min(porMinuto, fichas + (Math.max(0, t - desde) * porMinuto) / 60_000);
23
+ desde = t;
24
+ if (fichas < 1)
25
+ return false;
26
+ fichas -= 1;
27
+ return true;
28
+ },
29
+ };
30
+ }
31
+ export const PLAZO_PRIMER_MENSAJE_MS = 10_000;
32
+ const TIPOS = {
33
+ ".html": "text/html; charset=utf-8",
34
+ ".js": "text/javascript; charset=utf-8",
35
+ ".css": "text/css; charset=utf-8",
36
+ ".svg": "image/svg+xml",
37
+ ".png": "image/png",
38
+ ".woff2": "font/woff2",
39
+ ".json": "application/json",
40
+ };
41
+ async function servirEstatico(carpeta, peticion, respuesta) {
42
+ const ruta = new URL(peticion.url ?? "/", "http://x").pathname;
43
+ if (ruta === "/salud") {
44
+ respuesta.writeHead(200, { "content-type": "text/plain" }).end("ok");
45
+ return;
46
+ }
47
+ // Toda URL de sesión sirve la misma página: la sala la lee la web, el secreto está en el fragmento.
48
+ const relativa = ruta === "/" || ruta.startsWith("/r/") ? "index.html" : normalize(ruta).replace(/^(\.\.[/\\])+/, "").replace(/^[/\\]+/, "");
49
+ try {
50
+ const cuerpo = await readFile(join(carpeta, relativa));
51
+ respuesta.writeHead(200, {
52
+ "content-type": TIPOS[extname(relativa)] ?? "application/octet-stream",
53
+ "x-content-type-options": "nosniff",
54
+ "referrer-policy": "no-referrer",
55
+ ...(relativa === "index.html" ? { "cache-control": "no-store" } : {}),
56
+ });
57
+ respuesta.end(cuerpo);
58
+ }
59
+ catch {
60
+ respuesta.writeHead(404).end();
61
+ }
62
+ }
63
+ export async function arrancarServidor(opciones) {
64
+ const topes = opciones.topes ?? TOPES_POR_OMISION;
65
+ const ahora = opciones.ahora ?? Date.now;
66
+ const salas = new Salas(topes, ahora);
67
+ const carpetaWeb = opciones.carpetaWeb ?? CARPETA_WEB_DEL_PAQUETE;
68
+ const http = createServer((peticion, respuesta) => void servirEstatico(carpetaWeb, peticion, respuesta));
69
+ const wss = new WebSocketServer({ server: http, path: "/ws", maxPayload: topes.bytesPorMensaje });
70
+ const vivos = new WeakMap();
71
+ const barrido = setInterval(() => {
72
+ salas.barrer();
73
+ for (const c of wss.clients) {
74
+ if (vivos.get(c) === false)
75
+ c.terminate();
76
+ else {
77
+ vivos.set(c, false);
78
+ c.ping();
79
+ }
80
+ }
81
+ }, opciones.latidoMs ?? 10_000);
82
+ barrido.unref();
83
+ wss.on("connection", (ws) => {
84
+ let papel;
85
+ let cerrando = false;
86
+ const cortar = () => {
87
+ cerrando = true;
88
+ ws.close();
89
+ };
90
+ const enviar = (s) => {
91
+ if (ws.readyState === ws.OPEN)
92
+ ws.send(JSON.stringify(s));
93
+ // `cerrada` es lo último que oye un móvil: su sala ya no existe, y un socket que sigue abierto es un zombi.
94
+ if (s.t === "cerrada")
95
+ cortar();
96
+ };
97
+ vivos.set(ws, true);
98
+ ws.on("pong", () => vivos.set(ws, true));
99
+ const plazo = setTimeout(() => {
100
+ if (papel === undefined)
101
+ ws.terminate();
102
+ }, opciones.plazoPrimerMensajeMs ?? PLAZO_PRIMER_MENSAJE_MS);
103
+ plazo.unref();
104
+ // Hasta su primer mensaje nadie sabe qué es: va con el tope del móvil, el más estrecho.
105
+ const cubo = crearCubo(ahora, topes.mensajesPorMinuto);
106
+ ws.on("message", (datos) => {
107
+ if (cerrando || ws.readyState !== ws.OPEN)
108
+ return;
109
+ if (!cubo.gastar(papel?.tipo === "anfitrion" ? topes.mensajesPorMinutoAnfitrion : topes.mensajesPorMinuto)) {
110
+ enviar({ t: "error", motivo: "ritmo" });
111
+ cortar();
112
+ return;
113
+ }
114
+ const texto = String(datos);
115
+ if (papel === undefined) {
116
+ const comoAnfitrion = interpretarSobre(texto, "anfitrion");
117
+ const comoMovil = interpretarSobre(texto, "movil");
118
+ const inicio = comoAnfitrion?.t === "abrir" ? comoAnfitrion : comoMovil?.t === "entrar" ? comoMovil : undefined;
119
+ if (inicio === undefined) {
120
+ enviar({ t: "error", motivo: "mensaje-ilegible" });
121
+ return;
122
+ }
123
+ if (inicio.v !== VERSION_DEL_PROTOCOLO) {
124
+ enviar({ t: "error", motivo: "version" });
125
+ cortar();
126
+ return;
127
+ }
128
+ if (!FORMA_DE_SALA.test(inicio.sala)) {
129
+ enviar({ t: "error", motivo: "mensaje-ilegible" });
130
+ cortar();
131
+ return;
132
+ }
133
+ if (inicio.t === "abrir") {
134
+ if (salas.abrir(inicio.sala, enviar))
135
+ papel = { tipo: "anfitrion", sala: inicio.sala };
136
+ else
137
+ cortar();
138
+ }
139
+ else {
140
+ const id = salas.entrar(inicio.sala, enviar);
141
+ if (id !== undefined)
142
+ papel = { tipo: "movil", sala: inicio.sala, id };
143
+ else
144
+ cortar();
145
+ }
146
+ return;
147
+ }
148
+ if (papel.tipo === "anfitrion") {
149
+ const s = interpretarSobre(texto, "anfitrion");
150
+ if (s?.t === "dato")
151
+ salas.datoDelAnfitrion(papel.sala, s.para, s.carga);
152
+ else if (s?.t === "cerrar") {
153
+ salas.cerrar(papel.sala, enviar);
154
+ papel = undefined;
155
+ cortar();
156
+ }
157
+ else
158
+ enviar({ t: "error", motivo: "mensaje-ilegible" });
159
+ return;
160
+ }
161
+ const s = interpretarSobre(texto, "movil");
162
+ if (s?.t === "dato")
163
+ salas.datoDelMovil(papel.sala, papel.id, s.carga);
164
+ else
165
+ enviar({ t: "error", motivo: "mensaje-ilegible" });
166
+ });
167
+ // Un mensaje mayor que el tope emite `error` en el socket del servidor; sin oyente tumbaría el proceso.
168
+ // `ws` ya ha empezado el cierre con su código (1009 para el tamaño, `TOPE_DE_SOBRE`): terminar aquí
169
+ // cortaría el TCP antes de mandar ese marco y el cliente vería un 1006 sin motivo. Solo se termina
170
+ // lo que siga abierto; el cierre en curso lo remata el propio plazo de `ws`.
171
+ ws.on("error", () => {
172
+ cerrando = true;
173
+ if (ws.readyState === ws.OPEN)
174
+ ws.terminate();
175
+ });
176
+ ws.on("close", () => {
177
+ clearTimeout(plazo);
178
+ if (papel?.tipo === "anfitrion")
179
+ salas.anfitrionCaido(papel.sala, enviar);
180
+ if (papel?.tipo === "movil")
181
+ salas.salir(papel.sala, papel.id);
182
+ });
183
+ });
184
+ await new Promise((resolver) => http.listen(opciones.puerto, opciones.host ?? "127.0.0.1", resolver));
185
+ const direccion = http.address();
186
+ const puerto = typeof direccion === "object" && direccion !== null ? direccion.port : opciones.puerto;
187
+ return {
188
+ urlHttp: `http://127.0.0.1:${puerto}`,
189
+ urlWs: `ws://127.0.0.1:${puerto}/ws`,
190
+ cerrar: async () => {
191
+ clearInterval(barrido);
192
+ for (const c of wss.clients)
193
+ c.terminate();
194
+ await new Promise((r) => wss.close(() => r()));
195
+ await new Promise((r) => http.close(() => r()));
196
+ },
197
+ };
198
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * El tope de un sobre en BYTES, tal como viaja (el JSON entero del sobre). Quien manda tiene que
3
+ * mantener cada sobre por debajo: la carga va cifrada (AES-GCM) y en base64url, que suman en torno a
4
+ * un 33 % al contenido en claro. Por encima, el relé no contesta con un `error`: cierra el socket con
5
+ * el código 1009 de WebSocket (mensaje demasiado grande).
6
+ */
7
+ export declare const TOPE_DE_SOBRE = 1000000;
8
+ export type MotivoDeError = "version" | "sala-inexistente" | "sala-ocupada" | "tope-de-moviles" | "ritmo" | "mensaje-ilegible";
9
+ export type SobreDelAnfitrion = {
10
+ t: "abrir";
11
+ sala: string;
12
+ v: number;
13
+ } | {
14
+ t: "dato";
15
+ para?: string;
16
+ carga: string;
17
+ } | {
18
+ t: "cerrar";
19
+ };
20
+ export type SobreDelMovil = {
21
+ t: "entrar";
22
+ sala: string;
23
+ v: number;
24
+ } | {
25
+ t: "dato";
26
+ carga: string;
27
+ };
28
+ export type SobreDelServidor = {
29
+ t: "abierta";
30
+ } | {
31
+ t: "entrada";
32
+ id: string;
33
+ } | {
34
+ t: "presencia";
35
+ moviles: string[];
36
+ } | {
37
+ t: "anfitrion";
38
+ conectado: boolean;
39
+ } | {
40
+ t: "dato";
41
+ de?: string;
42
+ carga: string;
43
+ } | {
44
+ t: "error";
45
+ motivo: MotivoDeError;
46
+ } | {
47
+ t: "cerrada";
48
+ };
49
+ type Lado = "anfitrion" | "movil" | "servidor";
50
+ type SobreDe<L extends Lado> = L extends "anfitrion" ? SobreDelAnfitrion : L extends "movil" ? SobreDelMovil : SobreDelServidor;
51
+ /** Lee un sobre de un lado. Lo que no tiene exactamente una forma permitida es `undefined`. */
52
+ export declare function interpretarSobre<L extends Lado>(texto: string, lado: L): SobreDe<L> | undefined;
53
+ export {};
@@ -0,0 +1,69 @@
1
+ /**
2
+ * El tope de un sobre en BYTES, tal como viaja (el JSON entero del sobre). Quien manda tiene que
3
+ * mantener cada sobre por debajo: la carga va cifrada (AES-GCM) y en base64url, que suman en torno a
4
+ * un 33 % al contenido en claro. Por encima, el relé no contesta con un `error`: cierra el socket con
5
+ * el código 1009 de WebSocket (mensaje demasiado grande).
6
+ */
7
+ export const TOPE_DE_SOBRE = 1_000_000;
8
+ const MOTIVOS = [
9
+ "version",
10
+ "sala-inexistente",
11
+ "sala-ocupada",
12
+ "tope-de-moviles",
13
+ "ritmo",
14
+ "mensaje-ilegible",
15
+ ];
16
+ const esTexto = (x) => typeof x === "string" && x.length > 0;
17
+ /** Lee un sobre de un lado. Lo que no tiene exactamente una forma permitida es `undefined`. */
18
+ export function interpretarSobre(texto, lado) {
19
+ let o;
20
+ try {
21
+ const crudo = JSON.parse(texto);
22
+ if (typeof crudo !== "object" || crudo === null || Array.isArray(crudo))
23
+ return undefined;
24
+ o = crudo;
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ const r = sobreDe(o, lado);
30
+ return r;
31
+ }
32
+ function sobreDe(o, lado) {
33
+ if (lado === "anfitrion") {
34
+ if (o.t === "abrir" && esTexto(o.sala) && typeof o.v === "number")
35
+ return { t: "abrir", sala: o.sala, v: o.v };
36
+ if (o.t === "dato" && esTexto(o.carga)) {
37
+ if (o.para === undefined)
38
+ return { t: "dato", carga: o.carga };
39
+ return esTexto(o.para) ? { t: "dato", para: o.para, carga: o.carga } : undefined;
40
+ }
41
+ if (o.t === "cerrar")
42
+ return { t: "cerrar" };
43
+ return undefined;
44
+ }
45
+ if (lado === "movil") {
46
+ if (o.t === "entrar" && esTexto(o.sala) && typeof o.v === "number")
47
+ return { t: "entrar", sala: o.sala, v: o.v };
48
+ if (o.t === "dato" && esTexto(o.carga) && o.para === undefined)
49
+ return { t: "dato", carga: o.carga };
50
+ return undefined;
51
+ }
52
+ if (o.t === "abierta" || o.t === "cerrada")
53
+ return { t: o.t };
54
+ if (o.t === "entrada" && esTexto(o.id))
55
+ return { t: "entrada", id: o.id };
56
+ if (o.t === "presencia" && Array.isArray(o.moviles) && o.moviles.every(esTexto))
57
+ return { t: "presencia", moviles: o.moviles };
58
+ if (o.t === "anfitrion" && typeof o.conectado === "boolean")
59
+ return { t: "anfitrion", conectado: o.conectado };
60
+ if (o.t === "dato" && esTexto(o.carga)) {
61
+ if (o.de === undefined)
62
+ return { t: "dato", carga: o.carga };
63
+ return esTexto(o.de) ? { t: "dato", de: o.de, carga: o.carga } : undefined;
64
+ }
65
+ if (o.t === "error" && typeof o.motivo === "string" && MOTIVOS.includes(o.motivo)) {
66
+ return { t: "error", motivo: o.motivo };
67
+ }
68
+ return undefined;
69
+ }
@@ -0,0 +1,12 @@
1
+ /** La forma de un id de sala: 16 bytes aleatorios en base64url. La comprueban la URL y el relé. */
2
+ export declare const FORMA_DE_SALA: RegExp;
3
+ export interface Credenciales {
4
+ sala: string;
5
+ secreto: string;
6
+ }
7
+ export declare function generarCredenciales(): Credenciales;
8
+ /** `https://<host>/r/<sala>#<secreto>`: el secreto en el FRAGMENTO, que el navegador nunca envía. */
9
+ export declare function urlDeSesion(baseHttp: string, sala: string, secreto: string): string;
10
+ export declare function leerUrlDeSesion(href: string): Credenciales | undefined;
11
+ /** La base http(s) que sirve la web, a partir de la dirección ws(s) del puente. */
12
+ export declare function baseHttpDe(servidorWs: string): string;
@@ -0,0 +1,35 @@
1
+ import { aBase64url } from "./base64url.js";
2
+ const BYTES_DE_SALA = 16;
3
+ const BYTES_DE_SECRETO = 32;
4
+ /** La forma de un id de sala: 16 bytes aleatorios en base64url. La comprueban la URL y el relé. */
5
+ export const FORMA_DE_SALA = /^[A-Za-z0-9_-]{22}$/;
6
+ const SECRETO = /^[A-Za-z0-9_-]{43}$/;
7
+ export function generarCredenciales() {
8
+ const sala = crypto.getRandomValues(new Uint8Array(BYTES_DE_SALA));
9
+ const secreto = crypto.getRandomValues(new Uint8Array(BYTES_DE_SECRETO));
10
+ return { sala: aBase64url(sala), secreto: aBase64url(secreto) };
11
+ }
12
+ /** `https://<host>/r/<sala>#<secreto>`: el secreto en el FRAGMENTO, que el navegador nunca envía. */
13
+ export function urlDeSesion(baseHttp, sala, secreto) {
14
+ return `${baseHttp.replace(/\/+$/, "")}/r/${sala}#${secreto}`;
15
+ }
16
+ export function leerUrlDeSesion(href) {
17
+ let url;
18
+ try {
19
+ url = new URL(href);
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ const sala = /^\/r\/([^/]+)\/?$/.exec(url.pathname)?.[1];
25
+ const secreto = url.hash.replace(/^#/, "");
26
+ if (sala === undefined || !FORMA_DE_SALA.test(sala) || !SECRETO.test(secreto))
27
+ return undefined;
28
+ return { sala, secreto };
29
+ }
30
+ /** La base http(s) que sirve la web, a partir de la dirección ws(s) del puente. */
31
+ export function baseHttpDe(servidorWs) {
32
+ const url = new URL(servidorWs);
33
+ const protocolo = url.protocol === "wss:" ? "https:" : "http:";
34
+ return `${protocolo}//${url.host}`;
35
+ }
@@ -0,0 +1,2 @@
1
+ /** La versión del PROTOCOLO (lo que viaja), no la del paquete. Solo sube cuando cambia el sobre o el contenido. */
2
+ export declare const VERSION_DEL_PROTOCOLO = 1;
@@ -0,0 +1,2 @@
1
+ /** La versión del PROTOCOLO (lo que viaja), no la del paquete. Solo sube cuando cambia el sobre o el contenido. */
2
+ export const VERSION_DEL_PROTOCOLO = 1;
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@xone/xonecode-remoto",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ },
12
+ "./servidor": {
13
+ "types": "./dist/servidor/servidor.d.ts",
14
+ "import": "./dist/servidor/servidor.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "web"
20
+ ],
21
+ "dependencies": {
22
+ "ws": "^8.18.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/ws": "^8.5.12"
26
+ },
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.json",
29
+ "copiar-web": "node scripts/copiar-web.mjs",
30
+ "test": "vitest run --passWithNoTests",
31
+ "typecheck": "tsc -p tsconfig.json --noEmit"
32
+ },
33
+ "publishConfig": {
34
+ "access": "restricted"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/sleiva/xonecode-server.git",
39
+ "directory": "packages/remoto"
40
+ }
41
+ }
@@ -0,0 +1 @@
1
+ @font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2) format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2) format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2) format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-greek-wght-normal-CkhJZR-_.woff2) format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2) format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2) format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-latin-wght-normal-Dx4kXJAl.woff2) format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--azul: #0c3c60;--azul-hondo: #05263a;--sobre-azul: #ffffff;--sobre-azul-suave: rgba(224, 242, 254, .75);--cian: #00a3e0;--cian-oscuro: #0284c7;--verde: #10b981;--ambar: #f59e0b;--rojo: #ef4444;--fondo: #ffffff;--superficie: #f4f6f9;--texto: #1b1f24;--tenue: #5b6470;--borde: #dfe3e8;--codigo: #eef1f5;--anadido: #e7f6ec;--quitado: #fdecec;--sombra: 0 1px 2px rgba(5, 38, 58, .06), 0 4px 16px rgba(5, 38, 58, .08);color-scheme:light}@media(prefers-color-scheme:dark){:root{--verde: #34d399;--rojo: #f87171;--fondo: #111418;--superficie: #1a1f25;--texto: #e6e9ee;--tenue: #9aa3ad;--borde: #2a3038;--codigo: #1f252c;--anadido: #12301d;--quitado: #3a1616;--sombra: 0 1px 2px rgba(0, 0, 0, .4), 0 4px 16px rgba(0, 0, 0, .35);color-scheme:dark}}*{box-sizing:border-box}html,body{margin:0}body{background:var(--fondo);color:var(--texto);font:15px/1.5 Inter Variable,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased;-webkit-tap-highlight-color:transparent}button{font:inherit;cursor:pointer}button:disabled{opacity:.5;cursor:default}a{color:var(--cian-oscuro)}.pantalla{display:flex;flex-direction:column;min-height:100dvh;padding:0 16px env(safe-area-inset-bottom)}.cabecera{position:sticky;top:0;z-index:2;display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:0 -16px;padding:calc(10px + env(safe-area-inset-top)) 16px 10px;background:var(--azul);color:var(--sobre-azul);border-bottom:1px solid var(--azul-hondo)}.marca{font-weight:700;letter-spacing:-.01em}.punto{width:9px;height:9px;border-radius:50%;flex:none;box-shadow:0 0 0 2px #ffffff2e}.punto.verde{background:var(--verde)}.punto.ambar{background:var(--ambar)}.punto.rojo{background:var(--rojo)}.donde{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--sobre-azul-suave);font-size:14px}.trabajando{color:var(--sobre-azul-suave);font-size:13px}.parar{background:var(--rojo);color:#fff;border:0;border-radius:8px;padding:5px 12px;font-size:13px;font-weight:600}.aviso-de-conexion{width:100%;margin:0;color:var(--sobre-azul-suave);font-size:13px}.chat{list-style:none;margin:0;padding:16px 0;flex:1;display:flex;flex-direction:column;gap:12px}.chat .usuario{align-self:flex-end;max-width:85%;background:var(--superficie);border:1px solid var(--borde);border-radius:16px 16px 4px;padding:8px 12px;white-space:pre-wrap;overflow-wrap:anywhere}.chat .asistente{min-width:0;max-width:100%;overflow-wrap:anywhere}.chat .sistema,.chat .herramientas{color:var(--tenue);font-size:13px}.chat .herramientas{padding-left:10px;border-left:2px solid var(--borde)}.consulta p{margin:0 0 4px}.consulta button,.pregunta button{margin:4px 6px 0 0;border:1px solid var(--borde);border-radius:999px;padding:7px 14px;background:var(--fondo);color:var(--texto)}.markdown>:first-child{margin-top:0}.markdown>:last-child{margin-bottom:0}.markdown p,.markdown ul,.markdown ol,.markdown pre,.markdown blockquote,.markdown .tabla{margin:0 0 10px}.markdown h1,.markdown h2,.markdown h3,.markdown h4{line-height:1.3;margin:16px 0 6px}.markdown h1{font-size:20px}.markdown h2{font-size:18px}.markdown h3{font-size:16px}.markdown h4{font-size:15px}.markdown ul,.markdown ol{padding-left:22px}.markdown ul{list-style:disc}.markdown li{margin:2px 0}.markdown li>p{margin:0}.markdown code{font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--codigo);border-radius:4px;padding:1px 4px}.markdown pre{background:var(--codigo);border-radius:10px;padding:10px 12px;overflow-x:auto}.markdown pre code{background:none;padding:0}.markdown blockquote{border-left:3px solid var(--cian);padding-left:10px;color:var(--tenue)}.markdown hr{border:0;border-top:1px solid var(--borde);margin:14px 0}.markdown .tabla{overflow-x:auto}.markdown table{border-collapse:collapse;font-size:13px}.markdown th,.markdown td{border:1px solid var(--borde);padding:5px 8px;text-align:left;vertical-align:top}.markdown th{background:var(--superficie)}.imagen-omitida{color:var(--tenue)}.aprobacion,.tarea-que-espera{border:1px solid var(--borde);border-radius:14px;padding:12px;margin:12px 0;background:var(--fondo);box-shadow:var(--sombra)}.aprobacion h2{font-size:16px;margin:0 0 8px}.aprobacion h3{font-size:13px;margin:10px 0 0;word-break:break-all;font-family:ui-monospace,Menlo,monospace}.origen{color:var(--tenue);font-size:13px;margin:0}.diff{overflow-x:auto;font:12px/1.45 ui-monospace,Menlo,monospace;margin:6px 0;background:var(--superficie);border-radius:8px;padding:6px 0}.diff .anadido{background:var(--anadido)}.diff .quitado{background:var(--quitado)}.acciones{display:flex;gap:8px;justify-content:flex-end;margin-top:8px}.acciones button{border-radius:10px;padding:9px 16px;border:1px solid var(--borde);background:var(--fondo);color:var(--texto);font-weight:500}.acciones .primario{background:var(--azul);color:var(--sobre-azul);border-color:var(--azul)}.fallo{color:var(--rojo);font-size:13px;margin:0}.aviso-de-pregunta{border:1px solid var(--ambar);border-radius:14px;padding:10px 12px;margin:12px 0}.tarea-que-espera h3{font-size:16px;margin:0}.tarea-que-espera .motivo{margin:6px 0;overflow-wrap:anywhere}.tarea-que-espera textarea{width:100%;resize:vertical;border:1px solid var(--borde);border-radius:10px;padding:8px 10px;font:inherit;background:var(--fondo);color:var(--texto);margin-bottom:8px}.compositor{position:sticky;bottom:0;z-index:1;display:flex;flex-wrap:wrap;align-items:flex-end;gap:8px;margin:0 -8px;padding:8px 8px 12px;background:linear-gradient(to bottom,transparent,var(--fondo) 12px)}.compositor .fallo{flex:1 1 100%;padding:0 4px}.compositor textarea{flex:1;min-width:0;resize:none;max-height:40dvh;field-sizing:content;min-height:44px;border:1px solid var(--borde);border-radius:22px;padding:11px 16px;font:inherit;background:var(--fondo);color:var(--texto);box-shadow:var(--sombra)}.compositor textarea:focus{outline:none;border-color:var(--cian)}.compositor textarea::placeholder{color:var(--tenue)}.compositor .enviar{flex:none;width:44px;height:44px;border:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:var(--azul);color:var(--sobre-azul)}.compositor .enviar:active{background:var(--cian)}.pantalla.con-pestanas{--alto-pestanas: 56px}.pantalla.con-pestanas .compositor{bottom:var(--alto-pestanas)}.pestanas{position:sticky;bottom:0;z-index:2;display:flex;margin:0 -16px;background:var(--fondo);border-top:1px solid var(--borde)}.pestanas button{flex:1;height:var(--alto-pestanas, 56px);border:0;background:transparent;color:var(--tenue);display:flex;align-items:center;justify-content:center;gap:6px;font-weight:500}.pestanas button.activa{color:var(--azul);font-weight:600;box-shadow:inset 0 2px 0 var(--azul)}@media(prefers-color-scheme:dark){.pestanas button.activa{color:var(--cian);box-shadow:inset 0 2px 0 var(--cian)}}.insignia{min-width:20px;padding:0 6px;border-radius:10px;background:var(--rojo);color:#fff;font-size:12px;line-height:20px;text-align:center}.panel{flex:1;padding:4px 0 12px}.panel h2{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--tenue);margin:18px 0 6px}.panel .aprobacion h2{font-size:16px;text-transform:none;letter-spacing:0;color:var(--texto);margin:0 0 8px}.tenue{color:var(--tenue);font-size:14px;margin:4px 0}.filas{list-style:none;margin:0;padding:0;border:1px solid var(--borde);border-radius:14px;overflow:hidden}.filas li{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;padding:10px 12px;border-bottom:1px solid var(--borde)}.filas li:last-child{border-bottom:0}.fila-titulo{font-weight:600;flex:1 1 100%;overflow-wrap:anywhere}.estado{font-size:12px;font-weight:500;border-radius:10px;padding:0 8px;border:1px solid currentColor}.estado.rojo{color:var(--rojo)}.estado.ambar{color:var(--ambar)}.estado.verde{color:var(--verde)}.estado.gris{color:var(--tenue)}