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.
- package/README.md +102 -213
- package/bin/toolfunnel.js +372 -147
- package/docs/KNOWN_BUGS.md +99 -225
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +70 -0
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +31 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -0
- package/mcp/servers/mock-upstream/server.js +192 -12
- package/package.json +1 -1
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +344 -344
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +275 -275
- package/src/core/hook-loader.js +791 -791
- package/src/core/hook-runner.js +346 -346
- package/src/core/logger.js +171 -171
- package/src/core/matcher.js +51 -51
- package/src/core/metrics.js +75 -75
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -77
- package/src/core/tool-registry.js +180 -180
- package/src/extend/add-hook.md +152 -142
- package/src/extend/add-mcp.md +122 -121
- package/src/extend/configure.md +87 -0
- package/src/extend/create-tool.md +168 -152
- package/src/extend/howto.js +102 -98
- package/src/extend/package.md +187 -187
- package/src/extend/wrap.md +99 -0
- package/src/mcp/aggregator.js +1246 -807
- package/src/mcp/expose-store.js +460 -430
- package/src/mcp/gated-run.js +201 -194
- package/src/mcp/http-transport.js +1311 -981
- package/src/mcp/mcp-client.js +907 -530
- package/src/mcp/modern.js +372 -0
- package/src/mcp/protocol.js +468 -466
- package/src/mcp/server.js +2688 -1322
- package/src/packages/packager.js +715 -715
- package/src/tools/drift.js +138 -138
- package/src/tools/registry.js +513 -511
- package/src/tools/tool-state.js +170 -104
- package/src/ui/public/app.js +1174 -1055
- package/src/ui/public/index.html +286 -249
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1348 -1190
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +112 -112
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -102
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -129
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -0
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +227 -216
- package/docs/admin-ui.md +0 -404
- package/docs/configuration.md +0 -402
- package/docs/hooks-and-gating.md +0 -428
- package/docs/logging.md +0 -223
- package/docs/management.md +0 -429
- package/docs/oauth.md +0 -103
- package/docs/releasing.md +0 -31
- package/docs/setup.md +0 -352
- 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
|
|
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
|
|
27
|
-
* `tools/call
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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",
|
package/src/auth/config.js
CHANGED
|
@@ -1,174 +1,174 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* auth/config.js
|
|
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
|
|
9
|
-
* no dependency.
|
|
10
|
-
*
|
|
11
|
-
* Design rules (mirrors src/core/logger.js
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* setConfig
|
|
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
|
|
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 };
|