@mmmbuto/nexuscrew 0.9.20 → 0.9.21

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.
@@ -0,0 +1,128 @@
1
+ 'use strict';
2
+
3
+ const { normalizeIdentityContext } = require('../mcp/identity-schema.js');
4
+
5
+ const IDENTITY_BINDING_HEADER = 'x-nexuscrew-identity-binding';
6
+ const IDENTITY_BINDING_MISSING = 'NEXUSCREW_IDENTITY_BINDING_MISSING';
7
+ const IDENTITY_BINDING_INVALID = 'NEXUSCREW_IDENTITY_BINDING_INVALID';
8
+ const IDENTITY_BINDING_MISMATCH = 'NEXUSCREW_IDENTITY_BINDING_MISMATCH';
9
+
10
+ function bindingError(code, message) {
11
+ const error = new Error(message);
12
+ error.code = code;
13
+ return error;
14
+ }
15
+
16
+ function resolveInstanceId(instanceId) {
17
+ return typeof instanceId === 'function' ? instanceId() : instanceId;
18
+ }
19
+
20
+ function expectedFromSession(session, instanceId) {
21
+ if (!session) return null;
22
+ return { instanceId: resolveInstanceId(instanceId), tmuxSession: session };
23
+ }
24
+
25
+ // Il proof child firma i timestamp come stringhe decimali (JSON del canale
26
+ // persistito). Il contesto shared prodotto dal provider li converte, ma un
27
+ // binding che copia il proof deve restare verificabile senza allargare lo
28
+ // schema MCP generale: qui si accettano SOLO stringhe decimali intere.
29
+ function canonicalTimestamp(value) {
30
+ if (typeof value !== 'string' || !/^[0-9]+$/.test(value)) return value;
31
+ const parsed = Number(value);
32
+ return Number.isSafeInteger(parsed) ? parsed : value;
33
+ }
34
+
35
+ function normalizeBindingContext(raw) {
36
+ return {
37
+ ...raw,
38
+ issuedAt: canonicalTimestamp(raw && raw.issuedAt),
39
+ notBefore: canonicalTimestamp(raw && raw.notBefore),
40
+ expiresAt: canonicalTimestamp(raw && raw.expiresAt),
41
+ };
42
+ }
43
+
44
+ function createIdentityBindingGuard({
45
+ fleetP = null, instanceId = null, now = Date.now, sharedRequired = false,
46
+ } = {}) {
47
+ async function verify(req, { expected = null, localOnly = true } = {}) {
48
+ const raw = req && req.headers && req.headers[IDENTITY_BINDING_HEADER];
49
+ if (raw === undefined) {
50
+ if (sharedRequired) {
51
+ throw bindingError(IDENTITY_BINDING_MISSING, 'binding identita obbligatorio');
52
+ }
53
+ return null;
54
+ }
55
+
56
+ let parsed;
57
+ try { parsed = JSON.parse(String(raw)); } catch (_) {
58
+ throw bindingError(IDENTITY_BINDING_INVALID, 'binding identita non valido');
59
+ }
60
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
61
+ throw bindingError(IDENTITY_BINDING_INVALID, 'binding identita non valido');
62
+ }
63
+
64
+ let context;
65
+ try { context = normalizeIdentityContext(normalizeBindingContext(parsed.context), { now }); } catch (_) {
66
+ throw bindingError(IDENTITY_BINDING_INVALID, 'binding identita non valido');
67
+ }
68
+
69
+ const proof = parsed.proof;
70
+ if (!proof || typeof proof !== 'object' || Array.isArray(proof)) {
71
+ throw bindingError(IDENTITY_BINDING_INVALID, 'binding identita non valido');
72
+ }
73
+
74
+ const localId = resolveInstanceId(instanceId);
75
+ if (localOnly && localId && context.ownerInstanceId !== localId) {
76
+ throw bindingError(IDENTITY_BINDING_MISMATCH, 'binding identita locale atteso');
77
+ }
78
+
79
+ if (expected) {
80
+ if (expected.instanceId !== undefined && context.ownerInstanceId !== expected.instanceId) {
81
+ throw bindingError(IDENTITY_BINDING_MISMATCH, 'binding identita nodo discordante');
82
+ }
83
+ if (expected.cell !== undefined && context.cellId !== expected.cell) {
84
+ throw bindingError(IDENTITY_BINDING_MISMATCH, 'binding identita cella discordante');
85
+ }
86
+ if (expected.tmuxSession !== undefined && context.tmuxSession !== expected.tmuxSession) {
87
+ throw bindingError(IDENTITY_BINDING_MISMATCH, 'binding identita sessione discordante');
88
+ }
89
+ }
90
+
91
+ let fleet = null;
92
+ try { fleet = await fleetP; } catch (_) { fleet = null; }
93
+ const lease = fleet && fleet.lease;
94
+ if (!lease || typeof lease.childIntrospect !== 'function') {
95
+ throw bindingError(IDENTITY_BINDING_INVALID, 'binding identita non verificabile');
96
+ }
97
+
98
+ const out = lease.childIntrospect(proof);
99
+ if (!out || out.status !== 'live' || out.cellId !== context.cellId
100
+ || out.incarnationId !== context.threadId
101
+ || Number(out.expiresAt) !== Number(context.expiresAt)) {
102
+ throw bindingError(IDENTITY_BINDING_INVALID, 'binding identita non valido');
103
+ }
104
+
105
+ return context;
106
+ }
107
+
108
+ return {
109
+ verify,
110
+ header: IDENTITY_BINDING_HEADER,
111
+ codes: {
112
+ missing: IDENTITY_BINDING_MISSING,
113
+ invalid: IDENTITY_BINDING_INVALID,
114
+ mismatch: IDENTITY_BINDING_MISMATCH,
115
+ },
116
+ };
117
+ }
118
+
119
+ module.exports = {
120
+ IDENTITY_BINDING_HEADER,
121
+ IDENTITY_BINDING_MISSING,
122
+ IDENTITY_BINDING_INVALID,
123
+ IDENTITY_BINDING_MISMATCH,
124
+ bindingError,
125
+ createIdentityBindingGuard,
126
+ expectedFromSession,
127
+ resolveInstanceId,
128
+ };
@@ -416,6 +416,10 @@ function createLiveBridge({
416
416
  const threadIdsByCell = new Map();
417
417
  const threadStatusCache = new Map();
418
418
  const threadStatusInFlight = new Map();
419
+ // R4: riserve in-process per l'avvio Live. La chiave è la cella host: una
420
+ // sola start provvisoria per cella alla volta, e il commit ricontrolla la
421
+ // tupla congelata in riserva prima di accettare il thread creato.
422
+ const pendingLiveByCell = new Map();
419
423
  const threadStatusCacheMs = Number.isFinite(cfg.liveThreadStatusCacheMs)
420
424
  ? Math.max(0, cfg.liveThreadStatusCacheMs) : THREAD_STATUS_CACHE_MS;
421
425
 
@@ -442,6 +446,35 @@ function createLiveBridge({
442
446
  }
443
447
  }
444
448
 
449
+ // Tupla dell'avvio Live come la osserva questo ponte: designazione (cella,
450
+ // revision, idoneità) più stato firmato del lease dell'host. Generation ed
451
+ // epoch arrivano dallo stesso status quando il lease manager li espone.
452
+ function liveTuple(snap, leaseStatus) {
453
+ const lease = leaseStatus && typeof leaseStatus === 'object' ? leaseStatus : { state: leaseStatus };
454
+ return JSON.stringify({
455
+ hostCell: snap ? snap.hostCell : null,
456
+ revision: snap ? snap.revision : null,
457
+ eligible: snap ? snap.eligible === true : false,
458
+ lease: {
459
+ state: lease.state == null ? null : lease.state,
460
+ leaseId: lease.leaseId == null ? null : lease.leaseId,
461
+ generation: lease.generation == null ? null : lease.generation,
462
+ launchEpoch: lease.launchEpoch == null ? null : lease.launchEpoch,
463
+ },
464
+ });
465
+ }
466
+
467
+ async function leaseTupleFor(cellId) {
468
+ try {
469
+ const fleet = await fleetP;
470
+ const lease = fleet && fleet.lease;
471
+ if (!lease || typeof lease.status !== 'function') return { state: 'unavailable' };
472
+ return lease.status(cellId) || { state: 'none' };
473
+ } catch (_) {
474
+ return { state: 'unavailable' };
475
+ }
476
+ }
477
+
445
478
  async function rosterCell(cellId) {
446
479
  const fleet = await fleetP;
447
480
  if (!fleet || fleet.available !== true) return null;
@@ -540,6 +573,14 @@ function createLiveBridge({
540
573
  ? `${intestazione}\n\n${prompt.text}`
541
574
  : intestazione;
542
575
 
576
+ // R4: riserva della tupla e start provvisorio. Finché il commit non
577
+ // ricontrolla la stessa tupla, il thread NON viene accettato: nessun tool
578
+ // può attraversarlo, perché il ponte non ne registra l'id.
579
+ if (pendingLiveByCell.has(snap.hostCell)) return none('reservation-in-flight');
580
+ const reservedLease = await leaseTupleFor(snap.hostCell);
581
+ const reservedTuple = liveTuple(snap, reservedLease);
582
+ pendingLiveByCell.set(snap.hostCell, reservedTuple);
583
+
543
584
  let started;
544
585
  try {
545
586
  started = await startThreadOnControlSocket({
@@ -557,9 +598,37 @@ function createLiveBridge({
557
598
  } catch (e) {
558
599
  const reason = e && e.code === 'ETIMEOUT' ? 'bridge-timeout' : 'bridge-socket-failed';
559
600
  log(`[live-bridge] thread ponte NON creata (${reason}): ${e.message}`);
601
+ pendingLiveByCell.delete(snap.hostCell);
560
602
  return none(reason, { cell: snap.hostCell, detail: String(e.message) });
561
603
  }
562
604
 
605
+ // R4: commit. La designazione e il lease vengono riletti e confrontati con
606
+ // la tupla riservata: se qualcosa è cambiato durante lo start, il thread
607
+ // resta scartato (zero dispatch) e l'esito lo dichiara con l'id scartato.
608
+ let commitSnap;
609
+ let commitLease;
610
+ try {
611
+ commitSnap = await readDesignation();
612
+ commitLease = await leaseTupleFor(snap.hostCell);
613
+ } catch (e) {
614
+ pendingLiveByCell.delete(snap.hostCell);
615
+ log(`[live-bridge] commit illeggibile: thread ${started.threadId} scartato (${e.message})`);
616
+ return none('commit-unreadable', {
617
+ cell: snap.hostCell, discardedThread: started.threadId, detail: String(e.message),
618
+ });
619
+ }
620
+ if (liveTuple(commitSnap, commitLease) !== reservedTuple) {
621
+ // Primato alla causa radice: se il lease è cambiato, eligible oscilla
622
+ // con lui, e la designazione NON è la causa.
623
+ const leaseOf = (value) => liveTuple({ hostCell: null, revision: null, eligible: null }, value);
624
+ const reason = leaseOf(commitLease) !== leaseOf(reservedLease)
625
+ ? 'lease-changed' : 'designation-changed';
626
+ pendingLiveByCell.delete(snap.hostCell);
627
+ log(`[live-bridge] tupla cambiata in volo (${reason}): thread ${started.threadId} scartato`);
628
+ return none(reason, { cell: snap.hostCell, discardedThread: started.threadId });
629
+ }
630
+ pendingLiveByCell.delete(snap.hostCell);
631
+
563
632
  const { text, ...promptEcho } = prompt; // il testo del prompt non viaggia in risposta
564
633
  const out = {
565
634
  mode: 'native', cell: snap.hostCell, engine,
@@ -19,6 +19,10 @@
19
19
  // - Cella inattiva PRESERVA la designazione: lo store non cancella mai hostCell per
20
20
  // inattivita; `eligible` e' derivato dal roster al momento del GET.
21
21
  // - readonly => 403 su designate/clear.
22
+ //
23
+ // Fuori dal perimetro del binding MCP: designate/clear/bridge sono superficie di
24
+ // gestione locale dell'operatore, non effetti richiesti da una cella tramite il
25
+ // bridge. Il token del nodo resta l'autorita'; il binding shared non e' richiesto.
22
26
 
23
27
  const express = require('express');
24
28
  const { CELL_ID_RE } = require('./store.js');
@@ -51,10 +55,10 @@ function isActive(cell) {
51
55
  // distingue «non idonea perche' morta» da «non idonea perche' in recupero».
52
56
  // - I cinque stati (live|grace|expired|none|unavailable) restano DISTINCTI
53
57
  // fino a chi legge: collassarli e' rifare il difetto a un piano piu' su.
54
- // - FALLBACK FAIL-OPEN DICHIARATO: senza fleet.lease (installazione senza
55
- // lease) eligible torna tmux-only e host.lease='unavailable'. Non e' «va
56
- // bene lo stesso»: e' «la garanzia non e' disponibile qui», detto esplicito
57
- // perche' un eligible=true silenzioso verrebbe letto come confermata.
58
+ // - FALLBACK FAIL-CLOSED: senza fleet.lease (installazione senza lease)
59
+ // eligible resta false e host.lease='unavailable'. L'attach locale D resta
60
+ // disponibile al suo percorso, ma il dispatch Live non può diventare
61
+ // tmux-only senza la garanzia del lease.
58
62
  // - hostCell resta PRESERVATO in ogni caso (invariante dello store): oscilla
59
63
  // l'idoneita', non la scelta dell'operatore.
60
64
  function hostLeaseState(fleet, hostCell) {
@@ -68,7 +72,7 @@ function hostLeaseState(fleet, hostCell) {
68
72
  function eligibleOf(fleet, cell, hostCell) {
69
73
  const leaseState = hostLeaseState(fleet, hostCell);
70
74
  if (leaseState === null) return false; // senza soggetto non c'e' idoneita'
71
- if (leaseState === 'unavailable') return isActive(cell); // fail-open dichiarato
75
+ if (leaseState === 'unavailable') return false;
72
76
  return isActive(cell) && leaseState === 'live';
73
77
  }
74
78
 
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+ // Canale produttivo del contesto shared per il bridge MCP.
3
+ //
4
+ // Il proof child authority-backed, ottenuto da register/refresh in authority
5
+ // mode, viene persistito accanto al token in una directory privata del nodo:
6
+ // quello è l'unico canale condiviso fra i processi della cella (contratto:
7
+ // nessun isolamento fra processi dello stesso UID). L'env seleziona il FILE
8
+ // del canale ma mai l'identità: questa arriva soltanto dal proof firmato,
9
+ // verificato online dall'hub a ogni introspezione. Senza canale non esiste
10
+ // provider e il bridge resta sul percorso embedded legacy.
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+ const { readTokenSafe } = require('../auth/token.js');
15
+ const { cellIdFromTmuxSession, tmuxSessionForCell } = require('../fleet/definitions.js');
16
+ const {
17
+ identityContextError, IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE,
18
+ } = require('./identity-schema.js');
19
+
20
+ const INTROSPECT_TIMEOUT_MS = 8000;
21
+
22
+ function identityChannelDir(tokenPath) {
23
+ return path.join(path.dirname(tokenPath), 'mcp-identity');
24
+ }
25
+
26
+ function identityChannelPath({ tokenPath, session }) {
27
+ return path.join(identityChannelDir(tokenPath), `${session}.json`);
28
+ }
29
+
30
+ function readIdentityChannel(filePath) {
31
+ let raw;
32
+ try { raw = JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (_) { return null; }
33
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
34
+ const { cellId, proof, expiresAt } = raw;
35
+ if (typeof cellId !== 'string' || !cellId || !proof || typeof proof !== 'object'
36
+ || Array.isArray(proof) || !Number.isSafeInteger(expiresAt)) return null;
37
+ return { cellId, proof, expiresAt };
38
+ }
39
+
40
+ function persistIdentityChannel({ tokenPath, session, cellId, proof, expiresAt }) {
41
+ if (!tokenPath || !session || cellIdFromTmuxSession(session) !== cellId) return false;
42
+ const dir = identityChannelDir(tokenPath);
43
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
44
+ const filePath = identityChannelPath({ tokenPath, session });
45
+ fs.writeFileSync(filePath, `${JSON.stringify({ cellId, proof, expiresAt })}\n`, { mode: 0o600 });
46
+ return true;
47
+ }
48
+
49
+ function sharedContextFromIntrospection(out, now) {
50
+ const issuedAt = Number.isSafeInteger(Number(out.issuedAt)) ? Number(out.issuedAt) : now();
51
+ const incarnationId = typeof out.incarnationId === 'string' ? out.incarnationId : '';
52
+ const tmuxSession = typeof out.tmuxSession === 'string' && out.tmuxSession
53
+ ? out.tmuxSession : tmuxSessionForCell(out.cellId);
54
+ return {
55
+ version: '1',
56
+ kind: 'mcp-v1',
57
+ verified: true,
58
+ mode: 'shared',
59
+ bindingId: `${out.cellId}:${incarnationId}`,
60
+ ownerInstanceId: out.instanceId,
61
+ cellId: out.cellId,
62
+ tmuxSession,
63
+ connectionId: `${out.cellId}:${incarnationId}`,
64
+ threadId: incarnationId,
65
+ origin: 'daemon',
66
+ audience: 'nexuscrew-mcp',
67
+ scopes: ['mcp:tools/call'],
68
+ issuedAt,
69
+ notBefore: issuedAt,
70
+ expiresAt: out.expiresAt,
71
+ };
72
+ }
73
+
74
+ function createMcpIdentityProvider({ config, env = process.env, fetchImpl = fetch, now = Date.now } = {}) {
75
+ const cfg = config || require('../config.js').loadConfig();
76
+ const tokenPath = cfg && cfg.tokenPath;
77
+ const port = cfg && cfg.port;
78
+ const session = env && env.NEXUSCREW_MCP_SESSION;
79
+ if (!tokenPath || !Number.isSafeInteger(port) || !session) return null;
80
+ const channelPath = identityChannelPath({ tokenPath, session });
81
+ if (!readIdentityChannel(channelPath)) return null;
82
+ const baseUrl = `http://127.0.0.1:${port}`;
83
+
84
+ async function introspectChannel() {
85
+ const channel = readIdentityChannel(channelPath);
86
+ if (!channel) return { verified: false, mode: 'shared' };
87
+ let response;
88
+ try {
89
+ const token = readTokenSafe(tokenPath);
90
+ response = await fetchImpl(`${baseUrl}/api/lease/introspect`, {
91
+ method: 'POST',
92
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
93
+ body: JSON.stringify({ proof: channel.proof }),
94
+ signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS),
95
+ });
96
+ } catch (_) {
97
+ throw identityContextError(IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE,
98
+ 'NEXUSCREW_MCP_IDENTITY_AUTHORITY_UNAVAILABLE: authority non raggiungibile');
99
+ }
100
+ if (response.status >= 500) {
101
+ throw identityContextError(IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE,
102
+ 'NEXUSCREW_MCP_IDENTITY_AUTHORITY_UNAVAILABLE: authority non raggiungibile');
103
+ }
104
+ if (!response.ok) return { verified: false, mode: 'shared' };
105
+ const out = await response.json().catch(() => null);
106
+ if (!out || out.status !== 'live' || out.identityMode !== 'authority'
107
+ || out.cellId !== channel.cellId || typeof out.instanceId !== 'string' || !out.instanceId
108
+ || !Number.isSafeInteger(Number(out.expiresAt)) || Number(out.expiresAt) <= now()
109
+ || typeof out.incarnationId !== 'string' || !out.incarnationId
110
+ || out.incarnationId !== channel.proof.incarnationId) {
111
+ return { verified: false, mode: 'shared' };
112
+ }
113
+ return { context: sharedContextFromIntrospection({ ...out, expiresAt: Number(out.expiresAt) }, now()), proof: channel.proof };
114
+ }
115
+
116
+ async function provider({ tool } = {}) {
117
+ void tool; // il canale lease è per-connessione cella, non per-tool
118
+ const out = await introspectChannel();
119
+ return out.context ? out.context : out;
120
+ }
121
+ provider.currentProof = () => {
122
+ const channel = readIdentityChannel(channelPath);
123
+ return channel ? channel.proof : null;
124
+ };
125
+ return provider;
126
+ }
127
+
128
+ module.exports = {
129
+ createMcpIdentityProvider,
130
+ persistIdentityChannel,
131
+ identityChannelPath,
132
+ };
@@ -0,0 +1,168 @@
1
+ 'use strict';
2
+
3
+ const { isValidSession } = require('../files/store.js');
4
+
5
+ const IDENTITY_SCHEMA_VERSION = '1';
6
+ const IDENTITY_KINDS = Object.freeze(new Set(['connection-v1', 'thread-v1', 'mcp-v1']));
7
+ const IDENTITY_ORIGINS = Object.freeze(new Set(['local_tui', 'remote_live', 'daemon']));
8
+ const IDENTITY_CONTEXT_MISSING = 'NEXUSCREW_MCP_IDENTITY_CONTEXT_MISSING';
9
+ const IDENTITY_CONTEXT_UNVERIFIED = 'NEXUSCREW_MCP_IDENTITY_CONTEXT_UNVERIFIED';
10
+ const IDENTITY_CONTEXT_FROM_MISMATCH = 'NEXUSCREW_MCP_IDENTITY_CONTEXT_FROM_MISMATCH';
11
+ const IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE = 'NEXUSCREW_MCP_IDENTITY_AUTHORITY_UNAVAILABLE';
12
+
13
+ function identityContextError(code, message) {
14
+ const error = new Error(message);
15
+ error.code = code;
16
+ return error;
17
+ }
18
+
19
+ function requiredString(value, name, { max = 128 } = {}) {
20
+ if (typeof value !== 'string' || !value.trim() || (max !== null && value.length > max)) {
21
+ throw identityContextError(IDENTITY_CONTEXT_MISSING,
22
+ `contesto identita online: ${name} non valido`);
23
+ }
24
+ return value.trim();
25
+ }
26
+
27
+ function timestamp(value, name) {
28
+ const parsed = Number.isSafeInteger(value) && value >= 0
29
+ ? value
30
+ : (typeof value === 'string' && value.trim() ? Date.parse(value) : NaN);
31
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
32
+ throw identityContextError(IDENTITY_CONTEXT_MISSING,
33
+ `contesto identita online: ${name} non valido`);
34
+ }
35
+ return parsed;
36
+ }
37
+
38
+ function nonNegativeInteger(value, name) {
39
+ if (!Number.isSafeInteger(value) || value < 0) {
40
+ throw identityContextError(IDENTITY_CONTEXT_MISSING,
41
+ `contesto identita online: ${name} non valido`);
42
+ }
43
+ return value;
44
+ }
45
+
46
+ function normalizeLiveHost(raw) {
47
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
48
+ throw identityContextError(IDENTITY_CONTEXT_MISSING,
49
+ 'contesto identita online: liveHost non valido');
50
+ }
51
+ const out = {
52
+ ownerInstanceId: requiredString(raw.ownerInstanceId, 'liveHost.ownerInstanceId'),
53
+ cellId: requiredString(raw.cellId, 'liveHost.cellId'),
54
+ tmuxSession: requiredString(raw.tmuxSession, 'liveHost.tmuxSession'),
55
+ leaseId: requiredString(raw.leaseId, 'liveHost.leaseId'),
56
+ generation: nonNegativeInteger(raw.generation, 'liveHost.generation'),
57
+ epoch: nonNegativeInteger(raw.epoch, 'liveHost.epoch'),
58
+ designation: requiredString(raw.designation, 'liveHost.designation'),
59
+ pairingSessionId: requiredString(raw.pairingSessionId, 'liveHost.pairingSessionId'),
60
+ };
61
+ if (!isValidSession(out.tmuxSession) || out.cellId !== out.tmuxSession.slice(6)) {
62
+ throw identityContextError(IDENTITY_CONTEXT_MISSING,
63
+ 'contesto identita online: liveHost cella/sessione incoerenti');
64
+ }
65
+ return Object.freeze(out);
66
+ }
67
+
68
+ function normalizeScopes(raw) {
69
+ if (!Array.isArray(raw) || raw.length < 1 || raw.length > 32) {
70
+ throw identityContextError(IDENTITY_CONTEXT_MISSING,
71
+ 'contesto identita online: scopes non validi');
72
+ }
73
+ const scopes = raw.map((scope) => requiredString(scope, 'scope', { max: 64 }));
74
+ if (new Set(scopes).size !== scopes.length) {
75
+ throw identityContextError(IDENTITY_CONTEXT_MISSING,
76
+ 'contesto identita online: scopes duplicati');
77
+ }
78
+ return Object.freeze(scopes);
79
+ }
80
+
81
+ function normalizeIdentityContext(raw, { now = Date.now } = {}) {
82
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
83
+ throw identityContextError(IDENTITY_CONTEXT_MISSING, 'contesto identita online assente');
84
+ }
85
+ if (raw.verified !== true || raw.mode !== 'shared') {
86
+ throw identityContextError(IDENTITY_CONTEXT_UNVERIFIED,
87
+ 'contesto identita online non verificato: binding condiviso rifiutato');
88
+ }
89
+ if (raw.version !== IDENTITY_SCHEMA_VERSION || !IDENTITY_KINDS.has(raw.kind)) {
90
+ throw identityContextError(IDENTITY_CONTEXT_MISSING,
91
+ 'contesto identita online: versione o kind non validi');
92
+ }
93
+ if (!IDENTITY_ORIGINS.has(raw.origin)) {
94
+ throw identityContextError(IDENTITY_CONTEXT_MISSING,
95
+ 'contesto identita online: origin non valida');
96
+ }
97
+ const ownerInstanceId = requiredString(raw.ownerInstanceId, 'ownerInstanceId');
98
+ const cellId = requiredString(raw.cellId, 'cellId');
99
+ const tmuxSession = requiredString(raw.tmuxSession || raw.session, 'tmuxSession');
100
+ if (!isValidSession(tmuxSession) || cellId !== tmuxSession.slice(6)) {
101
+ throw identityContextError(IDENTITY_CONTEXT_MISSING,
102
+ 'contesto identita online: cella/sessione incoerenti');
103
+ }
104
+ const bindingId = requiredString(raw.bindingId, 'bindingId');
105
+ const audience = requiredString(raw.audience, 'audience');
106
+ const connectionId = requiredString(raw.connectionId, 'connectionId');
107
+ if ((raw.kind === 'thread-v1' || raw.kind === 'mcp-v1')) {
108
+ requiredString(raw.threadId, 'threadId');
109
+ }
110
+ const scopes = normalizeScopes(raw.scopes);
111
+ const issuedAt = timestamp(raw.issuedAt, 'issuedAt');
112
+ const notBefore = timestamp(raw.notBefore, 'notBefore');
113
+ const expiresAt = timestamp(raw.expiresAt, 'expiresAt');
114
+ const clock = Number(now());
115
+ if (!Number.isFinite(clock) || notBefore > clock) {
116
+ throw identityContextError(IDENTITY_CONTEXT_MISSING,
117
+ 'contesto identita online non ancora valido');
118
+ }
119
+ if (expiresAt <= clock || expiresAt <= issuedAt || notBefore > expiresAt) {
120
+ throw identityContextError(IDENTITY_CONTEXT_MISSING,
121
+ 'contesto identita online scaduto o temporalmente incoerente');
122
+ }
123
+ const from = { instanceId: ownerInstanceId, cell: cellId, tmuxSession };
124
+ if (raw.from !== undefined && JSON.stringify(raw.from) !== JSON.stringify(from)) {
125
+ throw identityContextError(IDENTITY_CONTEXT_FROM_MISMATCH,
126
+ 'contesto identita online: from discordante rifiutato');
127
+ }
128
+ const context = {
129
+ version: IDENTITY_SCHEMA_VERSION,
130
+ kind: raw.kind,
131
+ verified: true,
132
+ mode: 'shared',
133
+ origin: raw.origin,
134
+ bindingId,
135
+ ownerInstanceId,
136
+ cellId,
137
+ tmuxSession,
138
+ from,
139
+ audience,
140
+ connectionId,
141
+ scopes,
142
+ issuedAt: raw.issuedAt,
143
+ notBefore: raw.notBefore,
144
+ expiresAt: raw.expiresAt,
145
+ };
146
+ if (raw.threadId !== undefined) context.threadId = requiredString(raw.threadId, 'threadId');
147
+ // cwd is intentionally not subject to the short identity-field bound: it is
148
+ // a path claim, while the signed identity fields remain bounded above.
149
+ if (raw.cwd !== undefined) context.cwd = requiredString(raw.cwd, 'cwd', { max: null });
150
+ if (raw.liveHost !== undefined) context.liveHost = normalizeLiveHost(raw.liveHost);
151
+ if (raw.origin === 'remote_live' && !context.liveHost) {
152
+ throw identityContextError(IDENTITY_CONTEXT_MISSING,
153
+ 'contesto identita online: liveHost obbligatorio per remote_live');
154
+ }
155
+ return Object.freeze(context);
156
+ }
157
+
158
+ module.exports = {
159
+ IDENTITY_SCHEMA_VERSION,
160
+ IDENTITY_KINDS,
161
+ IDENTITY_ORIGINS,
162
+ IDENTITY_CONTEXT_MISSING,
163
+ IDENTITY_CONTEXT_UNVERIFIED,
164
+ IDENTITY_CONTEXT_FROM_MISMATCH,
165
+ IDENTITY_CONTEXT_AUTHORITY_UNAVAILABLE,
166
+ identityContextError,
167
+ normalizeIdentityContext,
168
+ };