@mmmbuto/nexuscrew 0.8.38 → 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,374 @@
1
+ 'use strict';
2
+ // lib/audio/routes.js — route audio locali: /speak, /stop, /capability, /status.
3
+ //
4
+ // Due invarianti reggono tutto il resto.
5
+ //
6
+ // 1) L'origine e' verificata, non dichiarata. `originResolver` e' obbligatorio e
7
+ // autoritativo: nessun header, campo del body o parametro di query concorre a
8
+ // stabilire chi sta parlando. Senza un'origine verificabile la richiesta
9
+ // muore con 401, non con un default permissivo.
10
+ //
11
+ // 2) Ogni decisione di suonare la prende il nodo che possiede l'altoparlante. Se
12
+ // il target e' remoto questo server non decide per lui: instrada e riporta
13
+ // l'esito per endpoint cosi' com'e', incluso `unknown`.
14
+ //
15
+ // Il corpo grezzo viene conservato (`req.rawBody`) perche' la firma del bridge
16
+ // copre i BYTE trasmessi: ri-serializzare il JSON per verificarlo produrrebbe
17
+ // una stringa diversa a parita' di significato.
18
+ const express = require('express');
19
+ const { handleSpeak, handleStop, isValidInstance } = require('./speak.js');
20
+ const { describeCapability } = require('./capability.js');
21
+ const { createReceiptStore } = require('./receipt.js');
22
+ const { createSpeakRateLimiter } = require('./rate-limit.js');
23
+ const { createGroupReceiptStore } = require('./group-receipt.js');
24
+ const { createGroupSpeaker } = require('./group-speak.js');
25
+
26
+ const UTTERANCE_ID_RE = /^[A-Za-z0-9._:-]{8,128}$/;
27
+ const LANG_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,34})$/;
28
+ const VOICE_RE = /^[^\u0000-\u001f\u007f]{1,64}$/;
29
+ const URGENCIES = new Set(['normal', 'high']);
30
+
31
+ function plainObject(value) {
32
+ return !!value && typeof value === 'object' && !Array.isArray(value);
33
+ }
34
+
35
+ function onlyKeys(value, allowed) {
36
+ return plainObject(value) && Object.keys(value).every((key) => allowed.has(key));
37
+ }
38
+
39
+ function optionalBounded(value, re) {
40
+ return value === undefined || (typeof value === 'string' && re.test(value));
41
+ }
42
+
43
+ // Il server non deve diventare tollerante verso campi futuri/non documentati:
44
+ // testo, route o identita' inattesi non vengono mai ignorati in silenzio.
45
+ // `originCell`/`originNode` sono gli unici campi aggiunti dal dispatcher sul
46
+ // percorso federato; il target li ricontrolla nel resolver, non li usa mai
47
+ // come fonte primaria dell'identita'.
48
+ function parseSpeakBody(body, { federated = false } = {}) {
49
+ const allowed = new Set(['target', 'text', 'lang', 'voice', 'urgency', 'utteranceId']);
50
+ if (federated) { allowed.add('originCell'); allowed.add('originNode'); }
51
+ if (!onlyKeys(body, allowed)) return { error: 'invalid-request' };
52
+ const target = typeof body.target === 'string' ? body.target : '';
53
+ if (!isValidInstance(target)) return { error: 'invalid-target' };
54
+ if (typeof body.text !== 'string' || body.text.length < 1 || body.text.length > 320) return { error: 'invalid-text' };
55
+ if (!optionalBounded(body.lang, LANG_RE) || !optionalBounded(body.voice, VOICE_RE)) return { error: 'invalid-request' };
56
+ if (body.urgency !== undefined && !URGENCIES.has(body.urgency)) return { error: 'invalid-request' };
57
+ if (body.utteranceId !== undefined && (typeof body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(body.utteranceId))) {
58
+ return { error: 'invalid-utterance' };
59
+ }
60
+ return {
61
+ target, text: body.text,
62
+ ...(body.lang !== undefined ? { lang: body.lang } : {}),
63
+ ...(body.voice !== undefined ? { voice: body.voice } : {}),
64
+ urgency: body.urgency || 'normal',
65
+ ...(body.utteranceId ? { utteranceId: body.utteranceId } : {}),
66
+ };
67
+ }
68
+
69
+ function parseStopBody(body, { federated = false } = {}) {
70
+ const allowed = new Set(['target', 'utteranceId']);
71
+ if (federated) { allowed.add('originCell'); allowed.add('originNode'); }
72
+ if (!onlyKeys(body, allowed)) return { error: 'invalid-request' };
73
+ const target = typeof body.target === 'string' ? body.target : '';
74
+ if (!isValidInstance(target)) return { error: 'invalid-target' };
75
+ if (body.utteranceId !== undefined && (typeof body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(body.utteranceId))) {
76
+ return { error: 'invalid-utterance' };
77
+ }
78
+ return { target, ...(body.utteranceId ? { utteranceId: body.utteranceId } : {}) };
79
+ }
80
+
81
+ function parseRemoteStatusBody(body) {
82
+ if (!onlyKeys(body, new Set(['target', 'utteranceId', 'originCell', 'originNode']))) return { error: 'invalid-request' };
83
+ if (!isValidInstance(body.target)) return { error: 'invalid-target' };
84
+ if (typeof body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(body.utteranceId)) return { error: 'invalid-utterance' };
85
+ return { target: body.target, utteranceId: body.utteranceId };
86
+ }
87
+
88
+ function parseGroupSpeakBody(body) {
89
+ if (!onlyKeys(body, new Set(['group', 'text', 'lang', 'voice', 'urgency', 'utteranceId']))) return { error: 'invalid-request' };
90
+ if (typeof body.group !== 'string') return { error: 'invalid-group' };
91
+ if (typeof body.text !== 'string' || body.text.length < 1 || body.text.length > 320) return { error: 'invalid-text' };
92
+ if (!optionalBounded(body.lang, LANG_RE) || !optionalBounded(body.voice, VOICE_RE)) return { error: 'invalid-request' };
93
+ if (body.urgency !== undefined && !URGENCIES.has(body.urgency)) return { error: 'invalid-request' };
94
+ if (body.utteranceId !== undefined && (typeof body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(body.utteranceId))) {
95
+ return { error: 'invalid-utterance' };
96
+ }
97
+ return {
98
+ group: body.group, text: body.text, urgency: body.urgency || 'normal',
99
+ ...(body.lang !== undefined ? { lang: body.lang } : {}),
100
+ ...(body.voice !== undefined ? { voice: body.voice } : {}),
101
+ ...(body.utteranceId ? { utteranceId: body.utteranceId } : {}),
102
+ };
103
+ }
104
+
105
+ function audioRoutes(deps = {}) {
106
+ if (!deps.originResolver || typeof deps.originResolver.resolve !== 'function') {
107
+ throw new Error('audioRoutes: originResolver e obbligatorio (origine verificata)');
108
+ }
109
+ const r = express.Router();
110
+ r.use(express.json({
111
+ limit: '16kb',
112
+ verify: (req, _res, buf) => { req.rawBody = Buffer.from(buf); },
113
+ }));
114
+
115
+ const originResolver = deps.originResolver;
116
+ const receiptStore = deps.receiptStore || createReceiptStore();
117
+ const rateLimiter = deps.rateLimiter || createSpeakRateLimiter();
118
+ const readonly = deps.readonly || (() => false);
119
+ const localNodeId = deps.localNodeId || (() => null);
120
+ const consent = deps.consent || (() => false);
121
+ const adapter = deps.adapter || null;
122
+ const queue = deps.queue || null;
123
+ const acl = deps.acl || null;
124
+ const dispatcher = deps.dispatcher || null;
125
+ const getGroup = deps.getGroup || (() => null);
126
+ const groupReceipts = deps.groupReceipts || createGroupReceiptStore();
127
+ const now = deps.now || Date.now;
128
+
129
+ const send = (res, code, body) => res.status(code).json(body);
130
+
131
+ // Un solo messaggio per ogni fallimento di origine. Distinguere "firma
132
+ // scaduta" da "nonce riusato" da "cella non attiva" darebbe a chi prova a
133
+ // forgiare un oracolo gratuito su cosa manca per riuscire.
134
+ async function originOr401(req, res, opts = {}) {
135
+ const resolved = await originResolver.resolve(req, opts);
136
+ if (!resolved || resolved.ok !== true) {
137
+ send(res, 401, { error: 'origine non verificabile' });
138
+ return null;
139
+ }
140
+ return resolved;
141
+ }
142
+
143
+ function gateDeps(origin, attested) {
144
+ return {
145
+ readonly, rateLimiter, queue, localNodeId, consent, now,
146
+ acl: acl ? acl.allows : null,
147
+ receipt: (status, o) => receiptStore.record({
148
+ origin: o.origin, target: o.target, status, reason: o.reason,
149
+ utteranceId: o.utteranceId, attested,
150
+ }),
151
+ updateReceipt: (id, status, reason) => receiptStore.update(id, status, reason),
152
+ readReceipt: (id, o) => receiptStore.get(o, id),
153
+ findReceipt: (id, o, target) => receiptStore.find(id, o, target),
154
+ };
155
+ }
156
+
157
+ function localCapability(target) {
158
+ const self = localNodeId();
159
+ if (!self || target !== self) return { status: 'refused', reason: 'invalid-target' };
160
+ const described = describeCapability({ adapter, consent: consent() === true, nodeId: self });
161
+ if (described.consent !== true) return { status: 'refused', reason: 'consent' };
162
+ if (described.installed !== true) return { status: 'refused', reason: 'no-adapter' };
163
+ if (described.liveness !== 'ready') return { status: 'unknown', reason: 'liveness-unknown' };
164
+ return { status: 'ready', capability: described };
165
+ }
166
+
167
+ async function capabilityForGroup({ target }) {
168
+ if (!isValidInstance(target)) return { status: 'refused', reason: 'invalid-target' };
169
+ if (target === localNodeId()) return localCapability(target);
170
+ if (!dispatcher || typeof dispatcher.probeCapability !== 'function') return { status: 'unreachable', reason: 'dispatch-unavailable' };
171
+ return dispatcher.probeCapability(target);
172
+ }
173
+
174
+ async function speakForGroup({ target, origin, utteranceId, text, lang, voice, urgency }) {
175
+ if (target === localNodeId()) {
176
+ return handleSpeak({
177
+ target, text, lang, voice, urgency, utteranceId, origin, trust: 'local-bridge', visited: [],
178
+ }, gateDeps(origin, false));
179
+ }
180
+ if (!dispatcher || typeof dispatcher.dispatch !== 'function') return { status: 'unreachable', reason: 'dispatch-unavailable' };
181
+ // Un gruppo non e' una scorciatoia attorno al budget `nc_speak`: prima del
182
+ // dispatch remoto consuma gli stessi tre bucket sul nodo origine. Il target
183
+ // li applichera' di nuovo in modo indipendente, perche' resta l'autorita'
184
+ // finale sul proprio altoparlante.
185
+ const rl = rateLimiter.check({ origin, target, urgency });
186
+ if (!rl.allowed) return { status: 'refused', reason: 'rate-limit' };
187
+ return dispatcher.dispatch({
188
+ resource: '/audio/speak', target, origin,
189
+ payload: { text, ...(lang ? { lang } : {}), ...(voice ? { voice } : {}), urgency, utteranceId },
190
+ });
191
+ }
192
+
193
+ async function statusForGroup({ target, origin, utteranceId }) {
194
+ if (target === localNodeId()) return receiptStore.get(origin, utteranceId) || { status: 'unknown', reason: 'not-found' };
195
+ if (!dispatcher || typeof dispatcher.dispatch !== 'function') return { status: 'unknown', reason: 'dispatch-unavailable' };
196
+ return dispatcher.dispatch({ resource: '/audio/speak/status', target, origin, payload: { utteranceId } });
197
+ }
198
+
199
+ async function stopForGroup({ target, origin, utteranceId }) {
200
+ if (target === localNodeId()) return handleStop({ target, utteranceId, origin }, gateDeps(origin, false));
201
+ if (!dispatcher || typeof dispatcher.dispatch !== 'function') return { status: 'unknown', reason: 'dispatch-unavailable' };
202
+ return dispatcher.dispatch({ resource: '/audio/stop', target, origin, payload: { utteranceId } });
203
+ }
204
+
205
+ const groupSpeaker = deps.groupSpeaker || createGroupSpeaker({
206
+ getGroup, receipts: groupReceipts,
207
+ capability: capabilityForGroup, speak: speakForGroup,
208
+ status: statusForGroup, stop: stopForGroup,
209
+ });
210
+
211
+ r.post('/speak', async (req, res) => {
212
+ const resolved = await originOr401(req, res); if (!resolved) return undefined;
213
+ const { origin, trust, visited } = resolved;
214
+ const parsed = parseSpeakBody(req.body, { federated: trust === 'federated' });
215
+ if (parsed.error) return send(res, 400, { status: 'refused', reason: parsed.error });
216
+ const { target, utteranceId } = parsed;
217
+ // Gruppo e speak singolo condividono il namespace dell'utteranceId per
218
+ // origine. Senza questo gate un id riusato potrebbe far sembrare `spoken`
219
+ // un endpoint che aveva pronunciato una richiesta precedente, diversa.
220
+ if (utteranceId && groupReceipts.get(origin, utteranceId)) {
221
+ return send(res, 422, { status: 'refused', reason: 'utterance-collision' });
222
+ }
223
+ // Target remoto: questo nodo non decide e non parla. Instrada sulla
224
+ // topologia autorizzata e restituisce l'esito del target senza addolcirlo.
225
+ if (dispatcher && isValidInstance(target) && !dispatcher.isLocal(target)) {
226
+ if (readonly()) return send(res, 403, { status: 'refused', reason: 'readonly' });
227
+ const rl = rateLimiter.check({ origin, target, urgency: parsed.urgency });
228
+ if (!rl.allowed) return send(res, 429, { status: 'refused', reason: 'rate-limit' });
229
+ const local = receiptStore.record({
230
+ origin, target, status: 'accepted', utteranceId, attested: trust === 'federated',
231
+ });
232
+ const out = await dispatcher.dispatch({
233
+ resource: '/audio/speak', target, origin,
234
+ payload: {
235
+ text: parsed.text, lang: parsed.lang, voice: parsed.voice, urgency: parsed.urgency,
236
+ utteranceId: local.utteranceId,
237
+ },
238
+ });
239
+ const merged = receiptStore.update(local.utteranceId, out.status, out.reason) || {
240
+ ...local, status: out.status, ...(out.reason ? { reason: out.reason } : {}),
241
+ };
242
+ const code = out.status === 'spoken' || out.status === 'accepted' || out.status === 'unknown' ? 200
243
+ : out.status === 'unreachable' ? 503 : 422;
244
+ return send(res, code, merged);
245
+ }
246
+
247
+ const out = handleSpeak({
248
+ target, text: parsed.text, lang: parsed.lang, voice: parsed.voice, urgency: parsed.urgency,
249
+ utteranceId, origin, trust, visited,
250
+ }, gateDeps(origin, trust === 'federated'));
251
+ const code = out.status === 'spoken' || out.status === 'accepted' || out.status === 'unknown' ? 200
252
+ : (out.reason === 'invalid-text' || out.reason === 'invalid-target' || out.reason === 'not-local-target') ? 400
253
+ : out.status === 'unreachable' ? 503 : 422;
254
+ return send(res, code, out);
255
+ });
256
+
257
+ r.post('/stop', async (req, res) => {
258
+ const resolved = await originOr401(req, res); if (!resolved) return undefined;
259
+ const { origin } = resolved;
260
+ const parsed = parseStopBody(req.body, { federated: resolved.trust === 'federated' });
261
+ if (parsed.error) return send(res, 400, { status: 'refused', reason: parsed.error });
262
+ const { target } = parsed;
263
+ if (dispatcher && isValidInstance(target) && !dispatcher.isLocal(target)) {
264
+ const out = await dispatcher.dispatch({
265
+ resource: '/audio/stop', target, origin,
266
+ payload: { ...(parsed.utteranceId ? { utteranceId: parsed.utteranceId } : {}) },
267
+ });
268
+ return send(res, out.status === 'accepted' ? 200 : out.status === 'unreachable' ? 503 : 422, out);
269
+ }
270
+ const out = handleStop({ target, utteranceId: parsed.utteranceId, origin }, gateDeps(origin, false));
271
+ return send(res, out.status === 'accepted' || out.status === 'unknown' ? 200 : 422, out);
272
+ });
273
+
274
+ // Capability del SOLO nodo locale. Non e' una directory: un chiamante non puo'
275
+ // usare questo endpoint per sondare la capability di un terzo nodo, che va
276
+ // interrogato sulla propria route federata con la credenziale giusta.
277
+ r.get('/capability', async (req, res) => {
278
+ const resolved = await originOr401(req, res, { requireCell: false }); if (!resolved) return undefined;
279
+ if (resolved.trust === 'federated' && acl && typeof acl.allows === 'function') {
280
+ const verdict = acl.allows({ trust: resolved.trust, origin: resolved.origin, visited: resolved.visited });
281
+ if (!verdict || verdict.allowed !== true) return send(res, 403, { error: 'capability non autorizzata' });
282
+ }
283
+ if (Object.keys(req.query || {}).some((key) => key !== 'target') || Array.isArray(req.query && req.query.target)) {
284
+ return send(res, 400, { error: 'query non valida' });
285
+ }
286
+ const self = localNodeId();
287
+ const target = String(req.query.target || self || '');
288
+ if (!isValidInstance(target)) return send(res, 400, { error: 'target non valido (instanceId 32 hex)' });
289
+ if (!self || target !== self) return send(res, 403, { error: 'capability descrive solo il nodo locale' });
290
+ return send(res, 200, describeCapability({ adapter, consent: consent() === true, nodeId: self }));
291
+ });
292
+
293
+ // Status per utteranceId, leggibile SOLO dalla stessa origine (nodo+cella).
294
+ // Per chiunque altro l'enunciato non esiste: distinguere 404 da 403
295
+ // permetterebbe di enumerare gli id altrui.
296
+ r.get('/speak/status/:utteranceId', async (req, res) => {
297
+ const resolved = await originOr401(req, res); if (!resolved) return undefined;
298
+ const rc = receiptStore.get(resolved.origin, String(req.params.utteranceId || ''));
299
+ if (!rc) return send(res, 404, { status: 'unknown', reason: 'not-found' });
300
+ return send(res, 200, rc);
301
+ });
302
+
303
+ // Status federato: POST e non GET perche' la cella attestata del chiamante
304
+ // viaggia nel body costruito dal dispatcher. Non e' una API per browser: la
305
+ // whitelist Hydra e la prova hop rendono il percorso raggiungibile solo da un
306
+ // origin node gia' autenticato. Il target riapplica l'ACL prima di rivelare
307
+ // anche il receipt redatto.
308
+ r.post('/speak/status', async (req, res) => {
309
+ const resolved = await originOr401(req, res); if (!resolved) return undefined;
310
+ if (resolved.trust !== 'federated') return send(res, 403, { status: 'refused', reason: 'federated-only' });
311
+ const parsed = parseRemoteStatusBody(req.body);
312
+ if (parsed.error) return send(res, 400, { status: 'refused', reason: parsed.error });
313
+ if (parsed.target !== localNodeId()) return send(res, 403, { status: 'refused', reason: 'not-local-target' });
314
+ if (acl && typeof acl.allows === 'function') {
315
+ const verdict = acl.allows({ trust: resolved.trust, origin: resolved.origin, visited: resolved.visited });
316
+ if (!verdict || verdict.allowed !== true) return send(res, 422, { status: 'refused', reason: 'acl' });
317
+ }
318
+ const rc = receiptStore.get(resolved.origin, parsed.utteranceId);
319
+ if (!rc) return send(res, 404, { status: 'unknown', reason: 'not-found' });
320
+ return send(res, 200, rc);
321
+ });
322
+
323
+ // I gruppi sono locali all'origine e non fanno parte della whitelist
324
+ // federata: la loro espansione avviene qui, quindi ogni endpoint riceve un
325
+ // comando /audio/speak esatto e applica autonomamente consenso/ACL/READONLY.
326
+ r.post('/groups/speak', async (req, res) => {
327
+ const resolved = await originOr401(req, res); if (!resolved) return undefined;
328
+ if (resolved.trust !== 'local-bridge') return send(res, 403, { status: 'refused', reason: 'local-bridge-required' });
329
+ const parsed = parseGroupSpeakBody(req.body);
330
+ if (parsed.error) return send(res, 400, { status: 'refused', reason: parsed.error });
331
+ if (readonly()) return send(res, 422, { status: 'refused', reason: 'readonly' });
332
+ if (parsed.utteranceId && receiptStore.get(resolved.origin, parsed.utteranceId)) {
333
+ return send(res, 422, { status: 'refused', reason: 'utterance-collision' });
334
+ }
335
+ const out = await groupSpeaker.speakGroup({ origin: resolved.origin, ...parsed });
336
+ const code = out.status === 'refused' ? 422 : 200;
337
+ return send(res, code, out);
338
+ });
339
+
340
+ r.get('/groups/status/:utteranceId', async (req, res) => {
341
+ const resolved = await originOr401(req, res); if (!resolved) return undefined;
342
+ if (resolved.trust !== 'local-bridge') return send(res, 403, { status: 'refused', reason: 'local-bridge-required' });
343
+ const utteranceId = String(req.params.utteranceId || '');
344
+ if (!UTTERANCE_ID_RE.test(utteranceId)) return send(res, 400, { status: 'refused', reason: 'invalid-utterance' });
345
+ const receipt = groupSpeaker.getStatus({ origin: resolved.origin, utteranceId });
346
+ if (!receipt) return send(res, 404, { status: 'unknown', reason: 'not-found' });
347
+ return send(res, 200, receipt);
348
+ });
349
+
350
+ r.post('/groups/stop', async (req, res) => {
351
+ const resolved = await originOr401(req, res); if (!resolved) return undefined;
352
+ if (resolved.trust !== 'local-bridge') return send(res, 403, { status: 'refused', reason: 'local-bridge-required' });
353
+ if (!onlyKeys(req.body, new Set(['utteranceId'])) || typeof req.body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(req.body.utteranceId)) {
354
+ return send(res, 400, { status: 'refused', reason: 'invalid-utterance' });
355
+ }
356
+ const out = await groupSpeaker.stopGroup({ origin: resolved.origin, utteranceId: req.body.utteranceId });
357
+ return send(res, out.status === 'refused' ? 422 : 200, out);
358
+ });
359
+
360
+ r.use((err, _req, res, _next) => {
361
+ if (err && (err.type === 'entity.too.large' || err.status === 413)) {
362
+ return res.status(413).json({ error: 'body troppo grande' });
363
+ }
364
+ if (err instanceof SyntaxError) return res.status(400).json({ error: 'JSON non valido' });
365
+ return res.status(err.status || 400).json({ error: 'richiesta audio non valida' });
366
+ });
367
+
368
+ return r;
369
+ }
370
+
371
+ module.exports = {
372
+ audioRoutes, UTTERANCE_ID_RE, LANG_RE, VOICE_RE,
373
+ parseSpeakBody, parseStopBody, parseGroupSpeakBody,
374
+ };
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+ // lib/audio/speak.js — catena di gate LATO TARGET: la decisione di emettere
3
+ // suono la prende il nodo che possiede l'altoparlante, sempre, anche quando la
4
+ // richiesta e' gia' passata da una route federata autorizzata.
5
+ //
6
+ // Ordine dei gate, deliberato:
7
+ // 0. testo valido (1..320) — nessun effetto collaterale
8
+ // 1. target esatto e uguale a QUESTO nodo — un nodo parla solo per se'
9
+ // 2. READONLY — `speak` e' una mutazione: ha
10
+ // un effetto fisico osservabile, quindi in sola lettura si rifiuta
11
+ // 3. ACL sull'origine
12
+ // 4. consenso audio locale (default OFF)
13
+ // 5. idempotenza per utteranceId — PRIMA del rate limit, cosi' un
14
+ // retry identico non consuma budget e non produce una seconda voce
15
+ // 6. rate limit dedicato
16
+ // 7. adapter/coda presenti — senza adapter si dice no, non
17
+ // si finge un `accepted`
18
+ // 8. accodamento
19
+ //
20
+ // I gate 3 e 4 stanno prima dell'idempotenza di proposito: un rifiuto per ACL o
21
+ // consenso deve restare un rifiuto anche al secondo tentativo, non trasformarsi
22
+ // in un receipt riusato.
23
+ const INSTANCE_ID_RE = /^[a-f0-9]{32}$/i;
24
+ const TEXT_MAX = 320;
25
+
26
+ function isValidInstance(v) {
27
+ return typeof v === 'string' && INSTANCE_ID_RE.test(v);
28
+ }
29
+
30
+ // handleSpeak(): ritorna SEMPRE uno stato per endpoint, mai un booleano.
31
+ // `deps.receipt(status, opts)` registra e ritorna il receipt redatto.
32
+ function handleSpeak(input, deps) {
33
+ const opts = input || {};
34
+ const d = deps || {};
35
+ const { target, origin } = opts;
36
+
37
+ if (typeof opts.text !== 'string' || opts.text.length < 1 || opts.text.length > TEXT_MAX) {
38
+ return { status: 'refused', reason: 'invalid-text' };
39
+ }
40
+ if (!isValidInstance(target)) return { status: 'refused', reason: 'invalid-target' };
41
+ // Un target diverso da questo nodo non e' affar suo: il nodo non ritrasmette
42
+ // e non fa da relay applicativo per la voce di un altro.
43
+ if (typeof d.localNodeId === 'function' && target !== d.localNodeId()) {
44
+ return { status: 'refused', reason: 'not-local-target' };
45
+ }
46
+ if (typeof d.readonly === 'function' && d.readonly()) {
47
+ return d.receipt('refused', { reason: 'readonly', target, origin, utteranceId: opts.utteranceId });
48
+ }
49
+ if (typeof d.acl === 'function') {
50
+ const verdict = d.acl({ trust: opts.trust, origin, visited: opts.visited });
51
+ if (!verdict || verdict.allowed !== true) {
52
+ // La reason resta generica verso l'esterno: la ragione precisa del rifiuto
53
+ // descriverebbe la topologia del nodo a chi non e' autorizzato a vederla.
54
+ return d.receipt('refused', { reason: 'acl', target, origin, utteranceId: opts.utteranceId });
55
+ }
56
+ }
57
+ if (typeof d.consent === 'function' && d.consent() !== true) {
58
+ return d.receipt('refused', { reason: 'consent', target, origin, utteranceId: opts.utteranceId });
59
+ }
60
+ if (opts.utteranceId && typeof d.findReceipt === 'function') {
61
+ const found = d.findReceipt(opts.utteranceId, origin, target);
62
+ if (found === 'collision') {
63
+ const ts = typeof d.now === 'function' ? d.now() : Date.now();
64
+ return {
65
+ status: 'refused', reason: 'utterance-collision', utteranceId: opts.utteranceId,
66
+ origin: { node: origin && origin.node, cell: origin && origin.cell }, target, timestamp: ts,
67
+ };
68
+ }
69
+ if (found) return found;
70
+ }
71
+ if (d.rateLimiter) {
72
+ const rl = d.rateLimiter.check({ origin, target, urgency: opts.urgency });
73
+ if (!rl.allowed) {
74
+ return d.receipt('refused', { reason: 'rate-limit', target, origin, utteranceId: opts.utteranceId });
75
+ }
76
+ }
77
+ if (!d.queue || typeof d.queue.enqueue !== 'function') {
78
+ return d.receipt('refused', { reason: 'no-adapter', target, origin, utteranceId: opts.utteranceId });
79
+ }
80
+ // Il receipt nasce PRIMA dell'accodamento: la coda aggiornera' questo stesso
81
+ // utteranceId quando l'adapter conferma l'avvio, fallisce o va in timeout.
82
+ const receipt = d.receipt('accepted', { target, origin, utteranceId: opts.utteranceId });
83
+ const admitted = d.queue.enqueue({
84
+ utteranceId: receipt.utteranceId,
85
+ text: opts.text, lang: opts.lang, voice: opts.voice, urgency: opts.urgency,
86
+ });
87
+ if (admitted.status !== 'accepted') {
88
+ return (d.updateReceipt && d.updateReceipt(receipt.utteranceId, admitted.status, admitted.reason))
89
+ || { ...receipt, status: admitted.status, ...(admitted.reason ? { reason: admitted.reason } : {}) };
90
+ }
91
+ // La coda puo' aver gia' concluso in modo sincrono (adapter fake nei test,
92
+ // fallimento immediato di spawn): si rilegge il receipt invece di riportare
93
+ // uno stato ormai superato.
94
+ return (d.readReceipt && d.readReceipt(receipt.utteranceId, origin)) || receipt;
95
+ }
96
+
97
+ // handleStop(): comando di controllo, non sintesi. Lo stop LOCALE e' sovrano e
98
+ // non dipende dalla rete: un endpoint deve poter tacere anche offline. Uno stop
99
+ // remoto per utteranceId vale solo per gli enunciati del chiamante.
100
+ function handleStop(input, deps) {
101
+ const opts = input || {};
102
+ const d = deps || {};
103
+ const { target, origin } = opts;
104
+ if (!isValidInstance(target)) return { status: 'refused', reason: 'invalid-target' };
105
+ if (typeof d.localNodeId === 'function' && target !== d.localNodeId()) {
106
+ return { status: 'refused', reason: 'not-local-target' };
107
+ }
108
+ // READONLY non blocca lo stop: fermare una voce e' sempre permesso. Impedirlo
109
+ // significherebbe che un nodo in sola lettura non puo' zittirsi.
110
+ if (opts.utteranceId) {
111
+ if (typeof d.findReceipt === 'function') {
112
+ const found = d.findReceipt(opts.utteranceId, origin, target);
113
+ if (!found || found === 'collision') {
114
+ return { status: 'unknown', reason: 'utterance-not-found', utteranceId: opts.utteranceId };
115
+ }
116
+ }
117
+ const acted = d.queue && typeof d.queue.stop === 'function' ? d.queue.stop(opts.utteranceId) : false;
118
+ if (d.updateReceipt && acted) d.updateReceipt(opts.utteranceId, 'refused', 'stopped');
119
+ return { status: 'accepted', utteranceId: opts.utteranceId, stopped: acted === true };
120
+ }
121
+ const acted = d.queue && typeof d.queue.stopAll === 'function' ? d.queue.stopAll() : false;
122
+ return { status: 'accepted', stopped: acted === true };
123
+ }
124
+
125
+ module.exports = { handleSpeak, handleStop, isValidInstance, INSTANCE_ID_RE, TEXT_MAX };
@@ -60,7 +60,11 @@ const ALIBABA_PI_MODELS = Object.freeze([
60
60
  ]);
61
61
 
62
62
  const CUSTOM_KEYS = ['displayName', 'protocol', 'baseUrl', 'envKey', 'providerId'];
63
- const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', ...CUSTOM_KEYS]);
63
+ const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', 'credentialSourcePolicy', ...CUSTOM_KEYS]);
64
+ // Explicit credential source policy. Default 'auto' preserves the legacy
65
+ // resolution order (runtime -> store -> shell -> key files -> legacy) so a
66
+ // pre-WP1 fleet.json migrates no-op: no existing cell changes resolution.
67
+ const CREDENTIAL_SOURCES = Object.freeze(['environment', 'nexuscrew-store', 'auto']);
64
68
  const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi', agy: 'Agy', shell: 'Shell' });
65
69
  const PROVIDER_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
66
70
 
@@ -167,7 +171,13 @@ function normalizeManagedSpec(value) {
167
171
  const permissionPolicy = value.permissionPolicy === undefined ? (profile.client === 'claude' ? 'unsafe' : 'standard') : value.permissionPolicy;
168
172
  if (permissionPolicy !== 'standard' && permissionPolicy !== 'unsafe') return null;
169
173
  if ((value.client === 'pi' || value.client === 'shell') && permissionPolicy !== 'standard') return null;
174
+ const credentialSourcePolicy = value.credentialSourcePolicy === undefined ? 'auto' : value.credentialSourcePolicy;
175
+ if (!CREDENTIAL_SOURCES.includes(credentialSourcePolicy)) return null;
170
176
  const out = { client: profile.client, provider: profile.provider, model, permissionPolicy };
177
+ // 'auto' is the default and stays ABSENT from the normalized spec, so a legacy
178
+ // fleet.json (no credentialSourcePolicy) migrates truly no-op: no added field,
179
+ // no change in resolution. Explicit environment|nexuscrew-store are recorded.
180
+ if (credentialSourcePolicy !== 'auto') out.credentialSourcePolicy = credentialSourcePolicy;
171
181
  if (profile.credentialProfile) out.credentialProfile = profile.credentialProfile;
172
182
  if (profile.credentialEnv) {
173
183
  const envKey = typeof value.envKey === 'string' && value.envKey.trim()
@@ -416,10 +426,20 @@ function credentialSources(cfg, home) {
416
426
  function credential(profile, spec, cfg, home) {
417
427
  if (profile.auth === 'login' || profile.auth === 'none') return { envKey: profile.auth, value: '', source: profile.auth };
418
428
  const envKey = profile.auth === 'dynamic' ? spec.envKey : profile.auth;
429
+ const policy = spec && CREDENTIAL_SOURCES.includes(spec.credentialSourcePolicy) ? spec.credentialSourcePolicy : 'auto';
419
430
  const sources = credentialSources(cfg, home);
420
431
  // The fixed shell file is already the user's environment source. Values are
421
432
  // consumed only in memory and passed to the selected child; never persisted
422
433
  // in fleet.json, service files, API responses or logs.
434
+ if (policy === 'environment') {
435
+ if (sources.runtime[envKey]) return { envKey, value: sources.runtime[envKey], source: 'environment' };
436
+ return { envKey, value: '', source: 'missing' };
437
+ }
438
+ if (policy === 'nexuscrew-store') {
439
+ if (sources.local[envKey]) return { envKey, value: sources.local[envKey], source: 'nexuscrew-store' };
440
+ return { envKey, value: '', source: 'missing' };
441
+ }
442
+ // auto: legacy resolution order (runtime -> store -> shell -> keys -> legacy).
423
443
  if (sources.runtime[envKey]) return { envKey, value: sources.runtime[envKey], source: 'environment' };
424
444
  if (sources.local[envKey]) return { envKey, value: sources.local[envKey], source: 'local' };
425
445
  if (sources.shell[envKey]) return { envKey, value: sources.shell[envKey], source: 'compatibility' };
@@ -430,6 +450,31 @@ function credential(profile, spec, cfg, home) {
430
450
  return { envKey, value: '', source: 'missing' };
431
451
  }
432
452
 
453
+ // The profile's "owned" env set. When the credential source is the local store,
454
+ // the child environment must not inherit any of these from the runtime: the
455
+ // entire set is enumerated and neutralized (unset), never just envKey. For
456
+ // Anthropic-compatible (claude.*) clients the set is the ANTHROPIC_* triple;
457
+ // every other client keeps the single profile envKey.
458
+ function credentialEnvNeutralizeSet(profile) {
459
+ if (!profile) return [];
460
+ if (profile.client === 'claude') return ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY'];
461
+ const auth = profile.auth;
462
+ const envKey = auth && auth !== 'dynamic' && auth !== 'login' && auth !== 'none'
463
+ ? auth : (profile.defaultEnvKey || '');
464
+ return envKey ? [envKey] : [];
465
+ }
466
+
467
+ // Apply nexuscrew-store neutralization to a composed child env: keys of the
468
+ // profile set that carry no NexusCrew-injected value are UNSET (deleted), never
469
+ // left as an empty string. 'auto'/'environment' are unchanged (legacy behavior).
470
+ function applyStoreNeutralization(env, spec, profile) {
471
+ if (!env || !spec || spec.credentialSourcePolicy !== 'nexuscrew-store') return env;
472
+ for (const k of credentialEnvNeutralizeSet(profile)) {
473
+ if (env[k] === undefined || env[k] === '') delete env[k];
474
+ }
475
+ return env;
476
+ }
477
+
433
478
  let ollamaCache = { at: 0, models: [] };
434
479
  async function discoverOllamaModels(opts = {}) {
435
480
  const now = Date.now(); const ttl = opts.ttlMs === undefined ? 30000 : opts.ttlMs;
@@ -527,6 +572,7 @@ function describeManaged(spec, cfg = {}) {
527
572
  credentialProfile: normalized.credentialProfile || '', model: normalized.model,
528
573
  permissionPolicy: normalized.permissionPolicy, protocol: normalized.protocol || profile.protocol,
529
574
  endpoint: normalized.baseUrl || profile.endpoint || '', auth: cred.envKey, authConfigured,
575
+ credentialSourcePolicy: normalized.credentialSourcePolicy || 'auto',
530
576
  credentialSource: authConfigured ? cred.source : 'missing',
531
577
  configured, models: [...(profile.models || [])], defaultModel: profile.model || '',
532
578
  binary: binary || '', displayName: normalized.displayName || profile.label,
@@ -814,6 +860,10 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
814
860
  if (cell?.prompt) args.push('--prompt-interactive');
815
861
  }
816
862
  if (spec.client !== 'shell' && cell?.prompt) args.push(cell.prompt);
863
+ // nexuscrew-store source: neutralize the profile's env set in the composed
864
+ // child env (unset, never empty), so the runtime cannot leak credentials that
865
+ // the local store is meant to own.
866
+ applyStoreNeutralization(env, spec, profile);
817
867
  let command = info.binary;
818
868
  if (needsExplicitNode(info.binary, cfg.platform || process.platform, cfg.env || process.env)) {
819
869
  command = cfg.nodeExecPath || process.execPath;
@@ -841,10 +891,11 @@ function publicCatalog() {
841
891
  module.exports = {
842
892
  CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, ALIBABA_TOKEN_PLAN_MODELS,
843
893
  ALIBABA_CODEX_MODELS, ALIBABA_TOKEN_PLAN_CONTEXT, ALIBABA_PI_MODELS,
844
- CLIENT_LABELS, normalizeManagedSpec,
894
+ CLIENT_LABELS, normalizeManagedSpec, profileFor,
845
895
  defaultDefinitions, defaultShellEngine, defaultAgyEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
846
896
  discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
847
897
  providerKeyPaths, parseProviderKeyFiles, credentialSources, credential,
898
+ credentialEnvNeutralizeSet, applyStoreNeutralization,
848
899
  ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell,
849
900
  shellLoginArgs, shellConfiguredCommandArgs, ENV_KEY_RE,
850
901
  };
@@ -13,6 +13,12 @@ function disabled(reason) {
13
13
  }
14
14
 
15
15
  async function selectProvider(cfg = {}) {
16
+ // Seam esplicito per i test: permette di far girare il server REALE con uno
17
+ // stato Fleet controllato, senza tmux ne' processi. Esiste perche' i confini
18
+ // che dipendono dalle celle attive (l'origine di Audio Share) vanno provati
19
+ // sul server vero: un test che inietta le dipendenze nel router non si
20
+ // accorgerebbe mai di un cablaggio sbagliato.
21
+ if (cfg.fleetSeam) return { mode: 'seam', reason: 'fleet iniettata (seam di test)', fleet: cfg.fleetSeam };
16
22
  if (cfg.fleetEnabled === false) return disabled('fleet disabilitata (fleetEnabled=false)');
17
23
  if (cfg.builtinEnabled === false) return disabled('fleet builtin disabilitata (builtinEnabled=false)');
18
24
  const fleet = await createBuiltinFleet({ ...cfg, fleetProviderReason: 'NexusCrew builtin fleet' });