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
package/src/ui/server.js
CHANGED
|
@@ -1,1190 +1,1348 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* server.js
|
|
5
|
-
*
|
|
6
|
-
* A zero-dependency node:http server that lets a human VIEW / SEARCH / CONFIGURE
|
|
7
|
-
* the gateway's tools, upstream MCPs, and hooks without hand-editing JSON. It does
|
|
8
|
-
* NOT reinvent any state: every read and write goes THROUGH the gateway's proven stores
|
|
9
|
-
* - the tool register (src/tools/registry.js :: loadRegistry
|
|
10
|
-
* - the enabled overlay (src/tools/tool-state.js :: loadToolState / setToolEnabled / clearToolState)
|
|
11
|
-
* - the MCP expose store (src/mcp/expose-store.js :: loadExposeStore
|
|
12
|
-
* - the hook loader (src/core/hook-loader.js :: loadManifest
|
|
13
|
-
* - the hook matcher (src/core/matcher.js :: matches)
|
|
14
|
-
* - the atomic writer (src/tools/registry.js :: atomicWriteJson) [tool Pre/Post gate writes]
|
|
15
|
-
* so a UI edit is byte-identical to a CLI edit and is visible to the running MCP
|
|
16
|
-
* server with no restart (the register/state/manifest are all re-read per call).
|
|
17
|
-
*
|
|
18
|
-
* The UI runs in-process for the trusted human operator, so it uses the stores
|
|
19
|
-
* DIRECTLY (no MCP round-trip). Every write is atomic (temp + rename) inside the store.
|
|
20
|
-
*
|
|
21
|
-
* SAFETY CONTRACT (mirrors src/mcp/http-transport.js):
|
|
22
|
-
* - start() HARD-REFUSES a non-loopback bind host. The UI has no auth path at all, and it can
|
|
23
|
-
* spawn processes (mcp add/discover) and write scripts (tools/hooks)
|
|
24
|
-
* an unauthenticated remote console. Loopback-only by design; no flag overrides it.
|
|
25
|
-
* - Binds 127.0.0.1 by default; a non-loopback Host header is rejected (DNS-rebind guard).
|
|
26
|
-
* - The request listener NEVER throws
|
|
27
|
-
* - start() rejects cleanly on EADDRINUSE (caller decides); stop() is idempotent.
|
|
28
|
-
* - Every store read is FRESH per request, so edits made elsewhere show immediately.
|
|
29
|
-
*
|
|
30
|
-
* CommonJS only. Node BUILT-INS only (node:http, node:fs, node:path)
|
|
31
|
-
* no CDN, no framework. The static assets under src/ui/public are vanilla HTML/CSS/JS.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
const http = require('node:http');
|
|
35
|
-
const fs = require('node:fs');
|
|
36
|
-
const path = require('node:path');
|
|
37
|
-
|
|
38
|
-
const { loadRegistry, atomicWriteJson, resolveMode } = require('../tools/registry');
|
|
39
|
-
const { loadToolState, isToolEnabled, isToolHidden, isToolHot, setToolEnabled, setToolHidden, setToolHot, clearToolState } = require('../tools/tool-state');
|
|
40
|
-
const { loadExposeStore } = require('../mcp/expose-store');
|
|
41
|
-
const { Aggregator } = require('../mcp/aggregator');
|
|
42
|
-
const { loadManifest } = require('../core/hook-loader');
|
|
43
|
-
const { matches } = require('../core/matcher');
|
|
44
|
-
const { META_TOOLS } = require('../mcp/protocol');
|
|
45
|
-
const logger = require('../core/logger');
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
'.
|
|
61
|
-
'.
|
|
62
|
-
'.
|
|
63
|
-
'.
|
|
64
|
-
'.
|
|
65
|
-
'.
|
|
66
|
-
'.
|
|
67
|
-
'.
|
|
68
|
-
'.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* @
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* @
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* @param {
|
|
119
|
-
* @param {
|
|
120
|
-
* @param {string}
|
|
121
|
-
* @
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
// so
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
'/api/tools/
|
|
162
|
-
'/api/tools/
|
|
163
|
-
'/api/tools/
|
|
164
|
-
'/api/tools/
|
|
165
|
-
'/api/tools/
|
|
166
|
-
'/api/
|
|
167
|
-
'/api/hooks/
|
|
168
|
-
'/api/
|
|
169
|
-
'/api/mcp/
|
|
170
|
-
'/api/
|
|
171
|
-
'/api/
|
|
172
|
-
'/api/
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
*
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* @param {
|
|
201
|
-
*
|
|
202
|
-
* @
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
// The
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
let
|
|
523
|
-
try {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
const
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
//
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
try {
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
return { status:
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
//
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
//
|
|
727
|
-
|
|
728
|
-
//
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
}
|
|
761
|
-
try {
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
return { status: 400, json: { ok: false, error: (
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
//
|
|
818
|
-
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
}
|
|
827
|
-
const
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
if (
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
} catch (err) {
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
//
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
'
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
'
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
status
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
const
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* server.js - the OPTIONAL config web UI for the gateway (loopback-only).
|
|
5
|
+
*
|
|
6
|
+
* A zero-dependency node:http server that lets a human VIEW / SEARCH / CONFIGURE
|
|
7
|
+
* the gateway's tools, upstream MCPs, and hooks without hand-editing JSON. It does
|
|
8
|
+
* NOT reinvent any state: every read and write goes THROUGH the gateway's proven stores -
|
|
9
|
+
* - the tool register (src/tools/registry.js :: loadRegistry -> add/update/remove/writeScript)
|
|
10
|
+
* - the enabled overlay (src/tools/tool-state.js :: loadToolState / setToolEnabled / clearToolState)
|
|
11
|
+
* - the MCP expose store (src/mcp/expose-store.js :: loadExposeStore -> addUpstream/addExpose/...)
|
|
12
|
+
* - the hook loader (src/core/hook-loader.js :: loadManifest -> addEntry/setEnabled/removeEntry)
|
|
13
|
+
* - the hook matcher (src/core/matcher.js :: matches)
|
|
14
|
+
* - the atomic writer (src/tools/registry.js :: atomicWriteJson) [tool Pre/Post gate writes]
|
|
15
|
+
* so a UI edit is byte-identical to a CLI edit and is visible to the running MCP
|
|
16
|
+
* server with no restart (the register/state/manifest are all re-read per call).
|
|
17
|
+
*
|
|
18
|
+
* The UI runs in-process for the trusted human operator, so it uses the stores
|
|
19
|
+
* DIRECTLY (no MCP round-trip). Every write is atomic (temp + rename) inside the store.
|
|
20
|
+
*
|
|
21
|
+
* SAFETY CONTRACT (mirrors src/mcp/http-transport.js):
|
|
22
|
+
* - start() HARD-REFUSES a non-loopback bind host. The UI has no auth path at all, and it can
|
|
23
|
+
* spawn processes (mcp add/discover) and write scripts (tools/hooks) - off-loopback it would be
|
|
24
|
+
* an unauthenticated remote console. Loopback-only by design; no flag overrides it.
|
|
25
|
+
* - Binds 127.0.0.1 by default; a non-loopback Host header is rejected (DNS-rebind guard).
|
|
26
|
+
* - The request listener NEVER throws - a bad request becomes a clean JSON error.
|
|
27
|
+
* - start() rejects cleanly on EADDRINUSE (caller decides); stop() is idempotent.
|
|
28
|
+
* - Every store read is FRESH per request, so edits made elsewhere show immediately.
|
|
29
|
+
*
|
|
30
|
+
* CommonJS only. Node BUILT-INS only (node:http, node:fs, node:path) - no new npm dep,
|
|
31
|
+
* no CDN, no framework. The static assets under src/ui/public are vanilla HTML/CSS/JS.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const http = require('node:http');
|
|
35
|
+
const fs = require('node:fs');
|
|
36
|
+
const path = require('node:path');
|
|
37
|
+
|
|
38
|
+
const { loadRegistry, atomicWriteJson, resolveMode } = require('../tools/registry');
|
|
39
|
+
const { loadToolState, isToolEnabled, isToolHidden, isToolHot, setToolEnabled, setToolHidden, setToolHot, clearToolState, getPassthrough, setPassthrough } = require('../tools/tool-state');
|
|
40
|
+
const { loadExposeStore } = require('../mcp/expose-store');
|
|
41
|
+
const { Aggregator } = require('../mcp/aggregator');
|
|
42
|
+
const { loadManifest } = require('../core/hook-loader');
|
|
43
|
+
const { matches } = require('../core/matcher');
|
|
44
|
+
const { META_TOOLS } = require('../mcp/protocol');
|
|
45
|
+
const logger = require('../core/logger');
|
|
46
|
+
const { loadServerConfig } = require('../core/server-config');
|
|
47
|
+
const authConfig = require('../auth/config');
|
|
48
|
+
const { isJoseInstalled, JOSE_PIN } = require('../auth/resource-server');
|
|
49
|
+
const { installJose } = require('../auth/install');
|
|
50
|
+
|
|
51
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
52
|
+
const DEFAULT_PORT = 9777;
|
|
53
|
+
// Config POST bodies are small, but an "add" may carry a scriptText file body; 256 KiB is
|
|
54
|
+
// generous headroom for a tool/hook script while still capping an abusive request.
|
|
55
|
+
const MAX_BODY_BYTES = 256 * 1024;
|
|
56
|
+
const VALID_HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse']);
|
|
57
|
+
|
|
58
|
+
// Static asset content types (vanilla, offline - no external fonts/CDN).
|
|
59
|
+
const CONTENT_TYPES = Object.freeze({
|
|
60
|
+
'.html': 'text/html; charset=utf-8',
|
|
61
|
+
'.css': 'text/css; charset=utf-8',
|
|
62
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
63
|
+
'.json': 'application/json; charset=utf-8',
|
|
64
|
+
'.jpg': 'image/jpeg',
|
|
65
|
+
'.jpeg': 'image/jpeg',
|
|
66
|
+
'.png': 'image/png',
|
|
67
|
+
'.svg': 'image/svg+xml',
|
|
68
|
+
'.ico': 'image/x-icon',
|
|
69
|
+
'.webp': 'image/webp',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Is the Host header loopback? We bind 127.0.0.1 only, but a forwarded/rebound
|
|
74
|
+
* request could still arrive with a non-loopback Host. Defence-in-depth: reject
|
|
75
|
+
* anything whose host part is not a recognised loopback name (mirrors
|
|
76
|
+
* http-transport.js::isLoopbackHost). A MISSING Host header is allowed - the bind
|
|
77
|
+
* address is already the hard boundary.
|
|
78
|
+
* @param {string|undefined} hostHeader
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
function isLoopbackHost(hostHeader) {
|
|
82
|
+
if (hostHeader == null || hostHeader === '') return true;
|
|
83
|
+
let host = String(hostHeader).trim();
|
|
84
|
+
if (host[0] === '[') {
|
|
85
|
+
const end = host.indexOf(']');
|
|
86
|
+
host = end === -1 ? host.slice(1) : host.slice(1, end);
|
|
87
|
+
} else {
|
|
88
|
+
// Strip an optional :port ONLY when unambiguous - exactly one colon means "host:port". A string
|
|
89
|
+
// with MULTIPLE colons is a bare IPv6 literal (e.g. "::1"); compare it whole rather than slicing
|
|
90
|
+
// it to "" at the first colon (which would mis-classify IPv6 loopback as non-loopback).
|
|
91
|
+
const first = host.indexOf(':');
|
|
92
|
+
if (first !== -1 && first === host.lastIndexOf(':')) host = host.slice(0, first);
|
|
93
|
+
}
|
|
94
|
+
host = host.toLowerCase();
|
|
95
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '0:0:0:0:0:0:0:1';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Is the BIND address loopback? STRICTER than the Host-header check above: a missing Host header
|
|
100
|
+
* is legitimately loopback (the bind address is the boundary), but a missing/empty BIND address is
|
|
101
|
+
* not a loopback claim - it must name a loopback host explicitly to pass.
|
|
102
|
+
* @param {string|undefined} bindHost
|
|
103
|
+
* @returns {boolean}
|
|
104
|
+
*/
|
|
105
|
+
function isLoopbackBindHost(bindHost) {
|
|
106
|
+
return typeof bindHost === 'string' && bindHost.trim() !== '' && isLoopbackHost(bindHost);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Regex-escape a literal so a matcher built from it FULL-matches the string verbatim. */
|
|
110
|
+
function escapeRegex(str) {
|
|
111
|
+
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The SURFACED name a discovered upstream tool is exposed under - an ENABLED expose `as` for
|
|
116
|
+
* (upstream, tool) if one exists, else the namespaced default `<upstream>_<tool>`. Mirrors
|
|
117
|
+
* Aggregator._surfacedName so the UI's per-tool curation keys match what the running gateway reads.
|
|
118
|
+
* @param {object} store the ExposeStore (for exposedName)
|
|
119
|
+
* @param {Array} enabledExposed store.listExposed({ upstream, enabledOnly:true })
|
|
120
|
+
* @param {string} upstreamId
|
|
121
|
+
* @param {string} toolName
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
function surfacedNameFor(store, enabledExposed, upstreamId, toolName) {
|
|
125
|
+
for (const e of enabledExposed || []) {
|
|
126
|
+
if (e && e.tool === toolName) return store.exposedName(e);
|
|
127
|
+
}
|
|
128
|
+
return `${upstreamId}_${toolName}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Race a promise against a timeout so a wedged upstream never hangs a UI request. Rejects with
|
|
132
|
+
* `msg` on timeout; the timer is unref'd so it never keeps the process alive. */
|
|
133
|
+
function withTimeout(promise, ms, msg) {
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const timer = setTimeout(() => reject(new Error(msg || 'timed out')), ms);
|
|
136
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
137
|
+
Promise.resolve(promise).then(
|
|
138
|
+
(v) => { clearTimeout(timer); resolve(v); },
|
|
139
|
+
(e) => { clearTimeout(timer); reject(e); }
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Diagnostics -> stderr (never the response). NEVER throws. */
|
|
145
|
+
function logErr(...parts) {
|
|
146
|
+
try {
|
|
147
|
+
process.stderr.write('[toolfunnel-ui] ' + parts.join(' ') + '\n');
|
|
148
|
+
} catch (_e) {
|
|
149
|
+
/* never let logging throw */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Config-change audit (UI -> activity log) ─────────────────────────────────────────────────────
|
|
154
|
+
// A management console mutates the gateway's security posture (which tools are visible/hot, which
|
|
155
|
+
// MCPs are attached, which hooks gate, whether OAuth is on). Auditing those changes is arguably more
|
|
156
|
+
// important than logging individual tool runs. This maps each config-MUTATING POST path to an audit
|
|
157
|
+
// record (body -> fields). Read-only / test endpoints (e.g. /api/mcp/discover) are deliberately ABSENT
|
|
158
|
+
// so they never log as a "change". Logging self-gates (logger.log is a no-op unless logging is on),
|
|
159
|
+
// so this is silent until the operator enables the activity log.
|
|
160
|
+
const CONFIG_EVENTS = {
|
|
161
|
+
'/api/tools/state': (b) => ({ event: 'tool_state', id: b.id, enabled: b.enabled, hidden: b.hidden, hot: b.hot }),
|
|
162
|
+
'/api/tools/hook': (b) => ({ event: 'tool_hook', id: b.id, hook: b.event, on: !!b.on }),
|
|
163
|
+
'/api/tools/add': (b) => ({ event: 'tool_add', id: b.entry && b.entry.id }),
|
|
164
|
+
'/api/tools/remove': (b) => ({ event: 'tool_remove', id: b.id }),
|
|
165
|
+
'/api/tools/mode': (b) => ({ event: 'tool_mode', id: b.id, mode: b.mode }),
|
|
166
|
+
'/api/tools/update': (b) => ({ event: 'tool_update', id: b.id }),
|
|
167
|
+
'/api/hooks/add': (b) => ({ event: 'hook_add', id: b.entry && b.entry.id }),
|
|
168
|
+
'/api/hooks/state': (b) => ({ event: 'hook_state', id: b.id, action: b.action }),
|
|
169
|
+
'/api/mcp/add': (b) => ({ event: 'mcp_add', id: b.upstream && b.upstream.id }),
|
|
170
|
+
'/api/mcp/state': (b) => ({ event: 'mcp_state', id: b.id, action: b.action }),
|
|
171
|
+
'/api/wrap': (b) => ({ event: 'wrap', upstream: (b && b.upstream) || null }),
|
|
172
|
+
'/api/identity': () => ({ event: 'identity_config' }),
|
|
173
|
+
'/api/auth/config': (b) => ({ event: 'auth_config', enabled: b.enabled, issuer: b.issuer }),
|
|
174
|
+
'/api/logs/config': (b) => ({ event: 'log_config', enabled: b.enabled }),
|
|
175
|
+
'/api/oauth/install': () => ({ event: 'oauth_install' }),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Write a config-change audit record (type:'config', via:'ui'). Drops undefined fields so a partial
|
|
180
|
+
* change (e.g. only `hot` toggled) records only what actually changed. Self-gating via logger.log
|
|
181
|
+
* (no-op unless logging is enabled). NEVER throws - an audit failure must not break a config write.
|
|
182
|
+
* @param {object} fields { event, ...changed }
|
|
183
|
+
*/
|
|
184
|
+
function logConfigChange(fields) {
|
|
185
|
+
try {
|
|
186
|
+
const rec = { type: 'config', via: 'ui' };
|
|
187
|
+
const f = fields || {};
|
|
188
|
+
for (const k of Object.keys(f)) {
|
|
189
|
+
if (f[k] !== undefined) rec[k] = f[k];
|
|
190
|
+
}
|
|
191
|
+
logger.log(rec);
|
|
192
|
+
} catch (_e) {
|
|
193
|
+
/* never let audit logging break the caller */
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* createUiServer - construct (but do NOT start) the config web UI server.
|
|
199
|
+
*
|
|
200
|
+
* @param {object} [opts]
|
|
201
|
+
* @param {string} [opts.host] bind address (default 127.0.0.1 - loopback only).
|
|
202
|
+
* @param {number} [opts.port] bind port (default 9777; pass 0 for an OS-assigned port).
|
|
203
|
+
* @param {string} [opts.root] config root holding tools/ mcp/ hooks/ (default: the resolved
|
|
204
|
+
* config home - TOOLFUNNEL_HOME / --config-dir / the package root).
|
|
205
|
+
* @returns {{ start: () => Promise<{url:string, port:number}>, stop: () => Promise<void>, get url():(string|null) }}
|
|
206
|
+
*/
|
|
207
|
+
function createUiServer(opts = {}) {
|
|
208
|
+
const o = opts || {};
|
|
209
|
+
const host = typeof o.host === 'string' && o.host.length > 0 ? o.host : DEFAULT_HOST;
|
|
210
|
+
// A port of 0 is VALID (OS-assigned) so distinguish "not provided" from 0.
|
|
211
|
+
const requestedPort = Number.isInteger(o.port) && o.port >= 0 ? o.port : DEFAULT_PORT;
|
|
212
|
+
// The config root: tools.register.json etc. live under <root>/tools, <root>/mcp, <root>/hooks.
|
|
213
|
+
// Default = the resolved config home (see src/core/config-home.js), so a --config-dir /
|
|
214
|
+
// TOOLFUNNEL_HOME gateway and its UI edit the SAME stores.
|
|
215
|
+
const { resolveConfigHome } = require('../core/config-home');
|
|
216
|
+
const root = typeof o.root === 'string' && o.root.length > 0 ? o.root : resolveConfigHome();
|
|
217
|
+
|
|
218
|
+
// ── Path anchors (everything resolved from the repo root) ────────────────────────────────────
|
|
219
|
+
const REGISTER_PATH = path.join(root, 'tools', 'tools.register.json');
|
|
220
|
+
const TOOL_STATE_PATH = path.join(root, 'tools', 'tools.state.json');
|
|
221
|
+
const EXPOSE_PATH = path.join(root, 'mcp', 'expose.json');
|
|
222
|
+
const MANIFEST_PATH = path.join(root, 'hooks', 'hooks.manifest.json');
|
|
223
|
+
const SCRIPTS_ROOT = path.join(root, 'tools', 'scripts');
|
|
224
|
+
// Where a Pre/Post hook script for a tool must be authored. The manifest command carries the
|
|
225
|
+
// PORTABLE ${HOOKS_DIR} token (expanded by the hook-loader at load); this is its real on-disk dir.
|
|
226
|
+
const HOOK_SCRIPTS_DIR = path.join(root, 'hooks', 'scripts');
|
|
227
|
+
const TOOLFUNNEL_JSON = path.join(root, 'toolfunnel.json');
|
|
228
|
+
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
229
|
+
|
|
230
|
+
// ── Mutable runtime state ─────────────────────────────────────────────────────────────────────
|
|
231
|
+
/** @type {import('node:http').Server|null} */
|
|
232
|
+
let httpServer = null;
|
|
233
|
+
/** @type {number|null} the actual bound port (resolved after listen - matters for port 0). */
|
|
234
|
+
let boundPort = null;
|
|
235
|
+
let started = false;
|
|
236
|
+
|
|
237
|
+
function currentUrl() {
|
|
238
|
+
return boundPort == null ? null : `http://${host}:${boundPort}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Store-backed config reads (FRESH per call so external edits show immediately) ──────────────
|
|
242
|
+
|
|
243
|
+
/** Read the hook manifest defensively -> { version, hooks:[] }. Never throws. */
|
|
244
|
+
function readManifest() {
|
|
245
|
+
try {
|
|
246
|
+
const d = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
247
|
+
if (d && typeof d === 'object' && !Array.isArray(d)) {
|
|
248
|
+
return {
|
|
249
|
+
version: typeof d.version === 'number' ? d.version : 1,
|
|
250
|
+
hooks: Array.isArray(d.hooks) ? d.hooks : [],
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
} catch (_e) {
|
|
254
|
+
/* missing/malformed -> empty manifest */
|
|
255
|
+
}
|
|
256
|
+
return { version: 1, hooks: [] };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Does any ENABLED manifest hook for `event` fire for `gateName`? (matcher.matches semantics) */
|
|
260
|
+
function hookFires(hooks, event, gateName) {
|
|
261
|
+
for (const h of hooks) {
|
|
262
|
+
if (!h || h.event !== event || h.enabled !== true) continue;
|
|
263
|
+
if (matches(h.matcher, gateName)) return true;
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The gate name the hook matcher is tested against: register `name`, falling back to `id`. */
|
|
269
|
+
function gateNameFor(entry) {
|
|
270
|
+
return (entry && entry.name) || (entry && entry.id) || '';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// GET /api/tools -> [{ id,name,summary,category, mode, enabled, hidden, hot, pre, post }]
|
|
274
|
+
function apiTools() {
|
|
275
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
276
|
+
const state = loadToolState(TOOL_STATE_PATH);
|
|
277
|
+
const hooks = readManifest().hooks;
|
|
278
|
+
return registry.list().map((b) => {
|
|
279
|
+
const gateName = b.name || b.id;
|
|
280
|
+
return {
|
|
281
|
+
id: b.id,
|
|
282
|
+
name: b.name,
|
|
283
|
+
summary: b.summary || '',
|
|
284
|
+
category: b.category || '',
|
|
285
|
+
// The RESOLVED execution mode ("gateway" runs the invoke here; "reference"
|
|
286
|
+
// hands the instructions back so the connected AI runs it). b.id is from the
|
|
287
|
+
// register so registry.mode never throws here.
|
|
288
|
+
mode: registry.mode(b.id),
|
|
289
|
+
// The visibility MATRIX axes (all keyed by the tool id):
|
|
290
|
+
// enabled - LEAN-VISIBLE in toolfunnel_list_tools + runnable (default ON).
|
|
291
|
+
// hidden - manager-list declutter only (default OFF).
|
|
292
|
+
// hot - promoted to the TOP-LEVEL every-turn surface (default OFF; opt-in).
|
|
293
|
+
enabled: isToolEnabled(state, b.id),
|
|
294
|
+
hidden: isToolHidden(state, b.id),
|
|
295
|
+
hot: isToolHot(state, b.id, false),
|
|
296
|
+
pre: hookFires(hooks, 'PreToolUse', gateName),
|
|
297
|
+
post: hookFires(hooks, 'PostToolUse', gateName),
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// POST /api/tools/state {id, enabled?, hidden?, hot?} -> persist whichever axes are present. Each
|
|
303
|
+
// is an independent merge-write (preserves the others). Backward-compatible: {id, enabled} still
|
|
304
|
+
// just flips enabled. `id` may be a local tool id, an upstream surfaced name, or a meta-tool name
|
|
305
|
+
// (the `hot` axis is keyed by the surfaced name the matrix assembler reads).
|
|
306
|
+
function apiSetState(body) {
|
|
307
|
+
const id = body && body.id;
|
|
308
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
309
|
+
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
310
|
+
}
|
|
311
|
+
let touched = false;
|
|
312
|
+
// Wrap the atomic writes (they can throw on EACCES/EPERM/ENOSPC) so the handler honours the
|
|
313
|
+
// POST contract "never throws" and returns a clean {ok:false} with a useful message, like the
|
|
314
|
+
// other write handlers - rather than escaping to the generic outer 500.
|
|
315
|
+
try {
|
|
316
|
+
if (Object.prototype.hasOwnProperty.call(body, 'enabled')) { setToolEnabled(TOOL_STATE_PATH, id, !!body.enabled); touched = true; }
|
|
317
|
+
if (Object.prototype.hasOwnProperty.call(body, 'hidden')) { setToolHidden(TOOL_STATE_PATH, id, !!body.hidden); touched = true; }
|
|
318
|
+
if (Object.prototype.hasOwnProperty.call(body, 'hot')) { setToolHot(TOOL_STATE_PATH, id, !!body.hot); touched = true; }
|
|
319
|
+
} catch (err) {
|
|
320
|
+
return { status: 500, json: { ok: false, error: (err && err.message) || 'tool state write failed' } };
|
|
321
|
+
}
|
|
322
|
+
if (!touched) {
|
|
323
|
+
return { status: 400, json: { ok: false, error: 'provide at least one of enabled, hidden, hot' } };
|
|
324
|
+
}
|
|
325
|
+
return { status: 200, json: { ok: true } };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// GET /api/surface -> the TOP-LEVEL (every-turn) surface summary + footgun warnings.
|
|
329
|
+
// meta - the 4 meta-tools with their hot state (default ON; hot:false hides one).
|
|
330
|
+
// promotedLocal - register tools promoted hot AND enabled.
|
|
331
|
+
// promotedOther - other hot+enabled state keys (upstream surfaced names) - a count proxy (the
|
|
332
|
+
// UI server can't enumerate live upstream tools without a discover, so this may
|
|
333
|
+
// over-count typo'd / disabled-upstream keys; it errs toward over-warning).
|
|
334
|
+
// curatedDirect - ENABLED expose[] entries: these are on the top-level surface every turn too
|
|
335
|
+
// (handleToolsList step 3 promotes every enabled expose entry, with NO hot flag),
|
|
336
|
+
// so the bloat warning MUST count them or it reports a false all-clear.
|
|
337
|
+
// warnings - hiding the management tools / promoting many (context bloat).
|
|
338
|
+
function apiSurface() {
|
|
339
|
+
const state = loadToolState(TOOL_STATE_PATH);
|
|
340
|
+
const metaNames = Object.values(META_TOOLS);
|
|
341
|
+
const meta = metaNames.map((name) => ({ name, hot: isToolHot(state, name, true) }));
|
|
342
|
+
|
|
343
|
+
let localIds = new Set();
|
|
344
|
+
try { localIds = new Set(loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT }).list().map((b) => b.id)); }
|
|
345
|
+
catch (_e) { /* report 0 promotions on a malformed register */ }
|
|
346
|
+
|
|
347
|
+
// Enabled expose[] entries are curated-direct: top-level every turn (no hot flag needed), so they
|
|
348
|
+
// count toward the bloat threshold exactly like a hot tool - statically enumerable, no discover.
|
|
349
|
+
// Collect their SURFACED names so a hot key that is ALSO an enabled-expose name isn't double-counted.
|
|
350
|
+
let curatedDirect = 0;
|
|
351
|
+
const exposedNames = new Set();
|
|
352
|
+
try {
|
|
353
|
+
const store = loadExposeStore(EXPOSE_PATH);
|
|
354
|
+
const exposed = store.listExposed({ enabledOnly: true });
|
|
355
|
+
curatedDirect = exposed.length;
|
|
356
|
+
for (const e of exposed) exposedNames.add(store.exposedName(e));
|
|
357
|
+
} catch (_e) { /* 0 on a malformed store */ }
|
|
358
|
+
|
|
359
|
+
let promotedLocal = 0;
|
|
360
|
+
let promotedOther = 0;
|
|
361
|
+
for (const key of Object.keys(state)) {
|
|
362
|
+
const e = state[key];
|
|
363
|
+
if (!e || e.hot !== true) continue; // only explicit promotions
|
|
364
|
+
if (metaNames.includes(key)) continue; // meta handled separately
|
|
365
|
+
if (e.enabled === false) continue; // disabled -> not actually on the surface
|
|
366
|
+
if (localIds.has(key)) promotedLocal += 1;
|
|
367
|
+
else if (exposedNames.has(key)) continue; // already counted as curated-direct (no double-count)
|
|
368
|
+
else promotedOther += 1;
|
|
369
|
+
}
|
|
370
|
+
const promotedTotal = promotedLocal + promotedOther + curatedDirect;
|
|
371
|
+
|
|
372
|
+
const warnings = [];
|
|
373
|
+
const listHot = (meta.find((m) => m.name === META_TOOLS.LIST) || {}).hot;
|
|
374
|
+
const runHot = (meta.find((m) => m.name === META_TOOLS.RUN) || {}).hot;
|
|
375
|
+
const metaOff = meta.filter((m) => !m.hot).length;
|
|
376
|
+
if (metaOff === metaNames.length) {
|
|
377
|
+
warnings.push('All four management tools are hidden from the top-level surface. A connected AI can no longer discover or run tools via the meta-tools - only the tools you have promoted are reachable. This is the "ordinary tools as an MCP" pattern; otherwise re-enable at least toolfunnel_list_tools and toolfunnel_run_tool.');
|
|
378
|
+
} else if (!listHot || !runHot) {
|
|
379
|
+
warnings.push('toolfunnel_list_tools and/or toolfunnel_run_tool is hidden - the AI may be unable to discover or run tools by name. Promote the specific tools it needs, or re-enable these meta-tools.');
|
|
380
|
+
}
|
|
381
|
+
if (promotedTotal > 10) {
|
|
382
|
+
warnings.push(promotedTotal + ' tools are promoted to every turn (including any enabled curated-direct expose entries). Each injects its schema into the model context on EVERY message - the opposite of the lean register. Promote only the few you call constantly; surface the rest leanly via toolfunnel_list_tools.');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return { meta, promotedLocal, promotedOther, curatedDirect, promotedTotal, warnings };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// POST /api/tools/hook {id, event, on} -> read-modify-write hooks.manifest.json atomically.
|
|
389
|
+
function apiSetHook(body) {
|
|
390
|
+
const id = body && body.id;
|
|
391
|
+
const event = body && body.event;
|
|
392
|
+
const on = !!(body && body.on);
|
|
393
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
394
|
+
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
395
|
+
}
|
|
396
|
+
if (!VALID_HOOK_EVENTS.has(event)) {
|
|
397
|
+
return { status: 400, json: { ok: false, error: 'event must be "PreToolUse" or "PostToolUse"' } };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Resolve the tool's GATE NAME (register name, falling back to id). Reuse the registry so an
|
|
401
|
+
// unknown id is rejected cleanly rather than fabricating a manifest entry for nothing.
|
|
402
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
403
|
+
if (!registry.has(id)) {
|
|
404
|
+
return { status: 404, json: { ok: false, error: `unknown tool id "${id}"` } };
|
|
405
|
+
}
|
|
406
|
+
const gateName = gateNameFor(registry.getEntry(id));
|
|
407
|
+
|
|
408
|
+
const manifest = readManifest();
|
|
409
|
+
const hooks = manifest.hooks;
|
|
410
|
+
const eventLower = event.toLowerCase();
|
|
411
|
+
const scriptName = `${id}-${eventLower}.js`;
|
|
412
|
+
const scriptPath = path.join(HOOK_SCRIPTS_DIR, scriptName);
|
|
413
|
+
|
|
414
|
+
if (on) {
|
|
415
|
+
// Append a literal-full-match hook iff one is not already enabled for (event, gateName).
|
|
416
|
+
if (!hookFires(hooks, event, gateName)) {
|
|
417
|
+
hooks.push({
|
|
418
|
+
event,
|
|
419
|
+
matcher: escapeRegex(gateName),
|
|
420
|
+
// The command carries the PORTABLE ${HOOKS_DIR} token (NOT interpolated here) - the
|
|
421
|
+
// hook-loader expands it to the absolute hooks dir at load time.
|
|
422
|
+
command: 'node "${HOOKS_DIR}/scripts/' + scriptName + '"',
|
|
423
|
+
enabled: true,
|
|
424
|
+
});
|
|
425
|
+
manifest.hooks = hooks;
|
|
426
|
+
atomicWriteJson(MANIFEST_PATH, manifest);
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
status: 200,
|
|
430
|
+
json: {
|
|
431
|
+
ok: true,
|
|
432
|
+
scriptPath,
|
|
433
|
+
note:
|
|
434
|
+
`Create ${scriptName} at this path to define the ${event} behaviour. ` +
|
|
435
|
+
'Until the script exists the gate is configured but has no script to run.',
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// OFF: drop every entry for this event whose matcher fires for this tool's gate name.
|
|
441
|
+
const next = hooks.filter((h) => !(h && h.event === event && matches(h.matcher, gateName)));
|
|
442
|
+
if (next.length !== hooks.length) {
|
|
443
|
+
manifest.hooks = next;
|
|
444
|
+
atomicWriteJson(MANIFEST_PATH, manifest);
|
|
445
|
+
}
|
|
446
|
+
return { status: 200, json: { ok: true } };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// GET /api/upstreams -> { upstreams:[...], expose:[...] } (READ-ONLY for v1).
|
|
450
|
+
function apiUpstreams() {
|
|
451
|
+
const store = loadExposeStore(EXPOSE_PATH);
|
|
452
|
+
return {
|
|
453
|
+
upstreams: store.listUpstreams(),
|
|
454
|
+
expose: store.listExposed(),
|
|
455
|
+
// The active passthrough wrap (0.6.0) - null when the normal funnel surface is live.
|
|
456
|
+
wrapping: getPassthrough(loadToolState(TOOL_STATE_PATH)),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* POST /api/wrap - set or clear the PASSTHROUGH wrap. Body { upstream:'<id>' } wraps (that
|
|
462
|
+
* upstream's tools become the ENTIRE surface, meta-tools hidden, every call still gated);
|
|
463
|
+
* { upstream:null } clears. The UI is one of the two recovery paths from an AI-set wrap
|
|
464
|
+
* (the other is `toolfunnel wrap --off`), so this endpoint must never itself be funnelled.
|
|
465
|
+
*/
|
|
466
|
+
function apiWrap(body) {
|
|
467
|
+
// Sibling handlers wrap their store writes so the "handler never throws, returns clean
|
|
468
|
+
// {ok:false,error}" contract holds even on EACCES/ENOSPC (mirrors apiSetState).
|
|
469
|
+
try {
|
|
470
|
+
const b = body || {};
|
|
471
|
+
const id = b.upstream;
|
|
472
|
+
if (id === null || id === undefined || id === '') {
|
|
473
|
+
const was = getPassthrough(loadToolState(TOOL_STATE_PATH));
|
|
474
|
+
setPassthrough(TOOL_STATE_PATH, null);
|
|
475
|
+
return { status: 200, json: { ok: true, wrapping: null, was } };
|
|
476
|
+
}
|
|
477
|
+
if (typeof id !== 'string') {
|
|
478
|
+
return { status: 400, json: { ok: false, error: 'upstream must be an attached upstream id, or null to clear' } };
|
|
479
|
+
}
|
|
480
|
+
const store = loadExposeStore(EXPOSE_PATH);
|
|
481
|
+
const upstream = store.getUpstream(id);
|
|
482
|
+
if (!upstream) {
|
|
483
|
+
return { status: 404, json: { ok: false, error: 'no upstream with id "' + id + '"' } };
|
|
484
|
+
}
|
|
485
|
+
if (upstream.enabled === false) {
|
|
486
|
+
return { status: 400, json: { ok: false, error: 'upstream "' + id + '" is disabled - enable it first' } };
|
|
487
|
+
}
|
|
488
|
+
setPassthrough(TOOL_STATE_PATH, id);
|
|
489
|
+
// Wrap security notice - same check the CLI prints: wrapping SUSPENDS the path-isolation
|
|
490
|
+
// guard for this upstream, so outside paths are permitted by design. The UI operator gets
|
|
491
|
+
// the same informed consent the CLI operator gets.
|
|
492
|
+
let warning = null;
|
|
493
|
+
try {
|
|
494
|
+
const { looksLikePath, isInside } = require('../mcp/aggregator');
|
|
495
|
+
const outsideArgs = (Array.isArray(upstream.args) ? upstream.args : [])
|
|
496
|
+
.filter((a) => looksLikePath(a) && !isInside(root, a));
|
|
497
|
+
if (outsideArgs.length) {
|
|
498
|
+
warning = 'WRAP SECURITY NOTICE: this upstream uses paths outside the gateway root (' +
|
|
499
|
+
outsideArgs.join(', ') + '). While wrapped, the path-isolation guard is suspended for it - ' +
|
|
500
|
+
'the wrapped server can reach whatever those paths reach. Every call still passes the ' +
|
|
501
|
+
'PreToolUse gate; to restrict it, add a PreToolUse hook or disable individual tools. ' +
|
|
502
|
+
'See the manual, "Wrapping & security".';
|
|
503
|
+
}
|
|
504
|
+
} catch (_e) { /* the notice is best-effort - never block the wrap on it */ }
|
|
505
|
+
return { status: 200, json: { ok: true, wrapping: id, ...(warning ? { warning } : {}) } };
|
|
506
|
+
} catch (err) {
|
|
507
|
+
return { status: 500, json: { ok: false, error: (err && err.message) || 'wrap update failed' } };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ── Identity & ports (toolfunnel.json - 0.6.0 identity settings) ─────────────────────────────
|
|
512
|
+
|
|
513
|
+
// GET /api/identity -> { file, effective, restartRequired:true }. `file` is the raw
|
|
514
|
+
// toolfunnel.json content (absent/bad JSON -> {}), `effective` is what the gateway resolves
|
|
515
|
+
// from it (flag > config > default - same loader the gateway boots with).
|
|
516
|
+
function apiIdentity() {
|
|
517
|
+
let file = {};
|
|
518
|
+
try {
|
|
519
|
+
const parsed = JSON.parse(fs.readFileSync(TOOLFUNNEL_JSON, 'utf8'));
|
|
520
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) file = parsed;
|
|
521
|
+
} catch (_e) { /* absent or bad JSON - the loader treats it as defaults too */ }
|
|
522
|
+
let effective = null;
|
|
523
|
+
try { effective = loadServerConfig(root); } catch (_e) { /* tolerant loader; belt-and-braces */ }
|
|
524
|
+
return { file, effective, restartRequired: true };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// POST /api/identity - merge identity/port fields into toolfunnel.json (unknown fields in the
|
|
528
|
+
// file are PRESERVED). A string field set to '' removes it (falls back to the default); ports
|
|
529
|
+
// must be 1..65535 or '' to remove. Changes take effect on the gateway's NEXT start (the server
|
|
530
|
+
// config is read at boot), which the UI states - no silent expectation of a live change.
|
|
531
|
+
// Preserved fields are NOT re-validated here: an invalid-typed value already in the file (e.g.
|
|
532
|
+
// serverName:42) rides along untouched - deliberate, because loadServerConfig's per-field
|
|
533
|
+
// tolerance is the single defence layer and it falls back to the default for exactly such
|
|
534
|
+
// fields at boot. Re-validating on write would silently EDIT config the operator didn't touch.
|
|
535
|
+
function apiSetIdentity(body) {
|
|
536
|
+
const b = body || {};
|
|
537
|
+
const STR_FIELDS = ['serverName', 'serverVersion', 'clientName', 'clientVersion'];
|
|
538
|
+
const PORT_FIELDS = ['httpPort', 'uiPort'];
|
|
539
|
+
try {
|
|
540
|
+
let file = {};
|
|
541
|
+
try {
|
|
542
|
+
const parsed = JSON.parse(fs.readFileSync(TOOLFUNNEL_JSON, 'utf8'));
|
|
543
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) file = parsed;
|
|
544
|
+
} catch (_e) { /* start from empty */ }
|
|
545
|
+
for (const f of STR_FIELDS) {
|
|
546
|
+
if (!(f in b)) continue;
|
|
547
|
+
const v = b[f];
|
|
548
|
+
if (v === '' || v === null) { delete file[f]; continue; }
|
|
549
|
+
if (typeof v !== 'string' || v.trim().length === 0) {
|
|
550
|
+
return { status: 400, json: { ok: false, error: f + ' must be a non-empty string (or empty to reset)' } };
|
|
551
|
+
}
|
|
552
|
+
file[f] = v.trim();
|
|
553
|
+
}
|
|
554
|
+
for (const f of PORT_FIELDS) {
|
|
555
|
+
if (!(f in b)) continue;
|
|
556
|
+
const v = b[f];
|
|
557
|
+
if (v === '' || v === null) { delete file[f]; continue; }
|
|
558
|
+
const n = Number(v);
|
|
559
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
560
|
+
return { status: 400, json: { ok: false, error: f + ' must be an integer 1..65535 (or empty to reset)' } };
|
|
561
|
+
}
|
|
562
|
+
file[f] = n;
|
|
563
|
+
}
|
|
564
|
+
// serveLegacy (modern-only policy): only an EXPLICIT false ever lands in the file -
|
|
565
|
+
// matching the loader's explicit-false-only flip. true/'' removes the field (the
|
|
566
|
+
// serve-both default), so an untouched config never carries policy it didn't ask for.
|
|
567
|
+
if ('serveLegacy' in b) {
|
|
568
|
+
const v = b.serveLegacy;
|
|
569
|
+
if (v === false || v === 'false') file.serveLegacy = false;
|
|
570
|
+
else delete file.serveLegacy;
|
|
571
|
+
}
|
|
572
|
+
atomicWriteJson(TOOLFUNNEL_JSON, file);
|
|
573
|
+
return { status: 200, json: { ok: true, file, effective: loadServerConfig(root), restartRequired: true } };
|
|
574
|
+
} catch (err) {
|
|
575
|
+
return { status: 500, json: { ok: false, error: (err && err.message) || 'identity update failed' } };
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// GET /api/status -> { tools, upstreams, hooks }.
|
|
580
|
+
function apiStatus() {
|
|
581
|
+
let tools = 0;
|
|
582
|
+
let upstreams = 0;
|
|
583
|
+
let hooks = 0;
|
|
584
|
+
try {
|
|
585
|
+
tools = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT }).list().length;
|
|
586
|
+
} catch (_e) { /* report 0 on a malformed register */ }
|
|
587
|
+
try {
|
|
588
|
+
upstreams = loadExposeStore(EXPOSE_PATH).listUpstreams().length;
|
|
589
|
+
} catch (_e) { /* report 0 */ }
|
|
590
|
+
hooks = readManifest().hooks.length;
|
|
591
|
+
return { tools, upstreams, hooks };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ── Activity log (logger.js - toggleable JSONL; never throws) ────────────────────────────────────
|
|
595
|
+
// The logger is its own self-gating store (DEFAULT OFF). getConfig/tail are contracted never to
|
|
596
|
+
// throw; setConfig is the writer and CAN throw, so its handler wraps it into a clean 500.
|
|
597
|
+
|
|
598
|
+
// GET /api/logs -> { entries:[...] } the last 100 parsed records (file order, oldest->newest).
|
|
599
|
+
function apiLogs() {
|
|
600
|
+
let entries = [];
|
|
601
|
+
try {
|
|
602
|
+
const t = logger.tail(100);
|
|
603
|
+
entries = Array.isArray(t) ? t : [];
|
|
604
|
+
} catch (_e) { /* tail is contracted not to throw; stay empty on any surprise */ }
|
|
605
|
+
return { entries };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// GET /api/logs/config -> { enabled, path } the resolved logger config (missing file -> disabled).
|
|
609
|
+
function apiLogsConfig() {
|
|
610
|
+
try {
|
|
611
|
+
return logger.getConfig();
|
|
612
|
+
} catch (_e) {
|
|
613
|
+
return { enabled: false, path: '' };
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// POST /api/logs/config { enabled?, path? } -> logger.setConfig (atomic merge). Validates field
|
|
618
|
+
// types so a bad shape is a clean 400 rather than reaching the writer; a write failure -> 500.
|
|
619
|
+
function apiSetLogsConfig(body) {
|
|
620
|
+
const b = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
|
|
621
|
+
const patch = {};
|
|
622
|
+
if (Object.prototype.hasOwnProperty.call(b, 'enabled')) {
|
|
623
|
+
if (typeof b.enabled !== 'boolean') {
|
|
624
|
+
return { status: 400, json: { ok: false, error: 'enabled must be a boolean' } };
|
|
625
|
+
}
|
|
626
|
+
patch.enabled = b.enabled;
|
|
627
|
+
}
|
|
628
|
+
if (Object.prototype.hasOwnProperty.call(b, 'path')) {
|
|
629
|
+
if (typeof b.path !== 'string' || b.path.length === 0) {
|
|
630
|
+
return { status: 400, json: { ok: false, error: 'path must be a non-empty string' } };
|
|
631
|
+
}
|
|
632
|
+
patch.path = b.path;
|
|
633
|
+
}
|
|
634
|
+
try {
|
|
635
|
+
const config = logger.setConfig(patch);
|
|
636
|
+
return { status: 200, json: { ok: true, config } };
|
|
637
|
+
} catch (err) {
|
|
638
|
+
return { status: 500, json: { ok: false, error: (err && err.message) || 'log config write failed' } };
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ── OAuth 2.1 (OPTIONAL; default OFF) - auth config + on-demand jose install ────────────────────
|
|
643
|
+
// No secrets here: issuer / audience / jwksUri are public identifiers (the JWKS itself is public),
|
|
644
|
+
// so the full config is safe to surface. `joseInstalled` + `configError` + `ready` drive the panel.
|
|
645
|
+
|
|
646
|
+
// GET /api/auth -> { config, joseInstalled, josePin, configError, ready }.
|
|
647
|
+
function apiAuth() {
|
|
648
|
+
let config;
|
|
649
|
+
try { config = authConfig.getConfig(); } catch (_e) { config = { enabled: false }; }
|
|
650
|
+
let jose = false;
|
|
651
|
+
try { jose = isJoseInstalled(); } catch (_e) { jose = false; }
|
|
652
|
+
let cfgErr = null;
|
|
653
|
+
try { cfgErr = authConfig.configError(config); } catch (_e) { cfgErr = null; }
|
|
654
|
+
// "ready" = if enabled, jose is present AND the config is coherent (the host would start).
|
|
655
|
+
const ready = !config.enabled || (jose && !cfgErr);
|
|
656
|
+
return { config, joseInstalled: jose, josePin: JOSE_PIN, configError: cfgErr, ready };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// POST /api/auth/config { enabled?, issuer?, audience?, jwksUri?, algorithms?, requiredScopes?,
|
|
660
|
+
// clockToleranceSec? } -> validate field types (clean 400 on a bad shape) then authConfig.setConfig
|
|
661
|
+
// (atomic merge). Permissive about partial configuration (you may set issuer now, audience later);
|
|
662
|
+
// the response reports joseInstalled + configError + ready so the UI can warn that an ENABLED but
|
|
663
|
+
// not-yet-ready config will be refused by the HTTP host at start.
|
|
664
|
+
function apiSetAuthConfig(body) {
|
|
665
|
+
const b = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
|
|
666
|
+
const patch = {};
|
|
667
|
+
const bad = (msg) => ({ status: 400, json: { ok: false, error: msg } });
|
|
668
|
+
|
|
669
|
+
if (Object.prototype.hasOwnProperty.call(b, 'enabled')) {
|
|
670
|
+
if (typeof b.enabled !== 'boolean') return bad('enabled must be a boolean');
|
|
671
|
+
patch.enabled = b.enabled;
|
|
672
|
+
}
|
|
673
|
+
for (const f of ['issuer', 'audience', 'jwksUri']) {
|
|
674
|
+
if (Object.prototype.hasOwnProperty.call(b, f)) {
|
|
675
|
+
if (typeof b[f] !== 'string') return bad(f + ' must be a string');
|
|
676
|
+
patch[f] = b[f];
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
for (const f of ['algorithms', 'requiredScopes']) {
|
|
680
|
+
if (Object.prototype.hasOwnProperty.call(b, f)) {
|
|
681
|
+
if (!Array.isArray(b[f]) || b[f].some((x) => typeof x !== 'string')) return bad(f + ' must be an array of strings');
|
|
682
|
+
patch[f] = b[f];
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (Object.prototype.hasOwnProperty.call(b, 'clockToleranceSec')) {
|
|
686
|
+
if (typeof b.clockToleranceSec !== 'number' || !Number.isFinite(b.clockToleranceSec) || b.clockToleranceSec < 0) {
|
|
687
|
+
return bad('clockToleranceSec must be a non-negative number');
|
|
688
|
+
}
|
|
689
|
+
patch.clockToleranceSec = b.clockToleranceSec;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Guard the classic footgun: enabling auth with no jose installed would make the HTTP host refuse
|
|
693
|
+
// to start. Block it with an actionable message rather than writing a config that breaks the host.
|
|
694
|
+
if (patch.enabled === true && !isJoseInstalled()) {
|
|
695
|
+
return bad('install the OAuth dependency first (click Install, or run `toolfunnel install-oauth`) before enabling auth');
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
let config;
|
|
699
|
+
try {
|
|
700
|
+
config = authConfig.setConfig(patch);
|
|
701
|
+
} catch (err) {
|
|
702
|
+
return { status: 500, json: { ok: false, error: (err && err.message) || 'auth config write failed' } };
|
|
703
|
+
}
|
|
704
|
+
const cfgErr = authConfig.configError(config);
|
|
705
|
+
const ready = !config.enabled || (isJoseInstalled() && !cfgErr);
|
|
706
|
+
return { status: 200, json: { ok: true, config, joseInstalled: isJoseInstalled(), configError: cfgErr, ready } };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// POST /api/oauth/install -> install the single optional dependency (jose@PIN) on demand. Async +
|
|
710
|
+
// potentially slow (it shells out to npm); installJose() owns a hard timeout and never throws.
|
|
711
|
+
async function apiOauthInstall() {
|
|
712
|
+
let res;
|
|
713
|
+
try {
|
|
714
|
+
res = await installJose();
|
|
715
|
+
} catch (err) {
|
|
716
|
+
return { status: 500, json: { ok: false, error: (err && err.message) || 'install failed' } };
|
|
717
|
+
}
|
|
718
|
+
// Trim npm's chatty output so the panel shows a useful tail without flooding the response.
|
|
719
|
+
const tail = (s) => (typeof s === 'string' ? s.slice(-2000) : '');
|
|
720
|
+
return {
|
|
721
|
+
status: res.ok ? 200 : 500,
|
|
722
|
+
json: { ok: res.ok, message: res.message, code: res.code, stdout: tail(res.stdout), stderr: tail(res.stderr), joseInstalled: isJoseInstalled() },
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// ── Tool register writes (registry.js - atomic via the store) ──────────────────────────────────
|
|
727
|
+
|
|
728
|
+
// POST /api/tools/add {entry} -> registry.add. When entry.scriptText is supplied for a SCRIPT
|
|
729
|
+
// invoke the body is authored under tools/scripts/<basename> FIRST (registry.writeScript, which
|
|
730
|
+
// is path-guarded), then the entry is registered. registry.add validates id/name/invoke/mode and
|
|
731
|
+
// rejects a duplicate id. Never throws - a bad shape becomes a clean 400.
|
|
732
|
+
function apiToolAdd(body) {
|
|
733
|
+
const entry = body && body.entry;
|
|
734
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
735
|
+
return { status: 400, json: { ok: false, error: 'entry (object) is required' } };
|
|
736
|
+
}
|
|
737
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
738
|
+
try {
|
|
739
|
+
const scriptText = entry.scriptText;
|
|
740
|
+
const clean = { ...entry };
|
|
741
|
+
delete clean.scriptText; // scriptText is a UI affordance, not a register field
|
|
742
|
+
// Author the script body before registering, so a script invoke points at a real file.
|
|
743
|
+
if (typeof scriptText === 'string' && clean.invoke && clean.invoke.type === 'script') {
|
|
744
|
+
registry.writeScript(clean.invoke.path, scriptText);
|
|
745
|
+
}
|
|
746
|
+
const added = registry.add(clean);
|
|
747
|
+
return { status: 200, json: { ok: true, entry: added } };
|
|
748
|
+
} catch (err) {
|
|
749
|
+
return { status: 400, json: { ok: false, error: (err && err.message) || 'tool add failed' } };
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// POST /api/tools/remove {id} -> registry.remove + clearToolState (drop the enabled/hidden overlay
|
|
754
|
+
// key so a re-added id starts clean). Unknown id -> 404.
|
|
755
|
+
function apiToolRemove(body) {
|
|
756
|
+
const id = body && body.id;
|
|
757
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
758
|
+
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
759
|
+
}
|
|
760
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
761
|
+
try {
|
|
762
|
+
registry.remove(id);
|
|
763
|
+
} catch (err) {
|
|
764
|
+
return { status: 404, json: { ok: false, error: (err && err.message) || 'tool remove failed' } };
|
|
765
|
+
}
|
|
766
|
+
// Best-effort overlay cleanup - a failure here must not undo the register removal.
|
|
767
|
+
try { clearToolState(TOOL_STATE_PATH, id); } catch (_e) { /* overlay cleanup is best-effort */ }
|
|
768
|
+
return { status: 200, json: { ok: true } };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// POST /api/tools/mode {id, mode} -> registry.update(id,{mode}). Switching a reference tool that has
|
|
772
|
+
// no invoke to "gateway" fails validation (nothing to run) and surfaces as a clean 400.
|
|
773
|
+
function apiToolMode(body) {
|
|
774
|
+
const id = body && body.id;
|
|
775
|
+
const mode = body && body.mode;
|
|
776
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
777
|
+
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
778
|
+
}
|
|
779
|
+
if (mode !== 'reference' && mode !== 'gateway') {
|
|
780
|
+
return { status: 400, json: { ok: false, error: 'mode must be "reference" or "gateway"' } };
|
|
781
|
+
}
|
|
782
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
783
|
+
try {
|
|
784
|
+
const updated = registry.update(id, { mode });
|
|
785
|
+
return { status: 200, json: { ok: true, mode: resolveMode(updated) } };
|
|
786
|
+
} catch (err) {
|
|
787
|
+
return { status: 400, json: { ok: false, error: (err && err.message) || 'tool mode update failed' } };
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// GET /api/tools/detail?id=<id> -> the FULL entry (name, summary, category, instructions, invoke,
|
|
792
|
+
// mode) plus the script BODY for a script invoke, so the UI can show + edit everything about one
|
|
793
|
+
// tool. Read fresh; unknown id -> 404.
|
|
794
|
+
function apiToolDetail(id) {
|
|
795
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
796
|
+
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
797
|
+
}
|
|
798
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
799
|
+
let entry;
|
|
800
|
+
try {
|
|
801
|
+
entry = registry.getEntry(id);
|
|
802
|
+
} catch (err) {
|
|
803
|
+
return { status: 404, json: { ok: false, error: (err && err.message) || `unknown tool id "${id}"` } };
|
|
804
|
+
}
|
|
805
|
+
// For a script invoke, read the body so the user can view/edit it. Resolve strictly inside the
|
|
806
|
+
// scripts root (defence-in-depth via basename); a missing/unreadable file leaves scriptText null.
|
|
807
|
+
let scriptText = null;
|
|
808
|
+
try {
|
|
809
|
+
if (entry.invoke && entry.invoke.type === 'script' && entry.invoke.path) {
|
|
810
|
+
const sp = path.join(SCRIPTS_ROOT, path.basename(entry.invoke.path));
|
|
811
|
+
if (fs.existsSync(sp)) scriptText = fs.readFileSync(sp, 'utf8');
|
|
812
|
+
}
|
|
813
|
+
} catch (_e) { /* leave scriptText null on any read failure */ }
|
|
814
|
+
return { status: 200, json: { ok: true, entry, scriptText } };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// POST /api/tools/update {id, patch:{name?,summary?,category?,instructions?,mode?,invoke?}, scriptText?}
|
|
818
|
+
// -> registry.update (shallow-merge + re-validate + atomic persist). When scriptText is supplied for
|
|
819
|
+
// a script invoke, the body is authored under tools/scripts/ (path-guarded) first. A bad shape /
|
|
820
|
+
// unknown id surfaces as a clean 400/404 (registry.update throws -> caught).
|
|
821
|
+
function apiToolUpdate(body) {
|
|
822
|
+
const id = body && body.id;
|
|
823
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
824
|
+
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
825
|
+
}
|
|
826
|
+
const patch = body && body.patch && typeof body.patch === 'object' && !Array.isArray(body.patch) ? body.patch : {};
|
|
827
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
828
|
+
if (!registry.has(id)) {
|
|
829
|
+
return { status: 404, json: { ok: false, error: `unknown tool id "${id}"` } };
|
|
830
|
+
}
|
|
831
|
+
try {
|
|
832
|
+
// Author the script body first (if provided) so the file matches the (possibly updated) invoke.
|
|
833
|
+
if (typeof body.scriptText === 'string') {
|
|
834
|
+
const inv = (patch.invoke && patch.invoke.type) ? patch.invoke : registry.getEntry(id).invoke;
|
|
835
|
+
if (inv && inv.type === 'script' && inv.path) registry.writeScript(inv.path, body.scriptText);
|
|
836
|
+
}
|
|
837
|
+
const updated = registry.update(id, patch);
|
|
838
|
+
return { status: 200, json: { ok: true, entry: updated } };
|
|
839
|
+
} catch (err) {
|
|
840
|
+
return { status: 400, json: { ok: false, error: (err && err.message) || 'tool update failed' } };
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ── Hook manifest writes (hook-loader.js - atomic; overlay-aware) ───────────────────────────────
|
|
845
|
+
|
|
846
|
+
/** The relative scripts/<file> a hook command references, if any (so a scriptText lands where the
|
|
847
|
+
* command will run it). Returns null when the command names no scripts/ file. */
|
|
848
|
+
function deriveHookScriptRel(command) {
|
|
849
|
+
if (typeof command !== 'string') return null;
|
|
850
|
+
const m = command.match(/scripts[\\/]([A-Za-z0-9._-]+)/);
|
|
851
|
+
return m ? 'scripts/' + m[1] : null;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// GET /api/hooks -> { hooks:[{ id,event,matcher,enabled,description }] }. loadManifest applies the
|
|
855
|
+
// hooks.state.json overlay over the manifest seed, so `enabled` is the LIVE state. Tool Pre/Post
|
|
856
|
+
// gates authored via /api/tools/hook have no id and are surfaced with id:"" (managed on the Tools tab).
|
|
857
|
+
function apiHooks() {
|
|
858
|
+
let loader;
|
|
859
|
+
try {
|
|
860
|
+
loader = loadManifest(MANIFEST_PATH);
|
|
861
|
+
} catch (_e) {
|
|
862
|
+
return { hooks: [] };
|
|
863
|
+
}
|
|
864
|
+
return {
|
|
865
|
+
hooks: loader.hooks.map((h) => ({
|
|
866
|
+
id: typeof h.id === 'string' ? h.id : '',
|
|
867
|
+
event: h.event || '',
|
|
868
|
+
matcher: h.matcher == null ? '' : String(h.matcher),
|
|
869
|
+
enabled: h.enabled === true,
|
|
870
|
+
description: h.description || '',
|
|
871
|
+
})),
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// POST /api/hooks/add {entry} -> hookLoader.addEntry (validates id/event/command + rejects dup id).
|
|
876
|
+
// When entry.scriptText is supplied the body is authored under hooks/scripts/ AFTER the entry exists
|
|
877
|
+
// (writeScript resolves the path from the entry's `script`, derived from the command if not given).
|
|
878
|
+
function apiHookAdd(body) {
|
|
879
|
+
const entry = body && body.entry;
|
|
880
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
881
|
+
return { status: 400, json: { ok: false, error: 'entry (object) is required' } };
|
|
882
|
+
}
|
|
883
|
+
let loader;
|
|
884
|
+
try {
|
|
885
|
+
loader = loadManifest(MANIFEST_PATH);
|
|
886
|
+
} catch (err) {
|
|
887
|
+
return { status: 500, json: { ok: false, error: 'cannot read hook manifest: ' + ((err && err.message) || err) } };
|
|
888
|
+
}
|
|
889
|
+
try {
|
|
890
|
+
const scriptText = entry.scriptText;
|
|
891
|
+
const spec = { ...entry };
|
|
892
|
+
delete spec.scriptText;
|
|
893
|
+
// If a script body is supplied but no explicit `script`, derive it from the command so the
|
|
894
|
+
// file written is the one the command runs.
|
|
895
|
+
if (typeof scriptText === 'string' && scriptText.length > 0 && (typeof spec.script !== 'string' || !spec.script)) {
|
|
896
|
+
const rel = deriveHookScriptRel(spec.command);
|
|
897
|
+
if (rel) spec.script = rel;
|
|
898
|
+
}
|
|
899
|
+
loader.addEntry(spec); // validates id/event/command; throws on duplicate id
|
|
900
|
+
if (typeof scriptText === 'string' && scriptText.length > 0) {
|
|
901
|
+
loader.writeScript(spec.id, scriptText); // path-guarded to hooks/scripts
|
|
902
|
+
}
|
|
903
|
+
return { status: 200, json: { ok: true, id: spec.id } };
|
|
904
|
+
} catch (err) {
|
|
905
|
+
return { status: 400, json: { ok: false, error: (err && err.message) || 'hook add failed' } };
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// POST /api/hooks/state {id, action} -> enable/disable (setEnabled, overlay-backed) or remove
|
|
910
|
+
// (removeEntry). Unknown id -> 404. setEnabled does not itself verify existence, so we check first
|
|
911
|
+
// to avoid writing a stray overlay key for a non-existent hook.
|
|
912
|
+
function apiHookState(body) {
|
|
913
|
+
const id = body && body.id;
|
|
914
|
+
const action = body && body.action;
|
|
915
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
916
|
+
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
917
|
+
}
|
|
918
|
+
if (action !== 'enable' && action !== 'disable' && action !== 'remove') {
|
|
919
|
+
return { status: 400, json: { ok: false, error: 'action must be "enable", "disable", or "remove"' } };
|
|
920
|
+
}
|
|
921
|
+
let loader;
|
|
922
|
+
try {
|
|
923
|
+
loader = loadManifest(MANIFEST_PATH);
|
|
924
|
+
} catch (err) {
|
|
925
|
+
return { status: 500, json: { ok: false, error: 'cannot read hook manifest: ' + ((err && err.message) || err) } };
|
|
926
|
+
}
|
|
927
|
+
try {
|
|
928
|
+
if (action === 'remove') {
|
|
929
|
+
const removed = loader.removeEntry(id);
|
|
930
|
+
if (!removed) {
|
|
931
|
+
return { status: 404, json: { ok: false, error: `unknown hook id "${id}"` } };
|
|
932
|
+
}
|
|
933
|
+
} else {
|
|
934
|
+
if (!loader.getSpec(id)) {
|
|
935
|
+
return { status: 404, json: { ok: false, error: `unknown hook id "${id}"` } };
|
|
936
|
+
}
|
|
937
|
+
loader.setEnabled(id, action === 'enable');
|
|
938
|
+
}
|
|
939
|
+
return { status: 200, json: { ok: true } };
|
|
940
|
+
} catch (err) {
|
|
941
|
+
return { status: 400, json: { ok: false, error: (err && err.message) || 'hook state update failed' } };
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// ── MCP upstream / expose writes (expose-store.js - atomic) ─────────────────────────────────────
|
|
946
|
+
|
|
947
|
+
// POST /api/mcp/add {upstream, expose?:[...]} -> addUpstream then addExpose per item. addUpstream
|
|
948
|
+
// validates a unique non-empty id, transport, and a stdio command. Each expose item is keyed to the
|
|
949
|
+
// new upstream id.
|
|
950
|
+
function apiMcpAdd(body) {
|
|
951
|
+
const upstream = body && body.upstream;
|
|
952
|
+
if (!upstream || typeof upstream !== 'object' || Array.isArray(upstream)) {
|
|
953
|
+
return { status: 400, json: { ok: false, error: 'upstream (object) is required' } };
|
|
954
|
+
}
|
|
955
|
+
const store = loadExposeStore(EXPOSE_PATH);
|
|
956
|
+
try {
|
|
957
|
+
const added = store.addUpstream(upstream);
|
|
958
|
+
const items = Array.isArray(body && body.expose) ? body.expose : [];
|
|
959
|
+
const exposed = [];
|
|
960
|
+
for (const item of items) {
|
|
961
|
+
exposed.push(store.addExpose({ ...item, upstream: added.id }));
|
|
962
|
+
}
|
|
963
|
+
return { status: 200, json: { ok: true, upstream: added, expose: exposed } };
|
|
964
|
+
} catch (err) {
|
|
965
|
+
return { status: 400, json: { ok: false, error: (err && err.message) || 'mcp add failed' } };
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// POST /api/mcp/state {id, action} -> enable/disable (setUpstreamEnabled) or remove (removeUpstream,
|
|
970
|
+
// which cascades the upstream's expose entries). Unknown id -> 404.
|
|
971
|
+
function apiMcpState(body) {
|
|
972
|
+
const id = body && body.id;
|
|
973
|
+
const action = body && body.action;
|
|
974
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
975
|
+
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
976
|
+
}
|
|
977
|
+
if (action !== 'enable' && action !== 'disable' && action !== 'remove' && action !== 'pin' && action !== 'unpin') {
|
|
978
|
+
return { status: 400, json: { ok: false, error: 'action must be "enable", "disable", "remove", "pin", or "unpin"' } };
|
|
979
|
+
}
|
|
980
|
+
const store = loadExposeStore(EXPOSE_PATH);
|
|
981
|
+
try {
|
|
982
|
+
if (action === 'remove') {
|
|
983
|
+
store.removeUpstream(id);
|
|
984
|
+
} else if (action === 'pin' || action === 'unpin') {
|
|
985
|
+
// legacyPin (0.6.0): keep speaking MCP 2024-11-05 to this upstream forever - opt-in,
|
|
986
|
+
// warns at startup and per call. Takes effect on the upstream's NEXT (re)connect.
|
|
987
|
+
store.updateUpstream(id, { legacyPin: action === 'pin' });
|
|
988
|
+
return { status: 200, json: { ok: true, legacyPin: action === 'pin' } };
|
|
989
|
+
} else {
|
|
990
|
+
store.setUpstreamEnabled(id, action === 'enable');
|
|
991
|
+
}
|
|
992
|
+
// Disabling/removing the WRAPPED upstream would brick the surface (empty list, no in-band
|
|
993
|
+
// recovery) - auto-clear the wrap so the funnel comes back.
|
|
994
|
+
let wrapCleared = false;
|
|
995
|
+
if (action !== 'enable') {
|
|
996
|
+
try {
|
|
997
|
+
if (getPassthrough(loadToolState(TOOL_STATE_PATH)) === id) {
|
|
998
|
+
setPassthrough(TOOL_STATE_PATH, null);
|
|
999
|
+
wrapCleared = true;
|
|
1000
|
+
}
|
|
1001
|
+
} catch (_e) { /* never let wrap-cleanup sink the primary action */ }
|
|
1002
|
+
}
|
|
1003
|
+
return { status: 200, json: { ok: true, ...(wrapCleared ? { wrapCleared: true } : {}) } };
|
|
1004
|
+
} catch (err) {
|
|
1005
|
+
return { status: 404, json: { ok: false, error: (err && err.message) || 'mcp state update failed' } };
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// POST /api/mcp/discover {id} -> LIVE connect + tools/list of one upstream (the "Test / Discover"
|
|
1010
|
+
// button). Constructs a throwaway Aggregator over the SAME store (so the isolation guard applies),
|
|
1011
|
+
// connects + lists, then closes it. Returns each discovered tool with its SURFACED name (an enabled
|
|
1012
|
+
// expose `as` else `<id>_<tool>` - exactly the name the lean list / matrix use) and its current
|
|
1013
|
+
// lean (enabled) + hot state, so the UI can curate per upstream tool. A connect/list failure (bad
|
|
1014
|
+
// command, crash, isolation) is a real user-facing outcome -> 200 { ok:false, error }, not a 500.
|
|
1015
|
+
async function apiMcpDiscover(body) {
|
|
1016
|
+
const id = body && body.id;
|
|
1017
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
1018
|
+
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
1019
|
+
}
|
|
1020
|
+
const store = loadExposeStore(EXPOSE_PATH);
|
|
1021
|
+
if (!store.getUpstream(id)) {
|
|
1022
|
+
return { status: 404, json: { ok: false, error: `unknown upstream "${id}"` } };
|
|
1023
|
+
}
|
|
1024
|
+
// Same wrap exemption as the live gateway: the WRAPPED upstream may reference paths outside
|
|
1025
|
+
// the root (transparent-wrapper mode). Without this provider the throwaway Aggregator
|
|
1026
|
+
// hard-refused exactly the upstreams the wrap security notice tells operators to curate
|
|
1027
|
+
// HERE - per-tool enabled:false via this discover.
|
|
1028
|
+
const agg = new Aggregator({
|
|
1029
|
+
store, v3Root: root,
|
|
1030
|
+
wrapTargetProvider: () => {
|
|
1031
|
+
try { return getPassthrough(loadToolState(TOOL_STATE_PATH)); } catch (_e) { return null; }
|
|
1032
|
+
},
|
|
1033
|
+
});
|
|
1034
|
+
let tools;
|
|
1035
|
+
try {
|
|
1036
|
+
// discover() connects the upstream lazily (isolation-guarded), lists its tools, caches+returns.
|
|
1037
|
+
// Race a timeout so a wedged upstream never hangs the UI request (closeAll reaps the child).
|
|
1038
|
+
tools = await withTimeout(agg.discover(id), 20000, `discover "${id}" timed out`);
|
|
1039
|
+
} catch (err) {
|
|
1040
|
+
try { await agg.closeAll(); } catch (_e) { /* best-effort teardown */ }
|
|
1041
|
+
return { status: 200, json: { ok: false, id, error: (err && err.message) || 'discover failed' } };
|
|
1042
|
+
}
|
|
1043
|
+
try { await agg.closeAll(); } catch (_e) { /* best-effort teardown */ }
|
|
1044
|
+
|
|
1045
|
+
const state = loadToolState(TOOL_STATE_PATH);
|
|
1046
|
+
const enabledExposed = store.listExposed({ upstream: id, enabledOnly: true });
|
|
1047
|
+
const items = (Array.isArray(tools) ? tools : []).map((t) => {
|
|
1048
|
+
const surfaced = surfacedNameFor(store, enabledExposed, id, t && t.name);
|
|
1049
|
+
return {
|
|
1050
|
+
tool: (t && t.name) || '',
|
|
1051
|
+
name: surfaced,
|
|
1052
|
+
description: (t && typeof t.description === 'string') ? t.description : '',
|
|
1053
|
+
enabled: isToolEnabled(state, surfaced),
|
|
1054
|
+
hot: isToolHot(state, surfaced, false),
|
|
1055
|
+
};
|
|
1056
|
+
});
|
|
1057
|
+
return { status: 200, json: { ok: true, id, tools: items } };
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// The POST routing table - path -> handler. Each handler takes the parsed body and returns
|
|
1061
|
+
// { status, json } (or a Promise of it - the dispatcher awaits); none throws (every store call is
|
|
1062
|
+
// wrapped). Built once per server instance.
|
|
1063
|
+
const POST_HANDLERS = {
|
|
1064
|
+
'/api/tools/state': apiSetState,
|
|
1065
|
+
'/api/tools/hook': apiSetHook,
|
|
1066
|
+
'/api/tools/add': apiToolAdd,
|
|
1067
|
+
'/api/tools/remove': apiToolRemove,
|
|
1068
|
+
'/api/tools/mode': apiToolMode,
|
|
1069
|
+
'/api/tools/update': apiToolUpdate,
|
|
1070
|
+
'/api/hooks/add': apiHookAdd,
|
|
1071
|
+
'/api/hooks/state': apiHookState,
|
|
1072
|
+
'/api/mcp/add': apiMcpAdd,
|
|
1073
|
+
'/api/mcp/state': apiMcpState,
|
|
1074
|
+
'/api/mcp/discover': apiMcpDiscover,
|
|
1075
|
+
'/api/wrap': apiWrap,
|
|
1076
|
+
'/api/identity': apiSetIdentity,
|
|
1077
|
+
'/api/logs/config': apiSetLogsConfig,
|
|
1078
|
+
'/api/auth/config': apiSetAuthConfig,
|
|
1079
|
+
'/api/oauth/install': apiOauthInstall,
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
// ── HTTP plumbing ───────────────────────────────────────────────────────────────────────────
|
|
1083
|
+
|
|
1084
|
+
/** Read a request body up to MAX_BODY_BYTES, then JSON.parse. Resolves {} for an empty body. */
|
|
1085
|
+
function readJsonBody(req) {
|
|
1086
|
+
return new Promise((resolve, reject) => {
|
|
1087
|
+
let size = 0;
|
|
1088
|
+
const chunks = [];
|
|
1089
|
+
let done = false;
|
|
1090
|
+
const finish = (fn, val) => { if (!done) { done = true; fn(val); } };
|
|
1091
|
+
req.on('data', (chunk) => {
|
|
1092
|
+
size += chunk.length;
|
|
1093
|
+
if (size > MAX_BODY_BYTES) {
|
|
1094
|
+
finish(reject, new Error('request body too large'));
|
|
1095
|
+
try { req.destroy(); } catch (_e) { /* ignore */ }
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
chunks.push(chunk);
|
|
1099
|
+
});
|
|
1100
|
+
req.on('end', () => {
|
|
1101
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
1102
|
+
if (raw.length === 0) return finish(resolve, {});
|
|
1103
|
+
try {
|
|
1104
|
+
finish(resolve, JSON.parse(raw));
|
|
1105
|
+
} catch (_e) {
|
|
1106
|
+
finish(reject, new Error('invalid JSON body'));
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
req.on('error', (err) => finish(reject, err));
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/** Write a JSON response. Defensive: a serialise failure degrades to a 500 text body. */
|
|
1114
|
+
function sendJson(res, status, obj) {
|
|
1115
|
+
let bodyStr;
|
|
1116
|
+
try {
|
|
1117
|
+
bodyStr = JSON.stringify(obj);
|
|
1118
|
+
} catch (_e) {
|
|
1119
|
+
bodyStr = '{"ok":false,"error":"failed to serialise response"}';
|
|
1120
|
+
status = 500;
|
|
1121
|
+
}
|
|
1122
|
+
res.writeHead(status, {
|
|
1123
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
1124
|
+
'Content-Length': Buffer.byteLength(bodyStr, 'utf8'),
|
|
1125
|
+
'Cache-Control': 'no-store',
|
|
1126
|
+
});
|
|
1127
|
+
res.end(bodyStr);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** Serve a static file from PUBLIC_DIR (read FRESH). 404 JSON when absent / outside the dir. */
|
|
1131
|
+
function sendStatic(res, relName) {
|
|
1132
|
+
// Resolve strictly inside PUBLIC_DIR (defence-in-depth against traversal).
|
|
1133
|
+
const resolved = path.resolve(PUBLIC_DIR, relName);
|
|
1134
|
+
const baseWithSep = PUBLIC_DIR.endsWith(path.sep) ? PUBLIC_DIR : PUBLIC_DIR + path.sep;
|
|
1135
|
+
if (resolved !== PUBLIC_DIR && !resolved.startsWith(baseWithSep)) {
|
|
1136
|
+
return sendJson(res, 403, { ok: false, error: 'forbidden' });
|
|
1137
|
+
}
|
|
1138
|
+
let data;
|
|
1139
|
+
try {
|
|
1140
|
+
data = fs.readFileSync(resolved);
|
|
1141
|
+
} catch (_e) {
|
|
1142
|
+
return sendJson(res, 404, { ok: false, error: 'not found: ' + relName });
|
|
1143
|
+
}
|
|
1144
|
+
const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()] || 'application/octet-stream';
|
|
1145
|
+
res.writeHead(200, {
|
|
1146
|
+
'Content-Type': type,
|
|
1147
|
+
'Content-Length': data.length,
|
|
1148
|
+
'Cache-Control': 'no-store',
|
|
1149
|
+
});
|
|
1150
|
+
res.end(data);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** The single request listener. Dispatches by method + path. NEVER throws. */
|
|
1154
|
+
async function onRequest(req, res) {
|
|
1155
|
+
try {
|
|
1156
|
+
// DNS-rebinding / non-loopback guard (defence-in-depth on top of the bind address).
|
|
1157
|
+
if (!isLoopbackHost(req.headers && req.headers.host)) {
|
|
1158
|
+
return sendJson(res, 403, { ok: false, error: 'forbidden: non-loopback Host' });
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const rawUrl = req.url || '/';
|
|
1162
|
+
const qIndex = rawUrl.indexOf('?');
|
|
1163
|
+
const pathName = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
|
|
1164
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
1165
|
+
|
|
1166
|
+
// CSRF guard. The bind address stops the NETWORK, not the browser: any web page the
|
|
1167
|
+
// operator visits can fire a no-cors POST at 127.0.0.1:<port>, and the mutating routes
|
|
1168
|
+
// below write tool scripts and register process-spawning upstreams. Browsers ALWAYS send
|
|
1169
|
+
// an Origin header on cross-origin POSTs, so: a PRESENT Origin must itself be loopback
|
|
1170
|
+
// (the UI's own pages), while an ABSENT Origin (curl, CLI clients, non-browser tools) is
|
|
1171
|
+
// allowed - for those, the bind address remains the boundary. GETs stay open (read-only).
|
|
1172
|
+
if (method !== 'GET') {
|
|
1173
|
+
const origin = req.headers && req.headers.origin;
|
|
1174
|
+
if (origin) {
|
|
1175
|
+
let originHost = null;
|
|
1176
|
+
try { originHost = new URL(origin).host; } catch (_e) { /* malformed -> reject below */ }
|
|
1177
|
+
if (!originHost || !isLoopbackHost(originHost)) {
|
|
1178
|
+
return sendJson(res, 403, { ok: false, error: 'forbidden: cross-origin request (CSRF guard)' });
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// ── Static assets ─────────────────────────────────────────────────────────────────────────
|
|
1184
|
+
if (method === 'GET' && (pathName === '/' || pathName === '/index.html')) {
|
|
1185
|
+
return sendStatic(res, 'index.html');
|
|
1186
|
+
}
|
|
1187
|
+
if (method === 'GET' && pathName === '/app.js') {
|
|
1188
|
+
return sendStatic(res, 'app.js');
|
|
1189
|
+
}
|
|
1190
|
+
if (method === 'GET' && pathName === '/styles.css') {
|
|
1191
|
+
return sendStatic(res, 'styles.css');
|
|
1192
|
+
}
|
|
1193
|
+
// Image assets (logo, favicon, ...): serve any safe-named image file from public/. The filename
|
|
1194
|
+
// regex + sendStatic's traversal guard keep this strictly inside the public dir.
|
|
1195
|
+
if (method === 'GET' && /^\/[A-Za-z0-9._-]+\.(jpg|jpeg|png|svg|ico|webp)$/.test(pathName)) {
|
|
1196
|
+
return sendStatic(res, pathName.slice(1));
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// ── JSON API (read fresh each call) ─────────────────────────────────────────────────────────
|
|
1200
|
+
if (method === 'GET' && pathName === '/api/tools') {
|
|
1201
|
+
return sendJson(res, 200, apiTools());
|
|
1202
|
+
}
|
|
1203
|
+
if (method === 'GET' && pathName === '/api/tools/detail') {
|
|
1204
|
+
let id = '';
|
|
1205
|
+
try {
|
|
1206
|
+
const qi = rawUrl.indexOf('?');
|
|
1207
|
+
id = qi === -1 ? '' : (new URLSearchParams(rawUrl.slice(qi + 1)).get('id') || '');
|
|
1208
|
+
} catch (_e) { id = ''; }
|
|
1209
|
+
const d = apiToolDetail(id);
|
|
1210
|
+
return sendJson(res, d.status, d.json);
|
|
1211
|
+
}
|
|
1212
|
+
if (method === 'GET' && pathName === '/api/upstreams') {
|
|
1213
|
+
return sendJson(res, 200, apiUpstreams());
|
|
1214
|
+
}
|
|
1215
|
+
if (method === 'GET' && pathName === '/api/identity') {
|
|
1216
|
+
return sendJson(res, 200, apiIdentity());
|
|
1217
|
+
}
|
|
1218
|
+
if (method === 'GET' && pathName === '/api/hooks') {
|
|
1219
|
+
return sendJson(res, 200, apiHooks());
|
|
1220
|
+
}
|
|
1221
|
+
if (method === 'GET' && pathName === '/api/status') {
|
|
1222
|
+
return sendJson(res, 200, apiStatus());
|
|
1223
|
+
}
|
|
1224
|
+
if (method === 'GET' && pathName === '/api/surface') {
|
|
1225
|
+
return sendJson(res, 200, apiSurface());
|
|
1226
|
+
}
|
|
1227
|
+
if (method === 'GET' && pathName === '/api/logs') {
|
|
1228
|
+
return sendJson(res, 200, apiLogs());
|
|
1229
|
+
}
|
|
1230
|
+
if (method === 'GET' && pathName === '/api/logs/config') {
|
|
1231
|
+
return sendJson(res, 200, apiLogsConfig());
|
|
1232
|
+
}
|
|
1233
|
+
if (method === 'GET' && pathName === '/api/auth') {
|
|
1234
|
+
return sendJson(res, 200, apiAuth());
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
if (method === 'POST' && Object.prototype.hasOwnProperty.call(POST_HANDLERS, pathName)) {
|
|
1238
|
+
let body;
|
|
1239
|
+
try {
|
|
1240
|
+
body = await readJsonBody(req);
|
|
1241
|
+
} catch (err) {
|
|
1242
|
+
return sendJson(res, 400, { ok: false, error: (err && err.message) || 'bad request body' });
|
|
1243
|
+
}
|
|
1244
|
+
// Handlers may be sync OR async (apiMcpDiscover connects an upstream) - await covers both.
|
|
1245
|
+
const result = await POST_HANDLERS[pathName](body);
|
|
1246
|
+
// Audit a config-MUTATING write that SUCCEEDED (self-gating - no-op unless logging is on).
|
|
1247
|
+
if (result && result.status >= 200 && result.status < 300 && result.json && result.json.ok === true && CONFIG_EVENTS[pathName]) {
|
|
1248
|
+
logConfigChange(CONFIG_EVENTS[pathName](body || {}));
|
|
1249
|
+
}
|
|
1250
|
+
return sendJson(res, result.status, result.json);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
return sendJson(res, 404, { ok: false, error: 'not found: ' + method + ' ' + pathName });
|
|
1254
|
+
} catch (err) {
|
|
1255
|
+
// Last-resort guard: any unexpected failure becomes a clean 500 JSON, never a crash.
|
|
1256
|
+
logErr('onRequest error:', (err && err.stack) || String(err));
|
|
1257
|
+
try {
|
|
1258
|
+
sendJson(res, 500, { ok: false, error: (err && err.message) || 'internal error' });
|
|
1259
|
+
} catch (_e) {
|
|
1260
|
+
/* response may already be committed */
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// ── Lifecycle ───────────────────────────────────────────────────────────────────────────────
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* start - bind the HTTP server. On EADDRINUSE the returned promise REJECTS with a clear error
|
|
1269
|
+
* (the caller decides) - it does NOT crash the process. Resolves to { url, port } once listening.
|
|
1270
|
+
* Port 0 -> an OS-assigned ephemeral port (read back from server.address()).
|
|
1271
|
+
* @returns {Promise<{url:string, port:number}>}
|
|
1272
|
+
*/
|
|
1273
|
+
function start() {
|
|
1274
|
+
if (started) return Promise.resolve({ url: currentUrl(), port: boundPort });
|
|
1275
|
+
// The HTTP transport refuses a non-loopback bind unless OAuth is enabled; the UI has NO auth
|
|
1276
|
+
// path, so its refusal is unconditional. This endpoint spawns processes and writes scripts -
|
|
1277
|
+
// "--ui --host 0.0.0.0" would hand an unauthenticated remote console to the LAN.
|
|
1278
|
+
if (!isLoopbackBindHost(host)) {
|
|
1279
|
+
return Promise.reject(new Error(
|
|
1280
|
+
`refusing to bind the config UI to non-loopback host "${host}" - the UI is unauthenticated ` +
|
|
1281
|
+
'and can spawn processes / write tool scripts. It is loopback-only by design; to configure ' +
|
|
1282
|
+
'the gateway from another machine, use an SSH tunnel to 127.0.0.1 instead.'
|
|
1283
|
+
));
|
|
1284
|
+
}
|
|
1285
|
+
httpServer = http.createServer(onRequest);
|
|
1286
|
+
return new Promise((resolve, reject) => {
|
|
1287
|
+
const onListenError = (err) => {
|
|
1288
|
+
httpServer.removeListener('listening', onListening);
|
|
1289
|
+
httpServer = null;
|
|
1290
|
+
reject(err);
|
|
1291
|
+
};
|
|
1292
|
+
const onListening = () => {
|
|
1293
|
+
httpServer.removeListener('error', onListenError);
|
|
1294
|
+
const addr = httpServer.address();
|
|
1295
|
+
boundPort = addr && typeof addr === 'object' ? addr.port : requestedPort;
|
|
1296
|
+
started = true;
|
|
1297
|
+
// Swap the one-shot bind-error handler for a steady-state one so a later socket error
|
|
1298
|
+
// (a client RST, etc.) is logged, not thrown.
|
|
1299
|
+
httpServer.on('error', (e) => logErr('http server error:', (e && e.message) || String(e)));
|
|
1300
|
+
resolve({ url: currentUrl(), port: boundPort });
|
|
1301
|
+
};
|
|
1302
|
+
httpServer.once('error', onListenError);
|
|
1303
|
+
httpServer.once('listening', onListening);
|
|
1304
|
+
httpServer.listen(requestedPort, host);
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/** stop - close the HTTP server. Idempotent and NEVER throws. */
|
|
1309
|
+
function stop() {
|
|
1310
|
+
return new Promise((resolve) => {
|
|
1311
|
+
if (!httpServer) {
|
|
1312
|
+
started = false;
|
|
1313
|
+
boundPort = null;
|
|
1314
|
+
return resolve();
|
|
1315
|
+
}
|
|
1316
|
+
const srv = httpServer;
|
|
1317
|
+
httpServer = null;
|
|
1318
|
+
let settled = false;
|
|
1319
|
+
const done = () => { if (!settled) { settled = true; started = false; boundPort = null; resolve(); } };
|
|
1320
|
+
try {
|
|
1321
|
+
srv.close(done);
|
|
1322
|
+
} catch (_e) {
|
|
1323
|
+
done();
|
|
1324
|
+
}
|
|
1325
|
+
try {
|
|
1326
|
+
if (typeof srv.closeAllConnections === 'function') srv.closeAllConnections();
|
|
1327
|
+
} catch (_e) {
|
|
1328
|
+
/* closeAllConnections is Node ≥18.2; absent -> close() still resolves */
|
|
1329
|
+
}
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
return {
|
|
1334
|
+
start,
|
|
1335
|
+
stop,
|
|
1336
|
+
get url() {
|
|
1337
|
+
return currentUrl();
|
|
1338
|
+
},
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
module.exports = {
|
|
1343
|
+
createUiServer,
|
|
1344
|
+
// Exported for unit tests / reuse - the small pure seams.
|
|
1345
|
+
isLoopbackHost,
|
|
1346
|
+
isLoopbackBindHost,
|
|
1347
|
+
escapeRegex,
|
|
1348
|
+
};
|