@mmmbuto/nexuscrew 0.8.16 → 0.8.17

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.
@@ -47,7 +47,7 @@ const nodesStore = require('../nodes/store.js');
47
47
  const nodesCmds = require('../nodes/commands.js');
48
48
  const nodesTunnel = require('../nodes/tunnel.js');
49
49
  const peering = require('../nodes/peering.js');
50
- const { probeHealth, waitForHealthyPeer } = require('../proxy/federation.js');
50
+ const { waitForHealthyPeer } = require('../proxy/federation.js');
51
51
  const { rotateToken } = require('../auth/token.js');
52
52
  const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
53
53
  const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
@@ -56,6 +56,11 @@ const { configJsonPath } = require('../config.js');
56
56
  const VERSION = require('../../package.json').version;
57
57
  const { scrubError } = require('../update/core.js');
58
58
 
59
+ // Sottomoduli estratti da questo file (modularizzazione behavior-preserving):
60
+ // il transaction handler di /nodes/pair e la superficie pubblica di peering.
61
+ const { createPairHandler } = require('./pairing-coordinator.js');
62
+ const { publicPeeringRoutes } = require('./public-peering-routes.js');
63
+
59
64
  // Whitelist chiavi scrivibili di config.json via API (lista chiusa dal task B2).
60
65
  const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate']);
61
66
  const ROLE_KEYS = new Set(['client', 'node']);
@@ -357,292 +362,16 @@ function settingsRoutes(deps = {}) {
357
362
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
358
363
  });
359
364
 
360
- // Hydra join in stadi espliciti: la PWA riceve un contratto strutturato
361
- // {error, code, stage, detail?, hint?, retryable?} — MAI credenziali, token,
362
- // header Authorization o contenuto di chiavi nel payload. Stadi distinti:
363
- // validation | conflict | ssh-start | ssh-ready | join | tunnel-final |
364
- // confirm | health (+ internal per gli inattesi)
365
- // Lo sleep fisso 900ms e' sostituito da un probe di readiness bounded
366
- // (peering.probeTransportReady) eseguito PRIMA di consumare l'invite one-time;
367
- // una risposta join ambigua (rete morta dopo l'invio) non viene mai rigiocata.
368
- // Su qualunque fallimento post-provisioning il rollback locale/remoto gira
369
- // esattamente una volta e lo stage fallito arriva al client.
370
- r.post('/nodes/pair', mutGate, async (req, res) => {
371
- const b = req.body || {};
372
- const redactSecrets = (s) => String(s || '')
373
- .replace(/Bearer\s+\S+/gi, 'Bearer ***')
374
- .replace(/[A-Za-z0-9_-]{40,}/g, '***');
375
- const fail = (status, stage, code, detail, extra = {}) => send(res, status, {
376
- error: redactSecrets(detail), stage, code, detail: redactSecrets(detail), retryable: false, ...extra,
377
- });
378
- if (!validName(b.name)) return fail(400, 'validation', 'bad-name', 'name non valido (slug a-z, 0-9, -, max 32)', { retryable: true });
379
- if (!nodesStore.parseSshTarget(b.ssh)) return fail(400, 'validation', 'bad-ssh', 'alias SSH non valido (atteso user@host o Host alias)', { retryable: true });
380
- const pair = peering.parsePairingUrl(b.pairingUrl);
381
- if (!pair) return fail(400, 'validation', 'bad-link', 'link di pairing non valido o corrotto', { retryable: true, hint: 'rigenera il link sul dispositivo che invita' });
382
- if (b.sshPort !== undefined && !nodesStore.isPort(b.sshPort)) return fail(400, 'validation', 'bad-ssh-port', 'sshPort non valida (1..65535)', { retryable: true });
383
- if (b.identityFile !== undefined && !nodesStore.isAbsPath(b.identityFile)) return fail(400, 'validation', 'bad-identity-file', 'identityFile non valido (path assoluto)', { retryable: true });
384
- if (b.label !== undefined && !nodesStore.validLabel(b.label)) return fail(400, 'validation', 'bad-label', 'label non valida (max 64 char, niente a capo)', { retryable: true });
385
- if (b.localLabel !== undefined && !nodesStore.validLabel(b.localLabel)) return fail(400, 'validation', 'bad-label', 'localLabel non valida (max 64 char, niente a capo)', { retryable: true });
386
- // label umana del peer come lo vedro' io (display); se assente usa lo slug.
387
- const peerLabel = nodesStore.sanitizeLabel(b.label, b.name);
388
- // etichetta umana con cui il peer vedra' questo dispositivo; default sensato.
389
- const localLabel = nodesStore.sanitizeLabel(b.localLabel, defaultDeviceName());
390
- const fetchImpl = seams.fetchImpl || fetch;
391
- const sleep = typeof seams.pairDelay === 'function' ? seams.pairDelay : undefined;
392
- const transportProbe = typeof seams.probeTransportReady === 'function'
393
- ? seams.probeTransportReady : peering.probeTransportReady;
394
- const requestTimeoutMs = Number.isInteger(seams.pairRequestTimeoutMs) && seams.pairRequestTimeoutMs > 0
395
- ? seams.pairRequestTimeoutMs : 6000;
396
- // Every protocol request must terminate. Readiness and federation health
397
- // already have their own bounded probes; join/confirm/cancel use this
398
- // wrapper so a half-open peer cannot leave the PWA waiting forever.
399
- const pairFetch = async (url, opts = {}, timeoutMs = requestTimeoutMs) => {
400
- const ctrl = new AbortController();
401
- const timer = setTimeout(() => ctrl.abort(), timeoutMs);
402
- try { return await fetchImpl(url, { ...opts, signal: ctrl.signal }); }
403
- finally { clearTimeout(timer); }
404
- };
405
-
406
- let provisionalPort = null;
407
- let portReservation = null;
408
- let rollbackCredential = null;
409
- let created = false;
410
- let rolledBack = false;
411
- // Rollback locale/remoto ESATTAMENTE una volta, best-effort: cancella la
412
- // credenziale provvisoria sul peer (se emessa) e rimuove nodo+tunnel locali.
413
- const rollback = async () => {
414
- if (rolledBack) return; rolledBack = true;
415
- if (portReservation) {
416
- try { await portReservation.release(); } catch (_) { /* best-effort */ }
417
- portReservation = null;
418
- }
419
- if (rollbackCredential && provisionalPort) {
420
- try {
421
- await pairFetch(`http://127.0.0.1:${provisionalPort}/pair/cancel`, {
422
- method: 'POST', headers: { 'content-type': 'application/json' },
423
- body: JSON.stringify({ instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId, credential: rollbackCredential }),
424
- }, Math.min(requestTimeoutMs, 3000));
425
- } catch (_) { /* best-effort */ }
426
- }
427
- try {
428
- const st = nodesStore.loadStore(nodesPath);
429
- const n = st && nodesStore.getNode(st, b.name);
430
- if (n && created) {
431
- nodesTunnel.stopTunnel({ home, name: b.name });
432
- nodesStore.atomicWriteStore(nodesPath, nodesStore.removeNode(st, b.name));
433
- }
434
- } catch (_) { /* best-effort */ }
435
- };
436
- const failRolledBack = async (status, stage, code, detail, extra = {}) => {
437
- await rollback();
438
- return fail(status, stage, code, detail, extra);
439
- };
440
-
441
- try {
442
- // --- conflict: il nome non deve gia' esistere --------------------------
443
- let st = nodesStore.loadOrInitStore(nodesPath);
444
- if (st.nodes.some((n) => n.name === b.name)) {
445
- return fail(409, 'conflict', 'name-exists', `nodo "${b.name}" gia' presente`, {
446
- retryable: true, hint: 'scegli un altro nome nelle opzioni avanzate e riprova',
447
- });
448
- }
449
- if (st.nodeId === pair.instanceId) {
450
- return fail(409, 'conflict', 'self-pairing', 'il link appartiene a questa stessa installazione', {
451
- hint: 'genera il link sul nodo remoto che vuoi collegare',
452
- });
453
- }
454
- const knownPeer = st.nodes.find((n) => n.nodeId === pair.instanceId);
455
- if (knownPeer) {
456
- return fail(409, 'conflict', 'peer-exists', `questa installazione e' gia' collegata come "${knownPeer.name}"`, {
457
- hint: 'usa il nodo esistente oppure rimuovilo prima di rifare il pairing',
458
- });
459
- }
460
- let localPort;
461
- try {
462
- portReservation = await nodesCmds.reserveLocalPort(st, {
463
- createServerImpl: seams.createPortServer,
464
- });
465
- localPort = portReservation.port;
466
- } catch (e) {
467
- return fail(502, 'ssh-start', 'local-port-unavailable',
468
- `nessuna porta locale disponibile per il tunnel: ${String((e && e.message) || e)}`, {
469
- retryable: true, hint: 'chiudi il processo che occupa le porte locali e riprova',
470
- });
471
- }
472
- provisionalPort = localPort;
473
- const acceptToken = crypto.randomBytes(32).toString('base64url');
474
- try {
475
- st = nodesStore.addNode(st, {
476
- name: b.name, ssh: b.ssh, sshPort: b.sshPort,
477
- remotePort: pair.port, localPort, identityFile: b.identityFile,
478
- transport: 'auto', autostart: true, visibility: 'network',
479
- direction: 'outbound', acceptToken, label: peerLabel,
480
- });
481
- } catch (e) {
482
- const msg = String((e && e.message) || e);
483
- const isDup = msg.includes('duplicato') || msg.includes('self-reference');
484
- return failRolledBack(isDup ? 409 : 400, isDup ? 'conflict' : 'validation', 'node-rejected', msg, { retryable: !isDup });
485
- }
486
- nodesStore.atomicWriteStore(nodesPath, st);
487
- created = true;
488
- const node = nodesStore.getNode(st, b.name);
489
-
490
- // --- ssh-start: supervisor del tunnel -L provvisorio --------------------
491
- // Il bind OS-aware ha protetto la scelta fino a questo punto. SSH deve
492
- // prendere la stessa porta, quindi rilasciamo immediatamente prima dello
493
- // spawn (eventuali race residue emergono come local-forward-bind).
494
- await portReservation.release();
495
- portReservation = null;
496
- const started = nodesTunnel.startForward({
497
- home, node, localAppPort: runtimePort(),
498
- spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
499
- sshBin: seams.sshBin, logFd: seams.logFd,
500
- });
501
- if (!started.started && started.reason !== 'already running') {
502
- return failRolledBack(502, 'ssh-start', 'tunnel-start-failed', started.reason || 'avvio del tunnel SSH fallito', {
503
- retryable: true,
504
- hint: 'verifica ssh e target/alias su questo dispositivo; il link NON e\' stato consumato e puoi riprovare',
505
- });
506
- }
507
-
508
- // --- ssh-ready: readiness bounded PRIMA di consumare l'invite -----------
509
- const ready = await transportProbe({
510
- port: localPort, capability: pair.invite, expectedInstanceId: pair.instanceId,
511
- fetchImpl, sleep,
512
- });
513
- if (!ready.ready) {
514
- const diagnosis = (seams.readTunnelDiagnostic || nodesTunnel.readTunnelDiagnostic)(home, b.name, pair.port);
515
- return failRolledBack(502, 'ssh-ready', (diagnosis && diagnosis.code) || ready.code || 'transport-not-ready',
516
- (diagnosis && diagnosis.detail)
517
- || `il peer non risponde attraverso il tunnel SSH (${ready.attempts} tentativi${ready.lastError ? `: ${ready.lastError}` : ''})`, {
518
- retryable: true,
519
- hint: (diagnosis && diagnosis.hint)
520
- || 'controlla target SSH, porta e chiavi; il link NON e\' stato consumato, puoi riprovare',
521
- });
522
- }
523
-
524
- // --- join: consuma l'invite one-time (UNA volta, mai replay) ------------
525
- let jr;
526
- try {
527
- jr = await pairFetch(`http://127.0.0.1:${localPort}/pair/join`, {
528
- method: 'POST', headers: { 'content-type': 'application/json' },
529
- body: JSON.stringify({
530
- invite: pair.invite,
531
- instanceId: st.nodeId,
532
- name: String(b.localName || os.hostname()).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'node',
533
- label: localLabel,
534
- port: runtimePort(),
535
- acceptToken,
536
- // Pairing establishes a private client-to-hub connection. Sharing
537
- // this device back through the hub is a separate explicit action.
538
- shared: false,
539
- roles: readRoles(configPath),
540
- }),
541
- });
542
- } catch (e) {
543
- // Risposta persa DOPO l'invio: l'invite potrebbe essere stato consumato.
544
- // Un join ambiguo non si rigioca mai.
545
- return failRolledBack(502, 'join', 'join-ambiguous',
546
- 'risposta di join persa: l\'invito potrebbe essere gia\' stato consumato', {
547
- hint: 'rigenera un nuovo link sul dispositivo che invita e riprova',
548
- });
549
- }
550
- const joined = await jr.json().catch(() => ({}));
551
- if (jr.status === 410) {
552
- return failRolledBack(502, 'join', 'invite-expired', 'invito scaduto o gia\' usato (one-time)', {
553
- hint: 'rigenera un nuovo link sul dispositivo che invita',
554
- });
555
- }
556
- if (!jr.ok) {
557
- return failRolledBack(502, 'join', 'join-rejected', joined.error || `join rifiutato dal peer (HTTP ${jr.status})`, {
558
- hint: 'rigenera un nuovo link e riprova',
559
- });
560
- }
561
- const joinedRoles = joined.roles === undefined ? null : nodesStore.parseRoles(joined.roles);
562
- if (!nodesStore.validToken(joined.credential) || !nodesStore.isPort(joined.reversePort)
563
- || !nodesStore.NODE_ID_RE.test(joined.instanceId) || (joined.roles !== undefined && !joinedRoles)) {
564
- return failRolledBack(502, 'join', 'join-invalid-response', 'risposta di join non valida dal peer', {
565
- hint: 'versioni NexusCrew incompatibili? aggiorna entrambi i nodi',
566
- });
567
- }
568
- rollbackCredential = joined.credential;
569
- if (joined.instanceId !== pair.instanceId) {
570
- return failRolledBack(502, 'join', 'peer-identity-mismatch',
571
- 'l\'identita\' del peer raggiunto non coincide con quella contenuta nel link', {
572
- hint: 'controlla che il target SSH punti al nodo che ha generato il link',
573
- });
574
- }
575
-
576
- // --- tunnel-final: connessione privata, solo -L -------------------------
577
- // reversePort resta negoziata per un futuro Share opt-in, ma il builder
578
- // non emette -R finche' shared non diventa true.
579
- st = nodesStore.loadOrInitStore(nodesPath);
580
- st = nodesStore.updateNode(st, b.name, {
581
- token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
582
- shared: false,
583
- ...(joinedRoles ? { roles: joinedRoles, rolesKnown: true } : {}),
584
- });
585
- nodesStore.atomicWriteStore(nodesPath, st);
586
- nodesTunnel.stopTunnel({ home, name: b.name });
587
- const finalStart = nodesTunnel.startForward({
588
- home, node: nodesStore.getNode(st, b.name), localAppPort: runtimePort(),
589
- spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
590
- sshBin: seams.sshBin, logFd: seams.logFd,
591
- });
592
- if (!finalStart.started && finalStart.reason !== 'already running') {
593
- return failRolledBack(502, 'tunnel-final', 'tunnel-restart-failed', finalStart.reason || 'riavvio del tunnel negoziato fallito', {});
594
- }
595
- const readyFinal = await transportProbe({
596
- port: localPort, capability: joined.credential, expectedInstanceId: joined.instanceId,
597
- fetchImpl, sleep,
598
- });
599
- if (!readyFinal.ready) {
600
- const diagnosis = (seams.readTunnelDiagnostic || nodesTunnel.readTunnelDiagnostic)(home, b.name, pair.port);
601
- return failRolledBack(502, 'tunnel-final', (diagnosis && diagnosis.code) || readyFinal.code || 'transport-not-ready',
602
- (diagnosis && diagnosis.detail)
603
- || `il tunnel negoziato non risponde o non corrisponde al peer atteso (${readyFinal.attempts} tentativi)`, {
604
- hint: (diagnosis && diagnosis.hint) || 'verifica il target SSH e riprova con un nuovo link',
605
- });
606
- }
607
-
608
- // --- confirm: idempotente lato peer -> bounded retry ---------------------
609
- let confirmed = null;
610
- for (let attempt = 0; attempt < 3; attempt += 1) {
611
- try {
612
- confirmed = await pairFetch(`http://127.0.0.1:${localPort}/pair/confirm`, {
613
- method: 'POST', headers: { 'content-type': 'application/json' },
614
- body: JSON.stringify({ credential: joined.credential }),
615
- }, Math.min(requestTimeoutMs, 3500));
616
- if (confirmed.ok) break;
617
- } catch (_) { /* retry: confirm e' idempotente lato peer */ }
618
- if (attempt < 2) await (sleep ? sleep() : new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1))));
619
- }
620
- if (!confirmed || !confirmed.ok) {
621
- const x = confirmed ? await confirmed.json().catch(() => ({})) : {};
622
- return failRolledBack(502, 'confirm', 'confirm-failed',
623
- x.error || `conferma pairing fallita (HTTP ${confirmed ? confirmed.status : 'irraggiungibile'})`, {
624
- hint: 'rigenera un nuovo link e riprova',
625
- });
626
- }
627
-
628
- // --- health: federazione AUTENTICATA verificata prima di paired:true ----
629
- const health = await probeHealth({
630
- port: localPort, token: joined.credential, expectedInstanceId: joined.instanceId,
631
- fetchImpl, now: Date.now(),
632
- });
633
- if (!health || health.status !== 'healthy') {
634
- return failRolledBack(502, 'health', 'federation-health-failed',
635
- (health && health.detail) || 'health federato non verificabile dopo la conferma', {
636
- hint: 'pairing annullato e ripulito: rigenera il link e riprova',
637
- });
638
- }
639
-
640
- send(res, 200, { paired: true, name: b.name, instanceId: joined.instanceId, transport: 'auto', health: { status: health.status } });
641
- } catch (e) {
642
- await rollback();
643
- fail(502, 'internal', 'unexpected', String((e && e.message) || e), {});
644
- }
645
- });
365
+ // --- POST /nodes/pair — hydra join in stadi espliciti ---------------------
366
+ // Transaction handler estratto in lib/settings/pairing-coordinator.js
367
+ // (createPairHandler): probe di readiness bounded, consumo one-time dell'invite,
368
+ // rollback locale/remoto esattamente una volta, health federato autenticato
369
+ // prima di paired:true. Qui resta solo la registrazione dietro mutGate;
370
+ // token/credenziali MAI nel payload (redazione nel handler estratto).
371
+ r.post('/nodes/pair', mutGate, createPairHandler({
372
+ send, validName, defaultDeviceName,
373
+ nodesPath, configPath, home, seams, runtimePort,
374
+ }));
646
375
 
647
376
  // --- POST /nodes — nodes add (riusa nodesCmds.nodesAdd) --------------------
648
377
  r.post('/nodes', mutGate, (req, res) => {
@@ -941,113 +670,4 @@ function settingsRoutes(deps = {}) {
941
670
  return r;
942
671
  }
943
672
 
944
- // Public only because the one-time invite itself is the capability. The route
945
- // exposes no generic API and creates a scoped peer credential, never a UI token.
946
- function publicPeeringRoutes(deps = {}) {
947
- const cfg = deps.cfg || {};
948
- const home = cfg.home || os.homedir();
949
- const configPath = cfg.configPath || configJsonPath();
950
- const nodesPath = deps.nodesPath || cfg.nodesPath || nodesStore.defaultNodesPath(home);
951
- const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
952
- const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
953
- const r = express.Router();
954
- const attempts = new Map();
955
- r.use(express.json({ limit: '8kb' }));
956
- // Identity proof non consuma la capability e non riceve mai invite/token in
957
- // chiaro. Serve a impedire che un qualunque listener HTTP sulla porta -L
958
- // venga scambiato per il nodo contenuto nel link.
959
- r.post('/identity', (req, res) => {
960
- const key = `identity:${String(req.socket && req.socket.remoteAddress || 'local')}`;
961
- const now = Date.now();
962
- const recent = (attempts.get(key) || []).filter((x) => now - x < 60_000);
963
- recent.push(now); attempts.set(key, recent);
964
- if (recent.length > 30) return res.status(429).json({ error: 'troppi tentativi' });
965
- const b = req.body || {};
966
- const proof = peering.capabilityIdentity({
967
- invitesPath, pendingPath, capabilityId: b.capabilityId, challenge: b.challenge, now,
968
- });
969
- if (!proof) return res.status(404).json({ error: 'capability non valida' });
970
- const st = nodesStore.loadStore(nodesPath);
971
- if (!st || !nodesStore.NODE_ID_RE.test(st.nodeId)) return res.status(503).json({ error: 'identita nodo non disponibile' });
972
- return res.json({ ok: true, instanceId: st.nodeId, proof });
973
- });
974
- r.post('/join', (req, res) => {
975
- const key = `join:${String(req.socket && req.socket.remoteAddress || 'local')}`;
976
- const now = Date.now();
977
- const recent = (attempts.get(key) || []).filter((x) => now - x < 60_000);
978
- recent.push(now); attempts.set(key, recent);
979
- if (recent.length > 10) return res.status(429).json({ error: 'troppi tentativi di pairing' });
980
- const b = req.body || {};
981
- const peerRoles = b.roles === undefined ? null : nodesStore.parseRoles(b.roles);
982
- if (!nodesStore.validToken(b.invite) || !nodesStore.NODE_ID_RE.test(b.instanceId)
983
- || !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)
984
- || (b.roles !== undefined && !peerRoles)
985
- // Pairing is always private. Publishing is a separate authenticated
986
- // action after the reverse channel is live and health-checked.
987
- || (b.shared !== undefined && b.shared !== false)) {
988
- return res.status(400).json({ error: 'pairing request non valida' });
989
- }
990
- if (b.label !== undefined && !nodesStore.validLabel(b.label)) {
991
- return res.status(400).json({ error: 'label non valida' });
992
- }
993
- if (!peering.consumeInvite({ invitesPath, invite: b.invite })) return res.status(410).json({ error: 'invito scaduto o gia usato' });
994
- try {
995
- const st = nodesStore.loadOrInitStore(nodesPath);
996
- if (st.nodeId === b.instanceId || st.nodes.some((n) => n.nodeId === b.instanceId)) return res.status(409).json({ error: 'peer duplicato' });
997
- let name = b.name;
998
- for (let i = 2; nodesStore.getNode(st, name); i += 1) name = `${b.name.slice(0, 28)}-${i}`;
999
- const reversePort = peering.allocateReversePort(st.nodes);
1000
- const credential = peering.createPending({ pendingPath, data: {
1001
- name, remotePort: b.port, reversePort, instanceId: b.instanceId, acceptToken: b.acceptToken,
1002
- shared: false,
1003
- label: nodesStore.sanitizeLabel(b.label, name),
1004
- ...(peerRoles ? { roles: { ...peerRoles, node: false }, rolesKnown: true } : { rolesKnown: false }),
1005
- } });
1006
- res.json({ paired: true, instanceId: st.nodeId, reversePort, credential, roles: readRoles(configPath) });
1007
- } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1008
- });
1009
- r.post('/confirm', (req, res) => {
1010
- const b = req.body || {};
1011
- if (!nodesStore.validToken(b.credential)) return res.status(400).json({ error: 'confirm non valido' });
1012
- const pending = peering.consumePending({ pendingPath, credential: b.credential });
1013
- if (!pending) {
1014
- const st = nodesStore.loadStore(nodesPath);
1015
- if (st && st.nodes.some((n) => n.acceptToken && peering.safeEqual(n.acceptToken, b.credential))) return res.json({ confirmed: true, idempotent: true });
1016
- return res.status(410).json({ error: 'pairing pending scaduto o gia usato' });
1017
- }
1018
- try {
1019
- let st = nodesStore.loadOrInitStore(nodesPath);
1020
- if (st.nodeId === pending.instanceId || st.nodes.some((n) => n.nodeId === pending.instanceId)) return res.status(409).json({ error: 'peer duplicato' });
1021
- st = nodesStore.addNode(st, {
1022
- name: pending.name, remotePort: pending.remotePort, localPort: pending.reversePort,
1023
- direction: 'inbound', transport: 'inbound', autostart: true,
1024
- visibility: 'network', shared: pending.shared === true, nodeId: pending.instanceId,
1025
- token: pending.acceptToken, acceptToken: b.credential,
1026
- ...(pending.roles ? { roles: pending.roles } : {}),
1027
- rolesKnown: pending.rolesKnown === true,
1028
- ...(pending.label ? { label: pending.label } : {}),
1029
- });
1030
- nodesStore.atomicWriteStore(nodesPath, st);
1031
- res.json({ confirmed: true });
1032
- } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1033
- });
1034
- r.post('/cancel', (req, res) => {
1035
- const b = req.body || {};
1036
- if (!nodesStore.NODE_ID_RE.test(b.instanceId) || !nodesStore.validToken(b.credential)) return res.status(400).json({ error: 'cancel non valido' });
1037
- try {
1038
- const pending = peering.consumePending({ pendingPath, credential: b.credential });
1039
- if (!pending || pending.instanceId !== b.instanceId) {
1040
- const st = nodesStore.loadStore(nodesPath);
1041
- const peer = st && st.nodes.find((n) => n.nodeId === b.instanceId && n.acceptToken && peering.safeEqual(n.acceptToken, b.credential));
1042
- if (!peer) return res.status(404).json({ error: 'pair non trovato' });
1043
- nodesStore.atomicWriteStore(nodesPath, nodesStore.removeNode(st, peer.name));
1044
- }
1045
- res.json({ cancelled: true });
1046
- } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
1047
- });
1048
- return r;
1049
- }
1050
-
1051
- function validPeerName(name) { return typeof name === 'string' && nodesStore.NODE_NAME_RE.test(name); }
1052
-
1053
673
  module.exports = { settingsRoutes, publicPeeringRoutes, deepScrubTokens };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.16",
3
+ "version": "0.8.17",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {