toolfunnel 0.4.0 → 0.6.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.
Files changed (78) hide show
  1. package/README.md +102 -213
  2. package/bin/toolfunnel.js +372 -147
  3. package/docs/KNOWN_BUGS.md +99 -225
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/design.md +70 -0
  6. package/docs/packaging.md +115 -115
  7. package/hooks/hooks.manifest.json +27 -27
  8. package/hooks/scripts/example-audit.js +21 -21
  9. package/hooks/scripts/example-deny-dangerous.js +31 -31
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -0
  12. package/mcp/servers/mock-upstream/server.js +192 -12
  13. package/package.json +1 -1
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +344 -344
  17. package/src/core/config-home.js +134 -134
  18. package/src/core/events.js +105 -105
  19. package/src/core/hook-engine.js +275 -275
  20. package/src/core/hook-loader.js +791 -791
  21. package/src/core/hook-runner.js +346 -346
  22. package/src/core/logger.js +171 -171
  23. package/src/core/matcher.js +51 -51
  24. package/src/core/metrics.js +75 -75
  25. package/src/core/requires.js +138 -138
  26. package/src/core/server-config.js +98 -77
  27. package/src/core/tool-registry.js +180 -180
  28. package/src/extend/add-hook.md +152 -142
  29. package/src/extend/add-mcp.md +122 -121
  30. package/src/extend/configure.md +87 -0
  31. package/src/extend/create-tool.md +168 -152
  32. package/src/extend/howto.js +102 -98
  33. package/src/extend/package.md +187 -187
  34. package/src/extend/wrap.md +99 -0
  35. package/src/mcp/aggregator.js +1246 -807
  36. package/src/mcp/expose-store.js +460 -430
  37. package/src/mcp/gated-run.js +201 -194
  38. package/src/mcp/http-transport.js +1311 -981
  39. package/src/mcp/mcp-client.js +907 -530
  40. package/src/mcp/modern.js +372 -0
  41. package/src/mcp/protocol.js +468 -466
  42. package/src/mcp/server.js +2688 -1322
  43. package/src/packages/packager.js +715 -715
  44. package/src/tools/drift.js +138 -138
  45. package/src/tools/registry.js +513 -511
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1174 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1348 -1190
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +112 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -216
  70. package/docs/admin-ui.md +0 -404
  71. package/docs/configuration.md +0 -402
  72. package/docs/hooks-and-gating.md +0 -428
  73. package/docs/logging.md +0 -223
  74. package/docs/management.md +0 -429
  75. package/docs/oauth.md +0 -103
  76. package/docs/releasing.md +0 -31
  77. package/docs/setup.md +0 -352
  78. package/docs/tools-and-upstreams.md +0 -469
@@ -13,7 +13,7 @@
13
13
  * the isolation guard requires it to stay inside the tree):
14
14
  * upstream: { id: "mock", command: "node", args: ["mcp/servers/mock-upstream/server.js"] }
15
15
  *
16
- * Protocol: JSON-RPC 2.0 over stdio with NEWLINE-DELIMITED framing — one compact JSON object per
16
+ * Protocol: JSON-RPC 2.0 over stdio with NEWLINE-DELIMITED framing - one compact JSON object per
17
17
  * line, "\n"-terminated, the MCP stdio transport every real SDK-based server speaks. We read lines
18
18
  * on stdin and answer in kind on stdout. (This fixture used to parse LSP Content-Length framing,
19
19
  * matching an old client bug; both were corrected together so the mock represents a REAL server.)
@@ -22,24 +22,31 @@
22
22
  * - ping -> "pong"
23
23
  * - add { a, b } -> a + b
24
24
  * - echo { text } -> text
25
+ * - blocks -> a MULTI-BLOCK result with structuredContent (envelope-fidelity fixture)
25
26
  *
26
- * Replies to `initialize`, ignores `notifications/initialized`, answers `tools/list` and
27
- * `tools/call`, never crashes on a bad frame, and exits cleanly on stdin EOF.
27
+ * Replies to `initialize`, ignores `notifications/initialized`, answers `tools/list`,
28
+ * `tools/call` and `resources/list` (one fixed resource - forward-fidelity fixture),
29
+ * never crashes on a bad frame, and exits cleanly on stdin EOF.
28
30
  *
29
31
  * Node built-ins only. CommonJS.
30
32
  */
31
33
 
32
34
  const PROTOCOL_VERSION = '2024-11-05';
33
- const SERVER_INFO = { name: 'mock-upstream', version: '1.0.0' };
35
+ // `title` is a fidelity fixture: the 2025-06-18 spec added serverInfo.title, and the wrap must
36
+ // deliver it VERBATIM (a whitelist in wrappedIdentity used to eat it - wrap-lab, 2026-07-17).
37
+ const SERVER_INFO = { name: 'mock-upstream', version: '1.0.0', title: 'Mock Upstream Fixture' };
34
38
 
35
39
  const TOOLS = [
36
40
  {
37
41
  name: 'ping',
38
- description: 'Health check — returns the string "pong".',
42
+ description: 'Health check - returns the string "pong".',
39
43
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
40
44
  },
41
45
  {
46
+ // title + annotations are fidelity fixtures: real servers ship them (and newer fields), and a
47
+ // transparent wrap must advertise the def BYTE-IDENTICAL (a projection used to drop both).
42
48
  name: 'add',
49
+ title: 'Add Numbers',
43
50
  description: 'Add two numbers and return the sum.',
44
51
  inputSchema: {
45
52
  type: 'object',
@@ -47,6 +54,7 @@ const TOOLS = [
47
54
  required: ['a', 'b'],
48
55
  additionalProperties: false,
49
56
  },
57
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
50
58
  },
51
59
  {
52
60
  name: 'echo',
@@ -58,14 +66,39 @@ const TOOLS = [
58
66
  additionalProperties: false,
59
67
  },
60
68
  },
69
+ {
70
+ name: 'blocks',
71
+ description: 'Return a multi-block result with structuredContent (envelope-fidelity fixture).',
72
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
73
+ },
61
74
  {
62
75
  name: 'crash',
63
- description: 'Exit the process immediately (simulates an upstream crash — for reconnect testing).',
76
+ description: 'Exit the process immediately (simulates an upstream crash - for reconnect testing).',
77
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
78
+ },
79
+ {
80
+ name: 'prog',
81
+ description: 'Progress fixture: emits two notifications/progress against the request _meta progressToken, then returns.',
82
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
83
+ },
84
+ {
85
+ name: 'slowtool',
86
+ description: 'In-flight tools/call fixture: replies after 2s - the cancel-translation window for the TOOL path.',
87
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
88
+ },
89
+ {
90
+ name: 'elicit',
91
+ description: 'Elicitation fixture (Bridge B): sends a server-initiated elicitation/create mid-call, holds the call open, completes from the answer.',
64
92
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
65
93
  },
66
94
  ];
67
95
 
68
- /** Write one JSON-RPC message as a newline-delimited line — the MCP stdio framing. */
96
+ // Outstanding SERVER->CLIENT requests this mock has issued (Bridge B fixture): id -> callback fed
97
+ // with the client's response ({ result } or { error }).
98
+ const outstandingServerRequests = new Map();
99
+ let nextServerRequestId = 9001;
100
+
101
+ /** Write one JSON-RPC message as a newline-delimited line - the MCP stdio framing. */
69
102
  function writeMessage(msg) {
70
103
  process.stdout.write(JSON.stringify(msg) + '\n');
71
104
  }
@@ -82,24 +115,111 @@ function handleMessage(msg) {
82
115
  const method = msg.method;
83
116
  const params = msg.params || {};
84
117
 
85
- // Notifications carry no id and must never be answered.
86
- if (id === undefined || id === null) return;
118
+ // Notifications carry no id and must never be answered. Cancel-fidelity fixture: when
119
+ // TF_MOCK_CANCEL_LOG is set, RECORD each received notifications/cancelled requestId - a
120
+ // gateway test can then prove which id (translated or raw) actually reached this server.
121
+ if (id === undefined || id === null) {
122
+ if (method === 'notifications/cancelled' && process.env.TF_MOCK_CANCEL_LOG) {
123
+ try {
124
+ require('node:fs').appendFileSync(process.env.TF_MOCK_CANCEL_LOG, String(params.requestId) + '\n');
125
+ } catch (_e) { /* fixture logging must never crash the mock */ }
126
+ }
127
+ return;
128
+ }
129
+
130
+ // A message with an id and NO method is a client RESPONSE - route it to the outstanding
131
+ // server-initiated request it answers (Bridge B fixture). Unknown ids are dropped.
132
+ if (method === undefined) {
133
+ const cb = outstandingServerRequests.get(id);
134
+ if (cb) {
135
+ outstandingServerRequests.delete(id);
136
+ try { cb(msg); } catch (_e) { /* fixture callback must never crash the mock */ }
137
+ }
138
+ return;
139
+ }
87
140
 
88
141
  switch (method) {
89
142
  case 'initialize':
143
+ // Identity-mirroring fixture: RECORD the clientInfo each connect presents when
144
+ // TF_MOCK_CLIENTINFO_LOG is set. A wire test reads the log across a reconnect - first
145
+ // entry = the gateway's boot identity, last entry = the mirrored downstream client.
146
+ if (process.env.TF_MOCK_CLIENTINFO_LOG) {
147
+ try {
148
+ require('node:fs').appendFileSync(process.env.TF_MOCK_CLIENTINFO_LOG,
149
+ JSON.stringify((params && params.clientInfo) || null) + '\n');
150
+ } catch (_e) { /* fixture logging must never crash the mock */ }
151
+ }
90
152
  return writeMessage({
91
153
  jsonrpc: '2.0',
92
154
  id,
93
155
  result: {
94
156
  protocolVersion: PROTOCOL_VERSION,
95
157
  serverInfo: SERVER_INFO,
96
- capabilities: { tools: {} },
158
+ // resources.subscribe: the per-URI update fixture - a legacy server only emits
159
+ // resources/updated AFTER a resources/subscribe, which is exactly the channel the
160
+ // gateway must forward, replay on reconnect, and bridge back.
161
+ capabilities: { tools: {}, resources: { subscribe: true } },
97
162
  },
98
163
  });
99
164
 
100
165
  case 'tools/list':
101
166
  return writeMessage({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
102
167
 
168
+ case 'server/discover':
169
+ // Era-probe fixture: TF_MOCK_DISCOVER_EMPTY=1 makes this server ACK unknown methods with `{}`
170
+ // - the "permissive legacy server" shape. A strict dual-era client probe must treat that as
171
+ // LEGACY (no supportedVersions listing the modern version). Default: honest -32601.
172
+ if (process.env.TF_MOCK_DISCOVER_EMPTY === '1') {
173
+ return writeMessage({ jsonrpc: '2.0', id, result: {} });
174
+ }
175
+ return writeMessage({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found: ' + method } });
176
+
177
+ case 'resources/list':
178
+ // Forward-fidelity fixture: a distinctive verbatim result so a gateway test can PROVE a
179
+ // forwarded non-tool method really reached this server (and came back untouched).
180
+ return writeMessage({
181
+ jsonrpc: '2.0', id,
182
+ result: { resources: [{ uri: 'mock://upstream/readme', name: 'readme', mimeType: 'text/plain' }] },
183
+ });
184
+
185
+ case 'resources/subscribe': {
186
+ // Per-URI update fixture. Ack, RECORD the subscribe when TF_MOCK_SUB_LOG is set (a replay
187
+ // test counts subscribes across a process swap - a fresh process logging a fresh subscribe
188
+ // PROVES the gateway replayed it), then emit one resources/updated shortly after.
189
+ const uri = typeof params.uri === 'string' ? params.uri : '';
190
+ if (process.env.TF_MOCK_SUB_LOG) {
191
+ try {
192
+ require('node:fs').appendFileSync(process.env.TF_MOCK_SUB_LOG, uri + '\n');
193
+ } catch (_e) { /* fixture logging must never crash the mock */ }
194
+ }
195
+ writeMessage({ jsonrpc: '2.0', id, result: {} });
196
+ setTimeout(() => writeMessage({
197
+ jsonrpc: '2.0', method: 'notifications/resources/updated', params: { uri },
198
+ }), 150);
199
+ return;
200
+ }
201
+
202
+ case 'resources/read': {
203
+ const uri = params.uri;
204
+ if (uri === 'mock://upstream/slow') {
205
+ // In-flight fixture: reply after 2s so a gateway test has a WIDE window to fire a
206
+ // cancel at a genuinely outstanding forwarded request (400ms made W1 a flake candidate
207
+ // on a loaded runner).
208
+ setTimeout(() => writeMessage({
209
+ jsonrpc: '2.0', id,
210
+ result: { contents: [{ uri, mimeType: 'text/plain', text: 'slow done' }] },
211
+ }), 2000);
212
+ return;
213
+ }
214
+ if (uri === 'mock://upstream/readme') {
215
+ return writeMessage({
216
+ jsonrpc: '2.0', id,
217
+ result: { contents: [{ uri, mimeType: 'text/plain', text: 'mock upstream readme' }] },
218
+ });
219
+ }
220
+ return writeMessage({ jsonrpc: '2.0', id, error: { code: -32602, message: 'Unknown resource: ' + uri } });
221
+ }
222
+
103
223
  case 'tools/call': {
104
224
  const name = params.name;
105
225
  const args = params.arguments || {};
@@ -119,12 +239,72 @@ function handleMessage(msg) {
119
239
  const text = typeof args.text === 'string' ? args.text : JSON.stringify(args.text);
120
240
  return writeMessage({ jsonrpc: '2.0', id, result: textResult(text) });
121
241
  }
242
+ if (name === 'blocks') {
243
+ // Envelope-fidelity fixture: two content blocks AND structuredContent. A transparent
244
+ // forward must deliver this envelope VERBATIM - any re-shaping collapses it.
245
+ return writeMessage({
246
+ jsonrpc: '2.0', id,
247
+ result: {
248
+ content: [
249
+ { type: 'text', text: 'first block' },
250
+ { type: 'text', text: 'second block' },
251
+ ],
252
+ structuredContent: { first: 'first block', second: 'second block' },
253
+ isError: false,
254
+ },
255
+ });
256
+ }
122
257
  if (name === 'crash') {
123
258
  // Simulate an upstream crash: exit WITHOUT replying. The gateway's in-flight request
124
- // rejects and its McpClient.onClose fires → the aggregator schedules a background
259
+ // rejects and its McpClient.onClose fires -> the aggregator schedules a background
125
260
  // reconnect. Exercised by reconnect.test.js.
126
261
  process.exit(1);
127
262
  }
263
+ if (name === 'slowtool') {
264
+ // The tool-path twin of mock://upstream/slow: a genuinely outstanding tools/call for
265
+ // cancel-translation tests.
266
+ setTimeout(() => writeMessage({ jsonrpc: '2.0', id, result: textResult('slowtool done') }), 2000);
267
+ return;
268
+ }
269
+ if (name === 'elicit') {
270
+ // Bridge B fixture: ask the CLIENT a question mid-call, hold the tools/call open, and
271
+ // complete it from the answer - exactly what a real eliciting legacy server does.
272
+ const reqId = nextServerRequestId++;
273
+ outstandingServerRequests.set(reqId, (resp) => {
274
+ const res = resp && resp.result;
275
+ if (resp && resp.error) {
276
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult('elicit error: ' + resp.error.message, true) });
277
+ }
278
+ const action = res && res.action;
279
+ if (action === 'accept') {
280
+ const colour = res.content && res.content.colour;
281
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult('colour: ' + colour) });
282
+ }
283
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult(String(action || 'no action')) });
284
+ });
285
+ // NO `mode` field - deliberately. Real pre-MRTR legacy upstreams send only
286
+ // {message, requestedSchema}; the BRIDGE must inject mode:"form" for the modern shape,
287
+ // and a fixture that includes it would mask that translation.
288
+ writeMessage({
289
+ jsonrpc: '2.0', id: reqId, method: 'elicitation/create',
290
+ params: {
291
+ message: 'Pick a colour',
292
+ requestedSchema: { type: 'object', properties: { colour: { type: 'string' } }, required: ['colour'] },
293
+ },
294
+ });
295
+ return; // the tools/call reply comes from the callback above
296
+ }
297
+ if (name === 'prog') {
298
+ // Progress fixture: a real server correlates progress via the REQUEST's _meta
299
+ // progressToken. If the gateway stripped _meta on the forward, token is
300
+ // undefined and no progress is emitted - the test's zero-count is the failure signal.
301
+ const token = params._meta && params._meta.progressToken;
302
+ if (token !== undefined) {
303
+ writeMessage({ jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: token, progress: 1, total: 2 } });
304
+ writeMessage({ jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: token, progress: 2, total: 2 } });
305
+ }
306
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult(token !== undefined ? 'prog done (token seen)' : 'prog done (NO token)') });
307
+ }
128
308
  return writeMessage({ jsonrpc: '2.0', id, result: textResult('unknown tool: ' + name, true) });
129
309
  } catch (err) {
130
310
  return writeMessage({ jsonrpc: '2.0', id, result: textResult('error: ' + ((err && err.message) || err), true) });
@@ -151,7 +331,7 @@ process.stdin.on('data', (chunk) => {
151
331
  while ((nl = buffer.indexOf('\n')) !== -1) {
152
332
  const line = buffer.slice(0, nl).replace(/\r$/, '').trim();
153
333
  buffer = buffer.slice(nl + 1);
154
- if (line.length === 0) continue; // blank separator line — ignore
334
+ if (line.length === 0) continue; // blank separator line - ignore
155
335
  let msg;
156
336
  try {
157
337
  msg = JSON.parse(line);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolfunnel",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "A zero-dependency MCP gateway: host your own tools, forward and curate tools from other MCP servers, expose them leanly to cut agent token cost, and gate every call through your own policy hooks before it runs.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -1,174 +1,174 @@
1
- 'use strict';
2
-
3
- /**
4
- * auth/config.js — the toggle + settings for OPTIONAL OAuth 2.1 resource-server validation.
5
- *
6
- * DEFAULT OFF (the gateway is loopback-only and unauthenticated until you turn this on). Nothing
7
- * is read or required unless auth has been explicitly enabled via setConfig(). A MISSING config
8
- * file means disabled — the safe default — so a fresh checkout authenticates nothing and pulls in
9
- * no dependency.
10
- *
11
- * Design rules (mirrors src/core/logger.js — the proven config pattern):
12
- * - Zero RUNTIME dependencies. Node built-ins only (fs, path). The jose library is pulled in ONLY
13
- * by the validator (src/auth/resource-server.js), ONLY when auth is enabled, and is installed
14
- * on demand (CLI `install-oauth` / the UI button). This module never touches jose.
15
- * - Config is read FRESH on every call (no caching), so a toggle/edit takes effect for the very
16
- * next request without a reconnect or restart — exactly like the logger + tool-state overlays.
17
- * - Nothing here throws. A bad/unreadable/malformed config degrades to the safe default (disabled)
18
- * so a misconfiguration can never crash the transport.
19
- *
20
- * Config file: <root>/auth/auth.config.json (NOT created until setConfig() writes it)
21
- * {
22
- * "enabled": false,
23
- * "issuer": "https://auth.example.com", // the OAuth 2.1 / OIDC authorization server
24
- * "audience": "https://gateway.example.com", // THIS gateway's resource URI — RFC 8707 audience
25
- * // binding; the confused-deputy defence
26
- * "jwksUri": "https://auth.example.com/.well-known/jwks.json", // explicit JWKS; else derived
27
- * // from the issuer via OIDC discovery
28
- * "algorithms": ["RS256", "ES256"], // the PINNED signature-algorithm allowlist
29
- * "requiredScopes": [], // optional — every listed scope must be present
30
- * "clockToleranceSec": 30 // exp/nbf skew tolerance (seconds)
31
- * }
32
- *
33
- * CommonJS only.
34
- */
35
-
36
- const fs = require('node:fs');
37
- const path = require('node:path');
38
-
39
- /** The CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package root — see
40
- * src/core/config-home.js). Auth config is user-state, so it lives with the home. */
41
- const { resolveConfigHome } = require('../core/config-home');
42
- const ROOT = resolveConfigHome();
43
-
44
- /** The toggle/config file. NOT created until setConfig() writes it. */
45
- const CONFIG_PATH = path.join(ROOT, 'auth', 'auth.config.json');
46
-
47
- /** Safe defaults — used whenever the config file is absent or unreadable. */
48
- const DEFAULTS = Object.freeze({
49
- enabled: false,
50
- issuer: '',
51
- audience: '',
52
- jwksUri: '',
53
- algorithms: ['RS256', 'ES256'],
54
- requiredScopes: [],
55
- clockToleranceSec: 30,
56
- });
57
-
58
- /** A string field, defaulting to '' when not a non-empty string. */
59
- function str(v, dflt) {
60
- return typeof v === 'string' && v.length > 0 ? v : dflt;
61
- }
62
-
63
- /** A string[] field — keep only non-empty strings; fall back to `dflt` when not a usable array. */
64
- function strArray(v, dflt) {
65
- if (!Array.isArray(v)) return dflt.slice();
66
- const out = v.filter((x) => typeof x === 'string' && x.length > 0);
67
- return out.length > 0 ? out : dflt.slice();
68
- }
69
-
70
- /**
71
- * getConfig — the resolved auth config. Reads the file fresh; a missing/unreadable/malformed file
72
- * resolves to the safe defaults (disabled). NEVER throws.
73
- *
74
- * @returns {{ enabled:boolean, issuer:string, audience:string, jwksUri:string,
75
- * algorithms:string[], requiredScopes:string[], clockToleranceSec:number }}
76
- */
77
- function getConfig() {
78
- try {
79
- const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
80
- const parsed = JSON.parse(raw);
81
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
82
- return cloneDefaults();
83
- }
84
- const clock = Number(parsed.clockToleranceSec);
85
- return {
86
- enabled: parsed.enabled === true,
87
- issuer: str(parsed.issuer, DEFAULTS.issuer),
88
- audience: str(parsed.audience, DEFAULTS.audience),
89
- jwksUri: str(parsed.jwksUri, DEFAULTS.jwksUri),
90
- algorithms: strArray(parsed.algorithms, DEFAULTS.algorithms),
91
- requiredScopes: strArray(parsed.requiredScopes, DEFAULTS.requiredScopes),
92
- // Clamp to a sane, non-negative tolerance; a >5min skew signals a clock problem, not validity.
93
- clockToleranceSec:
94
- Number.isFinite(clock) && clock >= 0 ? Math.min(Math.floor(clock), 300) : DEFAULTS.clockToleranceSec,
95
- };
96
- } catch (_err) {
97
- // Missing file = disabled (the safe default); also covers unreadable/malformed.
98
- return cloneDefaults();
99
- }
100
- }
101
-
102
- /** A fresh, mutation-safe copy of the defaults (arrays cloned so a caller can't poison the frozen one). */
103
- function cloneDefaults() {
104
- return {
105
- enabled: DEFAULTS.enabled,
106
- issuer: DEFAULTS.issuer,
107
- audience: DEFAULTS.audience,
108
- jwksUri: DEFAULTS.jwksUri,
109
- algorithms: DEFAULTS.algorithms.slice(),
110
- requiredScopes: DEFAULTS.requiredScopes.slice(),
111
- clockToleranceSec: DEFAULTS.clockToleranceSec,
112
- };
113
- }
114
-
115
- /**
116
- * configError — a human-readable reason the CURRENT config could not gate requests, or null if it
117
- * is coherent enough to enforce. Used by the transport + UI to fail CLOSED with a clear message
118
- * rather than silently mis-validating. Only meaningful when `enabled` is true.
119
- *
120
- * Enforcement requires: an issuer, an audience (the RFC 8707 confused-deputy defence — a gateway
121
- * that does not bind tokens to its own resource URI is exploitable), at least one pinned algorithm,
122
- * and a key source (explicit jwksUri OR an issuer to derive one from via OIDC discovery).
123
- *
124
- * @param {object} [cfg] defaults to getConfig()
125
- * @returns {string|null}
126
- */
127
- function configError(cfg) {
128
- const c = cfg || getConfig();
129
- if (!c.enabled) return null; // disabled → nothing to enforce, no error
130
- if (!c.issuer) return 'auth enabled but "issuer" is not set';
131
- if (!c.audience) return 'auth enabled but "audience" (this gateway\'s resource URI) is not set — required to bind tokens (RFC 8707)';
132
- if (!Array.isArray(c.algorithms) || c.algorithms.length === 0) return 'auth enabled but no signature "algorithms" are pinned';
133
- // (A key source is always available: issuer is mandatory above, and jwksUri is derived from it
134
- // via OIDC discovery when not given — so no separate jwksUri-or-issuer check is needed here.)
135
- return null;
136
- }
137
-
138
- /**
139
- * setConfig — atomically merge a patch into auth/auth.config.json (temp + rename). Merges with the
140
- * current resolved config so a partial patch (e.g. { enabled: true }) preserves the other fields.
141
- * Creates the auth/ dir and the file if absent — this is the ONLY function that creates it.
142
- *
143
- * @param {object} patch any subset of the config fields
144
- * @returns {object} the merged, written config
145
- */
146
- function setConfig(patch) {
147
- const current = getConfig();
148
- const p = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {};
149
-
150
- const next = {
151
- enabled: typeof p.enabled === 'boolean' ? p.enabled : current.enabled,
152
- issuer: typeof p.issuer === 'string' ? p.issuer : current.issuer,
153
- audience: typeof p.audience === 'string' ? p.audience : current.audience,
154
- jwksUri: typeof p.jwksUri === 'string' ? p.jwksUri : current.jwksUri,
155
- algorithms: Array.isArray(p.algorithms) ? strArray(p.algorithms, current.algorithms) : current.algorithms,
156
- requiredScopes: Array.isArray(p.requiredScopes)
157
- ? p.requiredScopes.filter((x) => typeof x === 'string' && x.length > 0)
158
- : current.requiredScopes,
159
- clockToleranceSec:
160
- Number.isFinite(Number(p.clockToleranceSec)) && Number(p.clockToleranceSec) >= 0
161
- ? Math.min(Math.floor(Number(p.clockToleranceSec)), 300)
162
- : current.clockToleranceSec,
163
- };
164
-
165
- fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
166
- // Atomic write: unique temp file in the same dir, then rename over the target.
167
- const tmp = CONFIG_PATH + '.' + process.pid + '.' + Date.now() + '.tmp';
168
- fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
169
- fs.renameSync(tmp, CONFIG_PATH);
170
-
171
- return next;
172
- }
173
-
174
- module.exports = { getConfig, setConfig, configError, CONFIG_PATH, DEFAULTS };
1
+ 'use strict';
2
+
3
+ /**
4
+ * auth/config.js - the toggle + settings for OPTIONAL OAuth 2.1 resource-server validation.
5
+ *
6
+ * DEFAULT OFF (the gateway is loopback-only and unauthenticated until you turn this on). Nothing
7
+ * is read or required unless auth has been explicitly enabled via setConfig(). A MISSING config
8
+ * file means disabled - the safe default - so a fresh checkout authenticates nothing and pulls in
9
+ * no dependency.
10
+ *
11
+ * Design rules (mirrors src/core/logger.js - the proven config pattern):
12
+ * - Zero RUNTIME dependencies. Node built-ins only (fs, path). The jose library is pulled in ONLY
13
+ * by the validator (src/auth/resource-server.js), ONLY when auth is enabled, and is installed
14
+ * on demand (CLI `install-oauth` / the UI button). This module never touches jose.
15
+ * - Config is read FRESH on every call (no caching), so a toggle/edit takes effect for the very
16
+ * next request without a reconnect or restart - exactly like the logger + tool-state overlays.
17
+ * - Nothing here throws. A bad/unreadable/malformed config degrades to the safe default (disabled)
18
+ * so a misconfiguration can never crash the transport.
19
+ *
20
+ * Config file: <root>/auth/auth.config.json (NOT created until setConfig() writes it)
21
+ * {
22
+ * "enabled": false,
23
+ * "issuer": "https://auth.example.com", // the OAuth 2.1 / OIDC authorization server
24
+ * "audience": "https://gateway.example.com", // THIS gateway's resource URI - RFC 8707 audience
25
+ * // binding; the confused-deputy defence
26
+ * "jwksUri": "https://auth.example.com/.well-known/jwks.json", // explicit JWKS; else derived
27
+ * // from the issuer via OIDC discovery
28
+ * "algorithms": ["RS256", "ES256"], // the PINNED signature-algorithm allowlist
29
+ * "requiredScopes": [], // optional - every listed scope must be present
30
+ * "clockToleranceSec": 30 // exp/nbf skew tolerance (seconds)
31
+ * }
32
+ *
33
+ * CommonJS only.
34
+ */
35
+
36
+ const fs = require('node:fs');
37
+ const path = require('node:path');
38
+
39
+ /** The CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package root - see
40
+ * src/core/config-home.js). Auth config is user-state, so it lives with the home. */
41
+ const { resolveConfigHome } = require('../core/config-home');
42
+ const ROOT = resolveConfigHome();
43
+
44
+ /** The toggle/config file. NOT created until setConfig() writes it. */
45
+ const CONFIG_PATH = path.join(ROOT, 'auth', 'auth.config.json');
46
+
47
+ /** Safe defaults - used whenever the config file is absent or unreadable. */
48
+ const DEFAULTS = Object.freeze({
49
+ enabled: false,
50
+ issuer: '',
51
+ audience: '',
52
+ jwksUri: '',
53
+ algorithms: ['RS256', 'ES256'],
54
+ requiredScopes: [],
55
+ clockToleranceSec: 30,
56
+ });
57
+
58
+ /** A string field, defaulting to '' when not a non-empty string. */
59
+ function str(v, dflt) {
60
+ return typeof v === 'string' && v.length > 0 ? v : dflt;
61
+ }
62
+
63
+ /** A string[] field - keep only non-empty strings; fall back to `dflt` when not a usable array. */
64
+ function strArray(v, dflt) {
65
+ if (!Array.isArray(v)) return dflt.slice();
66
+ const out = v.filter((x) => typeof x === 'string' && x.length > 0);
67
+ return out.length > 0 ? out : dflt.slice();
68
+ }
69
+
70
+ /**
71
+ * getConfig - the resolved auth config. Reads the file fresh; a missing/unreadable/malformed file
72
+ * resolves to the safe defaults (disabled). NEVER throws.
73
+ *
74
+ * @returns {{ enabled:boolean, issuer:string, audience:string, jwksUri:string,
75
+ * algorithms:string[], requiredScopes:string[], clockToleranceSec:number }}
76
+ */
77
+ function getConfig() {
78
+ try {
79
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
80
+ const parsed = JSON.parse(raw);
81
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
82
+ return cloneDefaults();
83
+ }
84
+ const clock = Number(parsed.clockToleranceSec);
85
+ return {
86
+ enabled: parsed.enabled === true,
87
+ issuer: str(parsed.issuer, DEFAULTS.issuer),
88
+ audience: str(parsed.audience, DEFAULTS.audience),
89
+ jwksUri: str(parsed.jwksUri, DEFAULTS.jwksUri),
90
+ algorithms: strArray(parsed.algorithms, DEFAULTS.algorithms),
91
+ requiredScopes: strArray(parsed.requiredScopes, DEFAULTS.requiredScopes),
92
+ // Clamp to a sane, non-negative tolerance; a >5min skew signals a clock problem, not validity.
93
+ clockToleranceSec:
94
+ Number.isFinite(clock) && clock >= 0 ? Math.min(Math.floor(clock), 300) : DEFAULTS.clockToleranceSec,
95
+ };
96
+ } catch (_err) {
97
+ // Missing file = disabled (the safe default); also covers unreadable/malformed.
98
+ return cloneDefaults();
99
+ }
100
+ }
101
+
102
+ /** A fresh, mutation-safe copy of the defaults (arrays cloned so a caller can't poison the frozen one). */
103
+ function cloneDefaults() {
104
+ return {
105
+ enabled: DEFAULTS.enabled,
106
+ issuer: DEFAULTS.issuer,
107
+ audience: DEFAULTS.audience,
108
+ jwksUri: DEFAULTS.jwksUri,
109
+ algorithms: DEFAULTS.algorithms.slice(),
110
+ requiredScopes: DEFAULTS.requiredScopes.slice(),
111
+ clockToleranceSec: DEFAULTS.clockToleranceSec,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * configError - a human-readable reason the CURRENT config could not gate requests, or null if it
117
+ * is coherent enough to enforce. Used by the transport + UI to fail CLOSED with a clear message
118
+ * rather than silently mis-validating. Only meaningful when `enabled` is true.
119
+ *
120
+ * Enforcement requires: an issuer, an audience (the RFC 8707 confused-deputy defence - a gateway
121
+ * that does not bind tokens to its own resource URI is exploitable), at least one pinned algorithm,
122
+ * and a key source (explicit jwksUri OR an issuer to derive one from via OIDC discovery).
123
+ *
124
+ * @param {object} [cfg] defaults to getConfig()
125
+ * @returns {string|null}
126
+ */
127
+ function configError(cfg) {
128
+ const c = cfg || getConfig();
129
+ if (!c.enabled) return null; // disabled -> nothing to enforce, no error
130
+ if (!c.issuer) return 'auth enabled but "issuer" is not set';
131
+ if (!c.audience) return 'auth enabled but "audience" (this gateway\'s resource URI) is not set - required to bind tokens (RFC 8707)';
132
+ if (!Array.isArray(c.algorithms) || c.algorithms.length === 0) return 'auth enabled but no signature "algorithms" are pinned';
133
+ // (A key source is always available: issuer is mandatory above, and jwksUri is derived from it
134
+ // via OIDC discovery when not given - so no separate jwksUri-or-issuer check is needed here.)
135
+ return null;
136
+ }
137
+
138
+ /**
139
+ * setConfig - atomically merge a patch into auth/auth.config.json (temp + rename). Merges with the
140
+ * current resolved config so a partial patch (e.g. { enabled: true }) preserves the other fields.
141
+ * Creates the auth/ dir and the file if absent - this is the ONLY function that creates it.
142
+ *
143
+ * @param {object} patch any subset of the config fields
144
+ * @returns {object} the merged, written config
145
+ */
146
+ function setConfig(patch) {
147
+ const current = getConfig();
148
+ const p = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {};
149
+
150
+ const next = {
151
+ enabled: typeof p.enabled === 'boolean' ? p.enabled : current.enabled,
152
+ issuer: typeof p.issuer === 'string' ? p.issuer : current.issuer,
153
+ audience: typeof p.audience === 'string' ? p.audience : current.audience,
154
+ jwksUri: typeof p.jwksUri === 'string' ? p.jwksUri : current.jwksUri,
155
+ algorithms: Array.isArray(p.algorithms) ? strArray(p.algorithms, current.algorithms) : current.algorithms,
156
+ requiredScopes: Array.isArray(p.requiredScopes)
157
+ ? p.requiredScopes.filter((x) => typeof x === 'string' && x.length > 0)
158
+ : current.requiredScopes,
159
+ clockToleranceSec:
160
+ Number.isFinite(Number(p.clockToleranceSec)) && Number(p.clockToleranceSec) >= 0
161
+ ? Math.min(Math.floor(Number(p.clockToleranceSec)), 300)
162
+ : current.clockToleranceSec,
163
+ };
164
+
165
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
166
+ // Atomic write: unique temp file in the same dir, then rename over the target.
167
+ const tmp = CONFIG_PATH + '.' + process.pid + '.' + Date.now() + '.tmp';
168
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
169
+ fs.renameSync(tmp, CONFIG_PATH);
170
+
171
+ return next;
172
+ }
173
+
174
+ module.exports = { getConfig, setConfig, configError, CONFIG_PATH, DEFAULTS };