toolfunnel 0.7.1 → 0.8.0
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/README.md +14 -4
- package/bin/toolfunnel.js +103 -24
- package/docs/KNOWN_BUGS.md +48 -28
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +1 -1
- package/docs/packaging.md +2 -1
- package/mcp/servers/mock-modern/server.js +1 -1
- package/mcp/servers/mock-upstream/server.js +3 -4
- package/package.json +1 -1
- package/src/auth/config.js +2 -4
- package/src/auth/resource-server.js +6 -1
- package/src/core/atomic-write.js +97 -0
- package/src/core/config-home.js +100 -8
- package/src/core/hook-engine.js +29 -54
- package/src/core/hook-loader.js +3 -61
- package/src/core/hook-runner.js +92 -43
- package/src/core/logger.js +5 -7
- package/src/core/metrics.js +17 -2
- package/src/core/output-cap.js +31 -0
- package/src/core/rename-migration.js +36 -21
- package/src/extend/add-hook.md +4 -1
- package/src/extend/create-tool.md +1 -1
- package/src/extend/package.md +1 -1
- package/src/mcp/aggregator.js +583 -54
- package/src/mcp/expose-store.js +246 -68
- package/src/mcp/http-transport.js +111 -17
- package/src/mcp/mcp-client.js +130 -26
- package/src/mcp/modern.js +9 -6
- package/src/mcp/protocol.js +12 -7
- package/src/mcp/server.js +268 -63
- package/src/mcp/tool-approvals.js +396 -0
- package/src/tools/registry.js +33 -87
- package/src/tools/tool-state.js +1 -1
- package/src/ui/public/app.js +60 -1
- package/src/ui/server.js +142 -16
- package/tools/scripts/tf-accept-change.js +67 -0
- package/tools/scripts/tf-mcp-add.js +28 -15
- package/tools/tools.register.json +16 -5
package/src/mcp/expose-store.js
CHANGED
|
@@ -53,60 +53,15 @@ function clone(value) {
|
|
|
53
53
|
return JSON.parse(JSON.stringify(value));
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
/** Remove STALE stranded temps for `base` in `dir` - copied from registry.js, same rules:
|
|
60
|
-
* scoped to the target's own temp shape, entirely best-effort. */
|
|
61
|
-
function sweepStaleTmp(dir, base) {
|
|
62
|
-
let names;
|
|
63
|
-
try { names = fs.readdirSync(dir); } catch (_e) { return; }
|
|
64
|
-
const prefix = `.${base}.`;
|
|
65
|
-
const now = Date.now();
|
|
66
|
-
for (const name of names) {
|
|
67
|
-
if (!name.startsWith(prefix) || !name.endsWith('.tmp')) continue;
|
|
68
|
-
const p = path.join(dir, name);
|
|
69
|
-
try {
|
|
70
|
-
if (now - fs.statSync(p).mtimeMs > STALE_TMP_MS) fs.unlinkSync(p);
|
|
71
|
-
} catch (_e) { /* a concurrent writer renamed/removed it - fine */ }
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Atomic write: serialise to a temp file in the SAME directory as the target (so
|
|
77
|
-
* the rename is atomic on the same filesystem/drive), fsync, then rename over the
|
|
78
|
-
* target. Copied verbatim from src/tools/registry.js so the two stores share one
|
|
79
|
-
* proven persistence pattern - including its cleanup contract: a failed write
|
|
80
|
-
* unlinks its own temp before rethrowing, and stale strandings are swept on the
|
|
81
|
-
* next successful write.
|
|
82
|
-
*/
|
|
83
|
-
function atomicWriteJson(targetPath, obj) {
|
|
84
|
-
const dir = path.dirname(targetPath);
|
|
85
|
-
const base = path.basename(targetPath);
|
|
86
|
-
sweepStaleTmp(dir, base);
|
|
87
|
-
const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
|
|
88
|
-
const data = JSON.stringify(obj, null, 2) + '\n';
|
|
89
|
-
try {
|
|
90
|
-
const fd = fs.openSync(tmp, 'w');
|
|
91
|
-
try {
|
|
92
|
-
fs.writeSync(fd, data, 0, 'utf8');
|
|
93
|
-
fs.fsyncSync(fd);
|
|
94
|
-
} finally {
|
|
95
|
-
fs.closeSync(fd);
|
|
96
|
-
}
|
|
97
|
-
fs.renameSync(tmp, targetPath);
|
|
98
|
-
} catch (err) {
|
|
99
|
-
try { fs.unlinkSync(tmp); } catch (_e) { /* best-effort - the error below is the story */ }
|
|
100
|
-
throw err;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
56
|
+
// THE atomic writer (temp + fsync + rename, failure-path unlink, stale sweep) - shared by every store.
|
|
57
|
+
const { atomicWriteJson } = require('../core/atomic-write');
|
|
103
58
|
|
|
104
59
|
/** True for a non-empty string. */
|
|
105
60
|
function isNonEmptyString(v) {
|
|
106
61
|
return typeof v === 'string' && v.length > 0;
|
|
107
62
|
}
|
|
108
63
|
|
|
109
|
-
const { migrateForStoreFile } = require('../core/rename-migration');
|
|
64
|
+
const { migrateForStoreFile, snapshotForStoreFile, restoreSnapshot } = require('../core/rename-migration');
|
|
110
65
|
|
|
111
66
|
/** The name an expose entry surfaces its tool under RIGHT NOW: an ENABLED entry's `as`
|
|
112
67
|
* (self-describing since addExpose defaults it); a disabled entry does not alias - the tool
|
|
@@ -170,7 +125,7 @@ function normaliseUpstream(raw) {
|
|
|
170
125
|
legacyPin: u.legacyPin === true,
|
|
171
126
|
// modernOnly: legacyPin's MIRROR - this upstream must speak the modern era; the client
|
|
172
127
|
// refuses the legacy fallback at connect instead of negotiating down. Off by default
|
|
173
|
-
// (default = speak whichever era the server understands).
|
|
128
|
+
// (default = speak whichever era the server understands).
|
|
174
129
|
modernOnly: u.modernOnly === true,
|
|
175
130
|
// allowCodeLoadingEnv: OPT-OUT of the isolation guard's code-loading env refusal for THIS
|
|
176
131
|
// upstream (NODE_OPTIONS and friends). Same pattern as legacyPin: off by default, explicit
|
|
@@ -204,6 +159,11 @@ function normaliseUpstream(raw) {
|
|
|
204
159
|
// connect, ONCE PER CLASS, about everything it excused. Same pattern as legacyPin - off by
|
|
205
160
|
// default, explicit in config where an auditor can see it, loud when it applies.
|
|
206
161
|
allowOutsidePaths: u.allowOutsidePaths === true,
|
|
162
|
+
// strictApproval (tool descriptor approval): a server VERSION change does not approve changed
|
|
163
|
+
// tool descriptors by itself - they wait for acceptance like a same-version change.
|
|
164
|
+
// Off by default (a version bump is the ordinary upgrade signal); on where the upstream's
|
|
165
|
+
// maintainer is not trusted to bump honestly. See src/mcp/tool-approvals.js.
|
|
166
|
+
strictApproval: u.strictApproval === true,
|
|
207
167
|
// Child working directory - the wrap era-probe and the client spawn both read it; it was
|
|
208
168
|
// dropped here, so a configured cwd never survived the store.
|
|
209
169
|
cwd: typeof u.cwd === 'string' && u.cwd.length ? u.cwd : undefined,
|
|
@@ -228,6 +188,9 @@ function normaliseUpstream(raw) {
|
|
|
228
188
|
function normaliseExpose(raw) {
|
|
229
189
|
const e = raw && typeof raw === 'object' ? raw : {};
|
|
230
190
|
return {
|
|
191
|
+
// Unknown fields ride along, as on upstreams (normaliseUpstream): every write rewrites the
|
|
192
|
+
// whole file, so dropping them erased hand-authored extras on the next unrelated edit.
|
|
193
|
+
...JSON.parse(JSON.stringify(e)),
|
|
231
194
|
upstream: typeof e.upstream === 'string' ? e.upstream : '',
|
|
232
195
|
tool: typeof e.tool === 'string' ? e.tool : '',
|
|
233
196
|
as: typeof e.as === 'string' ? e.as : '',
|
|
@@ -244,30 +207,158 @@ function normaliseExpose(raw) {
|
|
|
244
207
|
* @param {object} entry normalised upstream
|
|
245
208
|
* @param {Map} byId existing upstreams keyed by id (for uniqueness)
|
|
246
209
|
* @param {string} [ignoreId] an id to skip in the uniqueness check (updates)
|
|
210
|
+
* @param {string} [filePath] named in the message when given (load and store writes)
|
|
247
211
|
*/
|
|
248
|
-
function validateUpstream(entry, byId, ignoreId) {
|
|
212
|
+
function validateUpstream(entry, byId, ignoreId, filePath) {
|
|
213
|
+
const at = filePath ? `ExposeStore: ${filePath}:` : 'ExposeStore:';
|
|
249
214
|
if (!isNonEmptyString(entry.id)) {
|
|
250
|
-
throw new Error(
|
|
215
|
+
throw new Error(`${at} upstream "id" must be a non-empty string`);
|
|
251
216
|
}
|
|
252
217
|
if (byId.has(entry.id) && entry.id !== ignoreId) {
|
|
253
|
-
throw new Error(
|
|
218
|
+
throw new Error(`${at} upstream id "${entry.id}" already exists`);
|
|
254
219
|
}
|
|
255
220
|
if (!VALID_TRANSPORTS.has(entry.transport)) {
|
|
256
221
|
throw new Error(
|
|
257
|
-
|
|
222
|
+
`${at} upstream "${entry.id}": "transport" must be one of ${[...VALID_TRANSPORTS].join('|')}, got ${describeValue(entry.transport)}`
|
|
258
223
|
);
|
|
259
224
|
}
|
|
260
|
-
|
|
261
|
-
|
|
225
|
+
// A disabled entry is never spawned, so a placeholder without a command still loads.
|
|
226
|
+
if (entry.transport === 'stdio' && entry.enabled !== false && !isNonEmptyString(entry.command)) {
|
|
227
|
+
throw new Error(`${at} upstream "${entry.id}": a stdio upstream needs a non-empty "command"`);
|
|
262
228
|
}
|
|
263
229
|
if (entry.legacyPin === true && entry.modernOnly === true) {
|
|
264
230
|
throw new Error(
|
|
265
|
-
|
|
231
|
+
`${at} upstream "${entry.id}" sets BOTH legacyPin and modernOnly - they contradict (force legacy vs refuse legacy); pick one`
|
|
266
232
|
);
|
|
267
233
|
}
|
|
268
234
|
return entry;
|
|
269
235
|
}
|
|
270
236
|
|
|
237
|
+
// ── fail-loud config ────────────────────────────────────────────────────────────────────────
|
|
238
|
+
//
|
|
239
|
+
// The normalisers above replace a wrong-typed value with the default, silently: `"enabled":
|
|
240
|
+
// "false"` became ENABLED, `"timeoutMs": "5000"` the 120 s default, `"args": "a b"` no args,
|
|
241
|
+
// `"allowOutsidePaths": 1` false. The checks below run on the RAW object - at load, and on every
|
|
242
|
+
// write before normalising (which would coerce the evidence away) - and name the first bad field.
|
|
243
|
+
// Absent (undefined) and null both mean "unset" and keep the default. Unknown fields are not
|
|
244
|
+
// checked: they ride along untouched.
|
|
245
|
+
|
|
246
|
+
const UPSTREAM_BOOLEAN_FIELDS = ['enabled', 'legacyPin', 'modernOnly', 'allowCodeLoadingEnv', 'allowOutsidePaths', 'strictApproval'];
|
|
247
|
+
const UPSTREAM_STRING_FIELDS = ['transport', 'command', 'cwd', 'description'];
|
|
248
|
+
const UPSTREAM_MS_FIELDS = ['timeoutMs', 'requestTimeoutMs'];
|
|
249
|
+
const EXPOSE_STRING_FIELDS = ['upstream', 'tool', 'as', 'category'];
|
|
250
|
+
|
|
251
|
+
function isSet(v) {
|
|
252
|
+
return v !== undefined && v !== null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Unknown fields still ride along (see normaliseUpstream), but a misspelt known one did nothing
|
|
256
|
+
// and said nothing: `"strictAproval": true` loaded and left strictApproval off. Loading with a
|
|
257
|
+
// warnUnknown callback names each unknown field, with the nearest known one when it is close.
|
|
258
|
+
// Fields starting with "_" or "$" are comments or schema references, so they are not reported.
|
|
259
|
+
const UPSTREAM_KNOWN_FIELDS = ['id', 'args', 'env', ...UPSTREAM_BOOLEAN_FIELDS, ...UPSTREAM_STRING_FIELDS, ...UPSTREAM_MS_FIELDS];
|
|
260
|
+
const EXPOSE_KNOWN_FIELDS = [...EXPOSE_STRING_FIELDS, 'enabled'];
|
|
261
|
+
|
|
262
|
+
/** Edit distance between two short strings (case-insensitive); swapping two neighbours counts as one edit. */
|
|
263
|
+
function editDistance(a, b) {
|
|
264
|
+
a = a.toLowerCase(); b = b.toLowerCase();
|
|
265
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i]);
|
|
266
|
+
for (let j = 1; j <= b.length; j++) d[0][j] = j;
|
|
267
|
+
for (let i = 1; i <= a.length; i++) {
|
|
268
|
+
for (let j = 1; j <= b.length; j++) {
|
|
269
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
270
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return d[a.length][b.length];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** @returns {string[]} one message per unknown field of a RAW entry. */
|
|
277
|
+
function unknownFieldMessages(raw, known, at) {
|
|
278
|
+
const out = [];
|
|
279
|
+
for (const key of Object.keys(raw)) {
|
|
280
|
+
if (known.includes(key) || key.startsWith('_') || key.startsWith('$')) continue;
|
|
281
|
+
let best = null;
|
|
282
|
+
let bestD = Infinity;
|
|
283
|
+
for (const k of known) {
|
|
284
|
+
const d = editDistance(key, k);
|
|
285
|
+
if (d < bestD) { best = k; bestD = d; }
|
|
286
|
+
}
|
|
287
|
+
const guess = bestD <= Math.max(1, Math.floor(best.length / 4)) ? ` (did you mean "${best}"?)` : '';
|
|
288
|
+
out.push(`${at}: unknown field ${JSON.stringify(key)}${guess}. It is kept but has no effect.`);
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** A short, safe description of a value for an error message. */
|
|
294
|
+
function describeValue(v) {
|
|
295
|
+
if (v === null) return 'null';
|
|
296
|
+
if (Array.isArray(v)) return 'an array';
|
|
297
|
+
if (typeof v === 'string') return 'a string'; // never the value: args and env strings can hold secrets
|
|
298
|
+
if (typeof v === 'object') return 'an object';
|
|
299
|
+
return `${typeof v} ${String(v)}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** @returns {string|null} the first wrong-typed known field of a RAW upstream, or null. */
|
|
303
|
+
function upstreamFieldProblem(raw) {
|
|
304
|
+
for (const f of UPSTREAM_BOOLEAN_FIELDS) {
|
|
305
|
+
if (isSet(raw[f]) && typeof raw[f] !== 'boolean') return `"${f}" must be true or false, got ${describeValue(raw[f])}`;
|
|
306
|
+
}
|
|
307
|
+
for (const f of UPSTREAM_STRING_FIELDS) {
|
|
308
|
+
if (isSet(raw[f]) && typeof raw[f] !== 'string') return `"${f}" must be a string, got ${describeValue(raw[f])}`;
|
|
309
|
+
}
|
|
310
|
+
for (const f of UPSTREAM_MS_FIELDS) {
|
|
311
|
+
const v = raw[f];
|
|
312
|
+
if (isSet(v) && !(typeof v === 'number' && Number.isFinite(v) && v > 0)) {
|
|
313
|
+
return `"${f}" must be a positive number of milliseconds, got ${describeValue(v)}`;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (isSet(raw.args)) {
|
|
317
|
+
if (!Array.isArray(raw.args)) return `"args" must be an array of strings, got ${describeValue(raw.args)}`;
|
|
318
|
+
const i = raw.args.findIndex((a) => typeof a !== 'string');
|
|
319
|
+
if (i !== -1) return `"args" must be an array of strings, element ${i} is ${describeValue(raw.args[i])}`;
|
|
320
|
+
}
|
|
321
|
+
if (isSet(raw.env)) {
|
|
322
|
+
if (typeof raw.env !== 'object' || Array.isArray(raw.env)) {
|
|
323
|
+
return `"env" must be an object of NAME: value pairs, got ${describeValue(raw.env)}`;
|
|
324
|
+
}
|
|
325
|
+
// Scalars are coerced to strings by normaliseEnv (unchanged); an object or array value has
|
|
326
|
+
// no meaning as an environment variable - including the "__proto__" carrier, now refused
|
|
327
|
+
// loudly instead of dropped.
|
|
328
|
+
for (const key of Object.getOwnPropertyNames(raw.env)) {
|
|
329
|
+
const v = raw.env[key];
|
|
330
|
+
if (isSet(v) && typeof v === 'object') {
|
|
331
|
+
return `"env" value for ${JSON.stringify(key)} must be a string, number or boolean, got ${describeValue(v)}`;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** @returns {string|null} the first wrong-typed known field of a RAW expose entry, or null. */
|
|
339
|
+
function exposeFieldProblem(raw) {
|
|
340
|
+
for (const f of EXPOSE_STRING_FIELDS) {
|
|
341
|
+
if (isSet(raw[f]) && typeof raw[f] !== 'string') return `"${f}" must be a string, got ${describeValue(raw[f])}`;
|
|
342
|
+
}
|
|
343
|
+
if (isSet(raw.enabled) && typeof raw.enabled !== 'boolean') {
|
|
344
|
+
return `"enabled" must be true or false, got ${describeValue(raw.enabled)}`;
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** `upstream "u1"` - the id when it is a usable string, else the array position. */
|
|
350
|
+
function upstreamLabel(raw, index) {
|
|
351
|
+
const id = raw && typeof raw.id === 'string' && raw.id ? `upstream "${raw.id}"` : 'upstream';
|
|
352
|
+
return index === undefined ? id : `${id} (upstreams[${index}])`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** `expose entry (upstream="u1", tool="t1")`, with the array position at load. */
|
|
356
|
+
function exposeLabel(raw, index) {
|
|
357
|
+
const q = (v) => (typeof v === 'string' ? JSON.stringify(v) : '(missing)');
|
|
358
|
+
const where = index === undefined ? '' : ` (expose[${index}])`;
|
|
359
|
+
return `expose entry (upstream=${q(raw && raw.upstream)}, tool=${q(raw && raw.tool)})${where}`;
|
|
360
|
+
}
|
|
361
|
+
|
|
271
362
|
class ExposeStore {
|
|
272
363
|
/**
|
|
273
364
|
* @param {object} opts
|
|
@@ -279,29 +370,56 @@ class ExposeStore {
|
|
|
279
370
|
this._filePath = filePath;
|
|
280
371
|
|
|
281
372
|
const d = data && typeof data === 'object' ? data : {};
|
|
373
|
+
// Fail-loud config: a present top-level field of the wrong type refuses, never reads as empty.
|
|
374
|
+
for (const f of ['upstreams', 'expose']) {
|
|
375
|
+
if (isSet(d[f]) && !Array.isArray(d[f])) {
|
|
376
|
+
throw new Error(`ExposeStore: ${filePath}: "${f}" must be an array, got ${describeValue(d[f])}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (isSet(d.version) && typeof d.version !== 'number') {
|
|
380
|
+
throw new Error(`ExposeStore: ${filePath}: "version" must be a number, got ${describeValue(d.version)}`);
|
|
381
|
+
}
|
|
282
382
|
this._version = typeof d.version === 'number' ? d.version : 1;
|
|
283
383
|
|
|
284
384
|
// Index upstreams by id; reject duplicate ids at load (a duplicate id makes
|
|
285
385
|
// getUpstream / cascade-remove ambiguous). Keep insertion order via the Map.
|
|
286
386
|
this._upstreams = new Map();
|
|
287
387
|
const rawUpstreams = Array.isArray(d.upstreams) ? d.upstreams : [];
|
|
288
|
-
|
|
388
|
+
rawUpstreams.forEach((raw, i) => {
|
|
389
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
390
|
+
throw new Error(`ExposeStore: ${filePath}: upstreams[${i}] must be an object, got ${describeValue(raw)}`);
|
|
391
|
+
}
|
|
289
392
|
const u = normaliseUpstream(raw);
|
|
290
393
|
if (!isNonEmptyString(u.id)) {
|
|
291
|
-
throw new Error(`ExposeStore: an upstream is missing its id in ${filePath}`);
|
|
394
|
+
throw new Error(`ExposeStore: an upstream is missing its id in ${filePath} (upstreams[${i}])`);
|
|
292
395
|
}
|
|
293
396
|
if (this._upstreams.has(u.id)) {
|
|
294
397
|
throw new Error(`ExposeStore: duplicate upstream id "${u.id}" in ${filePath}`);
|
|
295
398
|
}
|
|
399
|
+
const problem = upstreamFieldProblem(raw);
|
|
400
|
+
if (problem) throw new Error(`ExposeStore: ${filePath}: ${upstreamLabel(raw, i)}: ${problem}`);
|
|
401
|
+
// The write-time shape rules (transport, a stdio command, the era-flag contradiction) hold
|
|
402
|
+
// at load too: a file that no write could have produced is refused, not half-run.
|
|
403
|
+
validateUpstream(u, this._upstreams, undefined, filePath);
|
|
296
404
|
this._upstreams.set(u.id, u);
|
|
297
|
-
}
|
|
405
|
+
});
|
|
298
406
|
|
|
299
407
|
// expose[] is kept as an ordered array; the natural key is (upstream,tool).
|
|
300
408
|
// Reject duplicate (upstream,tool) pairs at load.
|
|
301
409
|
this._expose = [];
|
|
302
410
|
const seenExpose = new Set();
|
|
303
411
|
const rawExpose = Array.isArray(d.expose) ? d.expose : [];
|
|
304
|
-
|
|
412
|
+
rawExpose.forEach((raw, i) => {
|
|
413
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
414
|
+
throw new Error(`ExposeStore: ${filePath}: expose[${i}] must be an object, got ${describeValue(raw)}`);
|
|
415
|
+
}
|
|
416
|
+
const problem = exposeFieldProblem(raw);
|
|
417
|
+
if (problem) throw new Error(`ExposeStore: ${filePath}: ${exposeLabel(raw, i)}: ${problem}`);
|
|
418
|
+
for (const f of ['upstream', 'tool']) {
|
|
419
|
+
if (!isNonEmptyString(raw[f])) {
|
|
420
|
+
throw new Error(`ExposeStore: ${filePath}: ${exposeLabel(raw, i)}: "${f}" must be a non-empty string`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
305
423
|
const e = normaliseExpose(raw);
|
|
306
424
|
const key = `${e.upstream} ${e.tool}`;
|
|
307
425
|
if (seenExpose.has(key)) {
|
|
@@ -311,7 +429,7 @@ class ExposeStore {
|
|
|
311
429
|
}
|
|
312
430
|
seenExpose.add(key);
|
|
313
431
|
this._expose.push(e);
|
|
314
|
-
}
|
|
432
|
+
});
|
|
315
433
|
}
|
|
316
434
|
|
|
317
435
|
// ── upstreams: read API (clones, never throw) ─────────────────────────────
|
|
@@ -334,8 +452,9 @@ class ExposeStore {
|
|
|
334
452
|
* stdio requires a non-empty command. Persists atomically. Returns the clone.
|
|
335
453
|
*/
|
|
336
454
|
addUpstream(entry) {
|
|
455
|
+
this._refuseWrongTypes(entry, upstreamFieldProblem, upstreamLabel);
|
|
337
456
|
const u = normaliseUpstream(entry);
|
|
338
|
-
validateUpstream(u, this._upstreams);
|
|
457
|
+
validateUpstream(u, this._upstreams, undefined, this._filePath);
|
|
339
458
|
this._upstreams.set(u.id, u);
|
|
340
459
|
this._persist();
|
|
341
460
|
return clone(u);
|
|
@@ -351,8 +470,9 @@ class ExposeStore {
|
|
|
351
470
|
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
352
471
|
throw new Error('ExposeStore.updateUpstream: patch must be an object');
|
|
353
472
|
}
|
|
473
|
+
this._refuseWrongTypes({ ...existing, ...patch, id }, upstreamFieldProblem, upstreamLabel);
|
|
354
474
|
const merged = normaliseUpstream({ ...existing, ...patch, id });
|
|
355
|
-
validateUpstream(merged, this._upstreams, id);
|
|
475
|
+
validateUpstream(merged, this._upstreams, id, this._filePath);
|
|
356
476
|
this._upstreams.set(id, merged);
|
|
357
477
|
this._persist();
|
|
358
478
|
return clone(merged);
|
|
@@ -423,6 +543,7 @@ class ExposeStore {
|
|
|
423
543
|
* namespaced `<upstream>_<tool>`. Persists atomically. Returns the clone.
|
|
424
544
|
*/
|
|
425
545
|
addExpose(entry) {
|
|
546
|
+
this._refuseWrongTypes(entry, exposeFieldProblem, exposeLabel);
|
|
426
547
|
const e = normaliseExpose(entry);
|
|
427
548
|
if (!isNonEmptyString(e.upstream)) {
|
|
428
549
|
throw new Error('ExposeStore.addExpose: expose.upstream must be a non-empty string');
|
|
@@ -443,9 +564,16 @@ class ExposeStore {
|
|
|
443
564
|
e.as = isNonEmptyString(e.as) ? e.as : `${e.upstream}_${e.tool}`;
|
|
444
565
|
// A new ENABLED alias renames the surface from the namespaced default it had while merely
|
|
445
566
|
// lean-discovered - migrate-or-refuse, exactly like updateExpose (no-op when as == default).
|
|
567
|
+
const snap = e.enabled && e.as !== `${e.upstream}_${e.tool}` ? snapshotForStoreFile(this._filePath) : null;
|
|
446
568
|
if (e.enabled) this._migrateSurfaced(`${e.upstream}_${e.tool}`, e.as);
|
|
447
569
|
this._expose.push(e);
|
|
448
|
-
|
|
570
|
+
try {
|
|
571
|
+
this._persist();
|
|
572
|
+
} catch (err) {
|
|
573
|
+
this._expose.pop();
|
|
574
|
+
if (snap) restoreSnapshot(snap); // a failed persist puts the migrated files back byte-for-byte
|
|
575
|
+
throw err;
|
|
576
|
+
}
|
|
449
577
|
return clone(e);
|
|
450
578
|
}
|
|
451
579
|
|
|
@@ -463,14 +591,22 @@ class ExposeStore {
|
|
|
463
591
|
throw new Error('ExposeStore.updateExpose: patch must be an object');
|
|
464
592
|
}
|
|
465
593
|
const prior = this._expose[idx];
|
|
594
|
+
this._refuseWrongTypes({ ...prior, ...patch, upstream, tool }, exposeFieldProblem, exposeLabel);
|
|
466
595
|
const merged = normaliseExpose({ ...prior, ...patch, upstream, tool });
|
|
467
596
|
// Rename-safety: a change to the SURFACED name - an `as` edit OR an enabled flip
|
|
468
597
|
// (only an ENABLED entry's `as` surfaces; a disabled entry's tool falls back to the
|
|
469
598
|
// namespaced default) - must carry the gate matchers and tools.state.json keys with it, or
|
|
470
599
|
// the rename is REFUSED. Runs BEFORE persist so a refusal leaves this file untouched.
|
|
600
|
+
const snap = surfacedOf(prior) !== surfacedOf(merged) ? snapshotForStoreFile(this._filePath) : null;
|
|
471
601
|
this._migrateSurfaced(surfacedOf(prior), surfacedOf(merged));
|
|
472
602
|
this._expose[idx] = merged;
|
|
473
|
-
|
|
603
|
+
try {
|
|
604
|
+
this._persist();
|
|
605
|
+
} catch (err) {
|
|
606
|
+
this._expose[idx] = prior;
|
|
607
|
+
if (snap) restoreSnapshot(snap);
|
|
608
|
+
throw err;
|
|
609
|
+
}
|
|
474
610
|
return clone(merged);
|
|
475
611
|
}
|
|
476
612
|
|
|
@@ -483,9 +619,16 @@ class ExposeStore {
|
|
|
483
619
|
// Removing an ENABLED aliased entry renames the surface back to the namespaced default -
|
|
484
620
|
// same migrate-or-refuse contract as updateExpose (rename-safety).
|
|
485
621
|
const prior = this._expose[idx];
|
|
622
|
+
const snap = surfacedOf(prior) !== `${prior.upstream}_${prior.tool}` ? snapshotForStoreFile(this._filePath) : null;
|
|
486
623
|
this._migrateSurfaced(surfacedOf(prior), `${prior.upstream}_${prior.tool}`);
|
|
487
624
|
this._expose.splice(idx, 1);
|
|
488
|
-
|
|
625
|
+
try {
|
|
626
|
+
this._persist();
|
|
627
|
+
} catch (err) {
|
|
628
|
+
this._expose.splice(idx, 0, prior);
|
|
629
|
+
if (snap) restoreSnapshot(snap);
|
|
630
|
+
throw err;
|
|
631
|
+
}
|
|
489
632
|
return true;
|
|
490
633
|
}
|
|
491
634
|
|
|
@@ -495,6 +638,15 @@ class ExposeStore {
|
|
|
495
638
|
migrateForStoreFile(this._filePath, oldName, newName);
|
|
496
639
|
}
|
|
497
640
|
|
|
641
|
+
/**
|
|
642
|
+
* The other half of "migrate BEFORE persist": that ordering makes a REFUSED migration leave this
|
|
643
|
+
* file untouched; a migration that succeeded followed by a failed persist would leave the
|
|
644
|
+
* manifest naming the NEW surface while the file still named the OLD one - the gate firing on a
|
|
645
|
+
* name nothing surfaced. Each mutating site snapshots the migration's files (snapshotForStoreFile)
|
|
646
|
+
* before migrating and restores the bytes on a failed persist. A reverse MIGRATION is not an
|
|
647
|
+
* unwind - it re-validates and a pattern hook matching the new name refuses it. rename-rollback.test.js.
|
|
648
|
+
*/
|
|
649
|
+
|
|
498
650
|
/** Toggle an expose entry's `enabled` flag; persists. Throws on unknown (upstream,tool). */
|
|
499
651
|
setExposeEnabled(upstream, tool, enabled) {
|
|
500
652
|
return this.updateExpose(upstream, tool, { enabled: !!enabled });
|
|
@@ -523,6 +675,13 @@ class ExposeStore {
|
|
|
523
675
|
return this._expose.findIndex((e) => e.upstream === upstream && e.tool === tool);
|
|
524
676
|
}
|
|
525
677
|
|
|
678
|
+
/** Fail-loud config on a WRITE: throw, naming the file, entry and field, before anything changes. */
|
|
679
|
+
_refuseWrongTypes(raw, problemOf, labelOf) {
|
|
680
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return; // the callers' own shape checks own this
|
|
681
|
+
const problem = problemOf(raw);
|
|
682
|
+
if (problem) throw new Error(`ExposeStore: ${this._filePath}: ${labelOf(raw)}: ${problem}`);
|
|
683
|
+
}
|
|
684
|
+
|
|
526
685
|
/** Serialise current state back to the config file atomically. */
|
|
527
686
|
_persist() {
|
|
528
687
|
atomicWriteJson(this._filePath, this.toJSON());
|
|
@@ -538,11 +697,18 @@ class ExposeStore {
|
|
|
538
697
|
* created until the first write (keeps the server starting clean).
|
|
539
698
|
* - MALFORMED JSON (or a duplicate id / duplicate expose key) -> throw a clear
|
|
540
699
|
* error so misconfiguration is caught at load, not at run time.
|
|
700
|
+
* - A PRESENT-BUT-WRONG-TYPED known field (`"enabled": "false"`, `"timeoutMs": "5000"`,
|
|
701
|
+
* `"args": "a b"`...) -> throw naming the file, the entry and the field (fail-loud config).
|
|
702
|
+
* Absent fields keep their defaults; `null` counts as absent.
|
|
703
|
+
*
|
|
704
|
+
* - With opts.warnUnknown, each unknown field on an upstream or expose entry is reported through
|
|
705
|
+
* it (gateway start and reload pass one; the web UI, which loads on every request, does not).
|
|
541
706
|
*
|
|
542
707
|
* @param {string} filePath
|
|
708
|
+
* @param {{ warnUnknown?: (message: string) => void }} [opts]
|
|
543
709
|
* @returns {ExposeStore}
|
|
544
710
|
*/
|
|
545
|
-
function loadExposeStore(filePath) {
|
|
711
|
+
function loadExposeStore(filePath, opts = {}) {
|
|
546
712
|
if (!isNonEmptyString(filePath)) {
|
|
547
713
|
throw new Error('loadExposeStore: filePath (non-empty string) is required');
|
|
548
714
|
}
|
|
@@ -562,7 +728,19 @@ function loadExposeStore(filePath) {
|
|
|
562
728
|
} catch (err) {
|
|
563
729
|
throw new Error(`loadExposeStore: config at "${filePath}" is not valid JSON: ${err.message}`);
|
|
564
730
|
}
|
|
565
|
-
|
|
731
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
732
|
+
throw new Error(`loadExposeStore: config at "${filePath}" must be a JSON object, got ${describeValue(data)}`);
|
|
733
|
+
}
|
|
734
|
+
const store = new ExposeStore({ filePath, data });
|
|
735
|
+
if (typeof opts.warnUnknown === 'function') {
|
|
736
|
+
(data.upstreams || []).forEach((raw, i) => {
|
|
737
|
+
for (const m of unknownFieldMessages(raw, UPSTREAM_KNOWN_FIELDS, `${filePath}: ${upstreamLabel(raw, i)}`)) opts.warnUnknown(m);
|
|
738
|
+
});
|
|
739
|
+
(data.expose || []).forEach((raw, i) => {
|
|
740
|
+
for (const m of unknownFieldMessages(raw, EXPOSE_KNOWN_FIELDS, `${filePath}: ${exposeLabel(raw, i)}`)) opts.warnUnknown(m);
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
return store;
|
|
566
744
|
}
|
|
567
745
|
|
|
568
746
|
module.exports = {
|