@winstonsayno/mcp-gateway 0.0.0-stage → 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 (114) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/LICENSE +21 -0
  3. package/README.md +377 -2
  4. package/dashboard/README.md +27 -0
  5. package/dashboard/index.html +415 -0
  6. package/dist/auth/middleware.d.ts +24 -0
  7. package/dist/auth/middleware.d.ts.map +1 -0
  8. package/dist/auth/middleware.js +108 -0
  9. package/dist/auth/middleware.js.map +1 -0
  10. package/dist/auth/ratelimit.d.ts +19 -0
  11. package/dist/auth/ratelimit.d.ts.map +1 -0
  12. package/dist/auth/ratelimit.js +81 -0
  13. package/dist/auth/ratelimit.js.map +1 -0
  14. package/dist/cli.d.ts +6 -0
  15. package/dist/cli.d.ts.map +1 -0
  16. package/dist/cli.js +129 -0
  17. package/dist/cli.js.map +1 -0
  18. package/dist/config/loader.d.ts +13 -0
  19. package/dist/config/loader.d.ts.map +1 -0
  20. package/dist/config/loader.js +268 -0
  21. package/dist/config/loader.js.map +1 -0
  22. package/dist/config/watcher.d.ts +29 -0
  23. package/dist/config/watcher.d.ts.map +1 -0
  24. package/dist/config/watcher.js +96 -0
  25. package/dist/config/watcher.js.map +1 -0
  26. package/dist/gateway/api.d.ts +29 -0
  27. package/dist/gateway/api.d.ts.map +1 -0
  28. package/dist/gateway/api.js +280 -0
  29. package/dist/gateway/api.js.map +1 -0
  30. package/dist/gateway/index.d.ts +49 -0
  31. package/dist/gateway/index.d.ts.map +1 -0
  32. package/dist/gateway/index.js +260 -0
  33. package/dist/gateway/index.js.map +1 -0
  34. package/dist/gateway/supervisor.d.ts +58 -0
  35. package/dist/gateway/supervisor.d.ts.map +1 -0
  36. package/dist/gateway/supervisor.js +205 -0
  37. package/dist/gateway/supervisor.js.map +1 -0
  38. package/dist/index.d.ts +15 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +12 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/middleware/cors.d.ts +19 -0
  43. package/dist/middleware/cors.d.ts.map +1 -0
  44. package/dist/middleware/cors.js +65 -0
  45. package/dist/middleware/cors.js.map +1 -0
  46. package/dist/middleware/error-handler.d.ts +40 -0
  47. package/dist/middleware/error-handler.d.ts.map +1 -0
  48. package/dist/middleware/error-handler.js +98 -0
  49. package/dist/middleware/error-handler.js.map +1 -0
  50. package/dist/middleware/request-id.d.ts +23 -0
  51. package/dist/middleware/request-id.d.ts.map +1 -0
  52. package/dist/middleware/request-id.js +30 -0
  53. package/dist/middleware/request-id.js.map +1 -0
  54. package/dist/middleware/timeout.d.ts +12 -0
  55. package/dist/middleware/timeout.d.ts.map +1 -0
  56. package/dist/middleware/timeout.js +39 -0
  57. package/dist/middleware/timeout.js.map +1 -0
  58. package/dist/monitor/index.d.ts +49 -0
  59. package/dist/monitor/index.d.ts.map +1 -0
  60. package/dist/monitor/index.js +204 -0
  61. package/dist/monitor/index.js.map +1 -0
  62. package/dist/proxy/index.d.ts +86 -0
  63. package/dist/proxy/index.d.ts.map +1 -0
  64. package/dist/proxy/index.js +389 -0
  65. package/dist/proxy/index.js.map +1 -0
  66. package/dist/registry/index.d.ts +41 -0
  67. package/dist/registry/index.d.ts.map +1 -0
  68. package/dist/registry/index.js +157 -0
  69. package/dist/registry/index.js.map +1 -0
  70. package/dist/transport/channel.d.ts +61 -0
  71. package/dist/transport/channel.d.ts.map +1 -0
  72. package/dist/transport/channel.js +38 -0
  73. package/dist/transport/channel.js.map +1 -0
  74. package/dist/transport/sse-parser.d.ts +30 -0
  75. package/dist/transport/sse-parser.d.ts.map +1 -0
  76. package/dist/transport/sse-parser.js +107 -0
  77. package/dist/transport/sse-parser.js.map +1 -0
  78. package/dist/transport/sse.d.ts +39 -0
  79. package/dist/transport/sse.d.ts.map +1 -0
  80. package/dist/transport/sse.js +142 -0
  81. package/dist/transport/sse.js.map +1 -0
  82. package/dist/transport/stdio.d.ts +35 -0
  83. package/dist/transport/stdio.d.ts.map +1 -0
  84. package/dist/transport/stdio.js +149 -0
  85. package/dist/transport/stdio.js.map +1 -0
  86. package/dist/transport/streamable-http.d.ts +45 -0
  87. package/dist/transport/streamable-http.d.ts.map +1 -0
  88. package/dist/transport/streamable-http.js +183 -0
  89. package/dist/transport/streamable-http.js.map +1 -0
  90. package/dist/transport/websocket.d.ts +38 -0
  91. package/dist/transport/websocket.d.ts.map +1 -0
  92. package/dist/transport/websocket.js +172 -0
  93. package/dist/transport/websocket.js.map +1 -0
  94. package/dist/utils/logger.d.ts +16 -0
  95. package/dist/utils/logger.d.ts.map +1 -0
  96. package/dist/utils/logger.js +60 -0
  97. package/dist/utils/logger.js.map +1 -0
  98. package/dist/utils/mutex.d.ts +25 -0
  99. package/dist/utils/mutex.d.ts.map +1 -0
  100. package/dist/utils/mutex.js +62 -0
  101. package/dist/utils/mutex.js.map +1 -0
  102. package/dist/utils/semaphore.d.ts +16 -0
  103. package/dist/utils/semaphore.d.ts.map +1 -0
  104. package/dist/utils/semaphore.js +42 -0
  105. package/dist/utils/semaphore.js.map +1 -0
  106. package/dist/utils/types.d.ts +215 -0
  107. package/dist/utils/types.d.ts.map +1 -0
  108. package/dist/utils/types.js +5 -0
  109. package/dist/utils/types.js.map +1 -0
  110. package/dist/utils/version.d.ts +6 -0
  111. package/dist/utils/version.d.ts.map +1 -0
  112. package/dist/utils/version.js +18 -0
  113. package/dist/utils/version.js.map +1 -0
  114. package/package.json +78 -4
@@ -0,0 +1,415 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>mcp-gateway — Dashboard</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0d1117;
10
+ --surface: #161b22;
11
+ --border: #30363d;
12
+ --text: #e6edf3;
13
+ --muted: #8b949e;
14
+ --accent: #58a6ff;
15
+ --green: #3fb950;
16
+ --yellow: #d29922;
17
+ --red: #f85149;
18
+ --purple: #bc8cff;
19
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
20
+ }
21
+ * { box-sizing: border-box; margin: 0; padding: 0; }
22
+ body { background: var(--bg); color: var(--text); font-family: var(--font); font-size: 14px; }
23
+
24
+ /* ── Layout ── */
25
+ header {
26
+ display: flex; align-items: center; gap: 12px;
27
+ padding: 14px 24px; border-bottom: 1px solid var(--border);
28
+ background: var(--surface);
29
+ }
30
+ header .logo { font-size: 18px; font-weight: 700; color: var(--accent); letter-spacing: -0.5px; }
31
+ header .version { font-size: 11px; background: var(--border); border-radius: 12px; padding: 2px 8px; color: var(--muted); }
32
+ header .status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); margin-left: auto; }
33
+ header .status-dot.offline { background: var(--red); }
34
+ header .status-label { font-size: 12px; color: var(--muted); }
35
+ header .refresh-btn {
36
+ background: none; border: 1px solid var(--border); color: var(--muted);
37
+ padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 12px;
38
+ }
39
+ header .refresh-btn:hover { border-color: var(--accent); color: var(--accent); }
40
+
41
+ header .auth { display: flex; align-items: center; gap: 6px; }
42
+ header .auth input[type=password] {
43
+ background: var(--bg); border: 1px solid var(--border); color: var(--text);
44
+ padding: 4px 8px; border-radius: 6px; font-size: 12px; width: 180px;
45
+ }
46
+ header .auth input.needed { border-color: var(--yellow); }
47
+ header .auth label { font-size: 11px; color: var(--muted); display: flex; align-items: center; gap: 4px; }
48
+ .notice { display: none; margin-bottom: 16px; padding: 10px 14px; border-radius: 8px; border: 1px solid var(--yellow); color: var(--yellow); background: rgba(210,153,34,.08); font-size: 13px; }
49
+ .notice.show { display: block; }
50
+ .muted { color: var(--muted); font-size: 11px; }
51
+
52
+ main { padding: 24px; max-width: 1200px; margin: 0 auto; }
53
+
54
+ /* ── Stat Cards ── */
55
+ .stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
56
+ .stat-card {
57
+ background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
58
+ padding: 16px; display: flex; flex-direction: column; gap: 4px;
59
+ }
60
+ .stat-card .label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
61
+ .stat-card .value { font-size: 28px; font-weight: 700; }
62
+ .stat-card .sub { font-size: 11px; color: var(--muted); }
63
+ .value.green { color: var(--green); }
64
+ .value.yellow { color: var(--yellow); }
65
+ .value.red { color: var(--red); }
66
+ .value.blue { color: var(--accent); }
67
+
68
+ /* ── Section ── */
69
+ .section { margin-bottom: 24px; }
70
+ .section-title { font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; }
71
+
72
+ /* ── Table ── */
73
+ .table-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
74
+ table { width: 100%; border-collapse: collapse; }
75
+ th { text-align: left; padding: 10px 16px; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border); }
76
+ td { padding: 10px 16px; border-bottom: 1px solid var(--border); vertical-align: middle; }
77
+ tr:last-child td { border-bottom: none; }
78
+ tr:hover td { background: rgba(255,255,255,0.02); }
79
+
80
+ /* ── Badge ── */
81
+ .badge {
82
+ display: inline-flex; align-items: center; gap: 5px;
83
+ font-size: 11px; padding: 2px 8px; border-radius: 12px; font-weight: 500;
84
+ }
85
+ .badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; }
86
+ .badge.healthy { background: rgba(63,185,80,.15); color: var(--green); }
87
+ .badge.healthy::before { background: var(--green); }
88
+ .badge.degraded { background: rgba(210,153,34,.15); color: var(--yellow); }
89
+ .badge.degraded::before { background: var(--yellow); }
90
+ .badge.offline { background: rgba(248,81,73,.15); color: var(--red); }
91
+ .badge.offline::before { background: var(--red); }
92
+
93
+ /* ── Method tag ── */
94
+ .method { font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 4px; font-family: monospace; }
95
+ .method.post { background: rgba(63,185,80,.15); color: var(--green); }
96
+ .method.get { background: rgba(88,166,255,.15); color: var(--accent); }
97
+
98
+ /* ── Code ── */
99
+ code { font-family: "SFMono-Regular", Consolas, monospace; font-size: 12px; color: var(--purple); }
100
+
101
+ /* ── Chart ── */
102
+ .chart-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
103
+ canvas { width: 100% !important; }
104
+
105
+ /* ── Two-col ── */
106
+ .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
107
+ @media (max-width: 768px) { .two-col { grid-template-columns: 1fr; } }
108
+
109
+ /* ── Empty state ── */
110
+ .empty { text-align: center; padding: 32px; color: var(--muted); }
111
+
112
+ /* ── Loader ── */
113
+ .loader { text-align: center; padding: 48px; color: var(--muted); }
114
+ @keyframes spin { to { transform: rotate(360deg); } }
115
+ .spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
116
+ </style>
117
+ </head>
118
+ <body>
119
+
120
+ <header>
121
+ <span class="logo">⇌ mcp-gateway</span>
122
+ <span class="version" id="version">v—</span>
123
+ <span id="status-dot" class="status-dot offline"></span>
124
+ <span id="status-label" class="status-label">Connecting…</span>
125
+ <form class="auth" id="auth-form" autocomplete="off">
126
+ <input type="password" id="api-key" placeholder="API key / JWT (if auth is on)" aria-label="API key" />
127
+ <label><input type="checkbox" id="remember" /> remember</label>
128
+ <button class="refresh-btn" type="submit">Use key</button>
129
+ <button class="refresh-btn" type="button" id="clear-key">Clear</button>
130
+ </form>
131
+ <button class="refresh-btn" onclick="loadAll()">↻ Refresh</button>
132
+ </header>
133
+
134
+ <main>
135
+ <div class="notice" id="auth-notice">This gateway requires authentication. Enter an API key (or JWT) above — it is kept in this browser tab only unless you tick “remember”, and is sent as <code>Authorization: Bearer …</code>.</div>
136
+ <!-- Stat Cards -->
137
+ <div class="stats-grid">
138
+ <div class="stat-card">
139
+ <span class="label">Servers</span>
140
+ <span class="value blue" id="stat-servers">—</span>
141
+ <span class="sub" id="stat-servers-sub">loading…</span>
142
+ </div>
143
+ <div class="stat-card">
144
+ <span class="label">Tools</span>
145
+ <span class="value blue" id="stat-tools">—</span>
146
+ <span class="sub">across all servers</span>
147
+ </div>
148
+ <div class="stat-card">
149
+ <span class="label">Total Requests</span>
150
+ <span class="value" id="stat-requests">—</span>
151
+ <span class="sub" id="stat-requests-sub">since startup</span>
152
+ </div>
153
+ <div class="stat-card">
154
+ <span class="label">Error Rate</span>
155
+ <span class="value" id="stat-error-rate">—</span>
156
+ <span class="sub">last hour</span>
157
+ </div>
158
+ <div class="stat-card">
159
+ <span class="label">Avg Latency</span>
160
+ <span class="value" id="stat-latency">—</span>
161
+ <span class="sub" id="stat-latency-sub">avg, last hour</span>
162
+ </div>
163
+ <div class="stat-card">
164
+ <span class="label">Uptime</span>
165
+ <span class="value green" id="stat-uptime">—</span>
166
+ <span class="sub" id="stat-started">—</span>
167
+ </div>
168
+ </div>
169
+
170
+ <!-- Servers + Tools -->
171
+ <div class="two-col">
172
+ <div class="section">
173
+ <div class="section-title">Servers</div>
174
+ <div class="table-wrap">
175
+ <table>
176
+ <thead><tr><th>ID</th><th>Name</th><th>Transport</th><th>Tools</th><th>Status</th></tr></thead>
177
+ <tbody id="servers-tbody"><tr><td colspan="5" class="loader"><span class="spinner"></span></td></tr></tbody>
178
+ </table>
179
+ </div>
180
+ </div>
181
+ <div class="section">
182
+ <div class="section-title">Tools</div>
183
+ <div class="table-wrap">
184
+ <table>
185
+ <thead><tr><th>Name</th><th>Server</th><th>Calls</th></tr></thead>
186
+ <tbody id="tools-tbody"><tr><td colspan="3" class="loader"><span class="spinner"></span></td></tr></tbody>
187
+ </table>
188
+ </div>
189
+ </div>
190
+ </div>
191
+
192
+ <!-- Recent Requests -->
193
+ <div class="section">
194
+ <div class="section-title">Recent Requests</div>
195
+ <div class="table-wrap">
196
+ <table>
197
+ <thead><tr><th>Time</th><th>Method</th><th>Tool</th><th>Server</th><th>Duration</th><th>Status</th></tr></thead>
198
+ <tbody id="requests-tbody"><tr><td colspan="6" class="loader"><span class="spinner"></span></td></tr></tbody>
199
+ </table>
200
+ </div>
201
+ </div>
202
+ </main>
203
+
204
+ <script>
205
+ const BASE = window.location.origin;
206
+ const KEY_NAME = 'mcp-gateway.apiKey';
207
+ const keyInput = document.getElementById('api-key');
208
+ const remember = document.getElementById('remember');
209
+ const notice = document.getElementById('auth-notice');
210
+ let authMissing = false;
211
+
212
+ function getKey() {
213
+ return sessionStorage.getItem(KEY_NAME) || localStorage.getItem(KEY_NAME) || '';
214
+ }
215
+ function setKey(key, persist) {
216
+ sessionStorage.removeItem(KEY_NAME);
217
+ localStorage.removeItem(KEY_NAME);
218
+ if (key) (persist ? localStorage : sessionStorage).setItem(KEY_NAME, key);
219
+ }
220
+ remember.checked = !!localStorage.getItem(KEY_NAME);
221
+ keyInput.value = getKey() ? '••••••••' : '';
222
+
223
+ document.getElementById('auth-form').addEventListener('submit', (e) => {
224
+ e.preventDefault();
225
+ const v = keyInput.value.trim();
226
+ if (v && v !== '••••••••') setKey(v, remember.checked);
227
+ keyInput.value = getKey() ? '••••••••' : '';
228
+ loadAll();
229
+ });
230
+ document.getElementById('clear-key').addEventListener('click', () => {
231
+ setKey('', false);
232
+ keyInput.value = '';
233
+ loadAll();
234
+ });
235
+
236
+ class AuthError extends Error {}
237
+
238
+ async function fetchJSON(path) {
239
+ const headers = { Accept: 'application/json' };
240
+ const key = getKey();
241
+ if (key) headers.Authorization = 'Bearer ' + key;
242
+ const r = await fetch(BASE + path, { headers });
243
+ if (r.status === 401) { authMissing = true; throw new AuthError('HTTP 401'); }
244
+ if (!r.ok && r.status !== 207) throw new Error(`HTTP ${r.status}`);
245
+ return r.json();
246
+ }
247
+
248
+ // Server-provided strings (names, ids, errors) are never trusted as HTML.
249
+ function esc(v) {
250
+ return String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
251
+ }
252
+
253
+ function fmtDuration(ms) {
254
+ if (ms == null) return '—';
255
+ if (ms < 1000) return Math.round(ms) + 'ms';
256
+ return (ms / 1000).toFixed(2) + 's';
257
+ }
258
+
259
+ function fmtUptime(seconds) {
260
+ if (seconds == null) return '—';
261
+ const h = Math.floor(seconds / 3600);
262
+ const m = Math.floor((seconds % 3600) / 60);
263
+ const s = Math.floor(seconds % 60);
264
+ if (h > 0) return `${h}h ${m}m`;
265
+ if (m > 0) return `${m}m ${s}s`;
266
+ return `${s}s`;
267
+ }
268
+
269
+ function statusBadge(status) {
270
+ const cls = status === 'online' ? 'healthy' : (status === 'degraded' || status === 'reconnecting') ? 'degraded' : 'offline';
271
+ return `<span class="badge ${cls}">${esc(status ?? 'unknown')}</span>`;
272
+ }
273
+
274
+ function reconnectInfo(h) {
275
+ const rc = h && h.reconnect;
276
+ if (!rc) return '';
277
+ const parts = [];
278
+ if (rc.state === 'scheduled' && rc.nextAttemptAt) {
279
+ const secs = Math.max(0, Math.round((new Date(rc.nextAttemptAt) - Date.now()) / 1000));
280
+ parts.push(`retry #${rc.attempt} in ${secs}s`);
281
+ } else if (rc.state === 'connecting') {
282
+ parts.push(rc.attempt ? `retry #${rc.attempt}…` : 'connecting…');
283
+ } else if (rc.state === 'gave-up') {
284
+ parts.push(`gave up after ${rc.attempt} tries`);
285
+ }
286
+ if (rc.reconnects) parts.push(`${rc.reconnects} reconnect${rc.reconnects === 1 ? '' : 's'}`);
287
+ return parts.length ? `<div class="muted">${esc(parts.join(' · '))}</div>` : '';
288
+ }
289
+
290
+ async function loadHealth() {
291
+ const dot = document.getElementById('status-dot');
292
+ const lbl = document.getElementById('status-label');
293
+ try {
294
+ const data = await fetchJSON('/api/v1/health');
295
+ dot.className = 'status-dot' + (data.status === 'ok' ? '' : ' offline');
296
+ lbl.textContent = data.status === 'ok' ? 'Gateway online' : 'Gateway degraded';
297
+ document.getElementById('version').textContent = 'v' + (data.version ?? '—');
298
+ const sv = data.servers ?? {};
299
+ document.getElementById('stat-servers').textContent = sv.total ?? '—';
300
+ const sub = [`${sv.online ?? 0} online`];
301
+ if (sv.reconnecting) sub.push(`${sv.reconnecting} reconnecting`);
302
+ if (sv.degraded) sub.push(`${sv.degraded} degraded`);
303
+ if (sv.offline) sub.push(`${sv.offline} offline`);
304
+ document.getElementById('stat-servers-sub').textContent = sub.join(', ');
305
+ document.getElementById('stat-uptime').textContent = fmtUptime(data.uptime);
306
+ document.getElementById('stat-started').textContent =
307
+ data.uptime != null ? 'since ' + new Date(Date.now() - data.uptime * 1000).toLocaleTimeString() : '';
308
+ } catch (e) {
309
+ // A protected /health still means the gateway is up.
310
+ lbl.textContent = e instanceof AuthError ? 'Authentication required' : 'Cannot reach gateway';
311
+ }
312
+ }
313
+
314
+ async function loadServers() {
315
+ const tbody = document.getElementById('servers-tbody');
316
+ try {
317
+ const data = await fetchJSON('/api/v1/servers');
318
+ if (!data.servers?.length) {
319
+ tbody.innerHTML = '<tr><td colspan="5" class="empty">No servers registered</td></tr>';
320
+ return;
321
+ }
322
+ tbody.innerHTML = data.servers.map(s => {
323
+ const h = s.health || {};
324
+ const err = h.errorMessage ? ` title="${esc(h.errorMessage)}"` : '';
325
+ return `
326
+ <tr>
327
+ <td><code>${esc(s.id)}</code></td>
328
+ <td>${esc(s.name)}</td>
329
+ <td><span class="badge healthy">${esc(s.transport ?? 'stdio')}</span></td>
330
+ <td>${esc(s.toolCount ?? 0)}</td>
331
+ <td${err}>${statusBadge(h.status)}${reconnectInfo(h)}</td>
332
+ </tr>`;
333
+ }).join('');
334
+ } catch (e) {
335
+ tbody.innerHTML = `<tr><td colspan="5" class="empty">${e instanceof AuthError ? 'API key required' : 'Failed to load servers'}</td></tr>`;
336
+ }
337
+ }
338
+
339
+ let toolCalls = new Map();
340
+
341
+ async function loadTools() {
342
+ const tbody = document.getElementById('tools-tbody');
343
+ try {
344
+ const data = await fetchJSON('/api/v1/tools');
345
+ document.getElementById('stat-tools').textContent = data.tools?.length ?? '—';
346
+ if (!data.tools?.length) {
347
+ tbody.innerHTML = '<tr><td colspan="3" class="empty">No tools available</td></tr>';
348
+ return;
349
+ }
350
+ tbody.innerHTML = data.tools.slice(0, 50).map(t => `
351
+ <tr>
352
+ <td><code>${esc(t.name)}</code></td>
353
+ <td>${esc(t.serverName ?? t.serverId ?? '—')}</td>
354
+ <td>${esc(toolCalls.get(t.name) ?? 0)}</td>
355
+ </tr>`).join('');
356
+ } catch (e) {
357
+ tbody.innerHTML = `<tr><td colspan="3" class="empty">${e instanceof AuthError ? 'API key required' : 'Failed to load tools'}</td></tr>`;
358
+ }
359
+ }
360
+
361
+ async function loadMetrics() {
362
+ try {
363
+ const m = await fetchJSON('/api/v1/metrics?format=json');
364
+ toolCalls = new Map((m.topTools ?? []).map(t => [t.name, t.count]));
365
+ document.getElementById('stat-requests').textContent = (m.totalRequests ?? 0).toLocaleString();
366
+ const errRate = m.totalRequests ? 1 - (m.successRate ?? 1) : 0;
367
+ const errEl = document.getElementById('stat-error-rate');
368
+ errEl.textContent = m.totalRequests ? (errRate * 100).toFixed(1) + '%' : '—';
369
+ errEl.className = 'value ' + (errRate > 0.05 ? 'red' : errRate > 0.01 ? 'yellow' : 'green');
370
+ document.getElementById('stat-latency').textContent = m.totalRequests ? fmtDuration(m.avgLatencyMs) : '—';
371
+ document.getElementById('stat-latency-sub').textContent =
372
+ m.totalRequests ? `avg · p95 ${fmtDuration(m.p95LatencyMs)}` : 'avg, last hour';
373
+ } catch { /* shown via other panels */ }
374
+ }
375
+
376
+ async function loadRequests() {
377
+ const tbody = document.getElementById('requests-tbody');
378
+ try {
379
+ const data = await fetchJSON('/api/v1/requests?limit=20');
380
+ const reqs = data.requests ?? [];
381
+ if (!reqs.length) {
382
+ tbody.innerHTML = '<tr><td colspan="6" class="empty">No requests yet</td></tr>';
383
+ return;
384
+ }
385
+ tbody.innerHTML = reqs.map(r => {
386
+ const color = r.success ? 'green' : 'red';
387
+ const err = r.errorMessage ? ` title="${esc(r.errorMessage)}"` : '';
388
+ return `<tr>
389
+ <td style="color:var(--muted)">${esc(new Date(r.timestamp).toLocaleTimeString())}</td>
390
+ <td><span class="method post">CALL</span></td>
391
+ <td><code>${esc(r.toolName ?? '—')}</code></td>
392
+ <td>${esc(r.serverId ?? '—')}</td>
393
+ <td>${fmtDuration(r.durationMs)}</td>
394
+ <td style="color:var(--${color})"${err}>${r.success ? 'ok' : 'error'}</td>
395
+ </tr>`;
396
+ }).join('');
397
+ } catch (e) {
398
+ tbody.innerHTML = `<tr><td colspan="6" class="empty">${e instanceof AuthError ? 'API key required' : 'Failed to load requests'}</td></tr>`;
399
+ }
400
+ }
401
+
402
+ async function loadAll() {
403
+ authMissing = false;
404
+ await loadMetrics(); // tool call counts feed the tools table
405
+ await Promise.allSettled([loadHealth(), loadServers(), loadTools(), loadRequests()]);
406
+ notice.classList.toggle('show', authMissing);
407
+ keyInput.classList.toggle('needed', authMissing);
408
+ }
409
+
410
+ // Initial load + auto-refresh every 10s
411
+ loadAll();
412
+ setInterval(loadAll, 10_000);
413
+ </script>
414
+ </body>
415
+ </html>
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Authentication middleware for the gateway HTTP API
3
+ *
4
+ * Security notes:
5
+ * - API keys are compared in constant time (SHA-256 digest + timingSafeEqual)
6
+ * so response timing does not leak how much of a key matched.
7
+ * - Misconfiguration fails CLOSED: an unknown / unimplemented strategy, an
8
+ * `api-key` strategy with no keys, or a `jwt` strategy with no secret throws
9
+ * at startup instead of silently disabling authentication.
10
+ * - The client id attached to a request is a hash fingerprint of the key,
11
+ * never a prefix of the key itself (prefixes were leaking into logs and the
12
+ * /requests endpoint, and keys sharing a prefix shared a rate-limit bucket).
13
+ */
14
+ import type { Request, RequestHandler } from 'express';
15
+ import type { AuthConfig } from '../utils/types.js';
16
+ export type AuthedRequest = Request & {
17
+ clientId?: string;
18
+ jwtPayload?: unknown;
19
+ };
20
+ export declare function createAuthMiddleware(config?: AuthConfig): RequestHandler;
21
+ export declare function fingerprint(key: string): string;
22
+ /** Constant-time membership check against a pre-hashed key list. */
23
+ export declare function matchesAnyKey(candidate: string, keyDigests: Buffer[]): boolean;
24
+ //# sourceMappingURL=middleware.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/auth/middleware.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,KAAK,EAAE,OAAO,EAA0B,cAAc,EAAE,MAAM,SAAS,CAAC;AAE/E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGpD,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAIlF,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,UAAU,GAAG,cAAc,CA2BxE;AAMD,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE/C;AAED,oEAAoE;AACpE,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,OAAO,CAQ9E"}
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Authentication middleware for the gateway HTTP API
3
+ *
4
+ * Security notes:
5
+ * - API keys are compared in constant time (SHA-256 digest + timingSafeEqual)
6
+ * so response timing does not leak how much of a key matched.
7
+ * - Misconfiguration fails CLOSED: an unknown / unimplemented strategy, an
8
+ * `api-key` strategy with no keys, or a `jwt` strategy with no secret throws
9
+ * at startup instead of silently disabling authentication.
10
+ * - The client id attached to a request is a hash fingerprint of the key,
11
+ * never a prefix of the key itself (prefixes were leaking into logs and the
12
+ * /requests endpoint, and keys sharing a prefix shared a rate-limit bucket).
13
+ */
14
+ import { createHash, timingSafeEqual } from 'crypto';
15
+ import { jwtVerify } from 'jose';
16
+ import { logger } from '../utils/logger.js';
17
+ const JWT_ALGORITHMS = ['HS256', 'HS384', 'HS512'];
18
+ export function createAuthMiddleware(config) {
19
+ if (!config || config.strategy === 'none') {
20
+ return (_req, _res, next) => next();
21
+ }
22
+ switch (config.strategy) {
23
+ case 'api-key': {
24
+ const keys = (config.apiKeys ?? []).filter((k) => k.length > 0);
25
+ if (keys.length === 0) {
26
+ throw new Error('auth.strategy is "api-key" but no auth.apiKeys are configured');
27
+ }
28
+ return apiKeyMiddleware(keys);
29
+ }
30
+ case 'jwt': {
31
+ if (!config.jwtSecret) {
32
+ throw new Error('auth.strategy is "jwt" but auth.jwtSecret is not configured');
33
+ }
34
+ if (config.jwtSecret.length < 32) {
35
+ logger.warn('auth.jwtSecret is shorter than 32 characters; use a longer random secret');
36
+ }
37
+ return jwtMiddleware(config.jwtSecret);
38
+ }
39
+ default:
40
+ // Fail closed. Previously this fell back to *no auth*, so configuring the
41
+ // advertised-but-unimplemented "oauth2" strategy exposed every endpoint.
42
+ throw new Error(`Auth strategy "${String(config.strategy)}" is not supported yet`);
43
+ }
44
+ }
45
+ function digest(value) {
46
+ return createHash('sha256').update(value, 'utf8').digest();
47
+ }
48
+ export function fingerprint(key) {
49
+ return createHash('sha256').update(key, 'utf8').digest('hex').slice(0, 12);
50
+ }
51
+ /** Constant-time membership check against a pre-hashed key list. */
52
+ export function matchesAnyKey(candidate, keyDigests) {
53
+ const c = digest(candidate);
54
+ let match = false;
55
+ // Compare against every key (no early exit) to keep timing independent of position.
56
+ for (const k of keyDigests) {
57
+ if (timingSafeEqual(c, k))
58
+ match = true;
59
+ }
60
+ return match;
61
+ }
62
+ function extractKey(req) {
63
+ const authHeader = req.headers.authorization;
64
+ if (authHeader && /^Bearer /i.test(authHeader)) {
65
+ return authHeader.slice(7).trim() || undefined;
66
+ }
67
+ const apiKeyHeader = req.headers['x-api-key'];
68
+ if (typeof apiKeyHeader === 'string' && apiKeyHeader.length > 0)
69
+ return apiKeyHeader;
70
+ return undefined;
71
+ }
72
+ function apiKeyMiddleware(validKeys) {
73
+ const keyDigests = validKeys.map(digest);
74
+ return (req, res, next) => {
75
+ const key = extractKey(req);
76
+ if (!key || !matchesAnyKey(key, keyDigests)) {
77
+ logger.warn(`Unauthorized request from ${req.ip}: missing or invalid API key`);
78
+ res.status(401).json({ error: 'Unauthorized', message: 'Valid API key required' });
79
+ return;
80
+ }
81
+ req.clientId = `key:${fingerprint(key)}`;
82
+ next();
83
+ };
84
+ }
85
+ function jwtMiddleware(secret) {
86
+ const secretKey = new TextEncoder().encode(secret);
87
+ return (req, res, next) => {
88
+ const authHeader = req.headers.authorization;
89
+ if (!authHeader || !/^Bearer /i.test(authHeader)) {
90
+ res.status(401).json({ error: 'Unauthorized', message: 'Bearer token required' });
91
+ return;
92
+ }
93
+ const token = authHeader.slice(7).trim();
94
+ // Two-argument then(): an error thrown downstream of next() must not be
95
+ // misreported as an auth failure.
96
+ jwtVerify(token, secretKey, { algorithms: JWT_ALGORITHMS }).then(({ payload }) => {
97
+ req.jwtPayload = payload;
98
+ req.clientId = `jwt:${String(payload.sub ?? 'unknown')}`;
99
+ next();
100
+ }, (err) => {
101
+ logger.warn(`JWT verification failed: ${err instanceof Error ? err.message : String(err)}`);
102
+ if (!res.headersSent) {
103
+ res.status(401).json({ error: 'Unauthorized', message: 'Invalid or expired token' });
104
+ }
105
+ });
106
+ };
107
+ }
108
+ //# sourceMappingURL=middleware.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"middleware.js","sourceRoot":"","sources":["../../src/auth/middleware.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * In-memory rate limiter (sliding-window counter).
3
+ *
4
+ * Each client keeps the request count of the current and the previous fixed
5
+ * window. The effective count is `current + previous * overlap`, where
6
+ * `overlap` is the fraction of the previous window still inside the sliding
7
+ * window. This smooths the 2x burst a plain fixed window allows at window
8
+ * boundaries while staying O(1) in memory per client.
9
+ *
10
+ * For multi-instance deployments, replace with a shared (e.g. Redis) store.
11
+ */
12
+ import type { RequestHandler } from 'express';
13
+ import type { RateLimitConfig } from '../utils/types.js';
14
+ export interface RateLimiter extends RequestHandler {
15
+ /** Stop the background cleanup timer and drop all state. */
16
+ close(): void;
17
+ }
18
+ export declare function createRateLimiter(config?: RateLimitConfig, now?: () => number): RateLimiter;
19
+ //# sourceMappingURL=ratelimit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ratelimit.d.ts","sourceRoot":"","sources":["../../src/auth/ratelimit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAmC,cAAc,EAAE,MAAM,SAAS,CAAC;AAC/E,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAUzD,MAAM,WAAW,WAAY,SAAQ,cAAc;IACjD,4DAA4D;IAC5D,KAAK,IAAI,IAAI,CAAC;CACf;AAED,wBAAgB,iBAAiB,CAC/B,MAAM,CAAC,EAAE,eAAe,EACxB,GAAG,GAAE,MAAM,MAAiB,GAC3B,WAAW,CAoEb"}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * In-memory rate limiter (sliding-window counter).
3
+ *
4
+ * Each client keeps the request count of the current and the previous fixed
5
+ * window. The effective count is `current + previous * overlap`, where
6
+ * `overlap` is the fraction of the previous window still inside the sliding
7
+ * window. This smooths the 2x burst a plain fixed window allows at window
8
+ * boundaries while staying O(1) in memory per client.
9
+ *
10
+ * For multi-instance deployments, replace with a shared (e.g. Redis) store.
11
+ */
12
+ import { logger } from '../utils/logger.js';
13
+ export function createRateLimiter(config, now = Date.now) {
14
+ if (!config) {
15
+ const passthrough = ((_req, _res, next) => next());
16
+ passthrough.close = () => { };
17
+ return passthrough;
18
+ }
19
+ const { limit, windowSeconds, perKey = true } = config;
20
+ const windowMs = windowSeconds * 1000;
21
+ const store = new Map();
22
+ // Periodically drop clients idle for two full windows. unref() so the timer
23
+ // never keeps the process alive (it previously blocked clean shutdown).
24
+ const cleanup = setInterval(() => {
25
+ const t = now();
26
+ for (const [key, entry] of store) {
27
+ if (t - entry.windowStart >= 2 * windowMs)
28
+ store.delete(key);
29
+ }
30
+ }, Math.max(windowMs, 1000));
31
+ cleanup.unref();
32
+ const limiter = ((req, res, next) => {
33
+ const clientId = perKey
34
+ ? (req.clientId ?? req.ip ?? 'anonymous')
35
+ : 'global';
36
+ const t = now();
37
+ const windowStart = Math.floor(t / windowMs) * windowMs;
38
+ let entry = store.get(clientId);
39
+ if (!entry) {
40
+ entry = { windowStart, current: 0, previous: 0 };
41
+ store.set(clientId, entry);
42
+ }
43
+ else if (entry.windowStart !== windowStart) {
44
+ // Roll the window forward. If more than one window has elapsed the
45
+ // previous window is empty.
46
+ entry.previous = windowStart - entry.windowStart === windowMs ? entry.current : 0;
47
+ entry.current = 0;
48
+ entry.windowStart = windowStart;
49
+ }
50
+ const overlap = 1 - (t - windowStart) / windowMs;
51
+ const estimated = entry.current + entry.previous * overlap;
52
+ const resetAt = windowStart + windowMs;
53
+ if (estimated >= limit) {
54
+ const retryAfter = Math.max(1, Math.ceil((resetAt - t) / 1000));
55
+ logger.warn(`Rate limit exceeded for ${clientId}`);
56
+ setRateLimitHeaders(res, limit, 0, resetAt);
57
+ res.status(429).set('Retry-After', String(retryAfter)).json({
58
+ error: 'Too Many Requests',
59
+ message: `Rate limit of ${limit} requests per ${windowSeconds}s exceeded`,
60
+ retryAfter,
61
+ });
62
+ return;
63
+ }
64
+ entry.current++;
65
+ setRateLimitHeaders(res, limit, Math.floor(limit - estimated - 1), resetAt);
66
+ next();
67
+ });
68
+ limiter.close = () => {
69
+ clearInterval(cleanup);
70
+ store.clear();
71
+ };
72
+ return limiter;
73
+ }
74
+ function setRateLimitHeaders(res, limit, remaining, resetAt) {
75
+ res.set({
76
+ 'X-RateLimit-Limit': String(limit),
77
+ 'X-RateLimit-Remaining': String(Math.max(0, remaining)),
78
+ 'X-RateLimit-Reset': String(Math.ceil(resetAt / 1000)),
79
+ });
80
+ }
81
+ //# sourceMappingURL=ratelimit.js.map