@zackbart/connecta 0.1.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 (116) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +249 -0
  4. package/SECURITY.md +25 -0
  5. package/assets/connecta-clay-hero.png +0 -0
  6. package/dist/activity.d.ts +82 -0
  7. package/dist/activity.d.ts.map +1 -0
  8. package/dist/activity.js +51 -0
  9. package/dist/activity.js.map +1 -0
  10. package/dist/auth/bearer.d.ts +10 -0
  11. package/dist/auth/bearer.d.ts.map +1 -0
  12. package/dist/auth/bearer.js +47 -0
  13. package/dist/auth/bearer.js.map +1 -0
  14. package/dist/auth/clerk.d.ts +20 -0
  15. package/dist/auth/clerk.d.ts.map +1 -0
  16. package/dist/auth/clerk.js +167 -0
  17. package/dist/auth/clerk.js.map +1 -0
  18. package/dist/auth/downstream-oauth.d.ts +78 -0
  19. package/dist/auth/downstream-oauth.d.ts.map +1 -0
  20. package/dist/auth/downstream-oauth.js +180 -0
  21. package/dist/auth/downstream-oauth.js.map +1 -0
  22. package/dist/catalog.d.ts +11 -0
  23. package/dist/catalog.d.ts.map +1 -0
  24. package/dist/catalog.js +144 -0
  25. package/dist/catalog.js.map +1 -0
  26. package/dist/connectors/api.d.ts +34 -0
  27. package/dist/connectors/api.d.ts.map +1 -0
  28. package/dist/connectors/api.js +36 -0
  29. package/dist/connectors/api.js.map +1 -0
  30. package/dist/connectors/remote-mcp.d.ts +33 -0
  31. package/dist/connectors/remote-mcp.d.ts.map +1 -0
  32. package/dist/connectors/remote-mcp.js +255 -0
  33. package/dist/connectors/remote-mcp.js.map +1 -0
  34. package/dist/credentials.d.ts +33 -0
  35. package/dist/credentials.d.ts.map +1 -0
  36. package/dist/credentials.js +177 -0
  37. package/dist/credentials.js.map +1 -0
  38. package/dist/execute.d.ts +40 -0
  39. package/dist/execute.d.ts.map +1 -0
  40. package/dist/execute.js +424 -0
  41. package/dist/execute.js.map +1 -0
  42. package/dist/executors/quickjs.d.ts +26 -0
  43. package/dist/executors/quickjs.d.ts.map +1 -0
  44. package/dist/executors/quickjs.js +267 -0
  45. package/dist/executors/quickjs.js.map +1 -0
  46. package/dist/favicon.d.ts +2 -0
  47. package/dist/favicon.d.ts.map +1 -0
  48. package/dist/favicon.js +49 -0
  49. package/dist/favicon.js.map +1 -0
  50. package/dist/index.d.ts +85 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +73 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/mcp-result.d.ts +9 -0
  55. package/dist/mcp-result.d.ts.map +1 -0
  56. package/dist/mcp-result.js +35 -0
  57. package/dist/mcp-result.js.map +1 -0
  58. package/dist/meta-tools.d.ts +100 -0
  59. package/dist/meta-tools.d.ts.map +1 -0
  60. package/dist/meta-tools.js +797 -0
  61. package/dist/meta-tools.js.map +1 -0
  62. package/dist/node.d.ts +9 -0
  63. package/dist/node.d.ts.map +1 -0
  64. package/dist/node.js +57 -0
  65. package/dist/node.js.map +1 -0
  66. package/dist/registry.d.ts +78 -0
  67. package/dist/registry.d.ts.map +1 -0
  68. package/dist/registry.js +299 -0
  69. package/dist/registry.js.map +1 -0
  70. package/dist/server.d.ts +31 -0
  71. package/dist/server.d.ts.map +1 -0
  72. package/dist/server.js +543 -0
  73. package/dist/server.js.map +1 -0
  74. package/dist/skills.d.ts +8 -0
  75. package/dist/skills.d.ts.map +1 -0
  76. package/dist/skills.js +60 -0
  77. package/dist/skills.js.map +1 -0
  78. package/dist/storage/file.d.ts +8 -0
  79. package/dist/storage/file.d.ts.map +1 -0
  80. package/dist/storage/file.js +53 -0
  81. package/dist/storage/file.js.map +1 -0
  82. package/dist/storage/memory.d.ts +4 -0
  83. package/dist/storage/memory.d.ts.map +1 -0
  84. package/dist/storage/memory.js +29 -0
  85. package/dist/storage/memory.js.map +1 -0
  86. package/dist/types.d.ts +219 -0
  87. package/dist/types.d.ts.map +1 -0
  88. package/dist/types.js +3 -0
  89. package/dist/types.js.map +1 -0
  90. package/dist/ui.d.ts +91 -0
  91. package/dist/ui.d.ts.map +1 -0
  92. package/dist/ui.js +1031 -0
  93. package/dist/ui.js.map +1 -0
  94. package/package.json +96 -0
  95. package/src/activity.ts +144 -0
  96. package/src/auth/bearer.ts +54 -0
  97. package/src/auth/clerk.ts +226 -0
  98. package/src/auth/downstream-oauth.ts +202 -0
  99. package/src/catalog.ts +166 -0
  100. package/src/connectors/api.ts +80 -0
  101. package/src/connectors/remote-mcp.ts +324 -0
  102. package/src/credentials.ts +259 -0
  103. package/src/execute.ts +550 -0
  104. package/src/executors/quickjs.ts +321 -0
  105. package/src/favicon.ts +53 -0
  106. package/src/index.ts +197 -0
  107. package/src/mcp-result.ts +43 -0
  108. package/src/meta-tools.ts +1120 -0
  109. package/src/node.ts +62 -0
  110. package/src/registry.ts +400 -0
  111. package/src/server.ts +758 -0
  112. package/src/skills.ts +63 -0
  113. package/src/storage/file.ts +62 -0
  114. package/src/storage/memory.ts +34 -0
  115. package/src/types.ts +254 -0
  116. package/src/ui.ts +1121 -0
package/src/ui.ts ADDED
@@ -0,0 +1,1121 @@
1
+ import type { CredentialVault } from "./credentials.js";
2
+ import type { Registry } from "./registry.js";
3
+ import type { ConnectaBranding, UiAuthConfig } from "./types.js";
4
+
5
+ /** Connecta's default monochrome "C" mark. */
6
+ export const CONNECTA_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
7
+ <style>
8
+ .fg { fill: #000 }
9
+ @media (prefers-color-scheme: dark) { .fg { fill: #fff } }
10
+ </style>
11
+ <path class="fg" d="M27 9.4A13 13 0 1 0 27 22.6l-4.4-2.5a8 8 0 1 1 0-8.2z"/>
12
+ </svg>`;
13
+
14
+ interface ResolvedBranding {
15
+ productName: string;
16
+ ownerName?: string;
17
+ ownerUrl?: string;
18
+ description: string;
19
+ }
20
+
21
+ export function resolveBranding(
22
+ branding?: ConnectaBranding,
23
+ ): ResolvedBranding {
24
+ return {
25
+ productName: branding?.productName?.trim() || "Connecta",
26
+ ...(branding?.ownerName?.trim()
27
+ ? { ownerName: branding.ownerName.trim() }
28
+ : {}),
29
+ ...(branding?.ownerUrl?.trim()
30
+ ? { ownerUrl: branding.ownerUrl.trim() }
31
+ : {}),
32
+ description:
33
+ branding?.description?.trim() ||
34
+ "Manage the services this Connecta instance makes available to agents.",
35
+ };
36
+ }
37
+
38
+ /**
39
+ * True only for absolute `http:`/`https:` URLs. Downstream connectors control
40
+ * their `authorizationUrl`, so a hostile/misconfigured one could hand back a
41
+ * `javascript:` (or other) scheme; gate it before it can become an href.
42
+ */
43
+ export function isSafeHttpUrl(url: unknown): boolean {
44
+ if (typeof url !== "string") return false;
45
+ try {
46
+ const scheme = new URL(url).protocol;
47
+ return scheme === "http:" || scheme === "https:";
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ export interface UiTool {
54
+ name: string;
55
+ address: string;
56
+ description?: string;
57
+ }
58
+
59
+ export interface UiConnector {
60
+ id: string;
61
+ title?: string;
62
+ description?: string;
63
+ status: "ok" | "auth_required" | "error";
64
+ message?: string;
65
+ authorizationUrl?: string;
66
+ toolCount: number;
67
+ tools: UiTool[];
68
+ credential?: {
69
+ label: string;
70
+ description?: string;
71
+ placeholder?: string;
72
+ fields?: Array<{
73
+ name: string;
74
+ label: string;
75
+ description?: string;
76
+ placeholder?: string;
77
+ inputType: "email" | "password" | "text";
78
+ configured: boolean;
79
+ lastFour?: string;
80
+ updatedAt?: string;
81
+ }>;
82
+ configured: boolean;
83
+ /** A stored value exists and may be deleted even if it cannot be decrypted. */
84
+ removable?: boolean;
85
+ lastFour?: string;
86
+ updatedAt?: string;
87
+ testable: boolean;
88
+ error?: string;
89
+ };
90
+ }
91
+
92
+ export interface UiData {
93
+ serverInfo: { name: string; version: string };
94
+ connectors: UiConnector[];
95
+ activityEnabled: boolean;
96
+ }
97
+
98
+ export interface FilteredUiConnector {
99
+ connector: UiConnector;
100
+ tools: UiTool[];
101
+ }
102
+
103
+ /**
104
+ * Filter by connector identity/description or tool name/description. A
105
+ * connector-level match stays visible even when it currently exposes no tools
106
+ * (for example while authorization is required).
107
+ */
108
+ export function filterUiConnectors(
109
+ connectors: UiConnector[],
110
+ query: string,
111
+ ): FilteredUiConnector[] {
112
+ const q = query.trim().toLowerCase();
113
+ const filtered: FilteredUiConnector[] = [];
114
+ for (const connector of connectors) {
115
+ const connectorText = [
116
+ connector.id,
117
+ connector.title,
118
+ connector.description,
119
+ connector.status,
120
+ ]
121
+ .join(" ")
122
+ .toLowerCase();
123
+ const connectorMatches = Boolean(q && connectorText.includes(q));
124
+ const tools = connector.tools.filter(
125
+ (tool) =>
126
+ !q ||
127
+ connectorMatches ||
128
+ `${tool.name} ${tool.description ?? ""}`.toLowerCase().includes(q),
129
+ );
130
+ if (q && tools.length === 0 && !connectorMatches) continue;
131
+ filtered.push({ connector, tools });
132
+ }
133
+ return filtered;
134
+ }
135
+
136
+ /**
137
+ * Build the read-only status payload served at `/ui/data`. Broken connectors are
138
+ * isolated: they surface status "error" with an empty tool list rather than
139
+ * failing the whole payload.
140
+ */
141
+ export async function buildUiData(
142
+ registry: Registry,
143
+ baseUrl: string,
144
+ serverInfo: { name: string; version: string },
145
+ credentialVault?: CredentialVault,
146
+ activityEnabled = false,
147
+ ): Promise<UiData> {
148
+ const requestScope = {};
149
+ const connectors = await Promise.all(
150
+ registry.listConnectors().map(async (c): Promise<UiConnector> => {
151
+ const status = await registry.statusFor(c.id, baseUrl, requestScope);
152
+ let tools: UiTool[] = [];
153
+ // `status()` on an unauthenticated remote connector starts OAuth and
154
+ // stores its state + PKCE verifier. Probing listTools immediately
155
+ // afterward would start a second flow, overwrite that state, and return
156
+ // the now-stale first URL to the operator. Only inspect tools after
157
+ // status proves the connector is healthy.
158
+ if (status.state === "ok") {
159
+ try {
160
+ tools = (
161
+ await registry.getTools(c.id, baseUrl, requestScope)
162
+ ).map((t) => ({
163
+ name: t.name,
164
+ address: `${c.id}.${t.name}`,
165
+ description: t.description,
166
+ }));
167
+ } catch {
168
+ // broken connector: reported via status "error", tools stay empty
169
+ }
170
+ }
171
+ let credential: UiConnector["credential"];
172
+ if (c.credential && credentialVault) {
173
+ const credentialFields = (
174
+ metadata?: Awaited<ReturnType<CredentialVault["metadata"]>>,
175
+ ) =>
176
+ c.credential?.fields?.map((field) => {
177
+ const fieldMetadata = metadata?.fields?.[field.name];
178
+ return {
179
+ name: field.name,
180
+ label: field.label,
181
+ ...(field.description
182
+ ? { description: field.description }
183
+ : {}),
184
+ ...(field.placeholder
185
+ ? { placeholder: field.placeholder }
186
+ : {}),
187
+ inputType: field.inputType ?? "password",
188
+ configured: Boolean(fieldMetadata),
189
+ ...(fieldMetadata
190
+ ? {
191
+ lastFour: fieldMetadata.lastFour,
192
+ updatedAt: fieldMetadata.updatedAt,
193
+ }
194
+ : {}),
195
+ };
196
+ });
197
+ try {
198
+ const metadata = await credentialVault.metadata(c.id);
199
+ const fields = credentialFields(metadata);
200
+ credential = {
201
+ label: c.credential.label,
202
+ ...(c.credential.description
203
+ ? { description: c.credential.description }
204
+ : {}),
205
+ ...(c.credential.placeholder
206
+ ? { placeholder: c.credential.placeholder }
207
+ : {}),
208
+ ...(fields?.length ? { fields } : {}),
209
+ configured: fields?.length
210
+ ? fields.every((field) => field.configured)
211
+ : Boolean(metadata),
212
+ removable: Boolean(metadata),
213
+ ...(metadata
214
+ ? {
215
+ lastFour: metadata.lastFour,
216
+ updatedAt: metadata.updatedAt,
217
+ }
218
+ : {}),
219
+ testable: Boolean(c.testCredential || c.testCredentials),
220
+ };
221
+ } catch {
222
+ const fields = credentialFields();
223
+ credential = {
224
+ label: c.credential.label,
225
+ ...(c.credential.description
226
+ ? { description: c.credential.description }
227
+ : {}),
228
+ ...(c.credential.placeholder
229
+ ? { placeholder: c.credential.placeholder }
230
+ : {}),
231
+ ...(fields?.length ? { fields } : {}),
232
+ configured: false,
233
+ removable: true,
234
+ testable: Boolean(c.testCredential || c.testCredentials),
235
+ error: "Stored credential could not be read.",
236
+ };
237
+ }
238
+ }
239
+ return {
240
+ id: c.id,
241
+ ...(c.title ? { title: c.title } : {}),
242
+ description: c.description,
243
+ status: status.state,
244
+ ...(status.message ? { message: status.message } : {}),
245
+ ...(isSafeHttpUrl(status.authorizationUrl)
246
+ ? { authorizationUrl: status.authorizationUrl }
247
+ : {}),
248
+ toolCount: tools.length,
249
+ tools,
250
+ ...(credential ? { credential } : {}),
251
+ };
252
+ }),
253
+ );
254
+ return { serverInfo, connectors, activityEnabled };
255
+ }
256
+
257
+ function escapeHtmlAttr(value: string): string {
258
+ return value
259
+ .replaceAll("&", "&amp;")
260
+ .replaceAll('"', "&quot;")
261
+ .replaceAll("<", "&lt;")
262
+ .replaceAll(">", "&gt;");
263
+ }
264
+
265
+ function jsonForInlineScript(value: unknown): string {
266
+ return JSON.stringify(value)
267
+ .replaceAll("<", "\\u003c")
268
+ .replaceAll(">", "\\u003e")
269
+ .replaceAll("&", "\\u0026");
270
+ }
271
+
272
+ /**
273
+ * The `/ui` shell carries no connector data: everything comes client-side from
274
+ * the auth-gated `/ui/data`. A configured Clerk provider gives operators a
275
+ * normal sign-in flow and a short-lived session token; bearer-only deployments
276
+ * retain the manual-token fallback.
277
+ */
278
+ export function renderUiHtml(
279
+ uiAuth?: UiAuthConfig,
280
+ mcpUrl = "/mcp",
281
+ branding?: ConnectaBranding,
282
+ ): string {
283
+ const auth = uiAuth ?? { kind: "bearer" as const };
284
+ const brand = resolveBranding(branding);
285
+ const title = brand.ownerName
286
+ ? `${brand.productName} — ${brand.ownerName}`
287
+ : brand.productName;
288
+ const owner = brand.ownerName
289
+ ? brand.ownerUrl
290
+ ? `<a class="brand navlink" href="${escapeHtmlAttr(brand.ownerUrl)}">${escapeHtmlAttr(brand.ownerName)}</a>`
291
+ : `<span class="brand">${escapeHtmlAttr(brand.ownerName)}</span>`
292
+ : `<span class="brand">${escapeHtmlAttr(brand.productName)}</span>`;
293
+ const product = brand.ownerName
294
+ ? `<span class="product">${escapeHtmlAttr(brand.productName)}</span>`
295
+ : "";
296
+ const clerkScript =
297
+ uiAuth?.kind === "clerk"
298
+ ? `<script defer crossorigin="anonymous" data-clerk-publishable-key="${escapeHtmlAttr(uiAuth.publishableKey)}" src="${escapeHtmlAttr(uiAuth.frontendApiUrl)}/npm/@clerk/clerk-js@6/dist/clerk.browser.js"></script>`
299
+ : "";
300
+
301
+ return `<!doctype html>
302
+ <html lang="en">
303
+ <head>
304
+ <meta charset="utf-8">
305
+ <meta name="viewport" content="width=device-width, initial-scale=1">
306
+ <meta name="theme-color" content="#ffffff">
307
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
308
+ <link rel="shortcut icon" href="/favicon.ico">
309
+ <title>${escapeHtmlAttr(title)}</title>
310
+ ${clerkScript}
311
+ <style>
312
+ :root {
313
+ color-scheme: light;
314
+ --ink: #000;
315
+ --paper: #fff;
316
+ --rule: #ccc;
317
+ --muted: #666;
318
+ --shell: 70rem;
319
+ --pad: 1rem;
320
+ --gap: 1.5rem;
321
+ --sans: "Helvetica Neue", Helvetica, Arial, sans-serif;
322
+ --mono: ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Code", Consolas,
323
+ monospace;
324
+ }
325
+ * { border-radius: 0; box-sizing: border-box; }
326
+ html {
327
+ background: var(--paper);
328
+ color: var(--ink);
329
+ font-family: var(--sans);
330
+ font-size: 16px;
331
+ line-height: 1.5;
332
+ -webkit-font-smoothing: antialiased;
333
+ text-rendering: optimizeLegibility;
334
+ }
335
+ body { margin: 0; min-height: 100vh; }
336
+ ::selection { background: var(--ink); color: var(--paper); }
337
+ :is(h1, h2, h3, p, ul, ol) { margin: 0; padding: 0; }
338
+ :is(h1, h2, h3) { font-size: inherit; font-weight: 400; }
339
+ :is(ul, ol) { list-style: none; }
340
+ a { color: inherit; }
341
+ button, input { font: inherit; }
342
+ button {
343
+ background: none;
344
+ border: 0;
345
+ color: inherit;
346
+ cursor: pointer;
347
+ margin: 0;
348
+ padding: 0;
349
+ text-align: left;
350
+ }
351
+ button:disabled { cursor: wait; opacity: .5; }
352
+ input {
353
+ background: var(--paper);
354
+ border: 1px solid var(--rule);
355
+ color: var(--ink);
356
+ min-height: 2rem;
357
+ padding: .2rem .5rem;
358
+ }
359
+ input:focus-visible { outline-offset: -1px; }
360
+ :is(a, button, input, summary):focus-visible {
361
+ outline: 1px solid var(--ink);
362
+ }
363
+ :is(a, button, summary):focus-visible { outline-offset: 2px; }
364
+
365
+ .shell {
366
+ margin: 0 auto;
367
+ max-width: var(--shell);
368
+ padding-left: var(--pad);
369
+ padding-right: var(--pad);
370
+ }
371
+ .pgrid {
372
+ column-gap: var(--gap);
373
+ display: grid;
374
+ grid-template-columns: repeat(3, minmax(0, 1fr));
375
+ row-gap: 1rem;
376
+ }
377
+ .pcap { grid-column: 1; }
378
+ .pbody { grid-column: 2 / -1; min-width: 0; }
379
+ .cap, .meta { color: var(--muted); font-size: .9em; }
380
+ .mono { font-family: var(--mono); font-size: .78rem; }
381
+ .hidden { display: none !important; }
382
+
383
+ .masthead {
384
+ align-items: start;
385
+ padding-bottom: var(--pad);
386
+ padding-top: var(--pad);
387
+ }
388
+ .brand {
389
+ font-weight: 500;
390
+ grid-column: 1;
391
+ text-decoration: none;
392
+ }
393
+ .mast-nav {
394
+ display: flex;
395
+ gap: var(--gap);
396
+ grid-column: 2 / -1;
397
+ justify-content: space-between;
398
+ min-width: 0;
399
+ }
400
+ .mast-actions {
401
+ display: flex;
402
+ gap: var(--gap);
403
+ justify-content: flex-end;
404
+ min-width: 0;
405
+ }
406
+ .navlink,
407
+ .linklike {
408
+ text-decoration: underline;
409
+ text-decoration-thickness: 1.5px;
410
+ text-underline-offset: .22em;
411
+ }
412
+ .navlink { text-decoration-color: transparent; }
413
+ .navlink:hover,
414
+ .navlink:focus-visible,
415
+ .navlink.active { text-decoration-color: currentColor; }
416
+ .linklike { text-decoration-color: currentColor; }
417
+ .linklike:hover,
418
+ .linklike:focus-visible { text-decoration-color: transparent; }
419
+
420
+ .page { padding-bottom: 5rem; }
421
+ .lead { margin-top: 6rem; }
422
+ .section,
423
+ .section + .section { margin-top: 3rem; }
424
+ .lead-copy,
425
+ .body-copy { max-width: 34em; }
426
+ .lead-copy > * + *,
427
+ .body-copy > * + * { margin-top: 1.5rem; }
428
+ .row,
429
+ .actions {
430
+ align-items: center;
431
+ display: flex;
432
+ flex-wrap: wrap;
433
+ gap: var(--gap);
434
+ }
435
+ .row input { flex: 1; min-width: 12rem; }
436
+
437
+ .gate-actions { margin-top: 1.5rem; }
438
+ #err { margin-top: 1.5rem; text-decoration: underline; }
439
+
440
+ .endpoint { border-bottom: 1px solid var(--rule); border-top: 1px solid var(--rule); }
441
+ .endpoint-row {
442
+ align-items: baseline;
443
+ display: flex;
444
+ gap: var(--gap);
445
+ min-width: 0;
446
+ padding: .75rem 0;
447
+ }
448
+ .endpoint-row code {
449
+ flex: 1;
450
+ min-width: 0;
451
+ overflow-x: auto;
452
+ white-space: nowrap;
453
+ }
454
+ .endpoint-row button { flex: none; }
455
+
456
+ .connector-tools { border-bottom: 1px solid var(--rule); }
457
+ .toolbar { margin-bottom: 1.5rem; }
458
+ .toolbar input { flex-basis: 18rem; }
459
+ #notice { margin-bottom: .75rem; }
460
+ #notice:empty { display: none; }
461
+ #notice:not(:empty) { text-decoration: underline; }
462
+ .error-notice, .msg { text-decoration: underline; }
463
+ .card { border-top: 1px solid var(--rule); padding: .75rem 0; }
464
+ .connector-head {
465
+ display: grid;
466
+ gap: var(--gap);
467
+ grid-template-columns: minmax(0, 2fr) minmax(10rem, 1fr);
468
+ }
469
+ .connector-title {
470
+ align-items: baseline;
471
+ display: flex;
472
+ gap: .5rem;
473
+ }
474
+ .card h2 { overflow-wrap: anywhere; }
475
+ .connector-state { text-align: right; }
476
+ .dot {
477
+ border: 1px solid var(--ink);
478
+ display: inline-block;
479
+ flex: none;
480
+ height: .5rem;
481
+ width: .5rem;
482
+ }
483
+ .dot.ok { background: var(--ink); }
484
+ .dot.auth_required {
485
+ background: linear-gradient(90deg, var(--ink) 50%, var(--paper) 50%);
486
+ }
487
+ .connector-description { margin-top: .25rem; max-width: 40rem; }
488
+ .connector-message,
489
+ .connector-auth { margin-top: .75rem; }
490
+
491
+ .credential { border-top: 1px solid var(--rule); margin-top: .75rem; padding-top: .75rem; }
492
+ .credential-head {
493
+ align-items: baseline;
494
+ display: flex;
495
+ flex-wrap: wrap;
496
+ gap: .25rem var(--gap);
497
+ justify-content: space-between;
498
+ }
499
+ .credential-copy { margin-top: .25rem; max-width: 40rem; }
500
+ .credential-actions { display: flex; flex-wrap: wrap; gap: var(--gap); margin-top: .75rem; }
501
+ .credential-form {
502
+ align-items: center;
503
+ display: flex;
504
+ flex-wrap: wrap;
505
+ gap: .75rem var(--gap);
506
+ margin-top: .75rem;
507
+ }
508
+ .credential-form > input { flex: 1 1 18rem; }
509
+ .credential-fields { display: grid; flex: 1 1 100%; gap: .75rem; }
510
+ .credential-field {
511
+ align-items: center;
512
+ display: grid;
513
+ gap: var(--gap);
514
+ grid-template-columns: minmax(9rem, 12rem) 1fr;
515
+ }
516
+ .credential-field input { min-width: 0; width: 100%; }
517
+ .danger { text-decoration-style: double; }
518
+
519
+ details { margin-top: .75rem; }
520
+ summary { cursor: pointer; list-style: none; width: max-content; }
521
+ summary::-webkit-details-marker { display: none; }
522
+ .tool-list { border-bottom: 1px solid var(--rule); margin-top: .5rem; }
523
+ .tool {
524
+ border-top: 1px solid var(--rule);
525
+ display: grid;
526
+ gap: .25rem var(--gap);
527
+ grid-template-columns: minmax(12rem, 1fr) minmax(0, 2fr);
528
+ padding: .5rem 0;
529
+ }
530
+ .tool code { font-family: var(--mono); font-size: .78rem; overflow-wrap: anywhere; }
531
+ .tool .td { color: var(--muted); font-size: .9em; }
532
+ .empty { border-top: 1px solid var(--rule); padding: .75rem 0; }
533
+
534
+ .activity-copy { margin-bottom: 1.5rem; }
535
+ .activity-controls { margin-bottom: 1.5rem; }
536
+ .activity-controls input { flex: 1 1 18rem; }
537
+ #activityNotice { margin-bottom: .75rem; }
538
+ .activity-ledger { border-bottom: 1px solid var(--rule); }
539
+ .activity-item {
540
+ border-top: 1px solid var(--rule);
541
+ display: grid;
542
+ gap: .25rem var(--gap);
543
+ grid-template-columns: minmax(9rem, .85fr) minmax(12rem, 1.4fr) minmax(8rem, .9fr);
544
+ padding: .75rem 0;
545
+ }
546
+ .activity-time,
547
+ .activity-actor,
548
+ .activity-detail { color: var(--muted); font-size: .82rem; }
549
+ .activity-address { font-family: var(--mono); font-size: .78rem; overflow-wrap: anywhere; }
550
+ .activity-outcome { font-size: .9em; }
551
+ .activity-item.error .activity-outcome,
552
+ .activity-item.timeout .activity-outcome { text-decoration: underline; }
553
+ .activity-empty { border-top: 1px solid var(--rule); padding: .75rem 0; }
554
+ .activity-more { margin-top: .75rem; }
555
+
556
+ @media (max-width: 36.99rem) {
557
+ .pgrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
558
+ .pcap,
559
+ .pbody { grid-column: 1 / -1; }
560
+ .masthead .brand { grid-column: 1; }
561
+ .mast-nav { grid-column: 1 / -1; grid-row: 2; justify-content: flex-start; }
562
+ .product { display: none; }
563
+ .mast-actions { font-size: .875rem; gap: .5rem; white-space: nowrap; }
564
+ .lead { margin-top: 4rem; }
565
+ .section,
566
+ .section + .section { margin-top: 2.5rem; }
567
+ .connector-head,
568
+ .tool,
569
+ .activity-item { grid-template-columns: 1fr; }
570
+ .connector-state { text-align: left; }
571
+ .credential-field { align-items: start; grid-template-columns: 1fr; gap: .25rem; }
572
+ }
573
+ </style>
574
+ </head>
575
+ <body>
576
+ <header class="masthead shell pgrid">
577
+ ${owner}
578
+ <div class="mast-nav">
579
+ ${product}
580
+ <nav id="appNav" class="mast-actions hidden" aria-label="Dashboard views">
581
+ <button id="configTab" class="navlink active" type="button"
582
+ aria-pressed="true">Connections</button>
583
+ <button id="activityTab" class="navlink hidden" type="button"
584
+ aria-pressed="false">Activity</button>
585
+ <button id="change" class="navlink hidden" type="button">Change token</button>
586
+ <button id="signout" class="navlink hidden" type="button">Sign out</button>
587
+ </nav>
588
+ </div>
589
+ </header>
590
+
591
+ <main id="gate" class="page shell hidden">
592
+ <section class="lead pgrid">
593
+ <h1 class="pcap">Tool connections</h1>
594
+ <div class="pbody lead-copy">
595
+ <p>${escapeHtmlAttr(brand.description)}</p>
596
+ <p id="gateCopy" class="meta"></p>
597
+ <div id="tokenGate" class="row gate-actions hidden">
598
+ <input id="token" type="password" placeholder="Bearer token" autocomplete="off"
599
+ aria-label="Bearer token">
600
+ <button id="save" class="linklike" type="button">Open dashboard</button>
601
+ </div>
602
+ <div id="clerkGate" class="actions gate-actions hidden">
603
+ <button id="signin" class="linklike" type="button">Team sign in</button>
604
+ <button id="gateSignout" class="linklike hidden" type="button">Sign out</button>
605
+ </div>
606
+ <p id="err"></p>
607
+ </div>
608
+ </section>
609
+ </main>
610
+
611
+ <main id="app" class="page shell hidden">
612
+ <section id="configView">
613
+ <div class="lead pgrid">
614
+ <h1 class="pcap">MCP connection</h1>
615
+ <div class="pbody lead-copy">
616
+ <p>Use this endpoint to give an MCP client access to the tools below.</p>
617
+ <div class="endpoint">
618
+ <div class="endpoint-row">
619
+ <code id="mcpUrl" class="mono"></code>
620
+ <button id="copyMcpUrl" class="linklike" type="button">Copy URL</button>
621
+ </div>
622
+ </div>
623
+ <p class="cap" id="serverInfo">Connecta status dashboard</p>
624
+ </div>
625
+ </div>
626
+ <section class="section pgrid" aria-labelledby="connectorsHeading">
627
+ <h2 class="pcap" id="connectorsHeading">Connectors</h2>
628
+ <div class="pbody">
629
+ <div class="row toolbar">
630
+ <input id="filter" type="search" placeholder="Filter connectors or tools…"
631
+ aria-label="Filter connectors or tools">
632
+ </div>
633
+ <p id="notice" role="status" aria-live="polite"></p>
634
+ <div id="list" class="connector-tools"></div>
635
+ </div>
636
+ </section>
637
+ </section>
638
+ <section id="activityView" class="hidden">
639
+ <div class="lead pgrid">
640
+ <h1 class="pcap">Tool activity</h1>
641
+ <div class="pbody">
642
+ <p class="activity-copy" id="activitySummary">Arguments and results are never stored.</p>
643
+ <div class="row activity-controls">
644
+ <input id="activitySearch" type="search"
645
+ placeholder="Search user, tool, or outcome…"
646
+ aria-label="Search loaded activity">
647
+ <button id="refreshActivity" class="linklike" type="button">Refresh</button>
648
+ </div>
649
+ <p id="activityNotice" class="meta" role="status" aria-live="polite"></p>
650
+ <div id="activityList" class="activity-ledger"></div>
651
+ <button id="moreActivity" class="linklike activity-more hidden" type="button">Load older</button>
652
+ </div>
653
+ </div>
654
+ </section>
655
+ </main>
656
+
657
+ <script>
658
+ const AUTH = ${jsonForInlineScript(auth)};
659
+ const MCP_URL = ${jsonForInlineScript(mcpUrl)};
660
+ const filterUiConnectors = ${filterUiConnectors.toString()};
661
+ const KEY = "connecta:token";
662
+ const $ = (id) => document.getElementById(id);
663
+ let DATA = null;
664
+ let ACTIVITY = [];
665
+ let ACTIVITY_CURSOR = null;
666
+ let ACTIVITY_LOADED = false;
667
+ $("mcpUrl").textContent = MCP_URL;
668
+
669
+ function esc(s) {
670
+ return String(s == null ? "" : s).replace(/[&<>"]/g, (c) =>
671
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
672
+ }
673
+
674
+ // Only http/https may become a clickable href — mirrors the server-side gate
675
+ // so a hostile scheme (javascript:, data:) can never be linked. Defense in depth.
676
+ function safeHttp(u) {
677
+ try {
678
+ const p = new URL(u).protocol;
679
+ return p === "http:" || p === "https:" ? u : null;
680
+ } catch (e) { return null; }
681
+ }
682
+
683
+ function formatDate(value) {
684
+ if (!value) return "";
685
+ const date = new Date(value);
686
+ return Number.isNaN(date.valueOf()) ? "" : date.toLocaleString();
687
+ }
688
+
689
+ function setNotice(message, isError) {
690
+ $("notice").textContent = message || "";
691
+ $("notice").classList.toggle("error-notice", Boolean(isError));
692
+ }
693
+
694
+ async function sessionToken() {
695
+ return AUTH.kind === "clerk"
696
+ ? await Clerk.session?.getToken()
697
+ : localStorage.getItem(KEY);
698
+ }
699
+
700
+ function showGate(msg) {
701
+ $("app").classList.add("hidden");
702
+ $("appNav").classList.add("hidden");
703
+ $("gate").classList.remove("hidden");
704
+ $("err").textContent = msg || "";
705
+ if (AUTH.kind === "clerk") {
706
+ const signedIn = Boolean(window.Clerk && Clerk.user);
707
+ $("gateCopy").textContent = signedIn
708
+ ? "Signed in with Clerk, but this account cannot open the dashboard."
709
+ : "Sign in with Clerk to open the dashboard.";
710
+ $("signin").classList.toggle("hidden", signedIn);
711
+ $("gateSignout").classList.toggle("hidden", !signedIn);
712
+ } else {
713
+ $("gateCopy").textContent = "";
714
+ }
715
+ }
716
+
717
+ async function load() {
718
+ let token;
719
+ try {
720
+ token = await sessionToken();
721
+ } catch (e) {
722
+ return showGate("Could not read the Clerk session: " + e.message);
723
+ }
724
+ if (!token) return showGate("");
725
+ let res;
726
+ try {
727
+ res = await fetch("/ui/data", { headers: { Authorization: "Bearer " + token } });
728
+ } catch (e) {
729
+ return showGate("Network error: " + e.message);
730
+ }
731
+ if (res.status === 401 || res.status === 403) {
732
+ if (AUTH.kind === "clerk") {
733
+ return showGate(
734
+ res.status === 403
735
+ ? "This Clerk account is not allowed to access connecta."
736
+ : "Your Clerk session was not accepted. Sign out and try again."
737
+ );
738
+ }
739
+ localStorage.removeItem(KEY);
740
+ return showGate("Token rejected — enter a valid bearer token.");
741
+ }
742
+ if (!res.ok) return showGate("Error " + res.status);
743
+ DATA = await res.json();
744
+ $("gate").classList.add("hidden");
745
+ $("app").classList.remove("hidden");
746
+ $("appNav").classList.remove("hidden");
747
+ const si = DATA.serverInfo || {};
748
+ $("serverInfo").textContent = (si.name || "Connecta") + " v" + (si.version || "?");
749
+ $("activityTab").classList.toggle("hidden", !DATA.activityEnabled);
750
+ render();
751
+ }
752
+
753
+ function showView(view) {
754
+ const activity = view === "activity";
755
+ $("configView").classList.toggle("hidden", activity);
756
+ $("activityView").classList.toggle("hidden", !activity);
757
+ $("configTab").classList.toggle("active", !activity);
758
+ $("activityTab").classList.toggle("active", activity);
759
+ $("configTab").setAttribute("aria-pressed", String(!activity));
760
+ $("activityTab").setAttribute("aria-pressed", String(activity));
761
+ if (activity && !ACTIVITY_LOADED) loadActivity(true);
762
+ }
763
+
764
+ function actorLabel(actor) {
765
+ if (!actor || !actor.kind) return "unknown";
766
+ return actor.id ? actor.kind + " · " + actor.id : actor.kind;
767
+ }
768
+
769
+ function renderActivity() {
770
+ const list = $("activityList");
771
+ list.innerHTML = "";
772
+ const query = $("activitySearch").value.trim().toLowerCase();
773
+ const visible = ACTIVITY.filter((event) => {
774
+ if (!query) return true;
775
+ const actor = event.actor || {};
776
+ return [
777
+ event.address,
778
+ event.connectorId,
779
+ event.toolName,
780
+ event.source,
781
+ event.outcome,
782
+ event.errorCode,
783
+ actor.kind,
784
+ actor.id,
785
+ ].some((value) => String(value || "").toLowerCase().includes(query));
786
+ });
787
+ const uniqueTools = new Set(ACTIVITY.map((event) => event.address)).size;
788
+ $("activitySummary").textContent = ACTIVITY.length
789
+ ? ACTIVITY.length + " loaded call" + (ACTIVITY.length === 1 ? "" : "s") +
790
+ " · " + uniqueTools + " tool" + (uniqueTools === 1 ? "" : "s") +
791
+ " · no arguments or results stored"
792
+ : "Arguments and results are never stored.";
793
+ if (visible.length === 0) {
794
+ list.innerHTML = '<div class="activity-empty">' +
795
+ (query ? "No loaded activity matches this search." :
796
+ "No connector tool calls recorded yet.") + "</div>";
797
+ }
798
+ for (const event of visible) {
799
+ const item = document.createElement("article");
800
+ item.className = "activity-item " + esc(event.outcome);
801
+ const retryCopy = event.attempts > 1 ? " · " + event.attempts + " attempts" : "";
802
+ const errorCopy = event.errorCode ? " · " + esc(event.errorCode) : "";
803
+ item.innerHTML =
804
+ '<div><div class="activity-time">' + esc(formatDate(event.occurredAt)) +
805
+ '</div><div class="activity-actor">' + esc(actorLabel(event.actor)) + '</div></div>' +
806
+ '<div><div class="activity-address">' + esc(event.address) +
807
+ '</div><div class="activity-detail">' + esc(event.source) + retryCopy +
808
+ errorCopy + '</div></div>' +
809
+ '<div><div class="activity-outcome">' + esc(event.outcome) +
810
+ '</div><div class="activity-detail">' + esc(event.durationMs) + ' ms</div></div>';
811
+ list.appendChild(item);
812
+ }
813
+ $("moreActivity").classList.toggle("hidden", !ACTIVITY_CURSOR);
814
+ }
815
+
816
+ async function loadActivity(reset) {
817
+ if (!DATA?.activityEnabled) return;
818
+ $("activityNotice").textContent = "Loading activity…";
819
+ $("refreshActivity").disabled = true;
820
+ $("moreActivity").disabled = true;
821
+ try {
822
+ const token = await sessionToken();
823
+ if (!token) throw new Error("Your session has expired.");
824
+ const cursor = reset ? null : ACTIVITY_CURSOR;
825
+ const params = new URLSearchParams({ limit: "50" });
826
+ if (cursor) params.set("cursor", cursor);
827
+ const res = await fetch("/ui/activity?" + params, {
828
+ headers: { Authorization: "Bearer " + token },
829
+ });
830
+ let payload = {};
831
+ try { payload = await res.json(); } catch (e) {}
832
+ if (!res.ok) {
833
+ throw new Error(payload.error || "Activity could not be loaded (" + res.status + ").");
834
+ }
835
+ ACTIVITY = reset
836
+ ? (payload.events || [])
837
+ : ACTIVITY.concat(payload.events || []);
838
+ ACTIVITY_CURSOR = payload.nextCursor || null;
839
+ ACTIVITY_LOADED = true;
840
+ $("activityNotice").textContent = "";
841
+ renderActivity();
842
+ } catch (e) {
843
+ $("activityNotice").textContent = e.message || "Activity could not be loaded.";
844
+ } finally {
845
+ $("refreshActivity").disabled = false;
846
+ $("moreActivity").disabled = false;
847
+ }
848
+ }
849
+
850
+ function render() {
851
+ const q = $("filter").value.trim().toLowerCase();
852
+ const list = $("list");
853
+ list.innerHTML = "";
854
+ for (const filtered of filterUiConnectors(DATA.connectors, q)) {
855
+ const c = filtered.connector;
856
+ const tools = filtered.tools;
857
+ const el = document.createElement("div");
858
+ el.className = "card";
859
+ const status = c.status === "ok"
860
+ ? "Connected"
861
+ : c.status === "auth_required"
862
+ ? "Authorization needed"
863
+ : "Unavailable";
864
+ let head = '<div class="connector-head"><div><div class="connector-title">' +
865
+ '<span class="dot ' + esc(c.status) + '" aria-hidden="true"></span>' +
866
+ '<h2>' + esc(c.title || c.id) + "</h2></div>";
867
+ if (c.description) {
868
+ head += '<p class="connector-description meta">' + esc(c.description) + "</p>";
869
+ }
870
+ head += '</div><div class="connector-state cap">' + esc(status) + " · " +
871
+ c.toolCount + (c.toolCount === 1 ? " tool" : " tools") +
872
+ '<br><span class="mono">' + esc(c.id) + "</span></div></div>";
873
+ if (c.message) {
874
+ head += '<p class="connector-message msg">' + esc(c.message) + "</p>";
875
+ }
876
+ if (c.authorizationUrl) {
877
+ const safe = safeHttp(c.authorizationUrl);
878
+ head += safe
879
+ ? '<p class="connector-auth"><a class="linklike" href="' + esc(safe) +
880
+ '" target="_blank" rel="noopener">Authorize connector →</a></p>'
881
+ : '<p class="connector-auth meta">Authorization URL: ' +
882
+ esc(c.authorizationUrl) + "</p>";
883
+ }
884
+ if (c.credential) {
885
+ const cred = c.credential;
886
+ const configured = Boolean(cred.configured);
887
+ const removable = configured || Boolean(cred.removable);
888
+ const state = configured
889
+ ? "configured · ••••" + esc(cred.lastFour || "")
890
+ : "not configured";
891
+ const updated = configured && cred.updatedAt
892
+ ? " · updated " + esc(formatDate(cred.updatedAt))
893
+ : "";
894
+ head += '<section class="credential" aria-label="' + esc(cred.label) + '">';
895
+ head += '<div class="credential-head"><span class="credential-label">' +
896
+ esc(cred.label) + '</span><span class="credential-state">' + state +
897
+ updated + "</span></div>";
898
+ if (cred.description) {
899
+ head += '<p class="credential-copy meta">' + esc(cred.description) + "</p>";
900
+ }
901
+ if (cred.error) {
902
+ head += '<div class="msg">' + esc(cred.error) + "</div>";
903
+ }
904
+ if (AUTH.kind === "clerk") {
905
+ head += '<div class="credential-actions">';
906
+ head += '<button class="linklike" type="button" data-credential-action="edit" data-connector="' +
907
+ esc(c.id) + '">' + (removable ? "Replace" : "Add credential") + "</button>";
908
+ if (configured && cred.testable) {
909
+ head += '<button class="linklike" type="button" data-credential-action="test" data-connector="' +
910
+ esc(c.id) + '">Test</button>';
911
+ }
912
+ if (removable) {
913
+ head += '<button type="button" class="linklike danger" data-credential-action="remove" data-connector="' +
914
+ esc(c.id) + '">Remove</button>';
915
+ }
916
+ head += "</div>";
917
+ head += '<div class="credential-form hidden" data-credential-form="' +
918
+ esc(c.id) + '">';
919
+ if (cred.fields && cred.fields.length) {
920
+ head += '<div class="credential-fields">';
921
+ for (const field of cred.fields) {
922
+ head += '<div class="credential-field"><label>' +
923
+ esc(field.label) + '</label><input type="' +
924
+ esc(field.inputType || "password") + '" data-credential-field="' +
925
+ esc(field.name) + '" aria-label="' + esc(field.label) +
926
+ '" placeholder="' + esc(field.placeholder || field.label) +
927
+ '" autocomplete="' +
928
+ (field.inputType === "password" ? "new-password" : "off") +
929
+ '" autocapitalize="none" spellcheck="false"></div>';
930
+ }
931
+ head += "</div>";
932
+ } else {
933
+ head += '<input type="password" data-credential-input="' +
934
+ esc(c.id) + '" aria-label="' + esc(cred.label) + '" placeholder="' +
935
+ esc(cred.placeholder || "Paste credential") +
936
+ '" autocomplete="new-password" autocapitalize="none" spellcheck="false">';
937
+ }
938
+ head += '<button class="linklike" type="button" data-credential-action="save" data-connector="' +
939
+ esc(c.id) + '">Save</button><button class="linklike" type="button" data-credential-action="cancel" data-connector="' +
940
+ esc(c.id) + '">Cancel</button></div>';
941
+ } else {
942
+ head += '<p class="credential-copy meta">Team sign in is required to manage this credential.</p>';
943
+ }
944
+ head += "</section>";
945
+ }
946
+ let body = "";
947
+ if (tools.length) {
948
+ body = "<details" + (q ? " open" : "") +
949
+ '><summary class="linklike">Show tools (' + tools.length + ")</summary>" +
950
+ '<div class="tool-list">';
951
+ for (const t of tools) {
952
+ body += '<div class="tool"><code>' + esc(t.address) + "</code>";
953
+ if (t.description) body += '<span class="td">' + esc(t.description) + "</span>";
954
+ body += "</div>";
955
+ }
956
+ body += "</div></details>";
957
+ }
958
+ el.innerHTML = head + body;
959
+ list.appendChild(el);
960
+ }
961
+ if (!list.children.length) {
962
+ list.innerHTML = '<p class="empty">No connectors or tools match this filter.</p>';
963
+ }
964
+ }
965
+
966
+ async function credentialRequest(connector, method, action, body) {
967
+ const token = await sessionToken();
968
+ if (!token) throw new Error("Your Clerk session has expired.");
969
+ const suffix = action ? "/" + action : "";
970
+ const res = await fetch(
971
+ "/ui/credentials/" + encodeURIComponent(connector) + suffix,
972
+ {
973
+ method,
974
+ headers: {
975
+ Authorization: "Bearer " + token,
976
+ ...(body ? { "Content-Type": "application/json" } : {}),
977
+ },
978
+ ...(body ? { body: JSON.stringify(body) } : {}),
979
+ },
980
+ );
981
+ if (res.status === 204) return null;
982
+ let payload = {};
983
+ try { payload = await res.json(); } catch (e) {}
984
+ if (!res.ok) throw new Error(payload.error || "Request failed (" + res.status + ").");
985
+ return payload;
986
+ }
987
+
988
+ function credentialForm(connector) {
989
+ return document.querySelector('[data-credential-form="' +
990
+ CSS.escape(connector) + '"]');
991
+ }
992
+
993
+ $("list").onclick = async (event) => {
994
+ const button = event.target.closest("[data-credential-action]");
995
+ if (!button) return;
996
+ const connector = button.dataset.connector;
997
+ const action = button.dataset.credentialAction;
998
+ const form = credentialForm(connector);
999
+
1000
+ if (action === "edit") {
1001
+ form.classList.remove("hidden");
1002
+ form.querySelector("input")?.focus();
1003
+ return;
1004
+ }
1005
+ if (action === "cancel") {
1006
+ form.querySelectorAll("input").forEach((input) => { input.value = ""; });
1007
+ form.classList.add("hidden");
1008
+ return;
1009
+ }
1010
+ if (action === "remove" && !window.confirm(
1011
+ "Remove this credential? The connector will stop authenticating until a replacement is added."
1012
+ )) return;
1013
+
1014
+ setNotice("");
1015
+ const buttons = [...document.querySelectorAll(
1016
+ '[data-connector="' + CSS.escape(connector) + '"]'
1017
+ )];
1018
+ buttons.forEach((item) => { item.disabled = true; });
1019
+ try {
1020
+ if (action === "save") {
1021
+ const fieldInputs = [...form.querySelectorAll("[data-credential-field]")];
1022
+ if (fieldInputs.length) {
1023
+ const values = {};
1024
+ for (const input of fieldInputs) {
1025
+ const value = input.value.trim();
1026
+ if (!value) throw new Error("Complete every credential field before saving.");
1027
+ values[input.dataset.credentialField] = value;
1028
+ }
1029
+ await credentialRequest(connector, "PUT", "", { values });
1030
+ } else {
1031
+ const input = form.querySelector("[data-credential-input]");
1032
+ const value = input.value.trim();
1033
+ if (!value) throw new Error("Paste a credential before saving.");
1034
+ await credentialRequest(connector, "PUT", "", { value });
1035
+ }
1036
+ form.querySelectorAll("input").forEach((input) => { input.value = ""; });
1037
+ setNotice("Credential saved.");
1038
+ await load();
1039
+ } else if (action === "remove") {
1040
+ await credentialRequest(connector, "DELETE");
1041
+ setNotice("Credential removed.");
1042
+ await load();
1043
+ } else if (action === "test") {
1044
+ const result = await credentialRequest(connector, "POST", "test");
1045
+ setNotice(
1046
+ result.message || (result.ok ? "Credential is valid." : "Credential test failed."),
1047
+ !result.ok,
1048
+ );
1049
+ }
1050
+ } catch (e) {
1051
+ setNotice(e.message || "Credential action failed.", true);
1052
+ } finally {
1053
+ buttons.forEach((item) => { item.disabled = false; });
1054
+ }
1055
+ };
1056
+
1057
+ $("save").onclick = () => {
1058
+ const v = $("token").value.trim();
1059
+ if (!v) return;
1060
+ localStorage.setItem(KEY, v);
1061
+ $("token").value = "";
1062
+ load();
1063
+ };
1064
+ $("change").onclick = () => { localStorage.removeItem(KEY); showGate(""); };
1065
+ $("copyMcpUrl").onclick = async () => {
1066
+ const button = $("copyMcpUrl");
1067
+ try {
1068
+ await navigator.clipboard.writeText(MCP_URL);
1069
+ button.textContent = "Copied";
1070
+ } catch (e) {
1071
+ button.textContent = "Copy failed";
1072
+ }
1073
+ window.setTimeout(() => { button.textContent = "Copy URL"; }, 1600);
1074
+ };
1075
+ $("signin").onclick = () => Clerk.redirectToSignIn({
1076
+ signInFallbackRedirectUrl: window.location.href,
1077
+ signUpFallbackRedirectUrl: window.location.href,
1078
+ });
1079
+ $("gateSignout").onclick = () => Clerk.signOut({ redirectUrl: window.location.href });
1080
+ $("signout").onclick = () => Clerk.signOut({ redirectUrl: window.location.href });
1081
+ $("filter").oninput = () => { if (DATA) render(); };
1082
+ $("configTab").onclick = () => showView("config");
1083
+ $("activityTab").onclick = () => showView("activity");
1084
+ $("refreshActivity").onclick = () => loadActivity(true);
1085
+ $("moreActivity").onclick = () => loadActivity(false);
1086
+ $("activitySearch").oninput = () => renderActivity();
1087
+
1088
+ async function init() {
1089
+ if (AUTH.kind === "clerk") {
1090
+ $("clerkGate").classList.remove("hidden");
1091
+ $("signout").classList.remove("hidden");
1092
+ try {
1093
+ if (!window.Clerk) {
1094
+ return showGate("Clerk could not load. Check your network and try again.");
1095
+ }
1096
+ await Clerk.load({
1097
+ ...(AUTH.signInUrl ? { signInUrl: AUTH.signInUrl } : {}),
1098
+ ...(AUTH.signUpUrl ? { signUpUrl: AUTH.signUpUrl } : {}),
1099
+ signInFallbackRedirectUrl: window.location.href,
1100
+ signUpFallbackRedirectUrl: window.location.href,
1101
+ afterSignOutUrl: window.location.href,
1102
+ });
1103
+ } catch (e) {
1104
+ return showGate("Clerk could not initialize: " + e.message);
1105
+ }
1106
+ } else {
1107
+ $("tokenGate").classList.remove("hidden");
1108
+ $("change").classList.remove("hidden");
1109
+ }
1110
+ await load();
1111
+ }
1112
+
1113
+ if (AUTH.kind === "clerk") {
1114
+ window.addEventListener("load", init);
1115
+ } else {
1116
+ init();
1117
+ }
1118
+ </script>
1119
+ </body>
1120
+ </html>`;
1121
+ }