@mmmbuto/nexuscrew 0.8.39 → 0.8.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -1
- package/README.md +2 -1
- package/frontend/dist/assets/index-CrZBnpKn.css +32 -0
- package/frontend/dist/assets/index-CwZySkm2.js +93 -0
- package/frontend/dist/assets/index-hq-2ORFQ.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/audio/acl.js +65 -0
- package/lib/audio/adapters.js +233 -0
- package/lib/audio/bridge-auth.js +176 -0
- package/lib/audio/capability.js +40 -0
- package/lib/audio/consent.js +64 -0
- package/lib/audio/dispatch.js +154 -0
- package/lib/audio/group-receipt.js +119 -0
- package/lib/audio/group-speak.js +146 -0
- package/lib/audio/groups.js +121 -0
- package/lib/audio/origin.js +128 -0
- package/lib/audio/queue.js +191 -0
- package/lib/audio/rate-limit.js +71 -0
- package/lib/audio/receipt.js +146 -0
- package/lib/audio/routes.js +374 -0
- package/lib/audio/speak.js +125 -0
- package/lib/cli/doctor.js +37 -0
- package/lib/config.js +7 -0
- package/lib/fleet/builtin.js +2 -1
- package/lib/fleet/launch.js +16 -0
- package/lib/fleet/managed.js +53 -2
- package/lib/fleet/provider.js +6 -0
- package/lib/fleet/runtime.js +14 -1
- package/lib/mcp/server.js +27 -3
- package/lib/mcp/tools.js +142 -0
- package/lib/nodes/tunnel-supervisor.js +33 -7
- package/lib/nodes/tunnel.js +21 -0
- package/lib/proxy/federation.js +41 -11
- package/lib/proxy/hop-proof.js +50 -0
- package/lib/server.js +97 -3
- package/lib/settings/routes.js +150 -4
- package/lib/tmux/lifecycle.js +24 -3
- package/package.json +1 -1
- package/skills/nexuscrew-agent/SKILL.md +30 -1
package/lib/fleet/managed.js
CHANGED
|
@@ -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
|
};
|
package/lib/fleet/provider.js
CHANGED
|
@@ -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' });
|
package/lib/fleet/runtime.js
CHANGED
|
@@ -21,7 +21,7 @@ const {
|
|
|
21
21
|
} = require('./managed.js');
|
|
22
22
|
const {
|
|
23
23
|
httpError, minimalEnv, tmuxExec,
|
|
24
|
-
composeClientInvocation,
|
|
24
|
+
composeClientInvocation, alternateScreenArgs,
|
|
25
25
|
waitAlive, waitStablePane, injectPrompt,
|
|
26
26
|
redactSecrets, sanitizeEarlyDiagnostic,
|
|
27
27
|
} = require('./launch.js');
|
|
@@ -246,6 +246,19 @@ function createBuiltinRuntime(ctx) {
|
|
|
246
246
|
throw httpError(dup ? 409 : 500, why, null,
|
|
247
247
|
{ phase: 'new-session', code: dup ? 'SESSION_DUPLICATE' : 'NEW_SESSION_FAILED' });
|
|
248
248
|
}
|
|
249
|
+
// Best effort per la sola sessione appena creata: non tocchiamo mai
|
|
250
|
+
// ~/.tmux.conf, opzioni globali o sessioni preesistenti. Un tmux che non
|
|
251
|
+
// accetta una delle opzioni non deve impedire l'avvio della cella.
|
|
252
|
+
const alternateSteps = alternateScreenArgs(cell.tmuxSession, cfg.alternateScreen);
|
|
253
|
+
if (alternateSteps) {
|
|
254
|
+
for (const args of alternateSteps) {
|
|
255
|
+
const configured = await tmuxExec(tmuxBin, args, { env: minimalEnv(), timeoutMs: 2000 });
|
|
256
|
+
if (configured.err) {
|
|
257
|
+
const log = typeof cfg.log === 'function' ? cfg.log : console.warn;
|
|
258
|
+
log(`fleet alternate-screen setup failed for ${cell.id}; continuing`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
249
262
|
const createdIds = create.stdout.trim().split('\n')[0].split('\t');
|
|
250
263
|
const sessionId = /^\$[0-9]+$/.test(createdIds[0] || '') ? createdIds[0] : '';
|
|
251
264
|
const windowId = /^@[0-9]+$/.test(createdIds[1] || '') ? createdIds[1] : '';
|
package/lib/mcp/server.js
CHANGED
|
@@ -20,10 +20,12 @@
|
|
|
20
20
|
const readline = require('node:readline');
|
|
21
21
|
const crypto = require('node:crypto');
|
|
22
22
|
const os = require('node:os');
|
|
23
|
+
const path = require('node:path');
|
|
23
24
|
const { execFile } = require('node:child_process');
|
|
24
25
|
const { loadConfig } = require('../config.js');
|
|
25
26
|
const { readTokenSafe } = require('../auth/token.js');
|
|
26
27
|
const { isValidSession } = require('../files/store.js');
|
|
28
|
+
const { loadOrCreateBridgeSecret, signedHeaders } = require('../audio/bridge-auth.js');
|
|
27
29
|
const VERSION = require('../../package.json').version;
|
|
28
30
|
const MCP_COMPANIONS = require('../../mcp-companions.json');
|
|
29
31
|
const { TOOLS, IDENTITY_CODE, IDENTITY_REMEDIATION } = require('./tools.js');
|
|
@@ -162,17 +164,39 @@ function createMcpServer(opts = {}) {
|
|
|
162
164
|
throw new Error('token NexusCrew non leggibile: il server e\' inizializzato? (nexuscrew init)');
|
|
163
165
|
}
|
|
164
166
|
|
|
165
|
-
|
|
167
|
+
// Segreto del bridge: file 0600 accanto al token, distinto dal token stesso.
|
|
168
|
+
// Serve dove il Bearer non basta — Audio Share — perche' il Bearer prova solo
|
|
169
|
+
// "qualcuno in loopback ce l'ha", non "questa e' la cella X".
|
|
170
|
+
const bridgeKeyPath = () => cfg.audioBridgeSecretPath || path.join(path.dirname(cfg.tokenPath), 'audio-bridge.key');
|
|
171
|
+
|
|
172
|
+
// `opts.signedSession` attiva la firma HMAC del bridge: copre metodo, path,
|
|
173
|
+
// sessione, timestamp, nonce e i BYTE del body effettivamente inviati. Per
|
|
174
|
+
// questo il payload viene serializzato UNA volta sola e riusato: firmare un
|
|
175
|
+
// JSON e spedirne un altro, anche solo con un ordine di chiavi diverso,
|
|
176
|
+
// produrrebbe una firma valida per un corpo che il server non vede mai.
|
|
177
|
+
async function api(method, apiPath, body, opts = {}) {
|
|
166
178
|
const token = readToken();
|
|
179
|
+
const payload = body !== undefined ? JSON.stringify(body) : undefined;
|
|
180
|
+
let signed = {};
|
|
181
|
+
if (opts.signedSession) {
|
|
182
|
+
try {
|
|
183
|
+
signed = signedHeaders(loadOrCreateBridgeSecret(bridgeKeyPath()), {
|
|
184
|
+
method, path: apiPath, session: opts.signedSession, rawBody: payload === undefined ? '' : payload,
|
|
185
|
+
});
|
|
186
|
+
} catch (_) {
|
|
187
|
+
throw new Error('segreto bridge audio non leggibile: il server e\' inizializzato? (nexuscrew init)');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
167
190
|
let r;
|
|
168
191
|
try {
|
|
169
192
|
r = await fetchImpl(`${baseUrl}${apiPath}`, {
|
|
170
193
|
method,
|
|
171
194
|
headers: {
|
|
172
195
|
authorization: `Bearer ${token}`,
|
|
173
|
-
...
|
|
196
|
+
...signed,
|
|
197
|
+
...(payload !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
174
198
|
},
|
|
175
|
-
...(
|
|
199
|
+
...(payload !== undefined ? { body: payload } : {}),
|
|
176
200
|
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
177
201
|
});
|
|
178
202
|
} catch (e) {
|
package/lib/mcp/tools.js
CHANGED
|
@@ -496,6 +496,148 @@ const TOOLS = [
|
|
|
496
496
|
return out;
|
|
497
497
|
},
|
|
498
498
|
},
|
|
499
|
+
{
|
|
500
|
+
name: 'nc_speak',
|
|
501
|
+
description: 'Speak un utterance audio verso un nodo target (exact instanceId). Ritorna un receipt caller-scoped con status per-endpoint (refused|unreachable|accepted|spoken|unknown) e attribution; MAI text/lang/voice o aggregate success. L\'origin genera un utteranceId immutabile se omesso.',
|
|
502
|
+
inputSchema: {
|
|
503
|
+
type: 'object',
|
|
504
|
+
properties: {
|
|
505
|
+
target: { type: 'string', description: 'exact target node instanceId (32 hex)' },
|
|
506
|
+
text: { type: 'string', description: 'utterance text (bounded)' },
|
|
507
|
+
lang: { type: 'string', description: 'BCP-47 lang hint (optional)' },
|
|
508
|
+
voice: { type: 'string', description: 'voice id hint (optional)' },
|
|
509
|
+
urgency: { type: 'string', enum: ['normal', 'high'], description: 'high urgency non bypassa i rate limit' },
|
|
510
|
+
utteranceId: { type: 'string', description: 'immutable id (generato se omesso)' },
|
|
511
|
+
},
|
|
512
|
+
required: ['target', 'text'],
|
|
513
|
+
},
|
|
514
|
+
async handler(args, ctx) {
|
|
515
|
+
const target = argString(args, 'target', { required: true, max: 32 });
|
|
516
|
+
const text = argString(args, 'text', { required: true, max: 320 });
|
|
517
|
+
const lang = argString(args, 'lang', { max: 35 });
|
|
518
|
+
const voice = argString(args, 'voice', { max: 64 });
|
|
519
|
+
if (args.urgency !== undefined && args.urgency !== 'normal' && args.urgency !== 'high') {
|
|
520
|
+
throw new Error('urgency deve essere "normal" o "high"');
|
|
521
|
+
}
|
|
522
|
+
const urgency = args.urgency === 'high' ? 'high' : 'normal';
|
|
523
|
+
const utteranceId = argString(args, 'utteranceId', { max: 128 });
|
|
524
|
+
const identity = await ctx.identity();
|
|
525
|
+
const session = requireSession(identity.session, 'nc_speak', identity.code);
|
|
526
|
+
// La sessione NON viaggia nel body: sarebbe una dichiarazione, e una
|
|
527
|
+
// dichiarazione e' falsificabile da chiunque abbia il token della UI.
|
|
528
|
+
// Viaggia dentro la firma HMAC del bridge, che il server verifica prima di
|
|
529
|
+
// attribuire l'enunciato a una cella.
|
|
530
|
+
const payload = { target, text, urgency, ...(lang ? { lang } : {}), ...(voice ? { voice } : {}), ...(utteranceId ? { utteranceId } : {}) };
|
|
531
|
+
const receipt = await ctx.api('POST', '/api/audio/speak', payload, { signedSession: session });
|
|
532
|
+
return { receipt };
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
name: 'nc_speak_status',
|
|
537
|
+
description: 'Interroga lo status di uno speak per utteranceId (read-only). Ritorna il receipt caller-scoped redacted (status/reason/attribution); MAI text/lang/voice/secret.',
|
|
538
|
+
inputSchema: {
|
|
539
|
+
type: 'object',
|
|
540
|
+
properties: { utteranceId: { type: 'string', description: 'utterance id da interrogare' } },
|
|
541
|
+
required: ['utteranceId'],
|
|
542
|
+
},
|
|
543
|
+
annotations: { readOnlyHint: true },
|
|
544
|
+
async handler(args, ctx) {
|
|
545
|
+
const utteranceId = argString(args, 'utteranceId', { required: true, max: 128 });
|
|
546
|
+
const identity = await ctx.identity();
|
|
547
|
+
const session = requireSession(identity.session, 'nc_speak_status', identity.code);
|
|
548
|
+
const receipt = await ctx.api('GET', `/api/audio/speak/status/${encodeURIComponent(utteranceId)}`, undefined, { signedSession: session });
|
|
549
|
+
return { receipt };
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
name: 'nc_speak_stop',
|
|
554
|
+
description: 'Ferma un enunciato su un nodo target. Con utteranceId ferma quello specifico (solo se e\' del chiamante), senza utteranceId ferma tutto sul target. Lo stop locale del nodo resta sovrano e non dipende da questa chiamata.',
|
|
555
|
+
inputSchema: {
|
|
556
|
+
type: 'object',
|
|
557
|
+
properties: {
|
|
558
|
+
target: { type: 'string', description: 'exact target node instanceId (32 hex)' },
|
|
559
|
+
utteranceId: { type: 'string', description: 'enunciato da fermare; omesso = tutti gli enunciati del target' },
|
|
560
|
+
},
|
|
561
|
+
required: ['target'],
|
|
562
|
+
},
|
|
563
|
+
async handler(args, ctx) {
|
|
564
|
+
const target = argString(args, 'target', { required: true, max: 32 });
|
|
565
|
+
const utteranceId = argString(args, 'utteranceId', { max: 128 });
|
|
566
|
+
const identity = await ctx.identity();
|
|
567
|
+
const session = requireSession(identity.session, 'nc_speak_stop', identity.code);
|
|
568
|
+
const receipt = await ctx.api('POST', '/api/audio/stop', {
|
|
569
|
+
target, ...(utteranceId ? { utteranceId } : {}),
|
|
570
|
+
}, { signedSession: session });
|
|
571
|
+
return { receipt };
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
name: 'nc_speak_group',
|
|
576
|
+
description: 'Speak verso un gruppo audio locale nominato. Il gruppo espande solo instanceId esatti: primary-failover prova un endpoint alla volta, fanout e esplicito. Ritorna subito un receipt per endpoint; usa nc_speak_group_status per gli aggiornamenti. Un gruppo non concede consenso e non aggira liveness/ACL del target.',
|
|
577
|
+
inputSchema: {
|
|
578
|
+
type: 'object',
|
|
579
|
+
properties: {
|
|
580
|
+
group: { type: 'string', description: 'nome del gruppo audio locale (a-z, 0-9, trattini)' },
|
|
581
|
+
text: { type: 'string', description: 'utterance text (bounded)' },
|
|
582
|
+
lang: { type: 'string', description: 'BCP-47 lang hint (optional)' },
|
|
583
|
+
voice: { type: 'string', description: 'voice id hint (optional)' },
|
|
584
|
+
urgency: { type: 'string', enum: ['normal', 'high'], description: 'high urgency non bypassa i rate limit' },
|
|
585
|
+
utteranceId: { type: 'string', description: 'immutable id (generato se omesso)' },
|
|
586
|
+
},
|
|
587
|
+
required: ['group', 'text'],
|
|
588
|
+
},
|
|
589
|
+
async handler(args, ctx) {
|
|
590
|
+
const group = argString(args, 'group', { required: true, max: 32 });
|
|
591
|
+
const text = argString(args, 'text', { required: true, max: 320 });
|
|
592
|
+
const lang = argString(args, 'lang', { max: 35 });
|
|
593
|
+
const voice = argString(args, 'voice', { max: 64 });
|
|
594
|
+
if (args.urgency !== undefined && args.urgency !== 'normal' && args.urgency !== 'high') {
|
|
595
|
+
throw new Error('urgency deve essere "normal" o "high"');
|
|
596
|
+
}
|
|
597
|
+
const urgency = args.urgency === 'high' ? 'high' : 'normal';
|
|
598
|
+
const utteranceId = argString(args, 'utteranceId', { max: 128 });
|
|
599
|
+
const identity = await ctx.identity();
|
|
600
|
+
const session = requireSession(identity.session, 'nc_speak_group', identity.code);
|
|
601
|
+
const receipt = await ctx.api('POST', '/api/audio/groups/speak', {
|
|
602
|
+
group, text, urgency,
|
|
603
|
+
...(lang ? { lang } : {}), ...(voice ? { voice } : {}), ...(utteranceId ? { utteranceId } : {}),
|
|
604
|
+
}, { signedSession: session });
|
|
605
|
+
return { receipt };
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
name: 'nc_speak_group_status',
|
|
610
|
+
description: 'Interroga lo status per-endpoint di un gruppo audio (read-only). Il receipt e caller-scoped e non contiene testo, lingua, voce o successi aggregati.',
|
|
611
|
+
inputSchema: {
|
|
612
|
+
type: 'object',
|
|
613
|
+
properties: { utteranceId: { type: 'string', description: 'utterance id del gruppo' } },
|
|
614
|
+
required: ['utteranceId'],
|
|
615
|
+
},
|
|
616
|
+
annotations: { readOnlyHint: true },
|
|
617
|
+
async handler(args, ctx) {
|
|
618
|
+
const utteranceId = argString(args, 'utteranceId', { required: true, max: 128 });
|
|
619
|
+
const identity = await ctx.identity();
|
|
620
|
+
const session = requireSession(identity.session, 'nc_speak_group_status', identity.code);
|
|
621
|
+
const receipt = await ctx.api('GET', `/api/audio/groups/status/${encodeURIComponent(utteranceId)}`, undefined, { signedSession: session });
|
|
622
|
+
return { receipt };
|
|
623
|
+
},
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
name: 'nc_speak_group_stop',
|
|
627
|
+
description: 'Ferma un gruppo audio per utteranceId: blocca i candidati non ancora tentati e invia Stop a ogni endpoint gia ammesso. Lo Stop locale del nodo resta sovrano anche offline.',
|
|
628
|
+
inputSchema: {
|
|
629
|
+
type: 'object',
|
|
630
|
+
properties: { utteranceId: { type: 'string', description: 'utterance id del gruppo da fermare' } },
|
|
631
|
+
required: ['utteranceId'],
|
|
632
|
+
},
|
|
633
|
+
async handler(args, ctx) {
|
|
634
|
+
const utteranceId = argString(args, 'utteranceId', { required: true, max: 128 });
|
|
635
|
+
const identity = await ctx.identity();
|
|
636
|
+
const session = requireSession(identity.session, 'nc_speak_group_stop', identity.code);
|
|
637
|
+
const receipt = await ctx.api('POST', '/api/audio/groups/stop', { utteranceId }, { signedSession: session });
|
|
638
|
+
return { receipt };
|
|
639
|
+
},
|
|
640
|
+
},
|
|
499
641
|
];
|
|
500
642
|
|
|
501
643
|
module.exports = {
|
|
@@ -21,6 +21,17 @@ const ownershipGraceMs = Number.isFinite(ownershipGraceRaw) && ownershipGraceRaw
|
|
|
21
21
|
const reverseFailureMaxRaw = Number(process.env.NEXUSCREW_TUNNEL_REVERSE_FAILURE_MAX || 8);
|
|
22
22
|
const reverseFailureMax = Number.isInteger(reverseFailureMaxRaw) && reverseFailureMaxRaw >= 1
|
|
23
23
|
? Math.min(reverseFailureMaxRaw, 32) : 8;
|
|
24
|
+
// Once the initial reverse-forward budget is exhausted the supervisor does NOT
|
|
25
|
+
// die: it stays "degraded" and retries at the fixed production cadence of 60s,
|
|
26
|
+
// so a transient reverse listener on the hub (e.g. a mobile reconnect) heals on
|
|
27
|
+
// its own without an OFF/ON. A short cadence is a test-only seam, never a
|
|
28
|
+
// production runtime override: otherwise a bad environment value could recreate
|
|
29
|
+
// the retry storm that this breaker is meant to prevent.
|
|
30
|
+
const steadyRetryTestMode = process.env.NEXUSCREW_TUNNEL_TEST_MODE === '1';
|
|
31
|
+
const steadyRetryMsRaw = Number(steadyRetryTestMode ? (process.env.NEXUSCREW_TUNNEL_STEADY_RETRY_MS || 60000) : 60000);
|
|
32
|
+
const steadyRetryMinMs = steadyRetryTestMode ? 1 : 60000;
|
|
33
|
+
const steadyRetryMs = Number.isFinite(steadyRetryMsRaw) && steadyRetryMsRaw >= steadyRetryMinMs
|
|
34
|
+
? Math.min(steadyRetryMsRaw, 120000) : 60000;
|
|
24
35
|
if (!sshBin || !statePath || !pidPath || !runId) process.exit(2);
|
|
25
36
|
|
|
26
37
|
let child = null;
|
|
@@ -92,6 +103,7 @@ function probeForward(expectedChild) {
|
|
|
92
103
|
if (stopping || child !== expectedChild || !child || child.exitCode != null) return;
|
|
93
104
|
if (ready) {
|
|
94
105
|
attempt = 0;
|
|
106
|
+
reverseFailures = 0;
|
|
95
107
|
logEvent(`forward ready stableMs=${stableMs}`);
|
|
96
108
|
if (!writeState('transport-ready', { sshPid: child.pid, stableMs, probe: 'tcp-forward' })) stop();
|
|
97
109
|
return;
|
|
@@ -137,14 +149,28 @@ function scheduleRetry(detail) {
|
|
|
137
149
|
retryTimer = setTimeout(run, delayMs);
|
|
138
150
|
}
|
|
139
151
|
|
|
140
|
-
|
|
152
|
+
// Bounded, attributable degraded state. The supervisor stays alive, advertises
|
|
153
|
+
// the reverse failure class plus the negotiated reversePort and the honest
|
|
154
|
+
// remote-listener attribution (unknown without hub privilege), then retries on
|
|
155
|
+
// a fixed cadence. It never exits on a reverse-forward failure: the tunnel
|
|
156
|
+
// self-heals when the channel frees up.
|
|
157
|
+
function enterDegraded(diagnosis) {
|
|
158
|
+
if (stopping) return finish();
|
|
141
159
|
const safe = diagnosis || { code: 'reverse-forward-failed', detail: 'canale inverso non disponibile' };
|
|
142
|
-
|
|
143
|
-
|
|
160
|
+
if (reverseFailures > reverseFailureMax) reverseFailures = reverseFailureMax;
|
|
161
|
+
// `ownsGeneration()` only proves that THIS local supervisor owns its pidfile;
|
|
162
|
+
// it cannot identify the process holding a listener on the hub. Never turn
|
|
163
|
+
// local pidfile ownership into a false remote-listener attribution.
|
|
164
|
+
const ownership = 'unknown';
|
|
165
|
+
logEvent(`ssh degraded code=${safe.code} reversePort=${reversePort || 'none'} ownership=${ownership} failures=${reverseFailures} steadyRetryMs=${steadyRetryMs}`);
|
|
166
|
+
if (!writeState('degraded', {
|
|
144
167
|
code: safe.code, detail: safe.detail,
|
|
145
|
-
...(safe.hint ? { hint: safe.hint } : {}),
|
|
146
|
-
|
|
147
|
-
|
|
168
|
+
...(safe.hint ? { hint: safe.hint } : {}),
|
|
169
|
+
reversePort, ownership, steadyRetryMs, terminal: false,
|
|
170
|
+
})) return stop();
|
|
171
|
+
// Keep the backoff counter honest for observers; the degraded retry is fixed.
|
|
172
|
+
if (attempt < reverseFailureMax) attempt = reverseFailureMax;
|
|
173
|
+
retryTimer = setTimeout(run, steadyRetryMs);
|
|
148
174
|
}
|
|
149
175
|
|
|
150
176
|
function run() {
|
|
@@ -176,7 +202,7 @@ function run() {
|
|
|
176
202
|
const reverseFailure = diagnosis && ['reverse-forward-bind', 'reverse-forward-failed'].includes(diagnosis.code);
|
|
177
203
|
if (reverseFailure) {
|
|
178
204
|
reverseFailures += 1;
|
|
179
|
-
if (reverseFailures >= reverseFailureMax) return
|
|
205
|
+
if (reverseFailures >= reverseFailureMax) return enterDegraded(diagnosis);
|
|
180
206
|
} else {
|
|
181
207
|
reverseFailures = 0;
|
|
182
208
|
}
|
package/lib/nodes/tunnel.js
CHANGED
|
@@ -290,6 +290,19 @@ function readTunnelState(home, name) {
|
|
|
290
290
|
const owned = state.supervisorPid === meta.pid && (!meta.runId || state.runId === meta.runId);
|
|
291
291
|
if (!owned) return { status: 'down', pid: meta.pid, since: meta.startTs || null, reason: 'starting' };
|
|
292
292
|
if (state.status === 'failed' && state.terminal === true) return terminalState();
|
|
293
|
+
if (state.status === 'degraded') {
|
|
294
|
+
return {
|
|
295
|
+
status: 'down', phase: 'degraded', reason: 'degraded', pid: meta.pid, since: meta.startTs || null,
|
|
296
|
+
...(typeof state.code === 'string' ? { code: state.code } : {}),
|
|
297
|
+
...(typeof state.detail === 'string' ? { detail: state.detail } : {}),
|
|
298
|
+
...(typeof state.hint === 'string' ? { hint: state.hint } : {}),
|
|
299
|
+
...(Number.isInteger(state.reversePort) ? { reversePort: state.reversePort } : {}),
|
|
300
|
+
...(typeof state.ownership === 'string' ? { ownership: state.ownership } : {}),
|
|
301
|
+
...(Number.isInteger(state.attempt) ? { attempt: state.attempt } : {}),
|
|
302
|
+
...(Number.isFinite(state.steadyRetryMs) ? { retryInMs: state.steadyRetryMs } : {}),
|
|
303
|
+
...(transport ? { transport } : {}),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
293
306
|
if (state.status === 'transport-ready' || state.status === 'up') {
|
|
294
307
|
return { status: 'up', phase: 'transport-ready', pid: meta.pid, since: meta.startTs || null,
|
|
295
308
|
...(Number.isInteger(state.attempt) ? { attempt: state.attempt } : {}), ...(transport ? { transport } : {}) };
|
|
@@ -330,6 +343,14 @@ function diagnoseTunnel(home, node, state = null) {
|
|
|
330
343
|
detail: `connessione SSH in retry${Number.isInteger(current.attempt) ? ` (tentativo ${current.attempt + 1})` : ''}`,
|
|
331
344
|
hint: 'usa Test connessione per vedere la causa; verifica target SSH, chiave e porta' };
|
|
332
345
|
}
|
|
346
|
+
if (current.reason === 'degraded' || current.phase === 'degraded') {
|
|
347
|
+
return {
|
|
348
|
+
stage: 'ssh', code: current.code || 'reverse-forward-failed', status: 'down', phase: 'degraded',
|
|
349
|
+
detail: current.detail || 'canale inverso non disponibile, retry fisso in corso',
|
|
350
|
+
hint: current.hint || 'il tunnel resta attivo e riprovera automaticamente; verifica il listener reverse sul hub',
|
|
351
|
+
...(Number.isInteger(current.reversePort) ? { reversePort: current.reversePort } : {}),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
333
354
|
if (current.reason === 'failed' || current.phase === 'failed') {
|
|
334
355
|
return {
|
|
335
356
|
stage: 'ssh', code: current.code || 'ssh-terminal-failure', status: 'down', phase: 'failed',
|
package/lib/proxy/federation.js
CHANGED
|
@@ -10,6 +10,7 @@ const { safeEqual } = require('../nodes/peering.js');
|
|
|
10
10
|
const {
|
|
11
11
|
sanitizeRequestHeaders, sanitizeResponseHeaders, stripLocalTokenQuery,
|
|
12
12
|
} = require('./node-proxy.js');
|
|
13
|
+
const { signHop, HOP_HEADER } = require('./hop-proof.js');
|
|
13
14
|
|
|
14
15
|
const MAX_HOPS = 4;
|
|
15
16
|
const ROUTE_DELIMITER = '_';
|
|
@@ -71,6 +72,10 @@ function knownResource(resource) {
|
|
|
71
72
|
// Hydra: the rest of /settings stays unreachable.
|
|
72
73
|
|| resource === '/settings/peering/invite'
|
|
73
74
|
|| resource === '/ws'
|
|
75
|
+
|| resource === '/audio/capability'
|
|
76
|
+
|| resource === '/audio/speak'
|
|
77
|
+
|| resource === '/audio/speak/status'
|
|
78
|
+
|| resource === '/audio/stop'
|
|
74
79
|
|| /^\/fleet\/(status|schema|definitions|credentials\/status|credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource);
|
|
75
80
|
}
|
|
76
81
|
|
|
@@ -97,9 +102,25 @@ function allowedResource(resource, method = 'GET') {
|
|
|
97
102
|
if (resource === '/ws') return method === 'GET';
|
|
98
103
|
if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) return method === 'GET';
|
|
99
104
|
if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) return method === 'POST';
|
|
105
|
+
// Audio: only capability read, speak and stop are exposed through Hydra.
|
|
106
|
+
// audio.consent is a LOCAL mutation and MUST stay unreachable federated.
|
|
107
|
+
if (resource === '/audio/capability') return method === 'GET';
|
|
108
|
+
if (resource === '/audio/speak') return method === 'POST';
|
|
109
|
+
if (resource === '/audio/speak/status') return method === 'POST';
|
|
110
|
+
if (resource === '/audio/stop') return method === 'POST';
|
|
100
111
|
return false;
|
|
101
112
|
}
|
|
102
113
|
|
|
114
|
+
// READONLY blocca ogni mutazione che crea o modifica stato, con due eccezioni
|
|
115
|
+
// audio deliberate: interrogare lo status federato usa POST soltanto per
|
|
116
|
+
// trasportare l'attestazione della cella, e Stop e' un'azione di sicurezza che
|
|
117
|
+
// deve poter zittire un endpoint anche quando quel nodo e' READONLY. Speak
|
|
118
|
+
// resta naturalmente bloccato.
|
|
119
|
+
function readonlyBlocksFederated(resource, method) {
|
|
120
|
+
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) return false;
|
|
121
|
+
return resource !== '/audio/speak/status' && resource !== '/audio/stop';
|
|
122
|
+
}
|
|
123
|
+
|
|
103
124
|
function allowedQuery(resource, method, rawUrl) {
|
|
104
125
|
if (!resource.startsWith('/diagnostics/')) return true;
|
|
105
126
|
const index = String(rawUrl || '').indexOf('?');
|
|
@@ -117,17 +138,20 @@ function allowedQuery(resource, method, rawUrl) {
|
|
|
117
138
|
return true;
|
|
118
139
|
}
|
|
119
140
|
|
|
120
|
-
function cleanHeaders(headers, credential, visited = null) {
|
|
141
|
+
function cleanHeaders(headers, credential, visited = null, hopProof = null) {
|
|
121
142
|
const out = sanitizeRequestHeaders(headers, credential);
|
|
122
143
|
for (const key of Object.keys(out)) {
|
|
123
144
|
if (['x-nexuscrew-route', 'x-nexuscrew-visited', 'x-nexuscrew-hop'].includes(key.toLowerCase())) delete out[key];
|
|
124
145
|
}
|
|
125
146
|
if (Array.isArray(visited) && visited.length) out['x-nexuscrew-visited'] = visited.join(',');
|
|
147
|
+
// La prova di hop viene aggiunta DOPO la cancellazione: il canale e' riservato
|
|
148
|
+
// al server, un valore arrivato dal client non sopravvive mai fin qui.
|
|
149
|
+
if (hopProof) out[HOP_HEADER] = hopProof;
|
|
126
150
|
return out;
|
|
127
151
|
}
|
|
128
152
|
|
|
129
|
-
function proxyHttp(req, res, { port, path, credential, visited = null }) {
|
|
130
|
-
const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited) }, (r) => {
|
|
153
|
+
function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null }) {
|
|
154
|
+
const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited, hopProof) }, (r) => {
|
|
131
155
|
res.writeHead(r.statusCode, sanitizeResponseHeaders(r.headers)); r.pipe(res);
|
|
132
156
|
});
|
|
133
157
|
up.setTimeout(30000, () => up.destroy());
|
|
@@ -135,22 +159,28 @@ function proxyHttp(req, res, { port, path, credential, visited = null }) {
|
|
|
135
159
|
req.pipe(up);
|
|
136
160
|
}
|
|
137
161
|
|
|
138
|
-
function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false }) {
|
|
162
|
+
function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null }) {
|
|
139
163
|
return (req, res) => {
|
|
140
164
|
const parsed = parseRoute(req.url);
|
|
141
165
|
if (!parsed || !allowedResource(parsed.resource, req.method)
|
|
142
166
|
|| !allowedQuery(parsed.resource, req.method, req.url)) return res.status(404).json({ error: 'not found' });
|
|
143
|
-
if (readonly() &&
|
|
167
|
+
if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
|
|
144
168
|
const st = store.loadStore(nodesPath);
|
|
145
169
|
if (!st) return res.status(503).json({ error: 'node store unavailable' });
|
|
146
170
|
const visited = controlledVisited(req, ingress, st.nodeId);
|
|
147
171
|
if (!visited) return res.status(409).json({ error: 'federation cycle rejected' });
|
|
148
172
|
if (parsed.route.length === 0) {
|
|
173
|
+
// Ultimo hop: la richiesta rientra nell'API locale con il Bearer locale e
|
|
174
|
+
// da li' in poi sarebbe indistinguibile da un POST diretto. La prova di
|
|
175
|
+
// hop e' cio' che la distingue, ed e' legata a metodo, path e catena.
|
|
176
|
+
const path = `/api${parsed.resource}${queryOf(req.url)}`;
|
|
177
|
+
const secret = typeof hopSecret === 'function' ? hopSecret() : hopSecret;
|
|
149
178
|
return proxyHttp(req, res, {
|
|
150
179
|
port: typeof localPort === 'function' ? localPort() : localPort,
|
|
151
|
-
path
|
|
180
|
+
path,
|
|
152
181
|
credential: localCredential(),
|
|
153
182
|
visited,
|
|
183
|
+
hopProof: signHop(secret, { method: req.method, path, visited }),
|
|
154
184
|
});
|
|
155
185
|
}
|
|
156
186
|
const next = st && store.getNode(st, parsed.route[0]);
|
|
@@ -514,7 +544,7 @@ async function collectLocalTopology({
|
|
|
514
544
|
return { instanceId: live.instanceId, nodes };
|
|
515
545
|
}
|
|
516
546
|
|
|
517
|
-
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null }) {
|
|
547
|
+
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null }) {
|
|
518
548
|
const r = express.Router();
|
|
519
549
|
r.use((req, res, next) => {
|
|
520
550
|
const peer = peerFromToken(nodesPath, bearerFrom(req));
|
|
@@ -576,13 +606,13 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
576
606
|
return res.status(e.status || 500).json({ error: String(e && e.message || e), ...(e.code ? { code: e.code } : {}) });
|
|
577
607
|
}
|
|
578
608
|
});
|
|
579
|
-
r.use('/route', (req, res) => routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly })(req, res));
|
|
609
|
+
r.use('/route', (req, res) => routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly, hopSecret })(req, res));
|
|
580
610
|
return r;
|
|
581
611
|
}
|
|
582
612
|
|
|
583
|
-
function localRouter({ nodesPath, localPort, localCredential, readonly }) {
|
|
613
|
+
function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecret = null }) {
|
|
584
614
|
const r = express.Router();
|
|
585
|
-
r.use((req, res) => routeHandler({ nodesPath, localPort, localCredential, readonly })(req, res));
|
|
615
|
+
r.use((req, res) => routeHandler({ nodesPath, localPort, localCredential, readonly, hopSecret })(req, res));
|
|
586
616
|
return r;
|
|
587
617
|
}
|
|
588
618
|
|
|
@@ -624,7 +654,7 @@ function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConn
|
|
|
624
654
|
|
|
625
655
|
module.exports = {
|
|
626
656
|
MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS,
|
|
627
|
-
peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery,
|
|
657
|
+
peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
|
|
628
658
|
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
|
|
629
659
|
probeHealth, waitForHealthyPeer, notifyHubShare, reconcilePeerShare, runShareRevokeBoot,
|
|
630
660
|
};
|