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.
@@ -53,60 +53,15 @@ function clone(value) {
53
53
  return JSON.parse(JSON.stringify(value));
54
54
  }
55
55
 
56
- /** Sibling temps older than this are strandings, not in-flight writes (see registry.js). */
57
- const STALE_TMP_MS = 60 * 60 * 1000;
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). Era-policy switches, 2026-07-18.
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('ExposeStore: upstream.id must be a non-empty string');
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(`ExposeStore: upstream id "${entry.id}" already exists`);
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
- `ExposeStore: upstream.transport must be one of ${[...VALID_TRANSPORTS].join('|')} (id=${entry.id})`
222
+ `${at} upstream "${entry.id}": "transport" must be one of ${[...VALID_TRANSPORTS].join('|')}, got ${describeValue(entry.transport)}`
258
223
  );
259
224
  }
260
- if (entry.transport === 'stdio' && !isNonEmptyString(entry.command)) {
261
- throw new Error(`ExposeStore: stdio upstream needs a non-empty "command" (id=${entry.id})`);
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
- `ExposeStore: upstream "${entry.id}" sets BOTH legacyPin and modernOnly - they contradict (force legacy vs refuse legacy); pick one`
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
- for (const raw of rawUpstreams) {
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
- for (const raw of rawExpose) {
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
- this._persist();
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
- this._persist();
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
- this._persist();
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
- return new ExposeStore({ filePath, data });
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 = {