toolfunnel 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/bin/toolfunnel.js +112 -0
  4. package/docs/admin-ui.md +404 -0
  5. package/docs/configuration.md +402 -0
  6. package/docs/hooks-and-gating.md +428 -0
  7. package/docs/logging.md +223 -0
  8. package/docs/management.md +429 -0
  9. package/docs/oauth.md +103 -0
  10. package/docs/setup.md +352 -0
  11. package/docs/tools-and-upstreams.md +469 -0
  12. package/hooks/hooks.manifest.json +27 -0
  13. package/hooks/scripts/.gitkeep +3 -0
  14. package/hooks/scripts/example-audit.js +21 -0
  15. package/hooks/scripts/example-deny-dangerous.js +31 -0
  16. package/mcp/expose.example.json +30 -0
  17. package/mcp/servers/mock-upstream/server.js +170 -0
  18. package/package.json +63 -0
  19. package/src/auth/config.js +172 -0
  20. package/src/auth/install.js +181 -0
  21. package/src/auth/resource-server.js +319 -0
  22. package/src/core/events.js +105 -0
  23. package/src/core/hook-engine.js +275 -0
  24. package/src/core/hook-loader.js +791 -0
  25. package/src/core/hook-runner.js +346 -0
  26. package/src/core/logger.js +169 -0
  27. package/src/core/matcher.js +51 -0
  28. package/src/core/metrics.js +75 -0
  29. package/src/core/tool-registry.js +180 -0
  30. package/src/extend/add-hook.md +142 -0
  31. package/src/extend/add-mcp.md +121 -0
  32. package/src/extend/create-tool.md +132 -0
  33. package/src/extend/howto.js +98 -0
  34. package/src/extend/package.md +154 -0
  35. package/src/mcp/aggregator.js +802 -0
  36. package/src/mcp/expose-store.js +430 -0
  37. package/src/mcp/gated-run.js +194 -0
  38. package/src/mcp/http-transport.js +962 -0
  39. package/src/mcp/mcp-client.js +524 -0
  40. package/src/mcp/protocol.js +466 -0
  41. package/src/mcp/server.js +1240 -0
  42. package/src/packages/packager.js +713 -0
  43. package/src/tools/drift.js +138 -0
  44. package/src/tools/registry.js +468 -0
  45. package/src/tools/tool-state.js +104 -0
  46. package/src/ui/public/app.js +1055 -0
  47. package/src/ui/public/index.html +249 -0
  48. package/src/ui/public/logo.jpg +0 -0
  49. package/src/ui/public/styles.css +535 -0
  50. package/src/ui/server.js +1144 -0
  51. package/tools/scripts/base64.js +139 -0
  52. package/tools/scripts/danger.js +61 -0
  53. package/tools/scripts/echo.js +39 -0
  54. package/tools/scripts/hash.js +101 -0
  55. package/tools/scripts/json.js +109 -0
  56. package/tools/scripts/text-stats.js +131 -0
  57. package/tools/scripts/tf-hook-add.js +152 -0
  58. package/tools/scripts/tf-hook-set.js +113 -0
  59. package/tools/scripts/tf-list.js +195 -0
  60. package/tools/scripts/tf-log.js +83 -0
  61. package/tools/scripts/tf-mcp-add.js +119 -0
  62. package/tools/scripts/tf-mcp-set.js +104 -0
  63. package/tools/scripts/tf-tool-add.js +129 -0
  64. package/tools/scripts/tf-tool-set.js +148 -0
  65. package/tools/scripts/uuid.js +102 -0
  66. package/tools/tools.register.json +171 -0
@@ -0,0 +1,1055 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * ToolFunnel management console — vanilla client. Zero dependencies, zero external
5
+ * assets, works offline. Talks to the loopback JSON API served by src/ui/server.js.
6
+ * Every edit is persisted server-side through the gateway's own stores (registry,
7
+ * tool-state, expose-store, hook-loader), so the running MCP server sees changes
8
+ * with no restart.
9
+ *
10
+ * Four tabs — Tools / MCPs / Hooks / Logs. The first three each have a live search box,
11
+ * a collapsible Add form, and a list whose rows carry activate/deactivate + remove controls.
12
+ * Logs is read-mostly: an on/off toggle bound to the logger config plus a newest-first view
13
+ * of recent activity records.
14
+ */
15
+
16
+ (function () {
17
+ // ── tiny DOM helpers ────────────────────────────────────────────────────────
18
+ var $ = function (sel) { return document.querySelector(sel); };
19
+ function el(tag, attrs, children) {
20
+ var node = document.createElement(tag);
21
+ if (attrs) {
22
+ Object.keys(attrs).forEach(function (k) {
23
+ if (k === 'class') node.className = attrs[k];
24
+ else if (k === 'text') node.textContent = attrs[k];
25
+ else if (k === 'html') node.innerHTML = attrs[k];
26
+ else if (k.slice(0, 2) === 'on' && typeof attrs[k] === 'function') node.addEventListener(k.slice(2), attrs[k]);
27
+ else if (attrs[k] === true) node.setAttribute(k, '');
28
+ else if (attrs[k] != null && attrs[k] !== false) node.setAttribute(k, attrs[k]);
29
+ });
30
+ }
31
+ (children || []).forEach(function (c) {
32
+ if (c == null) return;
33
+ node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
34
+ });
35
+ return node;
36
+ }
37
+
38
+ // ── fetch wrappers ──────────────────────────────────────────────────────────
39
+ function getJson(url) {
40
+ return fetch(url, { headers: { Accept: 'application/json' } }).then(function (r) { return r.json(); });
41
+ }
42
+ function postJson(url, body) {
43
+ return fetch(url, {
44
+ method: 'POST',
45
+ headers: { 'Content-Type': 'application/json' },
46
+ body: JSON.stringify(body),
47
+ }).then(function (r) { return r.json(); });
48
+ }
49
+
50
+ // ── toast ─────────────────────────────────────────────────────────────────--
51
+ var toastTimer = null;
52
+ function toast(msg, kind) {
53
+ var t = $('#toast');
54
+ t.innerHTML = '';
55
+ t.appendChild(typeof msg === 'string' ? document.createTextNode(msg) : msg);
56
+ t.className = 'toast show' + (kind === 'error' ? ' error' : kind === 'good' ? ' good' : '');
57
+ if (toastTimer) clearTimeout(toastTimer);
58
+ toastTimer = setTimeout(function () { t.className = 'toast'; }, 6000);
59
+ }
60
+ function errText(e) { return e && e.message ? e.message : String(e); }
61
+
62
+ // ── status counts ─────────────────────────────────────────────────────────--
63
+ function refreshStatus() {
64
+ return getJson('/api/status').then(function (s) {
65
+ $('#stat-tools').textContent = s && typeof s.tools === 'number' ? s.tools : '0';
66
+ $('#stat-upstreams').textContent = s && typeof s.upstreams === 'number' ? s.upstreams : '0';
67
+ $('#stat-hooks').textContent = s && typeof s.hooks === 'number' ? s.hooks : '0';
68
+ }).catch(function () { /* leave placeholders */ });
69
+ }
70
+
71
+ // ── shared controls ─────────────────────────────────────────────────────────
72
+ function makeSwitch(checked, small, onChange) {
73
+ var input = el('input', { type: 'checkbox' });
74
+ input.checked = !!checked;
75
+ input.addEventListener('change', function () { onChange(input); });
76
+ return el('label', { class: 'switch' + (small ? ' small' : '') }, [input, el('span', { class: 'slider' })]);
77
+ }
78
+ function toggleGroup(label, extraClass, sw) {
79
+ return el('div', { class: 'toggle-group ' + extraClass }, [sw, el('span', { class: 'toggle-label', text: label })]);
80
+ }
81
+ // A small destructive control with an inline confirm guard.
82
+ function removeButton(label, onConfirm) {
83
+ return el('button', {
84
+ class: 'btn-remove', type: 'button', title: 'Remove',
85
+ onclick: function () { if (window.confirm(label)) onConfirm(); },
86
+ }, ['Remove']);
87
+ }
88
+ // POST helper that drives a control disabled/revert/toast lifecycle. `apply` runs on success.
89
+ function driveSwitch(input, url, body, apply, onFail) {
90
+ input.disabled = true;
91
+ postJson(url, body).then(function (res) {
92
+ input.disabled = false;
93
+ if (!res || res.ok !== true) { onFail(); toast((res && res.error) || 'Action failed', 'error'); return; }
94
+ apply(res);
95
+ refreshStatus();
96
+ }).catch(function (e) { input.disabled = false; onFail(); toast('Request failed: ' + errText(e), 'error'); });
97
+ }
98
+
99
+ // ── generic filter ───────────────────────────────────────────────────────--
100
+ function filterList(items, query, hayFn) {
101
+ var q = (query || '').trim().toLowerCase();
102
+ if (!q) return items.slice();
103
+ return items.filter(function (it) { return hayFn(it).toLowerCase().indexOf(q) !== -1; });
104
+ }
105
+ // The authored innerHTML of each empty div (the rich "nothing configured" state) is
106
+ // captured once so it can be restored after a "no matches" message overwrote it.
107
+ var emptyOriginal = {};
108
+ function renderInto(listSel, emptySel, countSel, shown, total, renderFn, noMatchText) {
109
+ var list = $(listSel);
110
+ list.innerHTML = '';
111
+ shown.forEach(function (it) { list.appendChild(renderFn(it)); });
112
+ var empty = $(emptySel);
113
+ if (emptyOriginal[emptySel] === undefined) emptyOriginal[emptySel] = empty.innerHTML;
114
+ if (shown.length === 0) {
115
+ empty.hidden = false;
116
+ if (total === 0) empty.innerHTML = emptyOriginal[emptySel]; // nothing configured
117
+ else empty.textContent = noMatchText; // filtered to nothing
118
+ } else {
119
+ empty.hidden = true;
120
+ }
121
+ $(countSel).textContent = (shown.length !== total) ? (shown.length + '/' + total) : '';
122
+ }
123
+
124
+ // ── collapsible add forms ────────────────────────────────────────────────--
125
+ function wireAddForm(btnSel, formSel, cancelSel, onReset) {
126
+ var btn = $(btnSel), form = $(formSel);
127
+ function open(show) {
128
+ form.hidden = !show;
129
+ btn.setAttribute('aria-expanded', show ? 'true' : 'false');
130
+ btn.classList.toggle('is-open', show);
131
+ if (show) { var f = form.querySelector('input, select, textarea'); if (f) f.focus(); }
132
+ }
133
+ btn.addEventListener('click', function () { open(form.hidden); });
134
+ $(cancelSel).addEventListener('click', function () { onReset(); open(false); });
135
+ return { open: open };
136
+ }
137
+
138
+ /* ══════════════════════════════ TOOLS ══════════════════════════════ */
139
+ var tools = [];
140
+
141
+ function renderTool(t) {
142
+ var card = el('div', { class: 'row' + (t.enabled ? '' : ' is-disabled') + (t.hot ? ' is-hot' : ''), 'data-id': t.id });
143
+
144
+ var head = el('div', { class: 'row-head' }, [
145
+ el('span', { class: 'row-name', text: t.name || t.id }),
146
+ t.category ? el('span', { class: 'chip', text: t.category }) : null,
147
+ el('span', { class: 'row-id', text: t.id }),
148
+ ]);
149
+ var summary = el('p', { class: 'row-summary', text: t.summary || 'No summary.' });
150
+
151
+ // Execution-mode toggle (reference ↔ gateway). checked = gateway.
152
+ var modeSwitch = makeSwitch(t.mode === 'gateway', true, function (input) {
153
+ var want = input.checked ? 'gateway' : 'reference';
154
+ driveSwitch(input, '/api/tools/mode', { id: t.id, mode: want },
155
+ function (res) { t.mode = (res && res.mode) || want; modeLabel.textContent = t.mode; },
156
+ function () { input.checked = (t.mode === 'gateway'); });
157
+ });
158
+ var modeLabel = el('span', { class: 'toggle-label mode-label', text: t.mode || 'reference' });
159
+ var modeGroup = el('div', { class: 'toggle-group mode' }, [modeSwitch, modeLabel]);
160
+
161
+ // Pre / Post hook gates (POST /api/tools/hook).
162
+ function hookSwitch(eventName, label, current) {
163
+ return toggleGroup(label, 'hook', makeSwitch(current, true, function (input) {
164
+ var want = input.checked;
165
+ driveSwitch(input, '/api/tools/hook', { id: t.id, event: eventName, on: want },
166
+ function (res) {
167
+ if (eventName === 'PreToolUse') t.pre = want; else t.post = want;
168
+ if (want && res.scriptPath) {
169
+ toast(el('span', {}, [label + ' gate enabled for ', el('code', { text: t.id }), '. ', el('code', { text: res.scriptPath })]));
170
+ } else if (want) { toast(label + ' gate enabled for ' + t.id, 'good'); }
171
+ else { toast(label + ' gate removed for ' + t.id, 'good'); }
172
+ },
173
+ function () { input.checked = !want; });
174
+ }));
175
+ }
176
+
177
+ // Enable / disable — LEAN visibility (POST /api/tools/state).
178
+ var enableSwitch = makeSwitch(t.enabled, false, function (input) {
179
+ var want = input.checked;
180
+ driveSwitch(input, '/api/tools/state', { id: t.id, enabled: want },
181
+ // A disabled tool is never on the every-turn surface, so refresh the panel (count + warnings)
182
+ // — disabling a hot tool drops it from the surface, re-enabling restores it.
183
+ function () { t.enabled = want; card.classList.toggle('is-disabled', !want); loadSurface(); },
184
+ function () { input.checked = !want; });
185
+ });
186
+
187
+ // Hot — promote to the TOP-LEVEL every-turn surface (POST /api/tools/state {hot}). A hot tool is
188
+ // injected into the AI's context every turn AND becomes directly callable. Refresh the surface
189
+ // panel (count + bloat warnings) after a change.
190
+ var hotSwitch = makeSwitch(t.hot, false, function (input) {
191
+ var want = input.checked;
192
+ driveSwitch(input, '/api/tools/state', { id: t.id, hot: want },
193
+ function () { t.hot = want; card.classList.toggle('is-hot', want); loadSurface(); },
194
+ function () { input.checked = !want; });
195
+ });
196
+
197
+ // Hidden — declutter THIS manager list only (does not change what the AI sees). Re-filter on
198
+ // change so the row drops out when hidden (unless "show hidden" is on).
199
+ var hiddenSwitch = makeSwitch(t.hidden, false, function (input) {
200
+ var want = input.checked;
201
+ driveSwitch(input, '/api/tools/state', { id: t.id, hidden: want },
202
+ function () { t.hidden = want; applyToolFilter(); },
203
+ function () { input.checked = !want; });
204
+ });
205
+
206
+ // Details / edit — a lazy inline panel showing the full entry (instructions, invoke, script body)
207
+ // with editable fields + Save. Fetched from /api/tools/detail on first expand.
208
+ var detailPanel = el('div', { class: 'tool-detail', hidden: true });
209
+ var detailLoaded = false;
210
+ var detailsBtn = el('button', { class: 'btn btn-ghost btn-sm tool-details-btn', type: 'button' }, ['Details / edit']);
211
+ detailsBtn.addEventListener('click', function () {
212
+ var show = detailPanel.hidden;
213
+ detailPanel.hidden = !show;
214
+ detailsBtn.classList.toggle('is-open', show);
215
+ if (show && !detailLoaded) { detailLoaded = true; loadToolDetail(t, detailPanel); }
216
+ });
217
+
218
+ var controls = el('div', { class: 'row-controls' }, [
219
+ modeGroup,
220
+ el('div', { class: 'hook-toggles' }, [hookSwitch('PreToolUse', 'Pre', t.pre), hookSwitch('PostToolUse', 'Post', t.post)]),
221
+ toggleGroup('Enabled', 'enable', enableSwitch),
222
+ toggleGroup('Hot', 'hot', hotSwitch),
223
+ toggleGroup('Hidden', 'hidden', hiddenSwitch),
224
+ detailsBtn,
225
+ removeButton('Remove tool "' + t.id + '" from the register?', function () {
226
+ postJson('/api/tools/remove', { id: t.id }).then(function (res) {
227
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Remove failed', 'error'); return; }
228
+ toast('Removed tool ' + t.id, 'good');
229
+ loadTools(); refreshStatus();
230
+ }).catch(function (e) { toast('Request failed: ' + errText(e), 'error'); });
231
+ }),
232
+ ]);
233
+
234
+ card.appendChild(head); card.appendChild(summary); card.appendChild(controls); card.appendChild(detailPanel);
235
+ return card;
236
+ }
237
+
238
+ // Fetch one tool's full detail and render the editor into `panel`.
239
+ function loadToolDetail(t, panel) {
240
+ panel.innerHTML = '';
241
+ panel.appendChild(el('p', { class: 'muted', text: 'Loading…' }));
242
+ getJson('/api/tools/detail?id=' + encodeURIComponent(t.id)).then(function (res) {
243
+ panel.innerHTML = '';
244
+ if (!res || res.ok !== true || !res.entry) {
245
+ panel.appendChild(el('p', { class: 'muted', text: (res && res.error) || 'Failed to load details.' }));
246
+ return;
247
+ }
248
+ renderToolEditor(t, panel, res.entry, res.scriptText);
249
+ }).catch(function (e) {
250
+ panel.innerHTML = '';
251
+ panel.appendChild(el('p', { class: 'muted', text: 'Failed to load details: ' + errText(e) }));
252
+ });
253
+ }
254
+
255
+ // Build the editable detail form for one tool. Save → POST /api/tools/update.
256
+ function renderToolEditor(t, panel, entry, scriptText) {
257
+ var inv = entry.invoke || {};
258
+ function field(label, hint, control) {
259
+ return el('label', { class: 'field field-wide' }, [
260
+ el('span', {}, [label, hint ? el('em', { class: 'hint', text: ' ' + hint }) : null]),
261
+ control,
262
+ ]);
263
+ }
264
+ var fName = el('input', { type: 'text', value: entry.name || '' });
265
+ var fSummary = el('input', { type: 'text', value: entry.summary || '' });
266
+ var fCategory = el('input', { type: 'text', value: entry.category || '' });
267
+ var fMode = el('select', {}, [
268
+ el('option', { value: 'gateway', text: 'gateway — run here' }),
269
+ el('option', { value: 'reference', text: 'reference — AI runs it' }),
270
+ ]);
271
+ fMode.value = (t.mode === 'reference') ? 'reference' : 'gateway';
272
+ var fInstr = el('textarea', { rows: '4', spellcheck: 'false' }); fInstr.value = entry.instructions || '';
273
+
274
+ var fType = el('select', {}, [
275
+ el('option', { value: 'script', text: 'script' }),
276
+ el('option', { value: 'shell', text: 'shell' }),
277
+ el('option', { value: 'none', text: 'none (reference)' }),
278
+ ]);
279
+ fType.value = inv.type || 'none';
280
+ var fPath = el('input', { type: 'text', value: inv.path || '', spellcheck: 'false' });
281
+ var fCmd = el('input', { type: 'text', value: inv.command || '', spellcheck: 'false' });
282
+ var fScript = el('textarea', { rows: '8', spellcheck: 'false' }); fScript.value = scriptText || '';
283
+
284
+ var pathField = field('script path', '', fPath);
285
+ var cmdField = field('shell command', '', fCmd);
286
+ var scriptField = field('script body', '(authored under tools/scripts/)', fScript);
287
+ function syncInvoke() {
288
+ var ty = fType.value;
289
+ pathField.hidden = ty !== 'script';
290
+ cmdField.hidden = ty !== 'shell';
291
+ scriptField.hidden = ty !== 'script';
292
+ }
293
+ fType.addEventListener('change', syncInvoke);
294
+
295
+ var idTag = el('div', { class: 'tool-detail-id' }, [el('span', { class: 'row-id', text: 'id: ' + entry.id })]);
296
+ var grid = el('div', { class: 'field-grid' }, [
297
+ field('name', '', fName),
298
+ field('summary', '', fSummary),
299
+ field('category', '', fCategory),
300
+ field('mode', '', fMode),
301
+ field('invoke type', '', fType),
302
+ pathField, cmdField,
303
+ field('instructions', '', fInstr),
304
+ scriptField,
305
+ ]);
306
+
307
+ var saveBtn = el('button', { class: 'btn btn-primary', type: 'button' }, ['Save changes']);
308
+ saveBtn.addEventListener('click', function () {
309
+ var patch = {
310
+ name: fName.value.trim(),
311
+ summary: fSummary.value.trim(),
312
+ category: fCategory.value.trim(),
313
+ instructions: fInstr.value,
314
+ mode: fMode.value,
315
+ };
316
+ var ty = fType.value;
317
+ if (ty === 'script') { patch.invoke = { type: 'script', path: fPath.value.trim() }; }
318
+ else if (ty === 'shell') { patch.invoke = { type: 'shell', command: fCmd.value.trim() }; }
319
+ var body = { id: entry.id, patch: patch };
320
+ if (ty === 'script' && fScript.value.length) body.scriptText = fScript.value;
321
+ saveBtn.disabled = true;
322
+ postJson('/api/tools/update', body).then(function (res) {
323
+ saveBtn.disabled = false;
324
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Save failed', 'error'); return; }
325
+ toast('Saved ' + entry.id, 'good');
326
+ loadTools(); refreshStatus();
327
+ }).catch(function (e) { saveBtn.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
328
+ });
329
+
330
+ panel.appendChild(idTag);
331
+ panel.appendChild(grid);
332
+ panel.appendChild(el('div', { class: 'addform-actions' }, [saveBtn]));
333
+ syncInvoke();
334
+ }
335
+
336
+ function applyToolFilter() {
337
+ // Hidden tools are decluttered from THIS manager list unless "show hidden" is ticked. (This is the
338
+ // manager view only — `hidden` never affects the lean list / top-level surface the AI sees.)
339
+ var showHidden = $('#show-hidden') && $('#show-hidden').checked;
340
+ var base = showHidden ? tools : tools.filter(function (t) { return !t.hidden; });
341
+ var shown = filterList(base, $('#search-tools').value, function (t) {
342
+ return t.id + ' ' + (t.name || '') + ' ' + (t.summary || '') + ' ' + (t.category || '') + ' ' + (t.mode || '');
343
+ });
344
+ // Distinguish "register empty" (tools.length===0 → renderInto's authored message) from "all
345
+ // hidden" (tools exist but the hidden filter emptied the view) — pass tools.length as `total` so
346
+ // a non-empty register never reads as empty, and give an all-hidden-specific no-match message.
347
+ var noMatch = (base.length === 0 && tools.length > 0)
348
+ ? ('All ' + tools.length + ' tool' + (tools.length === 1 ? '' : 's') + ' are hidden — tick "show hidden" to see them.')
349
+ : 'No tools match your search.';
350
+ renderInto('#tool-list', '#tools-empty', '#count-tools', shown, tools.length, renderTool, noMatch);
351
+ }
352
+
353
+ function loadTools() {
354
+ return getJson('/api/tools').then(function (data) {
355
+ tools = Array.isArray(data) ? data : [];
356
+ applyToolFilter();
357
+ }).catch(function (e) {
358
+ $('#tool-list').innerHTML = '';
359
+ $('#tools-empty').hidden = false;
360
+ $('#tools-empty').textContent = 'Failed to load tools: ' + errText(e);
361
+ });
362
+ }
363
+
364
+ /* ── Top-level surface panel ─────────────────────────────────────────────────
365
+ * Shows which tools are injected EVERY turn: the 4 meta-tools (default on, each
366
+ * toggleable), the count of promoted tools, and footgun warnings (hiding the
367
+ * management tools / promoting too many = context bloat). Reads /api/surface. */
368
+ function loadSurface() {
369
+ return getJson('/api/surface').then(renderSurface).catch(function () { /* leave last state */ });
370
+ }
371
+ function renderSurface(data) {
372
+ var meta = (data && Array.isArray(data.meta)) ? data.meta : [];
373
+ var metaBox = $('#surface-meta');
374
+ metaBox.innerHTML = '';
375
+ meta.forEach(function (m) {
376
+ var sw = makeSwitch(m.hot, true, function (input) {
377
+ var want = input.checked;
378
+ driveSwitch(input, '/api/tools/state', { id: m.name, hot: want },
379
+ function () { m.hot = want; loadSurface(); },
380
+ function () { input.checked = !want; });
381
+ });
382
+ metaBox.appendChild(el('div', { class: 'surface-meta-item' + (m.hot ? '' : ' is-off') }, [sw, el('code', { text: m.name })]));
383
+ });
384
+ var total = (data && typeof data.promotedTotal === 'number') ? data.promotedTotal : 0;
385
+ var countEl = $('#surface-count');
386
+ countEl.textContent = total ? (total + ' promoted') : 'lean';
387
+ countEl.className = 'surface-count' + (total > 10 ? ' is-bloat' : total ? ' is-some' : '');
388
+
389
+ var warnBox = $('#surface-warnings');
390
+ var warnings = (data && Array.isArray(data.warnings)) ? data.warnings : [];
391
+ warnBox.innerHTML = '';
392
+ if (warnings.length) {
393
+ warnings.forEach(function (w) { warnBox.appendChild(el('p', { class: 'surface-warning', text: '⚠ ' + w })); });
394
+ warnBox.hidden = false;
395
+ } else {
396
+ warnBox.hidden = true;
397
+ }
398
+ }
399
+
400
+ function resetToolForm() {
401
+ ['#t-id', '#t-name', '#t-category', '#t-summary', '#t-path', '#t-command', '#t-instructions', '#t-scripttext']
402
+ .forEach(function (s) { $(s).value = ''; });
403
+ $('#t-mode').value = 'gateway';
404
+ $('#t-invoke-type').value = 'script';
405
+ syncToolFields();
406
+ }
407
+ // Show/hide invoke fields by mode + invoke type.
408
+ function syncToolFields() {
409
+ var mode = $('#t-mode').value;
410
+ var type = $('#t-invoke-type').value;
411
+ var ref = mode === 'reference';
412
+ $('#t-invoke-type-field').hidden = ref;
413
+ $('#t-path-field').hidden = ref || type !== 'script';
414
+ $('#t-command-field').hidden = ref || type !== 'shell';
415
+ $('#t-script-field').hidden = ref || type !== 'script';
416
+ }
417
+
418
+ function submitTool(ev) {
419
+ ev.preventDefault();
420
+ var id = $('#t-id').value.trim(), name = $('#t-name').value.trim();
421
+ if (!id) { toast('Tool id is required', 'error'); return; }
422
+ if (!name) { toast('Tool name is required', 'error'); return; }
423
+ var mode = $('#t-mode').value;
424
+ var entry = { id: id, name: name, mode: mode };
425
+ var v;
426
+ if ((v = $('#t-summary').value.trim())) entry.summary = v;
427
+ if ((v = $('#t-category').value.trim())) entry.category = v;
428
+ if ((v = $('#t-instructions').value.trim())) entry.instructions = v;
429
+ if (mode !== 'reference') {
430
+ var type = $('#t-invoke-type').value;
431
+ if (type === 'script') {
432
+ var path = $('#t-path').value.trim();
433
+ if (!path) { toast('Script path is required for a script invoke', 'error'); return; }
434
+ entry.invoke = { type: 'script', path: path };
435
+ var body = $('#t-scripttext').value;
436
+ if (body && body.length) entry.scriptText = body;
437
+ } else if (type === 'shell') {
438
+ var cmd = $('#t-command').value.trim();
439
+ if (!cmd) { toast('Shell command is required for a shell invoke', 'error'); return; }
440
+ entry.invoke = { type: 'shell', command: cmd };
441
+ }
442
+ }
443
+ var submit = $('#t-submit'); submit.disabled = true;
444
+ postJson('/api/tools/add', { entry: entry }).then(function (res) {
445
+ submit.disabled = false;
446
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Add failed', 'error'); return; }
447
+ toast('Added tool ' + id, 'good');
448
+ resetToolForm(); toolForm.open(false);
449
+ loadTools(); refreshStatus();
450
+ }).catch(function (e) { submit.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
451
+ }
452
+
453
+ /* ══════════════════════════════ MCPs ══════════════════════════════ */
454
+ var upstreams = [];
455
+ var exposeByUpstream = {};
456
+
457
+ function renderUpstream(u) {
458
+ var exposed = exposeByUpstream[u.id] || [];
459
+ var card = el('div', { class: 'row' + (u.enabled ? '' : ' is-disabled'), 'data-id': u.id });
460
+
461
+ var head = el('div', { class: 'row-head' }, [
462
+ el('span', { class: 'row-name mono', text: u.id }),
463
+ el('span', { class: 'badge', text: u.transport || 'stdio' }),
464
+ el('span', { class: 'badge ' + (u.enabled ? 'on' : 'off'), text: u.enabled ? 'enabled' : 'disabled' }),
465
+ ]);
466
+ var cmd = el('p', { class: 'row-cmd', text: [u.command].concat(u.args || []).join(' ') });
467
+ var desc = u.description ? el('p', { class: 'row-summary', text: u.description }) : null;
468
+
469
+ var exposedRows = exposed.length
470
+ ? exposed.map(function (e) {
471
+ return el('div', { class: 'exposed-row' }, [
472
+ el('span', { class: 'tool', text: e.tool }),
473
+ el('span', { class: 'arrow', text: '→' }),
474
+ el('span', { class: 'as', text: e.as || (u.id + '_' + e.tool) }),
475
+ e.category ? el('span', { class: 'chip', text: e.category }) : null,
476
+ el('span', { class: 'badge ' + (e.enabled ? 'on' : 'off'), text: e.enabled ? 'on' : 'off' }),
477
+ ]);
478
+ })
479
+ : [el('div', { class: 'exposed-row' }, [el('span', { class: 'none', text: 'No exposed tools.' })])];
480
+ var exposedBlock = el('div', { class: 'exposed' }, [el('p', { class: 'exposed-title', text: 'Exposed tools' })].concat(exposedRows));
481
+
482
+ var enableSwitch = makeSwitch(u.enabled, false, function (input) {
483
+ var want = input.checked;
484
+ driveSwitch(input, '/api/mcp/state', { id: u.id, action: want ? 'enable' : 'disable' },
485
+ function () { u.enabled = want; card.classList.toggle('is-disabled', !want); loadMcps(); },
486
+ function () { input.checked = !want; });
487
+ });
488
+
489
+ // Discovered-tools area (filled by the Discover button) — a LIVE connect + tools/list, then a
490
+ // per-tool Lean (enabled) + Hot toggle keyed by each tool's SURFACED name (what the gateway reads).
491
+ var discovered = el('div', { class: 'discovered', hidden: true });
492
+ var discoverBtn = el('button', { class: 'btn btn-ghost btn-sm', type: 'button', title: 'Connect to this upstream and list its tools' }, ['Discover tools']);
493
+ discoverBtn.addEventListener('click', function () {
494
+ discoverBtn.disabled = true;
495
+ var label = discoverBtn.textContent; discoverBtn.textContent = 'Discovering…';
496
+ postJson('/api/mcp/discover', { id: u.id }).then(function (res) {
497
+ discoverBtn.disabled = false; discoverBtn.textContent = label;
498
+ if (!res || res.ok !== true) {
499
+ toast((res && res.error) || 'Discover failed', 'error');
500
+ renderDiscovered(discovered, []);
501
+ discovered.hidden = false;
502
+ return;
503
+ }
504
+ var n = res.tools ? res.tools.length : 0;
505
+ toast(n + ' tool' + (n === 1 ? '' : 's') + ' discovered on ' + u.id, 'good');
506
+ renderDiscovered(discovered, res.tools || []);
507
+ discovered.hidden = false;
508
+ }).catch(function (e) { discoverBtn.disabled = false; discoverBtn.textContent = label; toast('Request failed: ' + errText(e), 'error'); });
509
+ });
510
+
511
+ var controls = el('div', { class: 'row-controls' }, [
512
+ toggleGroup('Enabled', 'enable', enableSwitch),
513
+ discoverBtn,
514
+ removeButton('Remove upstream "' + u.id + '" (and its exposed tools)?', function () {
515
+ postJson('/api/mcp/state', { id: u.id, action: 'remove' }).then(function (res) {
516
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Remove failed', 'error'); return; }
517
+ toast('Removed upstream ' + u.id, 'good');
518
+ loadMcps(); refreshStatus();
519
+ }).catch(function (e) { toast('Request failed: ' + errText(e), 'error'); });
520
+ }),
521
+ ]);
522
+
523
+ card.appendChild(head);
524
+ if (desc) card.appendChild(desc);
525
+ card.appendChild(cmd);
526
+ card.appendChild(exposedBlock);
527
+ card.appendChild(controls);
528
+ card.appendChild(discovered);
529
+ return card;
530
+ }
531
+
532
+ // Render the LIVE-discovered tools of one upstream, each with a Lean (enabled) + Hot toggle keyed
533
+ // by its SURFACED name (an enabled expose `as` else `<upstream>_<tool>`) — the exact key the running
534
+ // gateway reads, so a toggle here is byte-identical to editing tools.state.json.
535
+ function renderDiscovered(container, tools) {
536
+ container.innerHTML = '';
537
+ if (!tools.length) {
538
+ container.appendChild(el('p', { class: 'none', text: 'No tools discovered (the upstream did not connect, or advertises none).' }));
539
+ return;
540
+ }
541
+ container.appendChild(el('p', { class: 'exposed-title', text: 'Discovered tools — curate lean visibility / promote hot' }));
542
+ tools.forEach(function (t) {
543
+ var leanSwitch = makeSwitch(t.enabled, true, function (input) {
544
+ var want = input.checked;
545
+ driveSwitch(input, '/api/tools/state', { id: t.name, enabled: want },
546
+ // Disabling a hot upstream tool drops it from the surface too — refresh the panel.
547
+ function () { t.enabled = want; loadSurface(); }, function () { input.checked = !want; });
548
+ });
549
+ var hotSwitch = makeSwitch(t.hot, true, function (input) {
550
+ var want = input.checked;
551
+ driveSwitch(input, '/api/tools/state', { id: t.name, hot: want },
552
+ function () { t.hot = want; loadSurface(); }, function () { input.checked = !want; });
553
+ });
554
+ container.appendChild(el('div', { class: 'discovered-row' }, [
555
+ el('span', { class: 'tool mono', text: t.tool }),
556
+ el('span', { class: 'arrow', text: '→' }),
557
+ el('span', { class: 'as mono', text: t.name }),
558
+ t.description ? el('span', { class: 'disc-desc', text: t.description }) : null,
559
+ toggleGroup('Lean', 'lean', leanSwitch),
560
+ toggleGroup('Hot', 'hot', hotSwitch),
561
+ ]));
562
+ });
563
+ }
564
+
565
+ function applyMcpFilter() {
566
+ var shown = filterList(upstreams, $('#search-mcps').value, function (u) {
567
+ return u.id + ' ' + (u.command || '') + ' ' + (u.args || []).join(' ') + ' ' + (u.description || '');
568
+ });
569
+ renderInto('#mcp-list', '#mcps-empty', '#count-mcps', shown, upstreams.length, renderUpstream, 'No upstreams match your search.');
570
+ }
571
+
572
+ function loadMcps() {
573
+ return getJson('/api/upstreams').then(function (data) {
574
+ upstreams = (data && Array.isArray(data.upstreams)) ? data.upstreams : [];
575
+ var expose = (data && Array.isArray(data.expose)) ? data.expose : [];
576
+ exposeByUpstream = {};
577
+ expose.forEach(function (e) { (exposeByUpstream[e.upstream] = exposeByUpstream[e.upstream] || []).push(e); });
578
+ applyMcpFilter();
579
+ }).catch(function (e) {
580
+ $('#mcp-list').innerHTML = '';
581
+ $('#mcps-empty').hidden = false;
582
+ $('#mcps-empty').textContent = 'Failed to load upstreams: ' + errText(e);
583
+ });
584
+ }
585
+
586
+ // Dynamic expose-entry rows in the MCP add form.
587
+ function addExposeRow() {
588
+ var row = el('div', { class: 'expose-input-row' }, [
589
+ el('input', { type: 'text', class: 'x-tool', placeholder: 'tool', spellcheck: 'false' }),
590
+ el('input', { type: 'text', class: 'x-as', placeholder: 'as (optional)', spellcheck: 'false' }),
591
+ el('input', { type: 'text', class: 'x-cat', placeholder: 'category', spellcheck: 'false' }),
592
+ el('button', { type: 'button', class: 'btn-remove btn-sm', title: 'Remove row', onclick: function () { row.remove(); } }, ['×']),
593
+ ]);
594
+ $('#m-expose-rows').appendChild(row);
595
+ }
596
+ function collectExpose() {
597
+ var out = [];
598
+ $('#m-expose-rows').querySelectorAll('.expose-input-row').forEach(function (r) {
599
+ var tool = r.querySelector('.x-tool').value.trim();
600
+ if (!tool) return;
601
+ var item = { tool: tool };
602
+ var as = r.querySelector('.x-as').value.trim(); if (as) item.as = as;
603
+ var cat = r.querySelector('.x-cat').value.trim(); if (cat) item.category = cat;
604
+ out.push(item);
605
+ });
606
+ return out;
607
+ }
608
+ function resetMcpForm() {
609
+ ['#m-id', '#m-command', '#m-args', '#m-description'].forEach(function (s) { $(s).value = ''; });
610
+ $('#m-transport').value = 'stdio';
611
+ $('#m-expose-rows').innerHTML = '';
612
+ }
613
+
614
+ function submitMcp(ev) {
615
+ ev.preventDefault();
616
+ var id = $('#m-id').value.trim(), command = $('#m-command').value.trim();
617
+ if (!id) { toast('Upstream id is required', 'error'); return; }
618
+ if (!command) { toast('Command is required', 'error'); return; }
619
+ var argsRaw = $('#m-args').value.trim();
620
+ var upstream = { id: id, command: command, transport: $('#m-transport').value || 'stdio' };
621
+ if (argsRaw) upstream.args = argsRaw.split(/\s+/);
622
+ var desc = $('#m-description').value.trim(); if (desc) upstream.description = desc;
623
+ var payload = { upstream: upstream };
624
+ var expose = collectExpose();
625
+ if (expose.length) payload.expose = expose;
626
+ var submit = $('#m-submit'); submit.disabled = true;
627
+ postJson('/api/mcp/add', payload).then(function (res) {
628
+ submit.disabled = false;
629
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Add failed', 'error'); return; }
630
+ toast('Added upstream ' + id, 'good');
631
+ resetMcpForm(); mcpForm.open(false);
632
+ loadMcps(); refreshStatus();
633
+ }).catch(function (e) { submit.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
634
+ }
635
+
636
+ /* ══════════════════════════════ HOOKS ══════════════════════════════ */
637
+ var EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'PreCompact'];
638
+ var hooks = [];
639
+
640
+ function renderHook(h) {
641
+ var hasId = !!h.id;
642
+ var card = el('div', { class: 'row' + (h.enabled ? '' : ' is-disabled'), 'data-id': h.id || '' });
643
+
644
+ var head = el('div', { class: 'row-head' }, [
645
+ el('span', { class: 'row-name mono', text: h.id || '(tool gate)' }),
646
+ el('span', { class: 'chip', text: h.event || '?' }),
647
+ hasId ? null : el('span', { class: 'badge', text: 'auto' }),
648
+ ]);
649
+ var meta = el('p', { class: 'row-cmd', text: 'matcher: ' + (h.matcher && h.matcher.length ? h.matcher : '* (all)') });
650
+ var desc = h.description ? el('p', { class: 'row-summary', text: h.description }) : null;
651
+
652
+ var controls;
653
+ if (hasId) {
654
+ var enableSwitch = makeSwitch(h.enabled, false, function (input) {
655
+ var want = input.checked;
656
+ driveSwitch(input, '/api/hooks/state', { id: h.id, action: want ? 'enable' : 'disable' },
657
+ function () { h.enabled = want; card.classList.toggle('is-disabled', !want); },
658
+ function () { input.checked = !want; });
659
+ });
660
+ controls = el('div', { class: 'row-controls' }, [
661
+ toggleGroup('Enabled', 'enable', enableSwitch),
662
+ removeButton('Remove hook "' + h.id + '"? (its script file is left in place)', function () {
663
+ postJson('/api/hooks/state', { id: h.id, action: 'remove' }).then(function (res) {
664
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Remove failed', 'error'); return; }
665
+ toast('Removed hook ' + h.id, 'good');
666
+ loadHooks(); refreshStatus();
667
+ }).catch(function (e) { toast('Request failed: ' + errText(e), 'error'); });
668
+ }),
669
+ ]);
670
+ } else {
671
+ // Tool Pre/Post gate (no id) — managed from the Tools tab; show state read-only.
672
+ controls = el('div', { class: 'row-controls' }, [
673
+ el('span', { class: 'badge ' + (h.enabled ? 'on' : 'off'), text: h.enabled ? 'enabled' : 'disabled' }),
674
+ el('span', { class: 'managed-note', text: 'managed on Tools tab' }),
675
+ ]);
676
+ }
677
+
678
+ card.appendChild(head);
679
+ if (desc) card.appendChild(desc);
680
+ card.appendChild(meta);
681
+ card.appendChild(controls);
682
+ return card;
683
+ }
684
+
685
+ function applyHookFilter() {
686
+ var shown = filterList(hooks, $('#search-hooks').value, function (h) {
687
+ return (h.id || '') + ' ' + (h.event || '') + ' ' + (h.matcher || '') + ' ' + (h.description || '');
688
+ });
689
+ renderInto('#hook-list', '#hooks-empty', '#count-hooks', shown, hooks.length, renderHook, 'No hooks match your search.');
690
+ }
691
+
692
+ function loadHooks() {
693
+ return getJson('/api/hooks').then(function (data) {
694
+ hooks = (data && Array.isArray(data.hooks)) ? data.hooks : [];
695
+ applyHookFilter();
696
+ }).catch(function (e) {
697
+ $('#hook-list').innerHTML = '';
698
+ $('#hooks-empty').hidden = false;
699
+ $('#hooks-empty').textContent = 'Failed to load hooks: ' + errText(e);
700
+ });
701
+ }
702
+
703
+ function resetHookForm() {
704
+ ['#h-id', '#h-matcher', '#h-command', '#h-scripttext'].forEach(function (s) { $(s).value = ''; });
705
+ $('#h-event').value = EVENTS[2]; // PreToolUse default
706
+ }
707
+
708
+ function submitHook(ev) {
709
+ ev.preventDefault();
710
+ var id = $('#h-id').value.trim(), command = $('#h-command').value.trim();
711
+ if (!id) { toast('Hook id is required', 'error'); return; }
712
+ if (!command) { toast('Command is required', 'error'); return; }
713
+ var entry = { id: id, event: $('#h-event').value, command: command };
714
+ var matcher = $('#h-matcher').value.trim(); if (matcher) entry.matcher = matcher;
715
+ var body = $('#h-scripttext').value; if (body && body.length) entry.scriptText = body;
716
+ var submit = $('#h-submit'); submit.disabled = true;
717
+ postJson('/api/hooks/add', { entry: entry }).then(function (res) {
718
+ submit.disabled = false;
719
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Add failed', 'error'); return; }
720
+ toast('Added hook ' + id + ' (disabled — enable it to fire)', 'good');
721
+ resetHookForm(); hookForm.open(false);
722
+ loadHooks(); refreshStatus();
723
+ }).catch(function (e) { submit.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
724
+ }
725
+
726
+ /* ══════════════════════════════ LOGS ══════════════════════════════ */
727
+ // The activity log is a toggleable JSONL store (DEFAULT OFF). This tab binds an on/off
728
+ // switch to /api/logs/config and shows the most recent records newest-first.
729
+ var logConfig = { enabled: false, path: '' };
730
+ var logEntries = [];
731
+
732
+ // Reflect the current config in the switch, status word, and (when on) the log path.
733
+ function updateLogStatus() {
734
+ $('#log-enabled').checked = !!logConfig.enabled;
735
+ var status = $('#log-status');
736
+ status.textContent = logConfig.enabled ? 'logging is on' : 'logging is off';
737
+ status.className = 'log-status' + (logConfig.enabled ? ' on' : '');
738
+ $('#log-path').textContent = (logConfig.enabled && logConfig.path) ? logConfig.path : '';
739
+ }
740
+
741
+ function loadLogConfig() {
742
+ return getJson('/api/logs/config').then(function (cfg) {
743
+ logConfig = {
744
+ enabled: !!(cfg && cfg.enabled),
745
+ path: (cfg && typeof cfg.path === 'string') ? cfg.path : '',
746
+ };
747
+ updateLogStatus();
748
+ }).catch(function () {
749
+ logConfig = { enabled: false, path: '' };
750
+ updateLogStatus();
751
+ });
752
+ }
753
+
754
+ // Classify a record for its small type badge. The gateway emits type:'gate' / type:'tool';
755
+ // fall back defensively (a record carrying a `decision` is a gate, else a tool).
756
+ function logKind(e) {
757
+ var t = e && e.type;
758
+ if (t === 'gate' || t === 'tool' || t === 'config' || t === 'auth' || t === 'client' || t === 'mcp') return t;
759
+ if (e && e.decision != null) return 'gate';
760
+ if (e && (e.tool || e.ok != null)) return 'tool';
761
+ return 'event';
762
+ }
763
+ // The status word + tone per kind: gate → allow/deny, tool → ok/fail (blocked counts as fail).
764
+ function logStatusOf(e, kind) {
765
+ if (kind === 'gate') {
766
+ var d = (e && e.decision) || '?';
767
+ return { text: d, tone: d === 'deny' ? 'bad' : (d === 'allow' ? 'good' : '') };
768
+ }
769
+ if (kind === 'auth') {
770
+ var ev = (e && e.event) || '?';
771
+ return { text: ev, tone: ev === 'deny' ? 'bad' : (ev === 'allow' ? 'good' : '') };
772
+ }
773
+ if (kind === 'tool') {
774
+ if (e && e.blocked === true) return { text: 'blocked', tone: 'bad' };
775
+ if (e && e.ok === true) return { text: 'ok', tone: 'good' };
776
+ if (e && e.ok === false) return { text: 'fail', tone: 'bad' };
777
+ return { text: 'run', tone: '' };
778
+ }
779
+ // config / client / mcp / event — show the event name, neutral tone
780
+ return { text: (e && e.event) || kind, tone: '' };
781
+ }
782
+
783
+ function renderLogEntry(e) {
784
+ var kind = logKind(e);
785
+ var st = logStatusOf(e, kind);
786
+ var name = (e && (e.tool || e.name || e.id || e.client || e.path)) || '—';
787
+
788
+ var head = el('div', { class: 'log-head' }, [
789
+ el('span', { class: 'log-badge ' + kind, text: kind }),
790
+ el('span', { class: 'log-name mono', text: name }),
791
+ el('span', { class: 'badge ' + (st.tone === 'good' ? 'on' : st.tone === 'bad' ? 'bad' : 'off'), text: st.text }),
792
+ el('span', { class: 'log-ts', text: (e && e.ts) || '' }),
793
+ ]);
794
+
795
+ // Secondary line: the most useful remaining field(s) for the kind.
796
+ var detailText = '';
797
+ if (kind === 'gate') {
798
+ if (e && e.reason) detailText = String(e.reason);
799
+ } else if (kind === 'tool') {
800
+ var bits = [];
801
+ if (e && e.mode) bits.push('mode: ' + e.mode);
802
+ if (e && typeof e.durationMs === 'number') bits.push(e.durationMs + ' ms');
803
+ detailText = bits.join(' · ');
804
+ } else if (kind === 'auth') {
805
+ var ab = [];
806
+ if (e && e.error) ab.push(String(e.error));
807
+ if (e && e.status) ab.push('HTTP ' + e.status);
808
+ if (e && e.path) ab.push(e.path);
809
+ detailText = ab.join(' · ');
810
+ } else if (kind === 'config') {
811
+ var cb = [];
812
+ ['enabled', 'hidden', 'hot', 'action', 'mode', 'on', 'hook', 'issuer'].forEach(function (k) {
813
+ if (e && e[k] !== undefined) cb.push(k + ': ' + e[k]);
814
+ });
815
+ if (e && e.via) cb.push('via ' + e.via);
816
+ detailText = cb.join(' · ');
817
+ } else {
818
+ var eb = [];
819
+ ['client', 'protocolVersion'].forEach(function (k) { if (e && e[k] !== undefined) eb.push(k + ': ' + e[k]); });
820
+ detailText = eb.join(' · ');
821
+ }
822
+
823
+ var card = el('div', { class: 'row log-row' }, [head]);
824
+ if (detailText) card.appendChild(el('p', { class: 'log-detail', text: detailText }));
825
+ return card;
826
+ }
827
+
828
+ function renderLogEntries() {
829
+ var list = $('#log-list');
830
+ list.innerHTML = '';
831
+ // Newest first — tail() returns file order (oldest→newest), so reverse for display.
832
+ var rows = logEntries.slice().reverse();
833
+ rows.forEach(function (e) { list.appendChild(renderLogEntry(e)); });
834
+ var empty = $('#logs-empty');
835
+ if (rows.length === 0) {
836
+ empty.hidden = false;
837
+ empty.innerHTML = '';
838
+ if (!logConfig.enabled) {
839
+ empty.appendChild(el('p', {}, ['Logging is off.']));
840
+ empty.appendChild(el('p', { class: 'muted' }, ['Flip the switch above to start recording gate decisions and tool runs.']));
841
+ } else {
842
+ empty.appendChild(el('p', {}, ['No entries yet.']));
843
+ empty.appendChild(el('p', { class: 'muted' }, ['Gate decisions and tool runs will appear here once activity is logged.']));
844
+ }
845
+ } else {
846
+ empty.hidden = true;
847
+ }
848
+ $('#count-logs').textContent = rows.length ? String(rows.length) : '';
849
+ }
850
+
851
+ function loadLogEntries() {
852
+ return getJson('/api/logs').then(function (data) {
853
+ logEntries = (data && Array.isArray(data.entries)) ? data.entries : [];
854
+ renderLogEntries();
855
+ }).catch(function (e) {
856
+ $('#log-list').innerHTML = '';
857
+ logEntries = [];
858
+ $('#logs-empty').hidden = false;
859
+ $('#logs-empty').innerHTML = '';
860
+ $('#logs-empty').appendChild(el('p', {}, ['Failed to load logs: ' + errText(e)]));
861
+ });
862
+ }
863
+
864
+ function loadLogs() { return loadLogConfig().then(loadLogEntries); }
865
+
866
+ /* ══════════════════════════════ AUTH (OAuth 2.1) ══════════════════════════════ */
867
+ // Opt-in OAuth: the panel shows the optional jose dependency's install state + an Install button,
868
+ // and an enable+config form. Reads /api/auth; writes /api/auth/config and /api/oauth/install. The
869
+ // core gateway stays zero-dependency — jose is fetched only if the operator chooses to.
870
+ var authState = { config: { enabled: false }, joseInstalled: false, josePin: 'jose', configError: null, ready: true };
871
+
872
+ function renderAuth() {
873
+ var c = authState.config || {};
874
+ $('#auth-josepin').textContent = 'jose@' + (authState.josePin || '');
875
+
876
+ // Dependency state + Install button.
877
+ var depState = $('#auth-dep-state');
878
+ var installBtn = $('#auth-install-btn');
879
+ if (authState.joseInstalled) {
880
+ depState.textContent = '✓ jose@' + (authState.josePin || '') + ' installed — OAuth is available';
881
+ depState.className = 'auth-dep-state is-good';
882
+ installBtn.hidden = true;
883
+ } else {
884
+ depState.textContent = '○ OAuth dependency not installed — the core gateway is zero-dependency until you add it';
885
+ depState.className = 'auth-dep-state is-off';
886
+ installBtn.hidden = false;
887
+ }
888
+
889
+ // Form fields.
890
+ $('#auth-enabled').checked = !!c.enabled;
891
+ $('#auth-issuer').value = c.issuer || '';
892
+ $('#auth-audience').value = c.audience || '';
893
+ $('#auth-jwksuri').value = c.jwksUri || '';
894
+ $('#auth-algorithms').value = (Array.isArray(c.algorithms) ? c.algorithms : []).join(', ');
895
+ $('#auth-scopes').value = (Array.isArray(c.requiredScopes) ? c.requiredScopes : []).join(', ');
896
+ $('#auth-clock').value = (typeof c.clockToleranceSec === 'number') ? c.clockToleranceSec : '';
897
+
898
+ // Ready / misconfigured badge — only meaningful when auth is enabled.
899
+ var badge = $('#auth-ready-badge');
900
+ if (c.enabled && !authState.ready) {
901
+ badge.hidden = false;
902
+ badge.className = 'auth-ready-badge is-bad';
903
+ badge.textContent = '⚠ ' + (authState.configError || 'not ready — the HTTP host will refuse to start');
904
+ } else if (c.enabled && authState.ready) {
905
+ badge.hidden = false;
906
+ badge.className = 'auth-ready-badge is-good';
907
+ badge.textContent = '✓ active';
908
+ } else {
909
+ badge.hidden = true;
910
+ }
911
+ }
912
+
913
+ function loadAuth() {
914
+ return getJson('/api/auth').then(function (data) {
915
+ authState = {
916
+ config: (data && data.config) || { enabled: false },
917
+ joseInstalled: !!(data && data.joseInstalled),
918
+ josePin: (data && data.josePin) || '',
919
+ configError: (data && data.configError) || null,
920
+ ready: !(data && data.ready === false),
921
+ };
922
+ renderAuth();
923
+ }).catch(function (e) {
924
+ $('#auth-dep-state').textContent = 'Failed to load auth status: ' + errText(e);
925
+ });
926
+ }
927
+
928
+ // Parse a comma/space-separated field into a trimmed, non-empty string array.
929
+ function splitList(v) {
930
+ return (v || '').split(/[\s,]+/).map(function (s) { return s.trim(); }).filter(Boolean);
931
+ }
932
+
933
+ function submitAuth(ev) {
934
+ ev.preventDefault();
935
+ var patch = {
936
+ enabled: $('#auth-enabled').checked,
937
+ issuer: $('#auth-issuer').value.trim(),
938
+ audience: $('#auth-audience').value.trim(),
939
+ jwksUri: $('#auth-jwksuri').value.trim(),
940
+ algorithms: splitList($('#auth-algorithms').value),
941
+ requiredScopes: splitList($('#auth-scopes').value),
942
+ };
943
+ var clock = parseInt($('#auth-clock').value, 10);
944
+ if (!isNaN(clock) && clock >= 0) patch.clockToleranceSec = clock;
945
+
946
+ var btn = $('#auth-save'); btn.disabled = true;
947
+ postJson('/api/auth/config', patch).then(function (res) {
948
+ btn.disabled = false;
949
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Save failed', 'error'); return; }
950
+ toast(patch.enabled ? 'OAuth config saved — auth enabled' : 'OAuth config saved', 'good');
951
+ // Refresh from the server so the ready/misconfigured badge reflects the persisted state.
952
+ loadAuth();
953
+ }).catch(function (e) { btn.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
954
+ }
955
+
956
+ function installOauth() {
957
+ var btn = $('#auth-install-btn');
958
+ var logBox = $('#auth-install-log');
959
+ btn.disabled = true;
960
+ var label = btn.textContent; btn.textContent = 'Installing…';
961
+ logBox.hidden = false; logBox.textContent = 'Running npm install jose@' + (authState.josePin || '') + ' …';
962
+ postJson('/api/oauth/install', {}).then(function (res) {
963
+ btn.disabled = false; btn.textContent = label;
964
+ var out = (res && (res.stderr || res.stdout)) || (res && res.message) || '';
965
+ logBox.textContent = (res && res.message ? res.message + '\n\n' : '') + out;
966
+ if (res && res.ok) {
967
+ toast('OAuth dependency installed — you can enable auth now', 'good');
968
+ loadAuth();
969
+ } else {
970
+ toast((res && res.message) || 'Install failed', 'error');
971
+ }
972
+ }).catch(function (e) {
973
+ btn.disabled = false; btn.textContent = label;
974
+ logBox.textContent = 'Install request failed: ' + errText(e);
975
+ toast('Install failed: ' + errText(e), 'error');
976
+ });
977
+ }
978
+
979
+ /* ══════════════════════════════ tabs + wiring ══════════════════════════════ */
980
+ var loaded = { mcps: false, hooks: false, logs: false, auth: false };
981
+ function switchTab(name) {
982
+ document.querySelectorAll('.tab').forEach(function (b) { b.classList.toggle('is-active', b.getAttribute('data-tab') === name); });
983
+ $('#view-tools').classList.toggle('is-active', name === 'tools');
984
+ $('#view-mcps').classList.toggle('is-active', name === 'mcps');
985
+ $('#view-hooks').classList.toggle('is-active', name === 'hooks');
986
+ $('#view-logs').classList.toggle('is-active', name === 'logs');
987
+ $('#view-auth').classList.toggle('is-active', name === 'auth');
988
+ if (name === 'mcps' && !loaded.mcps) { loaded.mcps = true; loadMcps(); }
989
+ if (name === 'hooks' && !loaded.hooks) { loaded.hooks = true; loadHooks(); }
990
+ if (name === 'logs' && !loaded.logs) { loaded.logs = true; loadLogs(); }
991
+ if (name === 'auth' && !loaded.auth) { loaded.auth = true; loadAuth(); }
992
+ }
993
+
994
+ // Populate the hook event dropdown.
995
+ EVENTS.forEach(function (e) { $('#h-event').appendChild(el('option', { value: e, text: e })); });
996
+ $('#h-event').value = EVENTS[2];
997
+
998
+ document.querySelectorAll('.tab').forEach(function (b) {
999
+ b.addEventListener('click', function () { switchTab(b.getAttribute('data-tab')); });
1000
+ });
1001
+
1002
+ // Search inputs.
1003
+ $('#search-tools').addEventListener('input', applyToolFilter);
1004
+ $('#show-hidden').addEventListener('change', applyToolFilter);
1005
+ $('#search-mcps').addEventListener('input', applyMcpFilter);
1006
+ $('#search-hooks').addEventListener('input', applyHookFilter);
1007
+
1008
+ // Add forms.
1009
+ var toolForm = wireAddForm('#addbtn-tools', '#addform-tools', '#t-cancel', resetToolForm);
1010
+ var mcpForm = wireAddForm('#addbtn-mcps', '#addform-mcps', '#m-cancel', resetMcpForm);
1011
+ var hookForm = wireAddForm('#addbtn-hooks', '#addform-hooks', '#h-cancel', resetHookForm);
1012
+
1013
+ $('#addform-tools').addEventListener('submit', submitTool);
1014
+ $('#addform-mcps').addEventListener('submit', submitMcp);
1015
+ $('#addform-hooks').addEventListener('submit', submitHook);
1016
+ $('#t-mode').addEventListener('change', syncToolFields);
1017
+ $('#t-invoke-type').addEventListener('change', syncToolFields);
1018
+ $('#m-add-expose').addEventListener('click', addExposeRow);
1019
+ syncToolFields();
1020
+
1021
+ // Logs: bind the on/off switch to the config, and a manual refresh.
1022
+ $('#log-enabled').addEventListener('change', function () {
1023
+ var input = $('#log-enabled');
1024
+ var want = input.checked;
1025
+ driveSwitch(input, '/api/logs/config', { enabled: want },
1026
+ function (res) {
1027
+ if (res && res.config) {
1028
+ logConfig = { enabled: !!res.config.enabled, path: res.config.path || logConfig.path };
1029
+ } else {
1030
+ logConfig.enabled = want;
1031
+ }
1032
+ updateLogStatus();
1033
+ loadLogEntries();
1034
+ },
1035
+ function () { input.checked = !want; });
1036
+ });
1037
+ $('#logs-refresh').addEventListener('click', loadLogs);
1038
+
1039
+ // Auth: the install button + the config form.
1040
+ $('#auth-install-btn').addEventListener('click', installOauth);
1041
+ $('#auth-form').addEventListener('submit', submitAuth);
1042
+
1043
+ // Pre-seed the authored empty-state HTML BEFORE any load runs, so a first-load FAILURE (whose catch
1044
+ // writes error text straight into the empty div, bypassing renderInto) can't get cached by a later
1045
+ // renderInto as the canonical "nothing configured" message.
1046
+ ['#tools-empty', '#mcps-empty', '#hooks-empty', '#logs-empty'].forEach(function (sel) {
1047
+ var node = $(sel);
1048
+ if (node && emptyOriginal[sel] === undefined) emptyOriginal[sel] = node.innerHTML;
1049
+ });
1050
+
1051
+ // Boot.
1052
+ refreshStatus();
1053
+ loadTools();
1054
+ loadSurface();
1055
+ })();