@mmmbuto/nexuscrew 0.8.39 → 0.8.40

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,154 @@
1
+ 'use strict';
2
+ // lib/audio/dispatch.js — instradamento lato ORIGINE verso un target esatto.
3
+ //
4
+ // Regole non negoziabili:
5
+ // * il target e' un instanceId di nodo, esatto. Niente wildcard, niente "tutti",
6
+ // niente broadcast: se il chiamante non sa chi deve parlare, non parla nessuno;
7
+ // * la risoluzione avviene sulla topologia LIVE autorizzata di questo server
8
+ // (peer diretti + peer instradati realmente noti), non su un nome fornito dal
9
+ // chiamante e non su una cache arbitraria;
10
+ // * l'inoltro riusa la route federata esistente, quindi passa da `canTransit`,
11
+ // dalla whitelist delle risorse e da `controlledVisited`: la provenienza la
12
+ // costruisce il server, il chiamante non puo' iniettarla;
13
+ // * nessun esito ambiguo diventa un successo. Timeout, 5xx, corpo non
14
+ // interpretabile e stati sconosciuti restano `unreachable` o `unknown`.
15
+ const { resolvePeer } = require('../nodes/inventory.js');
16
+
17
+ const DISPATCH_TIMEOUT_MS = 8000;
18
+ const PER_ENDPOINT_STATUS = new Set(['refused', 'unreachable', 'accepted', 'spoken', 'unknown']);
19
+ const INSTANCE_ID_RE = /^[a-f0-9]{32}$/i;
20
+
21
+ function createDispatcher(opts = {}) {
22
+ const localNodeId = opts.localNodeId || (() => null);
23
+ const peers = opts.peers || (async () => []);
24
+ const localPort = opts.localPort || (() => 0);
25
+ const localToken = opts.localToken || (() => '');
26
+ const fetchImpl = opts.fetchImpl || fetch;
27
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DISPATCH_TIMEOUT_MS;
28
+
29
+ function isLocal(target) {
30
+ const self = localNodeId();
31
+ return !!self && target === self;
32
+ }
33
+
34
+ // resolveRoute(): ritorna la catena di nomi peer verso il target, oppure il
35
+ // motivo per cui non e' raggiungibile. Un target che non compare nella
36
+ // topologia autorizzata NON e' un errore del chiamante da spiegare in
37
+ // dettaglio: e' semplicemente irraggiungibile da qui.
38
+ async function resolveRoute(target) {
39
+ if (!INSTANCE_ID_RE.test(String(target || ''))) return { error: 'invalid-target' };
40
+ let list;
41
+ try { list = await peers(); } catch (_) { return { error: 'topology-unavailable' }; }
42
+ if (!Array.isArray(list) || !list.length) return { error: 'unknown-target' };
43
+ // Risoluzione per instanceId soltanto: risolvere per nome permetterebbe a un
44
+ // chiamante di colpire un nodo diverso da quello che intendeva se due
45
+ // installazioni condividono un'etichetta.
46
+ const found = resolvePeer(list.filter((p) => p && (p.nodeId || p.instanceId)), String(target));
47
+ if (found.error || !found.peer) return { error: 'unknown-target' };
48
+ const route = Array.isArray(found.peer.route) ? found.peer.route.filter(Boolean) : [];
49
+ if (!route.length) return { error: 'unknown-target' };
50
+ return { route, peer: found.peer };
51
+ }
52
+
53
+ // forward(): POST interno verso la propria route federata. Usa il Bearer
54
+ // locale perche' il destinatario del primo hop e' questo stesso server, che
55
+ // poi applica peering, ACL e whitelist come per qualunque altro inoltro.
56
+ async function forward(resource, route, body) {
57
+ const port = localPort();
58
+ const token = localToken();
59
+ if (!port || !token) return { status: 'unreachable', reason: 'local-transport-unavailable' };
60
+ const url = `http://127.0.0.1:${port}/api/route/${route.join('/')}/_${resource}`;
61
+ let res;
62
+ try {
63
+ res = await fetchImpl(url, {
64
+ method: 'POST',
65
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
66
+ body: JSON.stringify(body),
67
+ signal: AbortSignal.timeout(timeoutMs),
68
+ });
69
+ } catch (_) {
70
+ // Timeout o transport giu': mai un successo, mai uno stato inventato.
71
+ return { status: 'unreachable', reason: 'transport' };
72
+ }
73
+ let payload = null;
74
+ try { payload = await res.json(); } catch (_) { payload = null; }
75
+ // 404 del lookup receipt NON e' una route negata: il target ha risposto
76
+ // intenzionalmente `unknown/not-found` al caller gia' autenticato. Tenerlo
77
+ // distinto permette al failover di reagire all'assenza di ack senza
78
+ // scambiare il risultato per un ACL deny.
79
+ if (resource === '/audio/speak/status' && res.status === 404
80
+ && payload && payload.status === 'unknown') {
81
+ return { status: 'unknown', reason: payload.reason || 'not-found', httpStatus: res.status };
82
+ }
83
+ if (res.status === 403 || res.status === 404 || res.status === 409) {
84
+ return { status: 'refused', reason: 'route-denied', httpStatus: res.status };
85
+ }
86
+ if (res.status >= 500) return { status: 'unreachable', reason: 'peer-error', httpStatus: res.status };
87
+ if (!payload || typeof payload !== 'object' || !PER_ENDPOINT_STATUS.has(payload.status)) {
88
+ // Il peer ha risposto qualcosa che non e' un esito per endpoint: non si
89
+ // indovina. `unknown` e' esattamente questo caso.
90
+ return { status: 'unknown', reason: 'unreadable-endpoint-result', httpStatus: res.status };
91
+ }
92
+ return {
93
+ status: payload.status,
94
+ ...(payload.reason ? { reason: String(payload.reason).slice(0, 64) } : {}),
95
+ httpStatus: res.status,
96
+ };
97
+ }
98
+
99
+ // La capability e' una GET federata separata dalla sintesi. Il target decide
100
+ // se e' pronto e consenziente prima che un gruppo lo consideri eleggibile;
101
+ // unknown/non-200 non diventano mai "ready". La route federata ricostruisce
102
+ // l'origine-node dalla catena visited, quindi non serve (ne' si accetta) una
103
+ // cella dichiarata in query.
104
+ async function probeCapability(target) {
105
+ const resolved = await resolveRoute(target);
106
+ if (resolved.error === 'invalid-target') return { status: 'refused', reason: 'invalid-target' };
107
+ if (resolved.error) return { status: 'unreachable', reason: resolved.error };
108
+ const port = localPort(); const token = localToken();
109
+ if (!port || !token) return { status: 'unreachable', reason: 'local-transport-unavailable' };
110
+ const url = `http://127.0.0.1:${port}/api/route/${resolved.route.join('/')}/_/audio/capability?target=${encodeURIComponent(target)}`;
111
+ let res;
112
+ try {
113
+ res = await fetchImpl(url, {
114
+ method: 'GET', headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(timeoutMs),
115
+ });
116
+ } catch (_) { return { status: 'unreachable', reason: 'transport' }; }
117
+ let body = null;
118
+ try { body = await res.json(); } catch (_) { body = null; }
119
+ if (res.status === 401) return { status: 'unreachable', reason: 'auth-failed' };
120
+ if (res.status === 403 || res.status === 404 || res.status === 409) return { status: 'refused', reason: 'route-denied' };
121
+ if (res.status >= 500) return { status: 'unreachable', reason: 'peer-error' };
122
+ if (!body || body.nodeId !== target || typeof body.installed !== 'boolean'
123
+ || typeof body.consent !== 'boolean' || !['ready', 'unknown', 'unavailable'].includes(body.liveness)) {
124
+ return { status: 'unknown', reason: 'capability-unreadable' };
125
+ }
126
+ if (body.consent !== true) return { status: 'refused', reason: 'consent' };
127
+ if (body.installed !== true || body.liveness !== 'ready') return { status: 'refused', reason: 'no-adapter' };
128
+ return { status: 'ready', capability: body };
129
+ }
130
+
131
+ return {
132
+ isLocal,
133
+ resolveRoute,
134
+ probeCapability,
135
+ // dispatch(): unico ingresso per un target remoto.
136
+ async dispatch({ resource, target, origin, payload }) {
137
+ const resolved = await resolveRoute(target);
138
+ if (resolved.error === 'invalid-target') return { status: 'refused', reason: 'invalid-target' };
139
+ if (resolved.error) return { status: 'unreachable', reason: resolved.error };
140
+ return forward(resource, resolved.route, {
141
+ ...payload,
142
+ target,
143
+ // Attestazione della cella di origine. Il NODO di origine lo ricostruisce
144
+ // il destinatario dalla catena `visited`; questo campo serve solo perche'
145
+ // possa registrare *quale cella* quel nodo dichiara. La differenza fra
146
+ // verificato e attestato viaggia fino al receipt.
147
+ originCell: origin && origin.cell,
148
+ originNode: origin && origin.node,
149
+ });
150
+ },
151
+ };
152
+ }
153
+
154
+ module.exports = { createDispatcher, DISPATCH_TIMEOUT_MS, PER_ENDPOINT_STATUS };
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+ // lib/audio/group-receipt.js — receipt bounded di un gruppo Audio Share.
3
+ //
4
+ // Il receipt resta dell'origine nodo+cella e conserva SOLO la mappa endpoint
5
+ // redatta. Non contiene testo, lingua, voce, route, path o un booleano di
6
+ // successo aggregato: un gruppo puo' avere un endpoint spoken e un altro
7
+ // unreachable senza che un `ok:true` nasconda il dettaglio.
8
+ const crypto = require('node:crypto');
9
+ const { callerKey, STATUS, CAP, TTL_MS } = require('./receipt.js');
10
+
11
+ function createGroupReceiptStore({ now = Date.now } = {}) {
12
+ const byId = new Map();
13
+ const expired = (entry, at) => at - entry.timestamp > TTL_MS;
14
+ const cleanup = (at) => {
15
+ for (const [id, entry] of byId) if (expired(entry, at)) byId.delete(id);
16
+ };
17
+ const redact = (entry) => ({
18
+ utteranceId: entry.utteranceId,
19
+ origin: { node: entry.origin.node, cell: entry.origin.cell },
20
+ group: entry.group,
21
+ mode: entry.mode,
22
+ timestamp: entry.timestamp,
23
+ endpoints: entry.endpoints.map((endpoint) => ({
24
+ target: endpoint.target,
25
+ status: endpoint.status,
26
+ ...(endpoint.reason ? { reason: endpoint.reason } : {}),
27
+ })),
28
+ });
29
+ const find = (origin, utteranceId) => {
30
+ const key = callerKey(origin);
31
+ const entry = byId.get(utteranceId);
32
+ if (!key || !entry) return null;
33
+ if (expired(entry, now())) { byId.delete(utteranceId); return null; }
34
+ return entry.caller === key ? entry : null;
35
+ };
36
+
37
+ function begin({ origin, group, mode, targets, utteranceId } = {}) {
38
+ const caller = callerKey(origin);
39
+ if (!caller) throw new Error('group receipt: origin mancante');
40
+ if (typeof group !== 'string' || !group) throw new Error('group receipt: gruppo mancante');
41
+ if (!Array.isArray(targets) || !targets.length) throw new Error('group receipt: target mancanti');
42
+ const id = typeof utteranceId === 'string' && utteranceId ? utteranceId : crypto.randomUUID();
43
+ const existing = byId.get(id);
44
+ if (existing && !expired(existing, now())) {
45
+ const same = existing.caller === caller && existing.group === group && existing.mode === mode
46
+ && existing.endpoints.map((endpoint) => endpoint.target).join(',') === targets.join(',');
47
+ if (!same) throw new Error('utteranceId collision');
48
+ return { created: false, receipt: redact(existing) };
49
+ }
50
+ const timestamp = now();
51
+ cleanup(timestamp);
52
+ const entry = {
53
+ utteranceId: id, caller, origin: { node: origin.node, cell: origin.cell }, group, mode,
54
+ timestamp,
55
+ endpoints: targets.map((target) => ({ target, status: 'unknown', reason: 'not-attempted', admitted: false })),
56
+ };
57
+ byId.set(id, entry);
58
+ if (byId.size > CAP) {
59
+ const oldest = [...byId.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp);
60
+ while (byId.size > CAP && oldest.length) byId.delete(oldest.shift()[0]);
61
+ }
62
+ return { created: true, receipt: redact(entry) };
63
+ }
64
+
65
+ function update(origin, utteranceId, target, status, reason, admitted = false) {
66
+ if (!STATUS.includes(status)) throw new Error('group receipt: status non valida');
67
+ const entry = find(origin, utteranceId);
68
+ if (!entry) return null;
69
+ // Uno Stop locale prevale sull'orchestratore asincrono: un completamento
70
+ // tardivo non puo' riaprire un endpoint gia' cancellato e farlo parlare.
71
+ if (entry.stopped === true) return redact(entry);
72
+ const endpoint = entry.endpoints.find((value) => value.target === target);
73
+ if (!endpoint) return null;
74
+ endpoint.status = status;
75
+ endpoint.admitted = endpoint.admitted || admitted === true;
76
+ if (reason) endpoint.reason = String(reason).slice(0, 64);
77
+ else delete endpoint.reason;
78
+ return redact(entry);
79
+ }
80
+
81
+ function get(origin, utteranceId) {
82
+ const entry = find(origin, utteranceId);
83
+ return entry ? redact(entry) : null;
84
+ }
85
+
86
+ function admittedTargets(origin, utteranceId) {
87
+ const entry = find(origin, utteranceId);
88
+ return entry ? entry.endpoints.filter((endpoint) => endpoint.admitted).map((endpoint) => endpoint.target) : [];
89
+ }
90
+
91
+ // Segna la richiesta di stop prima di contattare gli endpoint. Gli endpoint
92
+ // non ancora tentati diventano `refused/stopped`; quelli gia' ammessi
93
+ // conservano l'ultimo stato noto, perche' un POST remoto accettato non prova
94
+ // ancora che la voce sia gia' cessata. La coda locale e il suo stop sovrano
95
+ // restano l'autorita' finale su quel dettaglio.
96
+ function stop(origin, utteranceId) {
97
+ const entry = find(origin, utteranceId);
98
+ if (!entry) return null;
99
+ entry.stopped = true;
100
+ for (const endpoint of entry.endpoints) {
101
+ if (!endpoint.admitted && endpoint.status === 'unknown' && endpoint.reason === 'not-attempted') {
102
+ endpoint.status = 'refused'; endpoint.reason = 'stopped';
103
+ }
104
+ }
105
+ return redact(entry);
106
+ }
107
+
108
+ function isStopped(origin, utteranceId) {
109
+ const entry = find(origin, utteranceId);
110
+ return !!(entry && entry.stopped === true);
111
+ }
112
+
113
+ return {
114
+ begin, update, get, admittedTargets, stop, isStopped,
115
+ count: () => { cleanup(now()); return byId.size; },
116
+ };
117
+ }
118
+
119
+ module.exports = { createGroupReceiptStore };
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+ // lib/audio/group-speak.js — orchestrazione origin-side di gruppi nominati.
3
+ //
4
+ // Il gruppo NON e' un'autorizzazione: espande una lista locale di instanceId e
5
+ // interroga ogni nodo. Primary/failover e' sequenziale; fanout e' esplicito e
6
+ // parallelo. Ogni esito resta per-endpoint, inclusi gli endpoint non tentati.
7
+ const { TEXT_MAX } = require('./speak.js');
8
+ const { validName, MODES } = require('./groups.js');
9
+ const { callerKey } = require('./receipt.js');
10
+ // Duplicato intenzionale del contratto di routes: importare routes qui
11
+ // creerebbe un ciclo (routes monta il group speaker) e renderebbe la regex
12
+ // `undefined` durante il caricamento CommonJS.
13
+ const UTTERANCE_ID_RE = /^[A-Za-z0-9._:-]{8,128}$/;
14
+
15
+ const ACK_TIMEOUT_MS = 5000;
16
+ const POLL_INTERVAL_MS = 250;
17
+
18
+ function validText(text) { return typeof text === 'string' && text.length >= 1 && text.length <= TEXT_MAX; }
19
+
20
+ function createGroupSpeaker(deps = {}) {
21
+ const getGroup = deps.getGroup || (() => null);
22
+ const receipts = deps.receipts;
23
+ const capability = deps.capability || (async () => ({ status: 'unreachable', reason: 'capability-unavailable' }));
24
+ const speak = deps.speak || (async () => ({ status: 'unreachable', reason: 'dispatch-unavailable' }));
25
+ const status = deps.status || (async () => ({ status: 'unknown', reason: 'status-unavailable' }));
26
+ const stop = deps.stop || (async () => ({ status: 'unknown', reason: 'stop-unavailable' }));
27
+ const now = deps.now || Date.now;
28
+ const sleep = deps.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
29
+ const ackTimeoutMs = Number.isFinite(deps.ackTimeoutMs) ? deps.ackTimeoutMs : ACK_TIMEOUT_MS;
30
+ const pollIntervalMs = Number.isFinite(deps.pollIntervalMs) ? deps.pollIntervalMs : POLL_INTERVAL_MS;
31
+ const running = new Set();
32
+
33
+ if (!receipts || typeof receipts.begin !== 'function') throw new Error('group speaker: receipt store obbligatorio');
34
+
35
+ async function awaitStart({ target, origin, utteranceId }) {
36
+ const deadline = now() + ackTimeoutMs;
37
+ while (now() < deadline) {
38
+ await sleep(Math.max(1, Math.min(pollIntervalMs, deadline - now())));
39
+ const current = await status({ target, origin, utteranceId });
40
+ if (!current || typeof current.status !== 'string') return { status: 'unknown', reason: 'status-unreadable' };
41
+ if (current.status !== 'accepted') return current;
42
+ }
43
+ return { status: 'unknown', reason: 'ack-timeout' };
44
+ }
45
+
46
+ async function runEndpoint({ target, origin, utteranceId, text, lang, voice, urgency, onAdmitted = null }) {
47
+ if (receipts.isStopped(origin, utteranceId)) return { status: 'refused', reason: 'stopped', admitted: false };
48
+ const cap = await capability({ target, origin });
49
+ if (!cap || cap.status !== 'ready') {
50
+ return {
51
+ status: cap && ['refused', 'unreachable', 'unknown'].includes(cap.status) ? cap.status : 'unknown',
52
+ ...(cap && cap.reason ? { reason: cap.reason } : { reason: 'capability-unavailable' }),
53
+ admitted: false,
54
+ };
55
+ }
56
+ if (receipts.isStopped(origin, utteranceId)) return { status: 'refused', reason: 'stopped', admitted: false };
57
+ const initial = await speak({ target, origin, utteranceId, text, lang, voice, urgency });
58
+ if (!initial || typeof initial.status !== 'string') return { status: 'unknown', reason: 'speak-unreadable', admitted: false };
59
+ if (initial.status !== 'accepted') {
60
+ // Anche uno `spoken` sincrono e' gia' ammesso: registra prima questo
61
+ // fatto minimo cosi' uno Stop concorrente non perde una finestra di un
62
+ // microtask fra la risposta dell'adapter e l'update finale del receipt.
63
+ if (initial.status === 'spoken' && typeof onAdmitted === 'function') onAdmitted();
64
+ return { status: initial.status, reason: initial.reason, admitted: initial.status === 'spoken' };
65
+ }
66
+ // L'endpoint ha ammesso l'enunciato: registrarlo PRIMA di aspettare l'ack
67
+ // permette a Stop di raggiungerlo anche se il polling resta appeso. Non e'
68
+ // ancora `spoken`, quindi il receipt non promette udibilita'.
69
+ if (typeof onAdmitted === 'function') onAdmitted();
70
+ const final = await awaitStart({ target, origin, utteranceId });
71
+ return { status: final.status, reason: final.reason, admitted: true };
72
+ }
73
+
74
+ function runKey(origin, utteranceId) { return `${callerKey(origin) || ''}\u0000${utteranceId}`; }
75
+
76
+ async function orchestrate({ origin, spec, utteranceId, text, lang, voice, urgency }) {
77
+ const one = async (target) => {
78
+ if (receipts.isStopped(origin, utteranceId)) return { status: 'refused', reason: 'stopped', admitted: false };
79
+ let result;
80
+ try {
81
+ result = await runEndpoint({
82
+ target, origin, utteranceId, text, lang, voice, urgency,
83
+ onAdmitted: () => receipts.update(origin, utteranceId, target, 'accepted', undefined, true),
84
+ });
85
+ }
86
+ catch (_) { result = { status: 'unknown', reason: 'endpoint-error', admitted: false }; }
87
+ receipts.update(origin, utteranceId, target, result.status, result.reason, result.admitted);
88
+ return result;
89
+ };
90
+ if (spec.mode === 'fanout') {
91
+ await Promise.all(spec.targets.map(one));
92
+ return;
93
+ }
94
+ for (const target of spec.targets) {
95
+ if (receipts.isStopped(origin, utteranceId)) return;
96
+ const result = await one(target);
97
+ if (result.status === 'spoken') return;
98
+ }
99
+ }
100
+
101
+ async function speakGroup({ origin, group, text, lang = '', voice = '', urgency = 'normal', utteranceId } = {}) {
102
+ if (!validName(group)) return { status: 'refused', reason: 'invalid-group' };
103
+ if (!validText(text)) return { status: 'refused', reason: 'invalid-text' };
104
+ if (utteranceId && !UTTERANCE_ID_RE.test(utteranceId)) return { status: 'refused', reason: 'invalid-utterance' };
105
+ const spec = getGroup(group);
106
+ if (!spec || !Array.isArray(spec.targets) || !MODES.includes(spec.mode)) return { status: 'refused', reason: 'unknown-group' };
107
+ let begun;
108
+ try { begun = receipts.begin({ origin, group, mode: spec.mode, targets: spec.targets, utteranceId }); }
109
+ catch (_) { return { status: 'refused', reason: 'utterance-collision' }; }
110
+ if (!begun.created) return begun.receipt; // retry idempotente: non risuona
111
+ const id = begun.receipt.utteranceId;
112
+ const key = runKey(origin, id);
113
+ // Il comando non aspetta fino a 8 × 5s: conserva subito un receipt
114
+ // interrogabile e completa in background. In questo modo la semantica
115
+ // resta asincrona come `nc_speak`; `not-attempted` non e' un falso esito,
116
+ // indica precisamente che il candidato non e' ancora stato provato.
117
+ running.add(key);
118
+ Promise.resolve().then(() => orchestrate({ origin, spec, utteranceId: id, text, lang, voice, urgency }))
119
+ .catch(() => {})
120
+ .finally(() => running.delete(key));
121
+ return begun.receipt;
122
+ }
123
+
124
+ async function stopGroup({ origin, utteranceId } = {}) {
125
+ if (!utteranceId || !UTTERANCE_ID_RE.test(utteranceId)) return { status: 'refused', reason: 'invalid-utterance' };
126
+ const existing = receipts.get(origin, utteranceId);
127
+ if (!existing) return { status: 'unknown', reason: 'not-found', utteranceId };
128
+ // Stop prima la pipeline locale: i candidati non ancora tentati non devono
129
+ // partire mentre la rete sta ricevendo gli stop degli endpoint gia' ammessi.
130
+ const admitted = receipts.admittedTargets(origin, utteranceId);
131
+ receipts.stop(origin, utteranceId);
132
+ await Promise.all(admitted.map(async (target) => {
133
+ try { await stop({ target, origin, utteranceId }); } catch (_) { /* receipt resta ultimo stato onesto */ }
134
+ }));
135
+ return receipts.get(origin, utteranceId);
136
+ }
137
+
138
+ return {
139
+ speakGroup, stopGroup,
140
+ getStatus: ({ origin, utteranceId }) => receipts.get(origin, utteranceId),
141
+ // Solo seam di test/diagnostica locale: non e' esposto dalle route.
142
+ isRunning: ({ origin, utteranceId }) => running.has(runKey(origin, utteranceId)),
143
+ };
144
+ }
145
+
146
+ module.exports = { createGroupSpeaker, ACK_TIMEOUT_MS, POLL_INTERVAL_MS };
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+ // lib/audio/groups.js — gruppi audio LOCALI dell'origine.
3
+ //
4
+ // Un gruppo e' solo una preferenza di consegna: non pubblica un nodo, non
5
+ // modifica Share/visibility e non concede consenso audio. Ogni endpoint della
6
+ // lista viene comunque rivalutato dal proprio nodo quando riceve speak.
7
+ // Tenerlo in un file distinto da audio.json preserva lo schema chiuso del
8
+ // consenso ({audio:{consent}}) e impedisce che una nuova preferenza allenti un
9
+ // confine fisico gia' esistente.
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+ const os = require('node:os');
13
+ const crypto = require('node:crypto');
14
+
15
+ const SCHEMA_VERSION = 1;
16
+ const GROUP_NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
17
+ const NODE_ID_RE = /^[a-f0-9]{32}$/i;
18
+ const MODES = Object.freeze(['primary-failover', 'fanout']);
19
+ const MAX_TARGETS = 8;
20
+
21
+ function groupsPath(cfg = {}, home = (cfg && cfg.home) || os.homedir()) {
22
+ if (cfg.audioGroupsPath) return cfg.audioGroupsPath;
23
+ if (cfg.tokenPath) return path.join(path.dirname(cfg.tokenPath), 'audio-groups.json');
24
+ return path.join(home, '.nexuscrew', 'audio-groups.json');
25
+ }
26
+
27
+ function emptyGroups() { return { schemaVersion: SCHEMA_VERSION, groups: {} }; }
28
+
29
+ function validName(name) { return typeof name === 'string' && GROUP_NAME_RE.test(name); }
30
+
31
+ function normalizeSpec(spec) {
32
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return null;
33
+ if (Object.keys(spec).some((key) => !['targets', 'mode'].includes(key))) return null;
34
+ if (!Array.isArray(spec.targets) || spec.targets.length < 1 || spec.targets.length > MAX_TARGETS) return null;
35
+ if (!MODES.includes(spec.mode)) return null;
36
+ const targets = spec.targets.map((value) => String(value || '').toLowerCase());
37
+ if (targets.some((value) => !NODE_ID_RE.test(value))) return null;
38
+ if (new Set(targets).size !== targets.length) return null;
39
+ return { targets, mode: spec.mode };
40
+ }
41
+
42
+ function normalizeStore(raw) {
43
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || raw.schemaVersion !== SCHEMA_VERSION) return emptyGroups();
44
+ if (!raw.groups || typeof raw.groups !== 'object' || Array.isArray(raw.groups)) return emptyGroups();
45
+ const groups = {};
46
+ for (const [name, spec] of Object.entries(raw.groups)) {
47
+ const normalized = validName(name) ? normalizeSpec(spec) : null;
48
+ if (!normalized) return emptyGroups(); // configurazione corrotta = nessun gruppo, mai guess
49
+ groups[name] = normalized;
50
+ }
51
+ return { schemaVersion: SCHEMA_VERSION, groups };
52
+ }
53
+
54
+ function readGroups(cfg = {}, home = (cfg && cfg.home) || os.homedir()) {
55
+ try {
56
+ const file = groupsPath(cfg, home);
57
+ // Una preferenza audio non deve diventare un lettore di file arbitrari: un
58
+ // symlink viene trattato come configurazione assente sia in lettura sia in
59
+ // scrittura. In particolare non restituiamo mai il contenuto del target.
60
+ if (fs.lstatSync(file).isSymbolicLink()) return emptyGroups();
61
+ return normalizeStore(JSON.parse(fs.readFileSync(file, 'utf8')));
62
+ }
63
+ catch (_) { return emptyGroups(); }
64
+ }
65
+
66
+ function atomicWrite(file, data) {
67
+ try {
68
+ if (fs.lstatSync(file).isSymbolicLink()) throw new Error('refusing symlink audio groups path');
69
+ } catch (err) {
70
+ if (err.code !== 'ENOENT') throw err;
71
+ }
72
+ const dir = path.dirname(file);
73
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
74
+ const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`);
75
+ try {
76
+ fs.writeFileSync(tmp, `${JSON.stringify(data)}\n`, { flag: 'wx', mode: 0o600 });
77
+ fs.chmodSync(tmp, 0o600);
78
+ fs.renameSync(tmp, file);
79
+ } catch (err) {
80
+ try { fs.unlinkSync(tmp); } catch (_) {}
81
+ throw err;
82
+ }
83
+ }
84
+
85
+ function listGroups(cfg = {}, home) {
86
+ const store = readGroups(cfg, home);
87
+ return Object.entries(store.groups)
88
+ .sort(([a], [b]) => a.localeCompare(b))
89
+ .map(([name, spec]) => ({ name, targets: [...spec.targets], mode: spec.mode }));
90
+ }
91
+
92
+ function getGroup(cfg = {}, name, home) {
93
+ if (!validName(name)) return null;
94
+ const spec = readGroups(cfg, home).groups[name];
95
+ return spec ? { name, targets: [...spec.targets], mode: spec.mode } : null;
96
+ }
97
+
98
+ function saveGroup(cfg = {}, name, spec, home) {
99
+ if (!validName(name)) throw new Error('nome gruppo audio non valido');
100
+ const normalized = normalizeSpec(spec);
101
+ if (!normalized) throw new Error('gruppo audio non valido');
102
+ const store = readGroups(cfg, home);
103
+ store.groups[name] = normalized;
104
+ atomicWrite(groupsPath(cfg, home), store);
105
+ return { name, targets: [...normalized.targets], mode: normalized.mode };
106
+ }
107
+
108
+ function removeGroup(cfg = {}, name, home) {
109
+ if (!validName(name)) throw new Error('nome gruppo audio non valido');
110
+ const store = readGroups(cfg, home);
111
+ if (!Object.prototype.hasOwnProperty.call(store.groups, name)) return false;
112
+ delete store.groups[name];
113
+ atomicWrite(groupsPath(cfg, home), store);
114
+ return true;
115
+ }
116
+
117
+ module.exports = {
118
+ SCHEMA_VERSION, GROUP_NAME_RE, NODE_ID_RE, MODES, MAX_TARGETS,
119
+ groupsPath, emptyGroups, validName, normalizeSpec, readGroups, listGroups,
120
+ getGroup, saveGroup, removeGroup,
121
+ };
@@ -0,0 +1,128 @@
1
+ 'use strict';
2
+ // lib/audio/origin.js — risoluzione dell'ORIGINE di una richiesta audio.
3
+ //
4
+ // L'origine non e' un campo del body: e' il risultato di una verifica. Esistono
5
+ // esattamente due modi legittimi di presentarsi, e nient'altro e' accettato.
6
+ //
7
+ // 1. locale via bridge MCP — la richiesta porta la firma HMAC del segreto di
8
+ // bridge (lib/audio/bridge-auth.js) e dichiara una sessione tmux che deve
9
+ // risultare una cella Fleet ATTIVA in questo momento. Lo stato Fleet e'
10
+ // asincrono: va atteso, non letto da un oggetto che sembra sincrono.
11
+ // Origine = { node: nodeId locale, cell: id logico della cella }.
12
+ //
13
+ // 2. federata — la richiesta e' l'ultimo hop di una route federata, provato
14
+ // dall'header di hop firmato dal segreto per-processo (lib/proxy/hop-proof)
15
+ // piu' la catena `visited` che il proxy costruisce lato server. Origine
16
+ // node = `visited[0]`, cioe' il nodo che ha aperto la catena, MAI un campo
17
+ // del body.
18
+ //
19
+ // Confine dichiarato, non nascosto: il nodo di origine e' verificato end-to-end
20
+ // (catena visited legata ai token di peering); la CELLA di origine remota e'
21
+ // invece *attestata* dal nodo di origine, che l'ha verificata localmente col
22
+ // bridge. Un proprietario di nodo puo' mentire su quale delle proprie celle ha
23
+ // parlato. Questo limite e' inerente al modello di fiducia fra nodi e va
24
+ // registrato come tale, non spacciato per autenticazione.
25
+ //
26
+ // Il Bearer — della UI o di federation — non concorre MAI a stabilire l'origine.
27
+ const { verifyRequest, SESSION_HEADER } = require('./bridge-auth.js');
28
+ const { verifyHop, HOP_HEADER } = require('../proxy/hop-proof.js');
29
+
30
+ const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
31
+ const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
32
+
33
+ function validNode(v) { return typeof v === 'string' && NODE_ID_RE.test(v); }
34
+ function validCell(v) { return typeof v === 'string' && CELL_ID_RE.test(v); }
35
+
36
+ // Catena visited gia' costruita dal server (controlledVisited). Qui si verifica
37
+ // solo la forma e la coerenza con il nodo locale: la prova di provenienza e'
38
+ // l'header di hop, non questa stringa.
39
+ function parseVisitedChain(raw, localNodeId) {
40
+ const ids = String(raw || '').split(',').filter(Boolean);
41
+ if (!ids.length || ids.length > 8) return null;
42
+ if (ids.some((id) => !validNode(id))) return null;
43
+ if (new Set(ids).size !== ids.length) return null;
44
+ if (ids.at(-1) !== localNodeId) return null;
45
+ return ids;
46
+ }
47
+
48
+ // createOriginResolver(): deps esplicite, nessun accesso globale.
49
+ // localNodeId() -> string|null nodeId REALE del nodo (dal node store)
50
+ // activeCells() -> Promise<[{cell, tmuxSession, active}]> stato Fleet atteso
51
+ // bridgeSecret() -> string|null segreto del bridge (file 0600)
52
+ // hopSecret() -> Buffer|null segreto per-processo della prova di hop
53
+ function createOriginResolver(deps = {}) {
54
+ const localNodeId = deps.localNodeId || (() => null);
55
+ const activeCells = deps.activeCells || (async () => []);
56
+ const bridgeSecret = deps.bridgeSecret || (() => null);
57
+ const hopSecret = deps.hopSecret || (() => null);
58
+ const nonceCache = deps.nonceCache || null;
59
+ const now = deps.now || Date.now;
60
+
61
+ async function resolve(req, opts = {}) {
62
+ // Capability e' una lettura per-nodo: un hop federato prova gia' il nodo
63
+ // origine, ma non porta una cella nel body di una GET. Speak/status/stop
64
+ // mantengono invece requireCell=true per scope receipt/rate/attribution.
65
+ const requireCell = opts.requireCell !== false;
66
+ const self = localNodeId();
67
+ // Senza identita' di nodo non si puo' ne' attribuire ne' verificare nulla:
68
+ // fail-closed invece di inventare un nodeId vuoto.
69
+ if (!validNode(self)) return { ok: false, reason: 'no-node-identity' };
70
+
71
+ const headers = req.headers || {};
72
+ const hopProof = headers[HOP_HEADER];
73
+ if (hopProof) {
74
+ const visited = parseVisitedChain(headers['x-nexuscrew-visited'], self);
75
+ if (!visited) return { ok: false, reason: 'bad-visited' };
76
+ const okHop = verifyHop(hopSecret(), {
77
+ method: req.method, path: req.originalUrl || req.url, visited,
78
+ }, hopProof);
79
+ if (!okHop) return { ok: false, reason: 'bad-hop' };
80
+ const originNode = visited[0];
81
+ // Un hop la cui catena inizia col nodo locale non e' un inbound federato:
82
+ // e' un giro su se stessi, e l'origine locale ha il suo percorso.
83
+ if (originNode === self) return { ok: false, reason: 'self-hop' };
84
+ const body = req.body || {};
85
+ const attested = body.originCell;
86
+ if (requireCell && !validCell(attested)) return { ok: false, reason: 'bad-attested-cell' };
87
+ if (!requireCell && attested !== undefined && !validCell(attested)) return { ok: false, reason: 'bad-attested-cell' };
88
+ // Coerenza: se il body dichiara anche un nodo, deve combaciare con la
89
+ // catena controllata dal server. In caso di conflitto vince visited e la
90
+ // richiesta viene rifiutata, non "corretta" in silenzio.
91
+ if (body.originNode !== undefined && body.originNode !== originNode) {
92
+ return { ok: false, reason: 'origin-mismatch' };
93
+ }
94
+ return {
95
+ ok: true,
96
+ origin: { node: originNode, ...(validCell(attested) ? { cell: attested } : {}) },
97
+ trust: 'federated', visited,
98
+ };
99
+ }
100
+
101
+ // Percorso locale: serve la firma del bridge. Nessun fallback sul body.
102
+ const verified = verifyRequest({
103
+ secret: bridgeSecret(),
104
+ method: req.method,
105
+ path: req.originalUrl || req.url,
106
+ headers,
107
+ rawBody: req.rawBody,
108
+ nonceCache,
109
+ now,
110
+ });
111
+ if (!verified.ok) return { ok: false, reason: verified.reason };
112
+ const session = headers[SESSION_HEADER];
113
+ let cells;
114
+ try {
115
+ cells = await activeCells();
116
+ } catch (_) {
117
+ return { ok: false, reason: 'fleet-unavailable' };
118
+ }
119
+ if (!Array.isArray(cells)) return { ok: false, reason: 'fleet-unavailable' };
120
+ const match = cells.find((c) => c && c.tmuxSession === session && c.active === true);
121
+ if (!match || !validCell(match.cell)) return { ok: false, reason: 'cell-not-active' };
122
+ return { ok: true, origin: { node: self, cell: match.cell }, trust: 'local-bridge' };
123
+ }
124
+
125
+ return { resolve };
126
+ }
127
+
128
+ module.exports = { createOriginResolver, parseVisitedChain, NODE_ID_RE, CELL_ID_RE };