terminal-smart-cli 0.98.1 → 0.99.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/hub/hub.js ADDED
@@ -0,0 +1,717 @@
1
+ 'use strict';
2
+ // Núcleo do `ts hub` (etapa 3 do plano). Usa só as funções de ./proto/ts-proto.js para
3
+ // cripto e formato; aqui fica a política: quem está autorizado, o que cada comando faz,
4
+ // pareamento com confirmação humana no PC, revogação imediata e Parar tudo (com confirmação
5
+ // de um consumidor externo, hub/parar.js). Terminais: o hub só LÊ instantâneos gravados por
6
+ // `ts hub exportar-telas` (hub/telas-arquivo.js); não fala com o tmux.
7
+
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const P = require('./proto/ts-proto.js');
11
+ const estado = require('./estado');
12
+ const { ConexaoRelay, normalizarRelay } = require('./relay-conexao');
13
+ const { criarProvedorTmux, provedorVazio, sanitizarTela, cortarTela, TELA_MAX_BYTES } = require('./tmux');
14
+ const { criarProvedorTelas } = require('./telas-arquivo');
15
+ const { Parada } = require('./parar');
16
+ const { mascararSegredos } = require('./mascara');
17
+
18
+ const RELAY_PADRAO = 'https://relay.terminalsmart.com.br';
19
+ // Com o relay oficial, a PWA vem de origem estática separada (R2): quem invadir o relay
20
+ // não troca a página que guarda a chave do aparelho.
21
+ const PWA_PADRAO = 'https://celular.terminalsmart.com.br';
22
+ const RX_CODIGO = /^E_[A-Z_]{1,40}$/;
23
+ const HANDSHAKES_POR_APARELHO = 4;
24
+ const ACOMPANHAMENTOS_POR_SESSAO = 8;
25
+ const TERMINAIS_MAX = 100;
26
+ const CONFIRMACAO_TENTATIVAS = 3; // códigos errados digitados no PC antes de recusar
27
+ const AVISOS_POR_APARELHO = 5; // hub.aparelhoNovo guardados para quem está sem sessão
28
+
29
+ /** "8 primeiros caracteres da impressão digital" digitados no PC (sem hífen, sem caixa). */
30
+ function confereImpressao(digitado, impressao) {
31
+ if (typeof digitado !== 'string' || typeof impressao !== 'string') return false;
32
+ const d = digitado.replace(/[-\s]/g, '').toUpperCase();
33
+ const alvo = impressao.replace(/-/g, '').toUpperCase();
34
+ return d.length >= 8 && d.length <= alvo.length && /^[0-9A-F]+$/.test(d) && alvo.startsWith(d);
35
+ }
36
+
37
+ /**
38
+ * Provedor de terminais: telas em arquivo (TS_HUB_TELAS_DIR) é o normal. O tmux direto
39
+ * (TS_HUB_TMUX_DIRETO=1) é só para desenvolvimento e testes. Sem nada configurado: nenhum.
40
+ */
41
+ function provedorPadrao(o, log) {
42
+ if (o.provedor) return o.provedor;
43
+ const env = o.env || process.env;
44
+ if (env.TS_HUB_TMUX_DIRETO === '1') {
45
+ log('AVISO: TS_HUB_TMUX_DIRETO=1 (o hub lê o tmux direto; só para desenvolvimento)');
46
+ return criarProvedorTmux();
47
+ }
48
+ const dir = o.telasDir || env.TS_HUB_TELAS_DIR;
49
+ if (dir) return criarProvedorTelas({ dir });
50
+ return provedorVazio();
51
+ }
52
+
53
+ function erro(codigo) { return Object.assign(new Error(codigo), { codigo }); }
54
+
55
+ function ehLocalHttp(u) {
56
+ try {
57
+ const x = new URL(String(u));
58
+ return x.protocol === 'http:' && /^(localhost|127\.0\.0\.1|\[::1\])$|\.localhost$/i.test(x.hostname);
59
+ } catch (_) { return false; }
60
+ }
61
+ function uidDoAmbiente(v) {
62
+ if (v === undefined || v === '') return 0;
63
+ const n = Number(v);
64
+ return Number.isSafeInteger(n) && n >= 0 ? n : 0;
65
+ }
66
+
67
+ function versaoPacote() {
68
+ try { return require('../package.json').version; } catch (_) { return '0'; }
69
+ }
70
+
71
+ class Hub {
72
+ /** Cria o hub a partir do diretório de estado (gera a chave do nó na primeira vez). */
73
+ static async criar(opcoes) {
74
+ const o = opcoes || {};
75
+ const dir = path.resolve(o.dir || estado.dirPadrao());
76
+ const avisar = o.log || (() => {});
77
+ estado.garantirDir(dir);
78
+ const { identidade, criada } = await estado.carregarOuCriarIdentidade(dir, { protetor: o.protetor, avisar });
79
+ const aparelhos = await new estado.Aparelhos(dir).carregar(avisar);
80
+ const hub = new Hub(Object.assign({}, o, { dir, identidade, aparelhos }));
81
+ hub.chaveCriada = criada;
82
+ if (aparelhos.restauradoDeBackup || aparelhos.mantidosRevogados.length) {
83
+ hub.auditar({ tipo: 'estado', resultado: 'backup_antigo' });
84
+ for (const id of aparelhos.mantidosRevogados) hub.auditar({ tipo: 'revogacao', aparelho: id, resultado: 'mantida' });
85
+ }
86
+ return hub;
87
+ }
88
+
89
+ constructor(o) {
90
+ this.dir = o.dir;
91
+ this.ident = o.identidade;
92
+ this.aparelhos = o.aparelhos;
93
+ this.log = o.log || (() => {});
94
+ this.agora = o.agora || Date.now;
95
+ this.nome = String(o.nome || process.env.TS_HUB_NOME || os.hostname()).slice(0, 64);
96
+ this.versao = versaoPacote();
97
+ const r = normalizarRelay(o.relay || process.env.TS_HUB_RELAY || RELAY_PADRAO);
98
+ this.relay = r.origem;
99
+ this.permitirLocal = r.local;
100
+ // Origem da PWA para o QR (TS_HUB_PWA_URL); padrão = PWA_PADRAO com o relay oficial, senão o
101
+ // próprio relay (https://host do relay).
102
+ const pwa = o.pwa || process.env.TS_HUB_PWA_URL || (r.origem === 'wss://relay.terminalsmart.com.br' ? PWA_PADRAO : undefined);
103
+ this.pwa = undefined;
104
+ if (pwa) {
105
+ try { this.pwa = P.validarOrigemPwa(String(pwa).trim().replace(/\/$/, ''), r.local || ehLocalHttp(pwa)); } catch (_) {
106
+ throw new Error('TS_HUB_PWA_URL inválida (use https://host[:porta], sem caminho; http:// só para localhost)');
107
+ }
108
+ }
109
+ this.parada = new Parada({
110
+ dir: o.pararDir !== undefined ? o.pararDir : (process.env.TS_HUB_PARAR_DIR || null),
111
+ prazoMs: o.pararPrazoMs, sondaMs: o.pararSondaMs,
112
+ donoUid: o.pararDonoUid !== undefined ? o.pararDonoUid : uidDoAmbiente(process.env.TS_HUB_PARAR_DONO_UID),
113
+ });
114
+ this.provedor = provedorPadrao(o, this.log);
115
+ this.sondagemMs = o.sondagemMs || 250;
116
+ this.intervaloTelaMs = o.intervaloTelaMs || 500; // ≤ 2 telas/s por terminal
117
+ this.conviteValidadeMs = o.conviteValidadeMs || P.LIMITES.CONVITE_VALIDADE_MS;
118
+ this.aparelhosMax = Math.min(P.LIMITES.APARELHOS_MAX,
119
+ o.aparelhosMax || Number(process.env.TS_HUB_APARELHOS_MAX) || 3);
120
+ this.auditar = estado.criarAuditoria(this.dir);
121
+ this.registro = new P.RegistroPareamentos({ identidade: this.ident });
122
+ this.convites = new Map(); // pid → { pid, exp, ouvinte, pedido, canal, falhas, timer }
123
+ this.handshakes = new Map(); // "<aparelho>|<sid>" → { estado, aparelhoId, criadoEm }
124
+ this.sessoes = new Map(); // sid → { sessao, sid, aparelhoId, fila, envio, acomp: Map }
125
+ this.inicio = Date.now();
126
+ this._cadeiaLista = Promise.resolve();
127
+ this._filasRota = new Map(); // aparelho → promessa: quadros de um aparelho em ordem
128
+ this._republicacoes = 0;
129
+ this._timerSondagem = null;
130
+ this._sondando = false;
131
+ // Folga para os limites do relay (30 quadros/s, 256 KiB/s por conexão): as telas usam no
132
+ // máximo isto, e o resto (respostas) passa sempre.
133
+ this._taxaTela = new P.LimitadorTaxa({ taxa: 160 * 1024, rajada: 320 * 1024 });
134
+ this._quadrosTela = new P.LimitadorTaxa({ taxa: 15, rajada: 20 });
135
+ // Balde global para sessao.ola: cada olá custa ECDH + assinatura antes de o aparelho
136
+ // provar a posse da chave (a confirmação vem depois).
137
+ this._baldeOla = new P.LimitadorTaxa({ taxa: o.olaPorSegundo || 10, rajada: o.olaRajada || 20 });
138
+ this._olaDescartados = 0;
139
+ this._olaUltimoAviso = 0;
140
+ this._avisosPendentes = new Map(); // aparelho → [{ nome, impressao }] (hub.aparelhoNovo)
141
+ this.conexao = new ConexaoRelay({
142
+ relay: this.relay, urlWs: r.urlWs, identidade: this.ident, log: this.log,
143
+ recuoMinMs: o.recuoMinMs, recuoMaxMs: o.recuoMaxMs, pingMs: o.pingMs, silencioMs: o.silencioMs, authMs: o.authMs,
144
+ WebSocket: o.WebSocket,
145
+ });
146
+ this.conexao.on('conectado', () => this._aoConectar());
147
+ this.conexao.on('desconectado', () => this._aoDesconectar());
148
+ this.conexao.on('quadro', q => this._aoQuadro(q));
149
+ }
150
+
151
+ get id() { return this.ident.id; }
152
+ get impressao() { return this.ident.impressao; }
153
+
154
+ iniciar() { this.conexao.iniciar(); return this; }
155
+
156
+ parar() {
157
+ this.conexao.parar();
158
+ for (const s of Array.from(this.sessoes.values())) this._encerrarSessao(s, 'E_SESSAO_DERRUBADA', true);
159
+ this.handshakes.clear();
160
+ for (const c of Array.from(this.convites.values())) this._fecharConvite(c, 'cancelado');
161
+ if (this._timerSondagem) { clearTimeout(this._timerSondagem); this._timerSondagem = null; }
162
+ }
163
+
164
+ /** Parado = o consumidor CONFIRMOU uma parada (não basta o pedido). */
165
+ parado() { return this.parada.parado(); }
166
+
167
+ get pararDisponivel() { return this.parada.disponivel(); }
168
+
169
+ status() {
170
+ return {
171
+ id: this.id,
172
+ impressao: this.impressao,
173
+ nome: this.nome,
174
+ versao: this.versao,
175
+ relay: this.relay,
176
+ relayEstado: this.conexao.estado,
177
+ aparelhos: this.aparelhos.tamanho,
178
+ sessoes: this.sessoes.size,
179
+ convites: this.convites.size,
180
+ parado: this.parado(),
181
+ pararDisponivel: this.pararDisponivel,
182
+ tempoNoArSeg: Math.floor((Date.now() - this.inicio) / 1000),
183
+ versaoLista: this.aparelhos.versaoLista,
184
+ provedorTerminais: this.provedor.nome || 'desconhecido',
185
+ };
186
+ }
187
+
188
+ listarAparelhos() {
189
+ return this.aparelhos.lista().map(a => ({
190
+ id: a.id, nome: a.nome, impressao: a.impressao, pareadoEm: a.pareadoEm,
191
+ sessoes: Array.from(this.sessoes.values()).filter(s => s.aparelhoId === a.id).length,
192
+ }));
193
+ }
194
+
195
+ // ─── Relay ──────────────────────────────────────────────────────────────────────────────
196
+
197
+ _aoConectar() {
198
+ this._republicacoes = 0;
199
+ this.log('conectado ao relay ' + this.relay);
200
+ this.publicarLista();
201
+ // Convites ainda sem pedido: o relay pode ter perdido o pid na queda.
202
+ for (const c of this.convites.values()) {
203
+ if (!c.pedido) this.conexao.enviar({ t: 'relay.parear.abrir', pid: c.pid, exp: c.exp });
204
+ }
205
+ }
206
+
207
+ _aoDesconectar() {
208
+ this.log('desconectado do relay');
209
+ // Reconexão = nova sessão (PROTOCOLO §4.3): nada de retomada.
210
+ for (const s of Array.from(this.sessoes.values())) this._encerrarSessao(s, 'E_SESSAO_DERRUBADA');
211
+ this.handshakes.clear();
212
+ }
213
+
214
+ /**
215
+ * Publica a lista assinada. A versão é persistida antes de enviar e nunca diminui; usa
216
+ * max(anterior + 1, relógio) para continuar crescente mesmo se o estado vier de um backup.
217
+ */
218
+ publicarLista() {
219
+ const p = this._cadeiaLista.then(async () => {
220
+ if (!this.conexao.conectado) return false;
221
+ const versao = Math.max(this.aparelhos.versaoLista + 1, this.agora());
222
+ this.aparelhos.versaoLista = versao;
223
+ this.aparelhos.salvar();
224
+ const q = await P.noAssinarListaAparelhos(this.ident, {
225
+ versao, aparelhos: this.aparelhos.lista().map(a => ({ id: a.id, spki: a.spki })), agora: this.agora(),
226
+ });
227
+ return this.conexao.enviar(q);
228
+ });
229
+ this._cadeiaLista = p.catch(e => { this.log('falha ao publicar a lista (' + (e.codigo || e.message) + ')'); });
230
+ return p.catch(() => false);
231
+ }
232
+
233
+ _aoQuadro(q) {
234
+ switch (q.t) {
235
+ case 'rota': return this._porAparelho(q.de, () => this._aoRota(q.de, q.dados));
236
+ case 'parear.chegou': {
237
+ if (typeof q.canal !== 'string') return;
238
+ // Pedidos do mesmo convite em ordem: o segundo só é tratado depois do primeiro.
239
+ const conv = this.convites.get(q.pid);
240
+ if (!conv) return;
241
+ conv.fila = conv.fila.then(() => this._aoPedido(q.pid, q.canal, q.dados)).catch(() => {});
242
+ return conv.fila;
243
+ }
244
+ case 'relay.erro':
245
+ this.log('relay.erro ' + q.codigo);
246
+ if (q.codigo === 'E_LISTA_VERSAO' && this._republicacoes++ < 3) this.publicarLista();
247
+ return;
248
+ default: return;
249
+ }
250
+ }
251
+
252
+ /** Processa os quadros de um mesmo aparelho na ordem de chegada (olá → confirma → dados). */
253
+ _porAparelho(de, fn) {
254
+ const anterior = this._filasRota.get(de) || Promise.resolve();
255
+ const p = anterior.then(fn).catch(() => {});
256
+ this._filasRota.set(de, p);
257
+ p.then(() => { if (this._filasRota.get(de) === p) this._filasRota.delete(de); });
258
+ return p;
259
+ }
260
+
261
+ _enviarRota(para, quadro) {
262
+ return this.conexao.enviar({ t: 'rota', para, dados: JSON.stringify(quadro) });
263
+ }
264
+
265
+ // ─── Pareamento (§3) ────────────────────────────────────────────────────────────────────
266
+
267
+ /**
268
+ * Abre um convite. `ouvinte(evento)` recebe:
269
+ * { evento:'pedido', pid, aparelho:{ id, nome, impressao } }
270
+ * { evento:'fim', pid, estado:'aceito'|'recusado'|'expirado'|'cancelado'|'invalido'|..., codigo? }
271
+ * Devolve { url, pid, exp, conectado }. A URL contém o segredo: só para a tela do QR.
272
+ */
273
+ async abrirConvite(ouvinte) {
274
+ if (this.aparelhos.tamanho >= this.aparelhosMax) throw erro('E_LIMITE');
275
+ const c = await this.registro.criarConvite({
276
+ relay: this.relay, pwa: this.pwa, permitirLocal: this.permitirLocal, validadeMs: this.conviteValidadeMs, agora: this.agora(),
277
+ });
278
+ const conv = {
279
+ pid: c.pid, exp: c.exp, ouvinte: ouvinte || (() => {}), pedido: null, canal: null, falhas: 0, timer: null,
280
+ fila: Promise.resolve(), errosConfirmacao: 0,
281
+ };
282
+ conv.timer = setTimeout(() => this._venceuConvite(conv), Math.max(0, c.exp - this.agora()) + 20);
283
+ this.convites.set(c.pid, conv);
284
+ this.conexao.enviar({ t: 'relay.parear.abrir', pid: c.pid, exp: c.exp });
285
+ this.auditar({ tipo: 'convite', resultado: 'aberto' });
286
+ return { url: c.url, pid: c.pid, exp: c.exp, conectado: this.conexao.conectado };
287
+ }
288
+
289
+ /**
290
+ * Vencimento do convite. A decisão no PC também termina aqui, com ou sem pedido aguardando
291
+ * (§3.6): o pedido chega no máximo em `exp` e a conexão anônima do celular vive 6 min a
292
+ * partir dele no relay, então a resposta sempre ainda encontra o celular.
293
+ */
294
+ _venceuConvite(conv) {
295
+ if (this.convites.get(conv.pid) !== conv) return;
296
+ this._fecharConvite(conv, 'expirado');
297
+ }
298
+
299
+ _fecharConvite(conv, estadoFinal, codigo) {
300
+ if (this.convites.get(conv.pid) !== conv) return;
301
+ this.convites.delete(conv.pid);
302
+ clearTimeout(conv.timer);
303
+ this.registro.cancelar(conv.pid);
304
+ this.conexao.enviar({ t: 'relay.parear.fechar', pid: conv.pid });
305
+ this.auditar({ tipo: 'pareamento', aparelho: conv.pedido ? conv.pedido.id : undefined, resultado: codigo || estadoFinal });
306
+ const ev = { evento: 'fim', pid: conv.pid, estado: estadoFinal };
307
+ if (codigo) ev.codigo = codigo;
308
+ try { conv.ouvinte(ev); } catch (_) {}
309
+ }
310
+
311
+ /** O dono do convite desistiu (fechou o `ts hub parear`): recusa o que estiver pendente. */
312
+ async cancelarConvite(pid) {
313
+ const conv = this.convites.get(pid);
314
+ if (!conv) return;
315
+ if (conv.pedido) { try { await this.decidir(pid, false); return; } catch (_) {} }
316
+ this._fecharConvite(conv, 'cancelado');
317
+ }
318
+
319
+ async _aoPedido(pid, canal, dados) {
320
+ const conv = this.convites.get(pid);
321
+ if (!conv) return; // pid não é nosso (ou já encerrado)
322
+ let quadro;
323
+ try { quadro = P.lerQuadroFimAFim(dados); } catch (_) { return; }
324
+ if (conv.pedido) return this._outroPedido(conv, canal, quadro);
325
+ let r;
326
+ // `pendentes` conta convites vigentes e não usados: se cair durante a chamada, o pedido
327
+ // decifrou e consumiu o convite (§3.4 passo 4), mesmo que depois tenha falhado.
328
+ const antes = this.registro.pendentes(this.agora());
329
+ try {
330
+ r = await this.registro.abrirPedido(quadro, { agora: this.agora() });
331
+ } catch (e) {
332
+ const codigo = (e && e.codigo) || 'E_INTERNO';
333
+ if (codigo === 'E_CONVITE_USADO') return this._outroPedido(conv, canal, quadro);
334
+ if (codigo === 'E_DECIFRAR') {
335
+ if (++conv.falhas >= P.LIMITES.PAREAR_FALHAS_MAX) this._fecharConvite(conv, 'invalido', codigo);
336
+ return;
337
+ }
338
+ if (codigo === 'E_CONVITE_EXPIRADO' || codigo === 'E_CONVITE_DESCONHECIDO') return this._fecharConvite(conv, 'expirado');
339
+ const consumido = this.registro.pendentes(this.agora()) < antes;
340
+ if (!consumido) return; // quadro malformado antes de decifrar: o convite segue valendo
341
+ // Depois de decifrar, qualquer defeito encerra o convite (já consumido).
342
+ return this._fecharConvite(conv, 'invalido', codigo);
343
+ }
344
+ if (this.convites.get(pid) !== conv) return;
345
+ conv.pedido = r.aparelho;
346
+ conv.canal = canal;
347
+ try {
348
+ const resp = await this.registro.responder(pid, 'pendente', { agora: this.agora() });
349
+ this.conexao.enviar({ t: 'parear.responder', canal, dados: JSON.stringify(resp) });
350
+ } catch (_) {}
351
+ try {
352
+ conv.ouvinte({ evento: 'pedido', pid, aparelho: { id: r.aparelho.id, nome: r.aparelho.nome, impressao: r.aparelho.impressao } });
353
+ } catch (_) {}
354
+ }
355
+
356
+ /**
357
+ * Segundo pedido para um convite já em análise (quem fotografou o QR chegou antes, ou
358
+ * depois): o aparelho que mandou ESTE pedido recebe "usado" assinado (a PWA mostra "Outro
359
+ * aparelho usou este convite — recuse no PC") e o PC é avisado. A decisão do primeiro segue.
360
+ */
361
+ async _outroPedido(conv, canal, quadro) {
362
+ if (this.convites.get(conv.pid) !== conv || !conv.pedido || canal === conv.canal) return;
363
+ let r;
364
+ try { r = await this.registro.responderUsado(quadro, { agora: this.agora() }); } catch (_) { return; }
365
+ this.conexao.enviar({ t: 'parear.responder', canal, dados: JSON.stringify(r.quadro) });
366
+ this.auditar({ tipo: 'pareamento', aparelho: r.aparelho.id, resultado: 'E_CONVITE_USADO' });
367
+ try { conv.ouvinte({ evento: 'outroAparelho', pid: conv.pid }); } catch (_) {}
368
+ }
369
+
370
+ /**
371
+ * Decisão humana no PC. Aceitar exige `confirmacao` = os 8 primeiros caracteres da
372
+ * impressão digital MOSTRADA NO CELULAR, digitados no PC (sem hífen, sem diferenciar
373
+ * maiúsculas). Código errado = E_CONFIRMACAO (o pedido segue aberto); no terceiro erro, o
374
+ * pareamento é recusado. Só então o aparelho é gravado e entra na lista. Devolve o estado final.
375
+ */
376
+ async decidir(pid, aceitar, confirmacao) {
377
+ const conv = this.convites.get(pid);
378
+ if (!conv || !conv.pedido) throw erro('E_PAREAMENTO_ESTADO');
379
+ const ap = conv.pedido;
380
+ let estadoFinal = aceitar === true ? 'aceito' : 'recusado';
381
+ if (estadoFinal === 'aceito' && !confereImpressao(confirmacao, ap.impressao)) {
382
+ this.auditar({ tipo: 'pareamento', aparelho: ap.id, resultado: 'E_CONFIRMACAO' });
383
+ if (++conv.errosConfirmacao < CONFIRMACAO_TENTATIVAS) throw erro('E_CONFIRMACAO');
384
+ estadoFinal = 'recusado';
385
+ }
386
+ if (estadoFinal === 'aceito' && !this.aparelhos.tem(ap.id) && this.aparelhos.tamanho >= this.aparelhosMax) {
387
+ estadoFinal = 'recusado';
388
+ this.auditar({ tipo: 'pareamento', aparelho: ap.id, resultado: 'E_LIMITE' });
389
+ }
390
+ let resp;
391
+ try {
392
+ resp = await this.registro.responder(pid, estadoFinal, { agora: this.agora() });
393
+ } catch (e) {
394
+ this._fecharConvite(conv, 'expirado', e.codigo);
395
+ return 'expirado';
396
+ }
397
+ if (estadoFinal === 'aceito') {
398
+ this.aparelhos.adicionar({ id: ap.id, spki: ap.spki, nome: ap.nome, impressao: ap.impressao, pareadoEm: new Date(this.agora()).toISOString() });
399
+ this.aparelhos.salvar();
400
+ // A lista vai antes da resposta, na mesma conexão: quando o celular souber que foi
401
+ // aceito, o relay já o conhece.
402
+ await this.publicarLista();
403
+ }
404
+ this.conexao.enviar({ t: 'parear.responder', canal: conv.canal, dados: JSON.stringify(resp) });
405
+ this._fecharConvite(conv, estadoFinal);
406
+ if (estadoFinal === 'aceito') this._avisarAparelhoNovo(ap);
407
+ return estadoFinal;
408
+ }
409
+
410
+ /**
411
+ * Cada pareamento aceito vira o evento hub.aparelhoNovo { nome, impressao } para os outros
412
+ * aparelhos: na hora, para quem tem sessão; na próxima sessão, para quem não tem (em
413
+ * memória, até AVISOS_POR_APARELHO; um reinício do hub perde o aviso, a auditoria fica).
414
+ */
415
+ _avisarAparelhoNovo(ap) {
416
+ const dados = { nome: ap.nome, impressao: ap.impressao };
417
+ const comSessao = new Set();
418
+ for (const s of this.sessoes.values()) {
419
+ if (s.aparelhoId === ap.id) continue;
420
+ comSessao.add(s.aparelhoId);
421
+ this._evento(s, 'hub.aparelhoNovo', dados);
422
+ }
423
+ for (const a of this.aparelhos.lista()) {
424
+ if (a.id === ap.id || comSessao.has(a.id)) continue;
425
+ const fila = this._avisosPendentes.get(a.id) || [];
426
+ fila.push(dados);
427
+ while (fila.length > AVISOS_POR_APARELHO) fila.shift();
428
+ this._avisosPendentes.set(a.id, fila);
429
+ }
430
+ }
431
+
432
+ // ─── Revogação ──────────────────────────────────────────────────────────────────────────
433
+
434
+ /** Tira o aparelho da lista, publica a nova versão e derruba as sessões dele na hora. */
435
+ async revogar(id) {
436
+ if (!this.aparelhos.tem(id)) return false;
437
+ this.aparelhos.remover(id);
438
+ // Primeiro o registro fora do aparelhos.json: um backup antigo não reautoriza o aparelho.
439
+ try { this.aparelhos.registrarRevogacao(id, this.agora()); } catch (_) { this.log('não consegui gravar revogados.jsonl'); }
440
+ this.aparelhos.salvar();
441
+ this._avisosPendentes.delete(id);
442
+ for (const s of Array.from(this.sessoes.values())) {
443
+ if (s.aparelhoId === id) this._encerrarSessao(s, 'E_APARELHO_NAO_AUTORIZADO');
444
+ }
445
+ for (const [k, h] of Array.from(this.handshakes)) if (h.aparelhoId === id) this.handshakes.delete(k);
446
+ this.auditar({ tipo: 'revogacao', aparelho: id, resultado: 'ok' });
447
+ await this.publicarLista();
448
+ return true;
449
+ }
450
+
451
+ // ─── Sessões (§4–5) ─────────────────────────────────────────────────────────────────────
452
+
453
+ async _aoRota(de, dados) {
454
+ let q;
455
+ try { q = P.lerQuadroFimAFim(dados); } catch (_) { return; }
456
+ if (typeof q.sid !== 'string') return;
457
+ if (q.t === 'sessao.ola') return this._ola(de, q);
458
+ if (q.t === 'sessao.confirma') return this._confirma(de, q);
459
+ if (q.t === 'dados') return this._dados(de, q);
460
+ }
461
+
462
+ _buscarAparelho() { return id => this.aparelhos.spki(id); }
463
+
464
+ async _ola(de, q) {
465
+ if (!this.aparelhos.tem(de) || this.sessoes.has(q.sid)) return;
466
+ const agora = this.agora();
467
+ for (const [k, h] of Array.from(this.handshakes)) if (agora - h.criadoEm > P.LIMITES.HANDSHAKE_MS) this.handshakes.delete(k);
468
+ const doAparelho = Array.from(this.handshakes).filter(([, h]) => h.aparelhoId === de);
469
+ while (doAparelho.length >= HANDSHAKES_POR_APARELHO) this.handshakes.delete(doAparelho.shift()[0]);
470
+ if (!this._baldeOla.permitir(1)) {
471
+ // Muitos olás no total: descarta sem gastar cripto. Log agregado, no máximo 1 por minuto.
472
+ this._olaDescartados++;
473
+ if (Date.now() - this._olaUltimoAviso > 60000) {
474
+ this._olaUltimoAviso = Date.now();
475
+ this.log('sessao.ola descartados por taxa: ' + this._olaDescartados);
476
+ this.auditar({ tipo: 'sessao', resultado: 'E_TAXA' });
477
+ this._olaDescartados = 0;
478
+ }
479
+ return;
480
+ }
481
+ let r;
482
+ try {
483
+ r = await P.noResponderSessao({ identidade: this.ident, quadro: q, origem: de, buscarAparelho: this._buscarAparelho(), agora });
484
+ } catch (e) {
485
+ this.auditar({ tipo: 'sessao', aparelho: de, resultado: (e && e.codigo) || 'E_INTERNO' });
486
+ return;
487
+ }
488
+ this.handshakes.set(de + '|' + q.sid, { estado: r.estado, aparelhoId: de, criadoEm: agora });
489
+ this._enviarRota(de, r.quadro);
490
+ }
491
+
492
+ async _confirma(de, q) {
493
+ const k = de + '|' + q.sid;
494
+ const h = this.handshakes.get(k);
495
+ if (!h) return;
496
+ this.handshakes.delete(k);
497
+ let sessao;
498
+ try {
499
+ sessao = await P.noConcluirSessao(h.estado, q, { buscarAparelho: this._buscarAparelho(), agora: this.agora() });
500
+ } catch (e) {
501
+ this.auditar({ tipo: 'sessao', aparelho: de, resultado: (e && e.codigo) || 'E_INTERNO' });
502
+ return;
503
+ }
504
+ if (!this.aparelhos.tem(de) || !this.conexao.conectado) { sessao.derrubar(); return; }
505
+ // O relay mantém uma conexão por aparelho (a nova derruba a anterior) e não avisa o nó
506
+ // quando o aparelho cai: a sessão nova substitui as anteriores do mesmo aparelho, que
507
+ // ficaram sem dono. Sem isso elas seguiam recebendo eventos (telas) até o limite.
508
+ for (const s of Array.from(this.sessoes.values())) {
509
+ if (s.aparelhoId === de) this._encerrarSessao(s, 'substituida');
510
+ }
511
+ const nova = {
512
+ sessao, sid: sessao.sid, aparelhoId: de, fila: Promise.resolve(), envio: Promise.resolve(), acomp: new Map(),
513
+ };
514
+ this.sessoes.set(sessao.sid, nova);
515
+ this.auditar({ tipo: 'sessao', aparelho: de, resultado: 'aberta' });
516
+ const avisos = this._avisosPendentes.get(de);
517
+ if (avisos) {
518
+ this._avisosPendentes.delete(de);
519
+ for (const a of avisos) this._evento(nova, 'hub.aparelhoNovo', a);
520
+ }
521
+ }
522
+
523
+ _dados(de, q) {
524
+ const s = this.sessoes.get(q.sid);
525
+ // Outro aparelho usando o sid de alguém: ignora sem derrubar a sessão alheia.
526
+ if (!s || s.aparelhoId !== de) return;
527
+ s.fila = s.fila.then(() => this._comando(s, q)).catch(() => {});
528
+ }
529
+
530
+ _encerrarSessao(s, motivo, silencioso) {
531
+ if (this.sessoes.get(s.sid) !== s) return;
532
+ this.sessoes.delete(s.sid);
533
+ s.acomp.clear();
534
+ s.sessao.derrubar();
535
+ if (!silencioso) this.auditar({ tipo: 'sessao', aparelho: s.aparelhoId, resultado: motivo || 'encerrada' });
536
+ }
537
+
538
+ /** Enfileira um quadro cifrado para envio na ordem em que foi produzido (§5). */
539
+ _enviarSessao(s, promessaQuadro) {
540
+ promessaQuadro.catch(() => {});
541
+ s.envio = s.envio
542
+ .then(() => promessaQuadro)
543
+ .then(quadro => { if (this.sessoes.get(s.sid) === s) this._enviarRota(s.aparelhoId, quadro); })
544
+ .catch(e => { if (s.sessao.derrubada) this._encerrarSessao(s, (e && e.codigo) || 'E_SESSAO_DERRUBADA'); });
545
+ return s.envio;
546
+ }
547
+
548
+ _evento(s, tipo, dados) {
549
+ return this._enviarSessao(s, P.noEnviarEvento(s.sessao, tipo, dados, { agora: this.agora() }));
550
+ }
551
+
552
+ // ─── Comandos (§6) ──────────────────────────────────────────────────────────────────────
553
+
554
+ async _comando(s, q) {
555
+ if (this.sessoes.get(s.sid) !== s) return;
556
+ let cmd;
557
+ try {
558
+ cmd = await P.noAbrirComando(s.sessao, q, { aparelhoAutorizado: id => this.aparelhos.tem(id), agora: this.agora() });
559
+ } catch (e) {
560
+ const codigo = (e && e.codigo) || 'E_INTERNO';
561
+ this.auditar({ tipo: 'comando', metodo: null, aparelho: s.aparelhoId, resultado: codigo });
562
+ if (s.sessao.derrubada) return this._encerrarSessao(s, codigo);
563
+ if (Number.isSafeInteger(e && e.seq)) this._enviarSessao(s, P.noResponder(s.sessao, e.seq, { erro: codigo }, { agora: this.agora() }));
564
+ return;
565
+ }
566
+ let r;
567
+ try {
568
+ r = { resultado: await this._executar(s, cmd.metodo, cmd.params) };
569
+ } catch (e) {
570
+ r = { erro: e && RX_CODIGO.test(String(e.codigo)) ? e.codigo : 'E_INTERNO' };
571
+ }
572
+ if (this.sessoes.get(s.sid) !== s) return; // revogado ou derrubado durante a execução
573
+ this._enviarSessao(s, P.noResponder(s.sessao, cmd.seq, r, { agora: this.agora() }));
574
+ this.auditar({ tipo: 'comando', metodo: cmd.metodo, aparelho: s.aparelhoId, resultado: r.erro || 'ok' });
575
+ if (r.erro) return;
576
+ if (cmd.metodo === 'terminal.watch') {
577
+ const w = s.acomp.get(cmd.params.terminalId);
578
+ if (w) w.pronto = true;
579
+ this._agendarSondagem();
580
+ } else if (cmd.metodo === 'hub.stopAll') {
581
+ for (const outra of this.sessoes.values()) this._evento(outra, 'hub.estado', { parado: true });
582
+ }
583
+ }
584
+
585
+ async _listarTerminais() {
586
+ let lista;
587
+ try { lista = await this.provedor.listar(); } catch (_) { lista = []; }
588
+ return Array.isArray(lista) ? lista.slice(0, TERMINAIS_MAX) : [];
589
+ }
590
+
591
+ async _executar(s, metodo, params) {
592
+ switch (metodo) {
593
+ case 'hub.status': {
594
+ const terminais = await this._listarTerminais();
595
+ return {
596
+ nome: this.nome, versao: this.versao, so: process.platform,
597
+ tempoNoArSeg: Math.floor((Date.now() - this.inicio) / 1000),
598
+ parado: this.parado(), pararDisponivel: this.pararDisponivel, terminais: terminais.length,
599
+ };
600
+ }
601
+ case 'terminals.list': {
602
+ const terminais = await this._listarTerminais();
603
+ return { terminais: terminais.map(t => ({ terminalId: t.terminalId, titulo: t.titulo, ativo: !!t.ativo })) };
604
+ }
605
+ case 'terminal.watch': {
606
+ const id = params.terminalId;
607
+ const historico = params.historico === undefined ? 0 : params.historico;
608
+ const t = (await this._listarTerminais()).find(x => x.terminalId === id);
609
+ if (!t) throw erro('E_TERMINAL_INEXISTENTE');
610
+ if (!s.acomp.has(id) && s.acomp.size >= ACOMPANHAMENTOS_POR_SESSAO) throw erro('E_LIMITE');
611
+ const tela = this._prepararTela(id, t.colunas, t.linhas, await this.provedor.capturar(id, historico));
612
+ s.acomp.set(id, { historico, ultimaTela: tela, colunas: t.colunas, linhas: t.linhas, ultimoEnvio: Date.now(), pronto: false });
613
+ return { terminalId: id, colunas: t.colunas, linhas: t.linhas, tela };
614
+ }
615
+ case 'terminal.unwatch':
616
+ s.acomp.delete(params.terminalId);
617
+ return null;
618
+ case 'hub.stopAll':
619
+ await this.pararTudo();
620
+ return { parado: true };
621
+ default:
622
+ throw erro('E_METODO');
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Parar tudo: grava o pedido em TS_HUB_PARAR_DIR/pedidos e espera a confirmação do
628
+ * consumidor externo (na VPS, unidade de root: cli/hub/vps/). Só com ela resolve; sem ela,
629
+ * E_PARAR_SEM_CONFIRMACAO (prazo de 10 s). Nó sem consumidor: E_PARAR_INDISPONIVEL. Os
630
+ * acompanhamentos de terminal param já no pedido. O evento hub.estado sai depois da resposta.
631
+ */
632
+ async pararTudo() {
633
+ if (!this.pararDisponivel) {
634
+ this.auditar({ tipo: 'parar-tudo', resultado: 'E_PARAR_INDISPONIVEL' });
635
+ throw erro('E_PARAR_INDISPONIVEL');
636
+ }
637
+ for (const s of this.sessoes.values()) s.acomp.clear();
638
+ this.auditar({ tipo: 'parar-tudo', resultado: 'pedido' });
639
+ try {
640
+ await this.parada.pedir();
641
+ } catch (e) {
642
+ const codigo = e && /^E_[A-Z_]{1,40}$/.test(String(e.codigo)) ? e.codigo : 'E_PARAR_SEM_CONFIRMACAO';
643
+ this.auditar({ tipo: 'parar-tudo', resultado: codigo });
644
+ this.log('parar tudo sem confirmação (' + codigo + ')');
645
+ throw erro(codigo);
646
+ }
647
+ this.auditar({ tipo: 'parar-tudo', resultado: 'confirmado' });
648
+ }
649
+
650
+ // ─── Telas ──────────────────────────────────────────────────────────────────────────────
651
+
652
+ /**
653
+ * Tela sanitizada (só SGR) que cabe inteira no texto claro de um quadro (32 KiB, já com o
654
+ * JSON e os escapes ). Se não couber, corta o histórico mais antigo (linhas do topo).
655
+ */
656
+ _prepararTela(terminalId, colunas, linhas, bruta) {
657
+ // A máscara já foi aplicada pelo exportar-telas; aqui de novo, por garantia.
658
+ const tela = mascararSegredos(sanitizarTela(bruta));
659
+ const cabe = t => Buffer.byteLength(t, 'utf8') <= TELA_MAX_BYTES &&
660
+ Buffer.byteLength(JSON.stringify({ k: 'resp', seq: Number.MAX_SAFE_INTEGER, ok: true,
661
+ resultado: { terminalId, colunas, linhas, tela: t } }), 'utf8') <= P.LIMITES.TEXTO_CLARO_MAX - 64;
662
+ return cortarTela(tela, cabe);
663
+ }
664
+
665
+ _temAcompanhamentos() {
666
+ for (const s of this.sessoes.values()) if (s.acomp.size) return true;
667
+ return false;
668
+ }
669
+
670
+ _agendarSondagem() {
671
+ if (this._timerSondagem || this._sondando || !this._temAcompanhamentos()) return;
672
+ this._timerSondagem = setTimeout(() => { this._timerSondagem = null; this._sondar(); }, this.sondagemMs);
673
+ }
674
+
675
+ async _sondar() {
676
+ this._sondando = true;
677
+ try {
678
+ const alvos = [];
679
+ for (const s of this.sessoes.values()) for (const [id, w] of s.acomp) if (w.pronto) alvos.push({ s, id, w });
680
+ if (!alvos.length) return;
681
+ const lista = await this._listarTerminais();
682
+ const porId = new Map(lista.map(t => [t.terminalId, t]));
683
+ const capturas = new Map();
684
+ for (const { s, id, w } of alvos) {
685
+ if (this.sessoes.get(s.sid) !== s || s.acomp.get(id) !== w) continue;
686
+ const t = porId.get(id);
687
+ if (!t) {
688
+ s.acomp.delete(id);
689
+ this._evento(s, 'terminal.fim', { terminalId: id });
690
+ continue;
691
+ }
692
+ const agora = Date.now();
693
+ if (agora - w.ultimoEnvio < this.intervaloTelaMs) continue;
694
+ const chave = id + '|' + w.historico;
695
+ let tela = capturas.get(chave);
696
+ if (tela === undefined) {
697
+ try { tela = this._prepararTela(id, t.colunas, t.linhas, await this.provedor.capturar(id, w.historico)); } catch (_) { tela = null; }
698
+ capturas.set(chave, tela);
699
+ }
700
+ if (tela === null || s.acomp.get(id) !== w) continue;
701
+ if (tela === w.ultimaTela && t.colunas === w.colunas && t.linhas === w.linhas) continue;
702
+ const bytes = Buffer.byteLength(tela, 'utf8') + 256;
703
+ if (!this._quadrosTela.permitir(1) ) continue;
704
+ if (!this._taxaTela.permitir(bytes)) continue;
705
+ w.ultimaTela = tela; w.colunas = t.colunas; w.linhas = t.linhas; w.ultimoEnvio = agora;
706
+ this._evento(s, 'terminal.tela', { terminalId: id, colunas: t.colunas, linhas: t.linhas, tela });
707
+ }
708
+ } catch (_) {
709
+ /* sondagem nunca derruba o hub */
710
+ } finally {
711
+ this._sondando = false;
712
+ this._agendarSondagem();
713
+ }
714
+ }
715
+ }
716
+
717
+ module.exports = { Hub, RELAY_PADRAO, PWA_PADRAO, confereImpressao };