@zackbart/connecta 0.6.0 → 0.7.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 (72) hide show
  1. package/CHANGELOG.md +403 -0
  2. package/README.md +163 -308
  3. package/dist/auth/bearer.d.ts +4 -3
  4. package/dist/auth/bearer.d.ts.map +1 -1
  5. package/dist/auth/bearer.js +10 -8
  6. package/dist/auth/bearer.js.map +1 -1
  7. package/dist/auth/clerk.d.ts +8 -7
  8. package/dist/auth/clerk.d.ts.map +1 -1
  9. package/dist/auth/clerk.js +27 -8
  10. package/dist/auth/clerk.js.map +1 -1
  11. package/dist/connector-scope.d.ts +13 -0
  12. package/dist/connector-scope.d.ts.map +1 -0
  13. package/dist/connector-scope.js +35 -0
  14. package/dist/connector-scope.js.map +1 -0
  15. package/dist/connectors/api.d.ts +5 -5
  16. package/dist/connectors/api.d.ts.map +1 -1
  17. package/dist/connectors/remote-mcp.d.ts +3 -3
  18. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  19. package/dist/connectors/remote-mcp.js +309 -10
  20. package/dist/connectors/remote-mcp.js.map +1 -1
  21. package/dist/credential-health.d.ts +20 -9
  22. package/dist/credential-health.d.ts.map +1 -1
  23. package/dist/credential-health.js +127 -63
  24. package/dist/credential-health.js.map +1 -1
  25. package/dist/credentials.d.ts +84 -1
  26. package/dist/credentials.d.ts.map +1 -1
  27. package/dist/credentials.js +109 -2
  28. package/dist/credentials.js.map +1 -1
  29. package/dist/index.d.ts +83 -82
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +101 -31
  32. package/dist/index.js.map +1 -1
  33. package/dist/meta-tools.d.ts +3 -3
  34. package/dist/meta-tools.d.ts.map +1 -1
  35. package/dist/meta-tools.js +16 -7
  36. package/dist/meta-tools.js.map +1 -1
  37. package/dist/registry.d.ts +3 -2
  38. package/dist/registry.d.ts.map +1 -1
  39. package/dist/registry.js +4 -3
  40. package/dist/registry.js.map +1 -1
  41. package/dist/server.d.ts +1 -1
  42. package/dist/server.d.ts.map +1 -1
  43. package/dist/server.js +154 -52
  44. package/dist/server.js.map +1 -1
  45. package/dist/skills.js +2 -2
  46. package/dist/skills.js.map +1 -1
  47. package/dist/toolkits.js +1 -1
  48. package/dist/types.d.ts +51 -26
  49. package/dist/types.d.ts.map +1 -1
  50. package/dist/ui.d.ts +52 -21
  51. package/dist/ui.d.ts.map +1 -1
  52. package/dist/ui.js +665 -196
  53. package/dist/ui.js.map +1 -1
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/package.json +3 -2
  57. package/src/auth/bearer.ts +10 -8
  58. package/src/auth/clerk.ts +28 -9
  59. package/src/connector-scope.ts +41 -0
  60. package/src/connectors/api.ts +5 -5
  61. package/src/connectors/remote-mcp.ts +348 -25
  62. package/src/credential-health.ts +151 -71
  63. package/src/credentials.ts +166 -3
  64. package/src/index.ts +202 -113
  65. package/src/meta-tools.ts +22 -7
  66. package/src/registry.ts +4 -3
  67. package/src/server.ts +197 -71
  68. package/src/skills.ts +2 -2
  69. package/src/toolkits.ts +1 -1
  70. package/src/types.ts +51 -26
  71. package/src/ui.ts +703 -195
  72. package/src/version.ts +1 -1
package/dist/ui.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { credentialTestRule, describeUndeclaredCredentialFields, storedCredentialShape, } from "./credentials.js";
2
+ import { closeConnectorScope } from "./connector-scope.js";
1
3
  /** Connecta's default monochrome "C" mark. */
2
4
  export const CONNECTA_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
3
5
  <style>
@@ -40,6 +42,19 @@ export function resolveBranding(branding) {
40
42
  themeColor: trimmedString(branding?.themeColor) ?? "#ffffff",
41
43
  };
42
44
  }
45
+ /**
46
+ * Whether the operator meant to supply a value here — the question every
47
+ * dropped-URL warning asks before naming a field, and one definition so the
48
+ * branding and `uiAuth` warnings cannot answer it differently. A non-string
49
+ * counts as set: the intent was there and is exactly what the warning reports
50
+ * on. A blank or whitespace-only string does not; that is indistinguishable
51
+ * from leaving the field alone, and both take the default silently.
52
+ */
53
+ function isSetUrlValue(value) {
54
+ return typeof value === "string"
55
+ ? trimmedString(value) !== undefined
56
+ : value !== undefined && value !== null;
57
+ }
43
58
  /**
44
59
  * Names of the branding URLs the operator set that failed their gate and were
45
60
  * replaced by a default. Lives beside the gates so the startup warning cannot
@@ -50,16 +65,15 @@ export function droppedBrandingUrls(branding) {
50
65
  if (!branding)
51
66
  return [];
52
67
  const resolved = resolveBranding(branding);
53
- // A non-string still counts as "set": the operator meant to supply a URL, and
54
- // that intent is exactly what the warning reports on. A blank string does not.
55
- const isSet = (value) => typeof value === "string"
56
- ? trimmedString(value) !== undefined
57
- : value !== undefined && value !== null;
58
68
  const faviconHref = branding.favicon?.href;
59
69
  return [
60
- ...(isSet(branding.productUrl) && !resolved.productUrl ? ["productUrl"] : []),
61
- ...(isSet(branding.ownerUrl) && !resolved.ownerUrl ? ["ownerUrl"] : []),
62
- ...(isSet(faviconHref) &&
70
+ ...(isSetUrlValue(branding.productUrl) && !resolved.productUrl
71
+ ? ["productUrl"]
72
+ : []),
73
+ ...(isSetUrlValue(branding.ownerUrl) && !resolved.ownerUrl
74
+ ? ["ownerUrl"]
75
+ : []),
76
+ ...(isSetUrlValue(faviconHref) &&
63
77
  trimmedString(faviconHref) !== resolved.faviconHref
64
78
  ? ["favicon.href"]
65
79
  : []),
@@ -106,8 +120,8 @@ const URL_STRIPPED_CHARS = /[\t\n\r]/g;
106
120
  * default href is the relative `/favicon.svg`, which `isSafeHttpUrl` alone would
107
121
  * reject — and it is kept narrow on both ends.
108
122
  *
109
- * Root-relative only, because `/ui` and `/oauth/callback/<id>` sit at different
110
- * depths and a document-relative path would resolve differently on each.
123
+ * Root-relative only, because operator and OAuth callback pages sit at
124
+ * different depths and a document-relative path would resolve differently.
111
125
  *
112
126
  * "Root-relative" is enforced structurally: exactly one leading `/` followed by
113
127
  * a character that is neither `/` nor `\`. Both of those would make the value an
@@ -133,20 +147,26 @@ export function isSafeIconHref(href) {
133
147
  }
134
148
  }
135
149
  /**
136
- * True only for an absolute `https:` URL — the gate for `uiAuth.frontendApiUrl`,
137
- * the last operator-config value that lands in a URL-valued HTML position (the
138
- * `<script src>` of `/ui`'s sign-in loader). `javascript:` in a `src` does not
139
- * execute, so this closes a hole in the *invariant* rather than a live vector:
140
- * every operator value reaching an `href`/`src` is validated, with no exception
141
- * left to remember.
150
+ * True only for an absolute `https:` URL — the gate every `uiAuth` URL passes:
151
+ * `frontendApiUrl`, which becomes the operator shell's sign-in loader source,
152
+ * and `signInUrl`/`signUpUrl`, which ClerkJS uses as *navigation targets* when
153
+ * the operator signs in. With those three gated, no operator-config value
154
+ * reaches the browser in a URL position attribute or navigation — without
155
+ * validation, and there is no exception left to remember.
142
156
  *
143
- * Stricter than `isSafeHttpUrl` on purpose. There is no `http:` carve-out and no
144
- * loopback carve-out, because nobody types this value: the shipped Clerk adapter
145
- * derives it from the publishable key and Clerk's Frontend API is always https.
146
- * A cleartext script source on the dashboard would be a downgrade even where a
147
- * browser's mixed-content rules had not already blocked it.
157
+ * Stricter than `isSafeHttpUrl` on purpose: no `http:` carve-out, no loopback
158
+ * carve-out, and no relative form. Nobody types `frontendApiUrl` the shipped
159
+ * Clerk adapter derives it from the publishable key, and Clerk's Frontend API is
160
+ * always https — and a cleartext script source on an operator page would be a
161
+ * downgrade even where a browser's mixed-content rules had not already blocked
162
+ * it. `signInUrl`/`signUpUrl` *are* typed by the operator, but what belongs
163
+ * there is a hosted Account Portal address (`https://accounts.<domain>` or
164
+ * `https://<slug>.accounts.dev`), which is https as well; `http:` would carry a
165
+ * sign-in over cleartext, and a path relative to this origin is meaningless
166
+ * because this server hosts no sign-in page of its own. So the looser gate would
167
+ * buy nothing real, and the same strictness holds for all three.
148
168
  */
149
- export function isSafeScriptSrcUrl(url) {
169
+ export function isSafeHttpsUrl(url) {
150
170
  if (typeof url !== "string")
151
171
  return false;
152
172
  try {
@@ -161,15 +181,58 @@ export function isSafeScriptSrcUrl(url) {
161
181
  * gate. Lives beside the gate for the same reason `droppedBrandingUrls` does: the
162
182
  * startup warning cannot then drift from what rendering actually drops. Every
163
183
  * field is read defensively rather than trusted, because a custom `InboundAuth`
164
- * is untyped at a JS call site — `isSafeScriptSrcUrl` takes `unknown`, and a
184
+ * is untyped at a JS call site — `isSafeHttpsUrl` takes `unknown`, and a
165
185
  * `uiAuth` that is not the clerk shape is reported as nothing to warn about.
186
+ *
187
+ * `frontendApiUrl` is required, so anything that fails its gate is a drop.
188
+ * `signInUrl` and `signUpUrl` are optional, so only a value the operator
189
+ * *supplied* and the gate then rejected is worth a warning — an unset field
190
+ * took no default away from anyone. `isSetUrlValue` decides that, the same way
191
+ * and for the same reasons it decides it for the branding URLs: a warning that
192
+ * fires for one and not the other would be reporting on the field rather than
193
+ * on the operator's intent. Rendering is not consulted for this: it drops on
194
+ * the gate alone, and a blank string fails that gate too — it is simply not
195
+ * *reported*, because a blank is indistinguishable from leaving the field
196
+ * alone.
166
197
  */
167
198
  export function droppedUiAuthUrls(uiAuth) {
168
199
  if (!uiAuth || uiAuth.kind !== "clerk")
169
200
  return [];
170
- return isSafeScriptSrcUrl(uiAuth.frontendApiUrl)
171
- ? []
172
- : ["uiAuth.frontendApiUrl"];
201
+ return [
202
+ ...(isSafeHttpsUrl(uiAuth.frontendApiUrl) ? [] : ["uiAuth.frontendApiUrl"]),
203
+ ...(isSetUrlValue(uiAuth.signInUrl) && !isSafeHttpsUrl(uiAuth.signInUrl)
204
+ ? ["uiAuth.signInUrl"]
205
+ : []),
206
+ ...(isSetUrlValue(uiAuth.signUpUrl) && !isSafeHttpsUrl(uiAuth.signUpUrl)
207
+ ? ["uiAuth.signUpUrl"]
208
+ : []),
209
+ ];
210
+ }
211
+ const OPERATOR_PAGE_LABELS = {
212
+ connections: "Connections",
213
+ credentials: "Credentials",
214
+ activity: "Activity",
215
+ };
216
+ export function operatorPageForPath(path) {
217
+ if (path === "/")
218
+ return "connections";
219
+ if (path === "/credentials")
220
+ return "credentials";
221
+ if (path === "/activity")
222
+ return "activity";
223
+ return undefined;
224
+ }
225
+ export function operatorPageTitle(page, configuredTitle) {
226
+ return `${OPERATOR_PAGE_LABELS[page]} — ${configuredTitle}`;
227
+ }
228
+ export function credentialManagementCapability(input) {
229
+ if (!input.eligibleClerkOperator)
230
+ return "requires_clerk";
231
+ if (!input.hasCredentialSlots)
232
+ return "no_slots";
233
+ if (!input.hasCredentialVault)
234
+ return "vault_not_configured";
235
+ return "available";
173
236
  }
174
237
  /**
175
238
  * Filter by connector identity/description or tool name/description. A
@@ -203,9 +266,12 @@ export function filterUiConnectors(connectors, query) {
203
266
  * isolated: they surface status "error" with an empty tool list rather than
204
267
  * failing the whole payload.
205
268
  */
206
- export async function buildUiData(registry, baseUrl, serverInfo, credentialVault, activityEnabled = false) {
269
+ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault, activityEnabled = false, credentialManagement = credentialVault
270
+ ? "available"
271
+ : "requires_clerk") {
207
272
  const requestScope = {};
208
- const connectors = await Promise.all(registry.listConnectors().map(async (c) => {
273
+ const connectorSet = registry.listConnectors();
274
+ const connectors = await Promise.all(connectorSet.map(async (c) => {
209
275
  const status = await registry.statusFor(c.id, baseUrl, requestScope);
210
276
  const credentialCheck = await registry.credentialHealthFor(c.id);
211
277
  let tools = [];
@@ -228,6 +294,10 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
228
294
  }
229
295
  let credential;
230
296
  if (c.credential && credentialVault) {
297
+ // One rule, shared with the test route: only the hook matching the
298
+ // declared credential shape can run, so the button is offered only
299
+ // where a click can succeed (src/credentials.ts).
300
+ const testRule = credentialTestRule(c);
231
301
  const credentialFields = (metadata) => c.credential?.fields?.map((field) => {
232
302
  const fieldMetadata = metadata?.fields?.[field.name];
233
303
  return {
@@ -252,6 +322,7 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
252
322
  try {
253
323
  const metadata = await credentialVault.metadata(c.id);
254
324
  const fields = credentialFields(metadata);
325
+ const shape = storedCredentialShape(c.credential, metadata?.fields ?? null);
255
326
  credential = {
256
327
  label: c.credential.label,
257
328
  ...(c.credential.description
@@ -261,9 +332,7 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
261
332
  ? { placeholder: c.credential.placeholder }
262
333
  : {}),
263
334
  ...(fields?.length ? { fields } : {}),
264
- configured: fields?.length
265
- ? fields.every((field) => field.configured)
266
- : Boolean(metadata),
335
+ configured: shape.state === "valid",
267
336
  removable: Boolean(metadata),
268
337
  ...(metadata
269
338
  ? {
@@ -271,7 +340,18 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
271
340
  updatedAt: metadata.updatedAt,
272
341
  }
273
342
  : {}),
274
- testable: Boolean(c.testCredential || c.testCredentials),
343
+ testable: testRule.mode !== null && shape.state !== "mismatch",
344
+ ...(shape.state === "mismatch"
345
+ ? { error: shape.message }
346
+ : {}),
347
+ // A dropped field leaves its secret in the vault, and the field
348
+ // list below only renders fields the connector still declares —
349
+ // so without this line there is nowhere an operator could see it.
350
+ ...(shape.state === "valid" && shape.undeclared.length
351
+ ? {
352
+ notice: describeUndeclaredCredentialFields(shape.undeclared),
353
+ }
354
+ : {}),
275
355
  };
276
356
  }
277
357
  catch {
@@ -287,7 +367,7 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
287
367
  ...(fields?.length ? { fields } : {}),
288
368
  configured: false,
289
369
  removable: true,
290
- testable: Boolean(c.testCredential || c.testCredentials),
370
+ testable: testRule.mode !== null,
291
371
  error: "Stored credential could not be read.",
292
372
  };
293
373
  }
@@ -316,8 +396,15 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
316
396
  : {}),
317
397
  ...(credential ? { credential } : {}),
318
398
  };
319
- }));
320
- return { serverInfo, connectors, activityEnabled };
399
+ })).finally(async () => {
400
+ await Promise.all(connectorSet.map((connector) => closeConnectorScope(connector, registry.contextFor(connector.id, baseUrl, requestScope))));
401
+ });
402
+ return {
403
+ serverInfo,
404
+ connectors,
405
+ activityEnabled,
406
+ credentialManagement,
407
+ };
321
408
  }
322
409
  function escapeHtmlAttr(value) {
323
410
  return value
@@ -333,36 +420,41 @@ function jsonForInlineScript(value) {
333
420
  .replaceAll("&", "\\u0026");
334
421
  }
335
422
  /**
336
- * The `/ui` shell carries no connector data: everything comes client-side from
337
- * the auth-gated `/ui/data`. A configured Clerk provider gives operators a
338
- * normal sign-in flow and a short-lived session token; bearer-only deployments
339
- * retain the manual-token fallback.
423
+ * Every operator page serves this same data-free shell. Connector, credential,
424
+ * and activity data arrives only through the authenticated `/ui/*` APIs.
340
425
  */
341
- export function renderUiHtml(uiAuth, mcpUrl = "/mcp", branding, nonce) {
426
+ export function renderUiHtml(uiAuth, mcpUrl = "/mcp", branding, nonce, page = "connections") {
342
427
  const clerk = uiAuth?.kind === "clerk" ? uiAuth : undefined;
343
428
  // The Clerk loader's origin. A value that fails the gate is dropped rather
344
429
  // than escaped into the page: the loader tag is simply not emitted, the gate
345
430
  // reports that Clerk could not load, and the rest of the shell still renders —
346
431
  // the same fallback-and-warn posture the branding URLs take, with the drop
347
432
  // named in a startup warning (see `droppedUiAuthUrls`).
348
- const clerkScriptOrigin = clerk && isSafeScriptSrcUrl(clerk.frontendApiUrl)
433
+ const clerkScriptOrigin = clerk && isSafeHttpsUrl(clerk.frontendApiUrl)
349
434
  ? clerk.frontendApiUrl
350
435
  : undefined;
351
436
  // Enumerated field by field, because this object is serialized into the page's
352
437
  // inline script: a rejected frontendApiUrl must not reach the document through
353
- // `AUTH` after being kept out of the `<script src>`.
438
+ // `AUTH` after being kept out of the `<script src>`, and a rejected
439
+ // signInUrl/signUpUrl — which `AUTH` is the only path into the page for — must
440
+ // not reach it at all. Dropping one leaves the key absent, so `Clerk.load`
441
+ // falls back to its own default the same way it does for an unset value.
354
442
  const auth = clerk
355
443
  ? {
356
444
  kind: clerk.kind,
357
445
  publishableKey: clerk.publishableKey,
358
446
  ...(clerkScriptOrigin ? { frontendApiUrl: clerkScriptOrigin } : {}),
359
- ...(clerk.signInUrl ? { signInUrl: clerk.signInUrl } : {}),
360
- ...(clerk.signUpUrl ? { signUpUrl: clerk.signUpUrl } : {}),
447
+ ...(isSafeHttpsUrl(clerk.signInUrl)
448
+ ? { signInUrl: clerk.signInUrl }
449
+ : {}),
450
+ ...(isSafeHttpsUrl(clerk.signUpUrl)
451
+ ? { signUpUrl: clerk.signUpUrl }
452
+ : {}),
361
453
  }
362
454
  : (uiAuth ?? { kind: "bearer" });
363
455
  const brand = resolveBranding(branding);
364
- const title = brand.pageTitle;
365
- // When the /ui response ships a nonce-based CSP, every <script> it emits must
456
+ const title = operatorPageTitle(page, brand.pageTitle);
457
+ // When an operator shell ships a nonce-based CSP, every script it emits must
366
458
  // carry that nonce to run; without a nonce the markup is unchanged.
367
459
  const nonceAttr = nonce ? ` nonce="${nonce}"` : "";
368
460
  // Top-left corner. With an owner set it reads "<owner> <product>"; without
@@ -401,6 +493,7 @@ ${clerkScript}
401
493
  --paper: #fff;
402
494
  --rule: #ccc;
403
495
  --muted: #666;
496
+ --trace: #f5f5f5;
404
497
  --shell: 70rem;
405
498
  --pad: 1rem;
406
499
  --gap: 1.5rem;
@@ -447,6 +540,15 @@ ${clerkScript}
447
540
  outline: 1px solid var(--ink);
448
541
  }
449
542
  :is(a, button, summary):focus-visible { outline-offset: 2px; }
543
+ .skip-link {
544
+ background: var(--paper);
545
+ left: var(--pad);
546
+ padding: .5rem;
547
+ position: fixed;
548
+ top: -4rem;
549
+ z-index: 10;
550
+ }
551
+ .skip-link:focus { top: var(--pad); }
450
552
 
451
553
  .shell {
452
554
  margin: 0 auto;
@@ -465,6 +567,15 @@ ${clerkScript}
465
567
  .cap, .meta { color: var(--muted); font-size: .9em; }
466
568
  .mono { font-family: var(--mono); font-size: .78rem; }
467
569
  .hidden { display: none !important; }
570
+ .visually-hidden {
571
+ clip: rect(0 0 0 0);
572
+ clip-path: inset(50%);
573
+ height: 1px;
574
+ overflow: hidden;
575
+ position: absolute;
576
+ white-space: nowrap;
577
+ width: 1px;
578
+ }
468
579
 
469
580
  .masthead {
470
581
  align-items: start;
@@ -489,6 +600,17 @@ ${clerkScript}
489
600
  justify-content: flex-end;
490
601
  min-width: 0;
491
602
  }
603
+ .page-nav,
604
+ .session-actions {
605
+ display: flex;
606
+ flex-wrap: wrap;
607
+ gap: .5rem var(--gap);
608
+ }
609
+ .mast-actions :is(a, button) {
610
+ align-items: center;
611
+ display: inline-flex;
612
+ min-height: 2rem;
613
+ }
492
614
  .navlink,
493
615
  .linklike {
494
616
  text-decoration: underline;
@@ -498,7 +620,7 @@ ${clerkScript}
498
620
  .navlink { text-decoration-color: transparent; }
499
621
  .navlink:hover,
500
622
  .navlink:focus-visible,
501
- .navlink.active { text-decoration-color: currentColor; }
623
+ .navlink[aria-current="page"] { text-decoration-color: currentColor; }
502
624
  .linklike { text-decoration-color: currentColor; }
503
625
  .linklike:hover,
504
626
  .linklike:focus-visible { text-decoration-color: transparent; }
@@ -546,7 +668,24 @@ ${clerkScript}
546
668
  #notice:empty { display: none; }
547
669
  #notice:not(:empty) { text-decoration: underline; }
548
670
  .error-notice, .msg { text-decoration: underline; }
549
- .card { border-top: 1px solid var(--rule); padding: .75rem 0; }
671
+ .card,
672
+ .credential-card,
673
+ .activity-item {
674
+ padding-left: 1.25rem;
675
+ position: relative;
676
+ }
677
+ .card::before,
678
+ .credential-card::before,
679
+ .activity-item::before {
680
+ background: var(--rule);
681
+ bottom: 0;
682
+ content: "";
683
+ left: .25rem;
684
+ position: absolute;
685
+ top: 0;
686
+ width: 1px;
687
+ }
688
+ .card { border-top: 1px solid var(--rule); padding-bottom: .75rem; padding-top: .75rem; }
550
689
  .connector-head {
551
690
  display: grid;
552
691
  gap: var(--gap);
@@ -557,14 +696,25 @@ ${clerkScript}
557
696
  display: flex;
558
697
  gap: .5rem;
559
698
  }
699
+ .connector-title .dot,
700
+ .activity-stamp .dot {
701
+ margin-left: -1.25rem;
702
+ }
703
+ .activity-stamp {
704
+ align-items: baseline;
705
+ display: flex;
706
+ gap: .75rem;
707
+ }
560
708
  .card h2 { overflow-wrap: anywhere; }
561
709
  .connector-state { text-align: right; }
562
710
  .dot {
711
+ background: var(--paper);
563
712
  border: 1px solid var(--ink);
564
713
  display: inline-block;
565
714
  flex: none;
566
715
  height: .5rem;
567
716
  width: .5rem;
717
+ z-index: 1;
568
718
  }
569
719
  .dot.ok { background: var(--ink); }
570
720
  .dot.auth_required {
@@ -574,7 +724,8 @@ ${clerkScript}
574
724
  .connector-message,
575
725
  .connector-auth { margin-top: .75rem; }
576
726
 
577
- .credential { border-top: 1px solid var(--rule); margin-top: .75rem; padding-top: .75rem; }
727
+ .credential-ledger { border-bottom: 1px solid var(--rule); }
728
+ .credential-card { border-top: 1px solid var(--rule); padding-bottom: .75rem; padding-top: .75rem; }
578
729
  .credential-head {
579
730
  align-items: baseline;
580
731
  display: flex;
@@ -583,7 +734,27 @@ ${clerkScript}
583
734
  justify-content: space-between;
584
735
  }
585
736
  .credential-copy { margin-top: .25rem; max-width: 40rem; }
737
+ .credential-field-summary {
738
+ border-top: 1px solid var(--rule);
739
+ margin-top: .75rem;
740
+ }
741
+ .credential-field-summary > div {
742
+ border-bottom: 1px solid var(--rule);
743
+ display: flex;
744
+ flex-wrap: wrap;
745
+ gap: .25rem var(--gap);
746
+ justify-content: space-between;
747
+ padding: .5rem 0;
748
+ }
586
749
  .credential-actions { display: flex; flex-wrap: wrap; gap: var(--gap); margin-top: .75rem; }
750
+ .credential-actions button,
751
+ .credential-form button,
752
+ .activity-controls button,
753
+ .activity-more {
754
+ align-items: center;
755
+ display: inline-flex;
756
+ min-height: 2.75rem;
757
+ }
587
758
  .credential-form {
588
759
  align-items: center;
589
760
  display: flex;
@@ -627,7 +798,8 @@ ${clerkScript}
627
798
  display: grid;
628
799
  gap: .25rem var(--gap);
629
800
  grid-template-columns: minmax(9rem, .85fr) minmax(12rem, 1.4fr) minmax(8rem, .9fr);
630
- padding: .75rem 0;
801
+ padding-bottom: .75rem;
802
+ padding-top: .75rem;
631
803
  }
632
804
  .activity-time,
633
805
  .activity-actor,
@@ -638,6 +810,16 @@ ${clerkScript}
638
810
  .activity-item.timeout .activity-outcome { text-decoration: underline; }
639
811
  .activity-empty { border-top: 1px solid var(--rule); padding: .75rem 0; }
640
812
  .activity-more { margin-top: .75rem; }
813
+ .unavailable {
814
+ background: var(--trace);
815
+ border-bottom: 1px solid var(--rule);
816
+ border-top: 1px solid var(--rule);
817
+ padding: .75rem;
818
+ }
819
+
820
+ @media (prefers-reduced-motion: reduce) {
821
+ html:focus-within { scroll-behavior: auto; }
822
+ }
641
823
 
642
824
  @media (max-width: 36.99rem) {
643
825
  .pgrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -646,7 +828,12 @@ ${clerkScript}
646
828
  .masthead .brand { grid-column: 1; }
647
829
  .mast-nav { grid-column: 1 / -1; grid-row: 2; justify-content: flex-start; }
648
830
  .product { display: none; }
649
- .mast-actions { font-size: .875rem; gap: .5rem; white-space: nowrap; }
831
+ .mast-actions {
832
+ align-items: flex-start;
833
+ flex-direction: column;
834
+ font-size: .875rem;
835
+ gap: .25rem;
836
+ }
650
837
  .lead { margin-top: 4rem; }
651
838
  .section,
652
839
  .section + .section { margin-top: 2.5rem; }
@@ -655,49 +842,58 @@ ${clerkScript}
655
842
  .activity-item { grid-template-columns: 1fr; }
656
843
  .connector-state { text-align: left; }
657
844
  .credential-field { align-items: start; grid-template-columns: 1fr; gap: .25rem; }
845
+ input { min-height: 2.75rem; }
658
846
  }
659
847
  </style>
660
848
  </head>
661
849
  <body>
850
+ <a class="skip-link" href="#operatorContent">Skip to operator page</a>
662
851
  <header class="masthead shell pgrid">
663
852
  ${owner}
664
853
  <div class="mast-nav">
665
854
  ${product}
666
- <nav id="appNav" class="mast-actions hidden" aria-label="Dashboard views">
667
- <button id="configTab" class="navlink active" type="button"
668
- aria-pressed="true">Connections</button>
669
- <button id="activityTab" class="navlink hidden" type="button"
670
- aria-pressed="false">Activity</button>
671
- <button id="change" class="navlink hidden" type="button">Change token</button>
672
- <button id="signout" class="navlink hidden" type="button">Sign out</button>
673
- </nav>
855
+ <div id="appNav" class="mast-actions hidden">
856
+ <nav class="page-nav" aria-label="Operator pages">
857
+ <a id="connectionsNav" class="navlink" href="/"
858
+ data-operator-page="connections"${page === "connections" ? ' aria-current="page"' : ""}>Connections</a>
859
+ <a id="credentialsNav" class="navlink hidden" href="/credentials"
860
+ data-operator-page="credentials"${page === "credentials" ? ' aria-current="page"' : ""}>Credentials</a>
861
+ <a id="activityNav" class="navlink hidden" href="/activity"
862
+ data-operator-page="activity"${page === "activity" ? ' aria-current="page"' : ""}>Activity</a>
863
+ </nav>
864
+ <div class="session-actions" aria-label="Session actions">
865
+ <button id="change" class="navlink hidden" type="button">Change token</button>
866
+ <button id="signout" class="navlink hidden" type="button">Sign out</button>
867
+ </div>
868
+ </div>
674
869
  </div>
675
870
  </header>
676
871
 
677
- <main id="gate" class="page shell hidden">
678
- <section class="lead pgrid">
679
- <h1 class="pcap">Tool connections</h1>
680
- <div class="pbody lead-copy">
681
- <p>${escapeHtmlAttr(brand.description)}</p>
682
- <p id="gateCopy" class="meta"></p>
683
- <div id="tokenGate" class="row gate-actions hidden">
684
- <input id="token" type="password" placeholder="Bearer token" autocomplete="off"
685
- aria-label="Bearer token">
686
- <button id="save" class="linklike" type="button">Open dashboard</button>
687
- </div>
688
- <div id="clerkGate" class="actions gate-actions hidden">
689
- <button id="signin" class="linklike" type="button">Team sign in</button>
690
- <button id="gateSignout" class="linklike hidden" type="button">Sign out</button>
872
+ <main id="operatorContent" class="page shell" tabindex="-1">
873
+ <section id="gate" class="hidden">
874
+ <div class="lead pgrid">
875
+ <h1 id="gateHeading" class="pcap" tabindex="-1">${OPERATOR_PAGE_LABELS[page]}</h1>
876
+ <div class="pbody lead-copy">
877
+ <p>${escapeHtmlAttr(brand.description)}</p>
878
+ <p id="gateCopy" class="meta"></p>
879
+ <div id="tokenGate" class="row gate-actions hidden">
880
+ <input id="token" type="password" placeholder="Bearer token" autocomplete="off"
881
+ aria-label="Bearer token">
882
+ <button id="save" class="linklike" type="button">Open operator pages</button>
883
+ </div>
884
+ <div id="clerkGate" class="actions gate-actions hidden">
885
+ <button id="signin" class="linklike" type="button">Team sign in</button>
886
+ <button id="gateSignout" class="linklike hidden" type="button">Sign out</button>
887
+ </div>
888
+ <p id="err" role="alert"></p>
691
889
  </div>
692
- <p id="err"></p>
693
890
  </div>
694
891
  </section>
695
- </main>
696
892
 
697
- <main id="app" class="page shell hidden">
698
- <section id="configView">
893
+ <div id="app" class="hidden">
894
+ <section id="connectionsView"${page === "connections" ? "" : ' class="hidden"'}>
699
895
  <div class="lead pgrid">
700
- <h1 class="pcap">MCP connection</h1>
896
+ <h1 id="connectionsHeading" class="pcap" tabindex="-1">Connections</h1>
701
897
  <div class="pbody lead-copy">
702
898
  <p>Use this endpoint to give an MCP client access to the tools below.</p>
703
899
  <div class="endpoint">
@@ -706,50 +902,80 @@ ${clerkScript}
706
902
  <button id="copyMcpUrl" class="linklike" type="button">Copy URL</button>
707
903
  </div>
708
904
  </div>
709
- <p class="cap" id="serverInfo">${escapeHtmlAttr(brand.productName)} status dashboard</p>
905
+ <p class="cap" id="serverInfo">${escapeHtmlAttr(brand.productName)} operator</p>
710
906
  </div>
711
907
  </div>
712
- <section class="section pgrid" aria-labelledby="connectorsHeading">
713
- <h2 class="pcap" id="connectorsHeading">Connectors</h2>
908
+ <section class="section pgrid" aria-labelledby="connectorLedgerHeading">
909
+ <h2 class="pcap" id="connectorLedgerHeading">Connectors</h2>
714
910
  <div class="pbody">
715
911
  <div class="row toolbar">
716
912
  <input id="filter" type="search" placeholder="Filter connectors or tools…"
717
913
  aria-label="Filter connectors or tools">
718
914
  </div>
719
- <p id="notice" role="status" aria-live="polite"></p>
720
- <div id="list" class="connector-tools"></div>
915
+ <div id="list" class="connector-tools" aria-busy="false"></div>
721
916
  </div>
722
917
  </section>
723
918
  </section>
724
- <section id="activityView" class="hidden">
919
+
920
+ <section id="credentialsView"${page === "credentials" ? "" : ' class="hidden"'}>
725
921
  <div class="lead pgrid">
726
- <h1 class="pcap">Tool activity</h1>
922
+ <h1 id="credentialsHeading" class="pcap" tabindex="-1">Credentials</h1>
923
+ <div class="pbody">
924
+ <p class="activity-copy">Rotate operator-managed connector credentials. Stored values are never returned or displayed.</p>
925
+ <p id="credentialNotice" class="meta" role="status" aria-live="polite"
926
+ tabindex="-1"></p>
927
+ <div id="credentialUnavailable" class="unavailable hidden"></div>
928
+ <div id="credentialList" class="credential-ledger" aria-busy="false"></div>
929
+ </div>
930
+ </div>
931
+ </section>
932
+
933
+ <section id="activityView"${page === "activity" ? "" : ' class="hidden"'}>
934
+ <div class="lead pgrid">
935
+ <h1 id="activityHeading" class="pcap" tabindex="-1">Activity</h1>
727
936
  <div class="pbody">
728
937
  <p class="activity-copy" id="activitySummary">Arguments and results are never stored.</p>
729
- <div class="row activity-controls">
730
- <input id="activitySearch" type="search"
731
- placeholder="Search user, tool, or outcome…"
732
- aria-label="Search loaded activity">
733
- <button id="refreshActivity" class="linklike" type="button">Refresh</button>
938
+ <div id="activityUnavailable" class="unavailable hidden">
939
+ Activity history is not configured. Add an <span class="mono">activity.store</span>
940
+ with a list reader to enable this page.
941
+ </div>
942
+ <div id="activityAvailable">
943
+ <div class="row activity-controls">
944
+ <input id="activitySearch" type="search"
945
+ placeholder="Search user, tool, or outcome…"
946
+ aria-label="Search loaded activity">
947
+ <button id="refreshActivity" class="linklike" type="button">Refresh</button>
948
+ </div>
949
+ <p id="activityNotice" class="meta" role="status" aria-live="polite"></p>
950
+ <div id="activityList" class="activity-ledger" aria-busy="false"></div>
951
+ <button id="moreActivity" class="linklike activity-more hidden" type="button">Load older</button>
734
952
  </div>
735
- <p id="activityNotice" class="meta" role="status" aria-live="polite"></p>
736
- <div id="activityList" class="activity-ledger"></div>
737
- <button id="moreActivity" class="linklike activity-more hidden" type="button">Load older</button>
738
953
  </div>
739
954
  </div>
740
955
  </section>
956
+ </div>
741
957
  </main>
742
958
 
743
959
  <script${nonceAttr}>
744
960
  const AUTH = ${jsonForInlineScript(auth)};
745
961
  const MCP_URL = ${jsonForInlineScript(mcpUrl)};
962
+ const INITIAL_PAGE = ${jsonForInlineScript(page)};
963
+ const TITLE_SUFFIX = ${jsonForInlineScript(brand.pageTitle)};
746
964
  const filterUiConnectors = ${filterUiConnectors.toString()};
747
965
  const KEY = "connecta:token";
748
966
  const $ = (id) => document.getElementById(id);
967
+ const PAGE_META = {
968
+ connections: { path: "/", label: "Connections" },
969
+ credentials: { path: "/credentials", label: "Credentials" },
970
+ activity: { path: "/activity", label: "Activity" },
971
+ };
749
972
  let DATA = null;
750
973
  let ACTIVITY = [];
751
974
  let ACTIVITY_CURSOR = null;
752
975
  let ACTIVITY_LOADED = false;
976
+ let CURRENT_PAGE = INITIAL_PAGE;
977
+ let SESSION_GENERATION = 0;
978
+ let ACTIVITY_GENERATION = 0;
753
979
  $("mcpUrl").textContent = MCP_URL;
754
980
 
755
981
  function esc(s) {
@@ -773,8 +999,9 @@ function formatDate(value) {
773
999
  }
774
1000
 
775
1001
  function setNotice(message, isError) {
776
- $("notice").textContent = message || "";
777
- $("notice").classList.toggle("error-notice", Boolean(isError));
1002
+ $("credentialNotice").textContent = message || "";
1003
+ $("credentialNotice").classList.toggle("error-notice", Boolean(isError));
1004
+ $("credentialNotice").setAttribute("role", isError ? "alert" : "status");
778
1005
  }
779
1006
 
780
1007
  async function sessionToken() {
@@ -783,7 +1010,45 @@ async function sessionToken() {
783
1010
  : localStorage.getItem(KEY);
784
1011
  }
785
1012
 
1013
+ function clearActivityState() {
1014
+ ACTIVITY_GENERATION += 1;
1015
+ ACTIVITY = [];
1016
+ ACTIVITY_CURSOR = null;
1017
+ ACTIVITY_LOADED = false;
1018
+ $("activityList").innerHTML = "";
1019
+ $("activityList").setAttribute("aria-busy", "false");
1020
+ $("activityNotice").textContent = "";
1021
+ $("activityNotice").setAttribute("role", "status");
1022
+ $("activitySummary").textContent = "Arguments and results are never stored.";
1023
+ $("activitySearch").value = "";
1024
+ $("refreshActivity").disabled = false;
1025
+ $("moreActivity").disabled = false;
1026
+ $("moreActivity").classList.add("hidden");
1027
+ }
1028
+
1029
+ function clearIdentityState() {
1030
+ SESSION_GENERATION += 1;
1031
+ DATA = null;
1032
+ clearActivityState();
1033
+ $("list").innerHTML = "";
1034
+ $("filter").value = "";
1035
+ $("credentialList").innerHTML = "";
1036
+ $("credentialList").setAttribute("aria-busy", "false");
1037
+ $("credentialNotice").textContent = "";
1038
+ $("credentialNotice").setAttribute("role", "status");
1039
+ $("credentialNotice").classList.remove("error-notice");
1040
+ $("credentialUnavailable").textContent = "";
1041
+ $("credentialUnavailable").classList.add("hidden");
1042
+ $("credentialList").classList.add("hidden");
1043
+ $("activityUnavailable").classList.add("hidden");
1044
+ $("activityAvailable").classList.add("hidden");
1045
+ $("credentialsNav").classList.add("hidden");
1046
+ $("activityNav").classList.add("hidden");
1047
+ $("serverInfo").textContent = ${escapeScriptString(brand.productName + " operator")};
1048
+ }
1049
+
786
1050
  function showGate(msg) {
1051
+ clearIdentityState();
787
1052
  $("app").classList.add("hidden");
788
1053
  $("appNav").classList.add("hidden");
789
1054
  $("gate").classList.remove("hidden");
@@ -791,8 +1056,8 @@ function showGate(msg) {
791
1056
  if (AUTH.kind === "clerk") {
792
1057
  const signedIn = Boolean(window.Clerk && Clerk.user);
793
1058
  $("gateCopy").textContent = signedIn
794
- ? "Signed in with Clerk, but this account cannot open the dashboard."
795
- : "Sign in with Clerk to open the dashboard.";
1059
+ ? "Signed in with Clerk, but this account cannot open deployment-wide operator pages."
1060
+ : "Sign in with Clerk to open this operator page.";
796
1061
  $("signin").classList.toggle("hidden", signedIn);
797
1062
  $("gateSignout").classList.toggle("hidden", !signedIn);
798
1063
  } else {
@@ -800,20 +1065,87 @@ function showGate(msg) {
800
1065
  }
801
1066
  }
802
1067
 
1068
+ function pageForPath(path) {
1069
+ if (path === "/credentials") return "credentials";
1070
+ if (path === "/activity") return "activity";
1071
+ return "connections";
1072
+ }
1073
+
1074
+ function credentialUnavailableCopy(capability) {
1075
+ if (capability === "no_slots") {
1076
+ return "No connectors declare operator-managed credential slots. Connector credentials remain configuration-as-code until a slot is declared.";
1077
+ }
1078
+ if (capability === "vault_not_configured") {
1079
+ return "Credential storage is not configured. Set credentials.encryptionKey before managing connector credentials here.";
1080
+ }
1081
+ return "Credential management requires an eligible Clerk operator. Bearer-authenticated sessions can inspect connections but cannot manage stored credentials.";
1082
+ }
1083
+
1084
+ function updateCapabilities() {
1085
+ const credentialsAvailable =
1086
+ DATA?.credentialManagement === "available";
1087
+ $("credentialsNav").classList.toggle("hidden", !credentialsAvailable);
1088
+ $("activityNav").classList.toggle("hidden", !DATA?.activityEnabled);
1089
+ $("credentialUnavailable").classList.toggle("hidden", credentialsAvailable);
1090
+ $("credentialList").classList.toggle("hidden", !credentialsAvailable);
1091
+ $("credentialUnavailable").textContent = credentialsAvailable
1092
+ ? ""
1093
+ : credentialUnavailableCopy(DATA?.credentialManagement);
1094
+ $("activityUnavailable").classList.toggle("hidden", Boolean(DATA?.activityEnabled));
1095
+ $("activityAvailable").classList.toggle("hidden", !DATA?.activityEnabled);
1096
+ }
1097
+
1098
+ function activatePage(page, options) {
1099
+ const next = PAGE_META[page] ? page : "connections";
1100
+ CURRENT_PAGE = next;
1101
+ for (const name of Object.keys(PAGE_META)) {
1102
+ $(name + "View").classList.toggle("hidden", name !== next);
1103
+ const link = $(name + "Nav");
1104
+ if (name === next) link.setAttribute("aria-current", "page");
1105
+ else link.removeAttribute("aria-current");
1106
+ }
1107
+ $("gateHeading").textContent = PAGE_META[next].label;
1108
+ document.title = PAGE_META[next].label + " — " + TITLE_SUFFIX;
1109
+ if (DATA) {
1110
+ updateCapabilities();
1111
+ if (next === "connections") renderConnections();
1112
+ if (next === "credentials") renderCredentials();
1113
+ if (next === "activity" && DATA.activityEnabled && !ACTIVITY_LOADED) {
1114
+ loadActivity(true);
1115
+ }
1116
+ }
1117
+ if (next !== "credentials") $("credentialList").innerHTML = "";
1118
+ // Focus what is actually on screen. While gated the page views are hidden, so
1119
+ // focusing their heading is a silent no-op that drops focus to <body> and
1120
+ // restarts the next Tab from the top of the document — the gate's own h1 is
1121
+ // the visible heading, and activatePage has just relabelled it.
1122
+ if (options?.focus) $(DATA ? next + "Heading" : "gateHeading").focus();
1123
+ }
1124
+
1125
+ function navigateTo(page, href) {
1126
+ history.pushState({ operatorPage: page }, "", href);
1127
+ activatePage(page, { focus: true });
1128
+ }
1129
+
803
1130
  async function load() {
1131
+ const generation = SESSION_GENERATION;
804
1132
  let token;
805
1133
  try {
806
1134
  token = await sessionToken();
807
1135
  } catch (e) {
1136
+ if (generation !== SESSION_GENERATION) return;
808
1137
  return showGate("Could not read the Clerk session: " + e.message);
809
1138
  }
1139
+ if (generation !== SESSION_GENERATION) return;
810
1140
  if (!token) return showGate("");
811
1141
  let res;
812
1142
  try {
813
1143
  res = await fetch("/ui/data", { headers: { Authorization: "Bearer " + token } });
814
1144
  } catch (e) {
1145
+ if (generation !== SESSION_GENERATION) return;
815
1146
  return showGate("Network error: " + e.message);
816
1147
  }
1148
+ if (generation !== SESSION_GENERATION) return;
817
1149
  if (res.status === 401 || res.status === 403) {
818
1150
  if (AUTH.kind === "clerk") {
819
1151
  return showGate(
@@ -826,25 +1158,22 @@ async function load() {
826
1158
  return showGate("Token rejected — enter a valid bearer token.");
827
1159
  }
828
1160
  if (!res.ok) return showGate("Error " + res.status);
829
- DATA = await res.json();
1161
+ let data;
1162
+ try {
1163
+ data = await res.json();
1164
+ } catch (e) {
1165
+ if (generation !== SESSION_GENERATION) return;
1166
+ return showGate("Operator data could not be read.");
1167
+ }
1168
+ if (generation !== SESSION_GENERATION) return;
1169
+ DATA = data;
830
1170
  $("gate").classList.add("hidden");
831
1171
  $("app").classList.remove("hidden");
832
1172
  $("appNav").classList.remove("hidden");
833
1173
  const si = DATA.serverInfo || {};
834
1174
  $("serverInfo").textContent = (si.name || ${escapeScriptString(brand.productName)}) + " v" + (si.version || "?");
835
- $("activityTab").classList.toggle("hidden", !DATA.activityEnabled);
836
- render();
837
- }
838
-
839
- function showView(view) {
840
- const activity = view === "activity";
841
- $("configView").classList.toggle("hidden", activity);
842
- $("activityView").classList.toggle("hidden", !activity);
843
- $("configTab").classList.toggle("active", !activity);
844
- $("activityTab").classList.toggle("active", activity);
845
- $("configTab").setAttribute("aria-pressed", String(!activity));
846
- $("activityTab").setAttribute("aria-pressed", String(activity));
847
- if (activity && !ACTIVITY_LOADED) loadActivity(true);
1175
+ updateCapabilities();
1176
+ activatePage(CURRENT_PAGE);
848
1177
  }
849
1178
 
850
1179
  function actorLabel(actor) {
@@ -883,12 +1212,21 @@ function renderActivity() {
883
1212
  }
884
1213
  for (const event of visible) {
885
1214
  const item = document.createElement("article");
886
- item.className = "activity-item " + esc(event.outcome);
887
- const retryCopy = event.attempts > 1 ? " · " + event.attempts + " attempts" : "";
1215
+ const outcomeClass = ["success", "error", "timeout"].includes(event.outcome)
1216
+ ? event.outcome
1217
+ : "error";
1218
+ item.className = "activity-item " + outcomeClass;
1219
+ const retryCopy = event.attempts > 1
1220
+ ? " · " + esc(event.attempts) + " attempts"
1221
+ : "";
888
1222
  const errorCopy = event.errorCode ? " · " + esc(event.errorCode) : "";
889
1223
  item.innerHTML =
890
- '<div><div class="activity-time">' + esc(formatDate(event.occurredAt)) +
891
- '</div><div class="activity-actor">' + esc(actorLabel(event.actor)) + '</div></div>' +
1224
+ '<div class="activity-stamp"><span class="dot ' +
1225
+ (outcomeClass === "success" ? "ok" : "") +
1226
+ '" aria-hidden="true"></span><div><time class="activity-time" datetime="' +
1227
+ esc(event.occurredAt) + '">' +
1228
+ esc(formatDate(event.occurredAt)) +
1229
+ '</time><div class="activity-actor">' + esc(actorLabel(event.actor)) + '</div></div></div>' +
892
1230
  '<div><div class="activity-address">' + esc(event.address) +
893
1231
  '</div><div class="activity-detail">' + esc(event.source) + retryCopy +
894
1232
  errorCopy + '</div></div>' +
@@ -901,22 +1239,45 @@ function renderActivity() {
901
1239
 
902
1240
  async function loadActivity(reset) {
903
1241
  if (!DATA?.activityEnabled) return;
1242
+ const sessionGeneration = SESSION_GENERATION;
1243
+ if (reset) ACTIVITY_GENERATION += 1;
1244
+ const activityGeneration = ACTIVITY_GENERATION;
1245
+ const isCurrent = () =>
1246
+ sessionGeneration === SESSION_GENERATION &&
1247
+ activityGeneration === ACTIVITY_GENERATION;
904
1248
  $("activityNotice").textContent = "Loading activity…";
1249
+ $("activityNotice").setAttribute("role", "status");
1250
+ $("activityList").setAttribute("aria-busy", "true");
905
1251
  $("refreshActivity").disabled = true;
906
1252
  $("moreActivity").disabled = true;
907
1253
  try {
908
1254
  const token = await sessionToken();
909
- if (!token) throw new Error("Your session has expired.");
1255
+ if (!isCurrent()) return;
1256
+ if (!token) return showGate("Your session has expired.");
910
1257
  const cursor = reset ? null : ACTIVITY_CURSOR;
911
1258
  const params = new URLSearchParams({ limit: "50" });
912
1259
  if (cursor) params.set("cursor", cursor);
913
1260
  const res = await fetch("/ui/activity?" + params, {
914
1261
  headers: { Authorization: "Bearer " + token },
915
1262
  });
1263
+ if (!isCurrent()) return;
916
1264
  let payload = {};
917
1265
  try { payload = await res.json(); } catch (e) {}
1266
+ if (!isCurrent()) return;
1267
+ if (res.status === 401) {
1268
+ return showGate("Your session was not accepted. Sign in again.");
1269
+ }
1270
+ if (res.status === 403) {
1271
+ clearActivityState();
1272
+ $("activityNotice").setAttribute("role", "alert");
1273
+ $("activityNotice").textContent =
1274
+ "This identity may not read activity history.";
1275
+ return;
1276
+ }
918
1277
  if (!res.ok) {
919
- throw new Error(payload.error || "Activity could not be loaded (" + res.status + ").");
1278
+ throw new Error(
1279
+ payload.error || "Activity could not be loaded (" + res.status + ")."
1280
+ );
920
1281
  }
921
1282
  ACTIVITY = reset
922
1283
  ? (payload.events || [])
@@ -926,14 +1287,19 @@ async function loadActivity(reset) {
926
1287
  $("activityNotice").textContent = "";
927
1288
  renderActivity();
928
1289
  } catch (e) {
1290
+ if (!isCurrent()) return;
1291
+ $("activityNotice").setAttribute("role", "alert");
929
1292
  $("activityNotice").textContent = e.message || "Activity could not be loaded.";
930
1293
  } finally {
931
- $("refreshActivity").disabled = false;
932
- $("moreActivity").disabled = false;
1294
+ if (isCurrent()) {
1295
+ $("activityList").setAttribute("aria-busy", "false");
1296
+ $("refreshActivity").disabled = false;
1297
+ $("moreActivity").disabled = false;
1298
+ }
933
1299
  }
934
1300
  }
935
1301
 
936
- function render() {
1302
+ function renderConnections() {
937
1303
  const q = $("filter").value.trim().toLowerCase();
938
1304
  const list = $("list");
939
1305
  list.innerHTML = "";
@@ -981,66 +1347,8 @@ function render() {
981
1347
  esc(c.authorizationUrl) + "</p>";
982
1348
  }
983
1349
  if (c.credential) {
984
- const cred = c.credential;
985
- const configured = Boolean(cred.configured);
986
- const removable = configured || Boolean(cred.removable);
987
- const state = configured
988
- ? "configured · ••••" + esc(cred.lastFour || "")
989
- : "not configured";
990
- const updated = configured && cred.updatedAt
991
- ? " · updated " + esc(formatDate(cred.updatedAt))
992
- : "";
993
- head += '<section class="credential" aria-label="' + esc(cred.label) + '">';
994
- head += '<div class="credential-head"><span class="credential-label">' +
995
- esc(cred.label) + '</span><span class="credential-state">' + state +
996
- updated + "</span></div>";
997
- if (cred.description) {
998
- head += '<p class="credential-copy meta">' + esc(cred.description) + "</p>";
999
- }
1000
- if (cred.error) {
1001
- head += '<div class="msg">' + esc(cred.error) + "</div>";
1002
- }
1003
- if (AUTH.kind === "clerk") {
1004
- head += '<div class="credential-actions">';
1005
- head += '<button class="linklike" type="button" data-credential-action="edit" data-connector="' +
1006
- esc(c.id) + '">' + (removable ? "Replace" : "Add credential") + "</button>";
1007
- if (configured && cred.testable) {
1008
- head += '<button class="linklike" type="button" data-credential-action="test" data-connector="' +
1009
- esc(c.id) + '">Test</button>';
1010
- }
1011
- if (removable) {
1012
- head += '<button type="button" class="linklike danger" data-credential-action="remove" data-connector="' +
1013
- esc(c.id) + '">Remove</button>';
1014
- }
1015
- head += "</div>";
1016
- head += '<div class="credential-form hidden" data-credential-form="' +
1017
- esc(c.id) + '">';
1018
- if (cred.fields && cred.fields.length) {
1019
- head += '<div class="credential-fields">';
1020
- for (const field of cred.fields) {
1021
- head += '<div class="credential-field"><label>' +
1022
- esc(field.label) + '</label><input type="' +
1023
- esc(field.inputType || "password") + '" data-credential-field="' +
1024
- esc(field.name) + '" aria-label="' + esc(field.label) +
1025
- '" placeholder="' + esc(field.placeholder || field.label) +
1026
- '" autocomplete="' +
1027
- (field.inputType === "password" ? "new-password" : "off") +
1028
- '" autocapitalize="none" spellcheck="false"></div>';
1029
- }
1030
- head += "</div>";
1031
- } else {
1032
- head += '<input type="password" data-credential-input="' +
1033
- esc(c.id) + '" aria-label="' + esc(cred.label) + '" placeholder="' +
1034
- esc(cred.placeholder || "Paste credential") +
1035
- '" autocomplete="new-password" autocapitalize="none" spellcheck="false">';
1036
- }
1037
- head += '<button class="linklike" type="button" data-credential-action="save" data-connector="' +
1038
- esc(c.id) + '">Save</button><button class="linklike" type="button" data-credential-action="cancel" data-connector="' +
1039
- esc(c.id) + '">Cancel</button></div>';
1040
- } else {
1041
- head += '<p class="credential-copy meta">Team sign in is required to manage this credential.</p>';
1042
- }
1043
- head += "</section>";
1350
+ head += '<p class="connector-auth"><a class="linklike" href="/credentials" ' +
1351
+ 'data-operator-page="credentials">Manage credential →</a></p>';
1044
1352
  }
1045
1353
  let body = "";
1046
1354
  if (tools.length) {
@@ -1058,12 +1366,122 @@ function render() {
1058
1366
  list.appendChild(el);
1059
1367
  }
1060
1368
  if (!list.children.length) {
1061
- list.innerHTML = '<p class="empty">No connectors or tools match this filter.</p>';
1369
+ list.innerHTML = '<p class="empty">' +
1370
+ (q
1371
+ ? "No connectors or tools match this filter."
1372
+ : "No connectors are declared in this deployment.") +
1373
+ "</p>";
1062
1374
  }
1063
1375
  }
1064
1376
 
1065
- async function credentialRequest(connector, method, action, body) {
1377
+ function renderCredentials() {
1378
+ const list = $("credentialList");
1379
+ list.innerHTML = "";
1380
+ if (DATA.credentialManagement !== "available") return;
1381
+ for (const c of DATA.connectors.filter((connector) => connector.credential)) {
1382
+ const cred = c.credential;
1383
+ const configured = Boolean(cred.configured);
1384
+ const removable = configured || Boolean(cred.removable);
1385
+ const state = configured
1386
+ ? cred.fields?.length
1387
+ ? "configured"
1388
+ : "configured · ••••" + esc(cred.lastFour || "")
1389
+ : "not configured";
1390
+ const updated = configured && cred.updatedAt
1391
+ ? " · updated " + esc(formatDate(cred.updatedAt))
1392
+ : "";
1393
+ const el = document.createElement("section");
1394
+ el.className = "credential-card";
1395
+ el.id = "credential-" + c.id;
1396
+ el.setAttribute("aria-labelledby", "credential-title-" + c.id);
1397
+ let body = '<div class="credential-head"><div class="connector-title">' +
1398
+ '<span class="dot ' + (configured ? "ok" : "auth_required") +
1399
+ '" aria-hidden="true"></span><h2 id="credential-title-' + esc(c.id) + '">' +
1400
+ esc(c.title || c.id) + '</h2></div><span class="credential-state">' +
1401
+ state + updated + "</span></div>";
1402
+ body += '<p class="mono">' + esc(c.id) + " · " + esc(cred.label) + "</p>";
1403
+ if (cred.description) {
1404
+ body += '<p class="credential-copy meta">' + esc(cred.description) + "</p>";
1405
+ }
1406
+ if (cred.fields?.length) {
1407
+ body += '<div class="credential-field-summary">';
1408
+ for (const field of cred.fields) {
1409
+ const fieldState = field.configured
1410
+ ? "configured · ••••" + esc(field.lastFour || "") +
1411
+ (field.updatedAt ? " · updated " + esc(formatDate(field.updatedAt)) : "")
1412
+ : "not configured";
1413
+ body += '<div><span>' + esc(field.label) +
1414
+ '</span><span class="meta">' + fieldState + "</span></div>";
1415
+ }
1416
+ body += "</div>";
1417
+ }
1418
+ if (c.credentialCheck) {
1419
+ const check = c.credentialCheck;
1420
+ const verdict = check.state === "ok"
1421
+ ? "healthy"
1422
+ : check.state === "auth_required"
1423
+ ? "needs authorization"
1424
+ : "check failed";
1425
+ body += '<p class="connector-check meta">Liveness: ' + esc(verdict) +
1426
+ " · " + esc(formatDate(check.checkedAt)) +
1427
+ (check.message ? " — " + esc(check.message) : "") + "</p>";
1428
+ }
1429
+ if (cred.error) body += '<div class="msg">' + esc(cred.error) + "</div>";
1430
+ // Leftover stored fields are not an error — the credential still works, so
1431
+ // this stays muted copy rather than the msg block an actual failure earns.
1432
+ if (cred.notice) {
1433
+ body += '<p class="credential-copy meta">' + esc(cred.notice) + "</p>";
1434
+ }
1435
+ body += '<div class="credential-actions">';
1436
+ body += '<button class="linklike" type="button" data-credential-action="edit" data-connector="' +
1437
+ esc(c.id) + '">' + (removable ? "Replace" : "Add credential") + "</button>";
1438
+ if (configured && cred.testable) {
1439
+ body += '<button class="linklike" type="button" data-credential-action="test" data-connector="' +
1440
+ esc(c.id) + '">Test</button>';
1441
+ }
1442
+ if (removable) {
1443
+ body += '<button type="button" class="linklike danger" data-credential-action="remove" data-connector="' +
1444
+ esc(c.id) + '">Remove</button>';
1445
+ }
1446
+ body += "</div>";
1447
+ body += '<div class="credential-form hidden" data-credential-form="' +
1448
+ esc(c.id) + '">';
1449
+ if (cred.fields && cred.fields.length) {
1450
+ body += '<div class="credential-fields">';
1451
+ for (let index = 0; index < cred.fields.length; index += 1) {
1452
+ const field = cred.fields[index];
1453
+ const inputId = "credential-input-" + c.id + "-" + index;
1454
+ body += '<div class="credential-field"><label for="' + esc(inputId) + '">' +
1455
+ esc(field.label) + '</label><input id="' + esc(inputId) + '" type="' +
1456
+ esc(field.inputType || "password") + '" data-credential-field="' +
1457
+ esc(field.name) + '" placeholder="' + esc(field.placeholder || field.label) +
1458
+ '" autocomplete="' +
1459
+ (field.inputType === "password" ? "new-password" : "off") +
1460
+ '" autocapitalize="none" spellcheck="false"></div>';
1461
+ }
1462
+ body += "</div>";
1463
+ } else {
1464
+ const inputId = "credential-input-" + c.id;
1465
+ body += '<label class="visually-hidden" for="' + esc(inputId) + '">' +
1466
+ esc(cred.label) + '</label><input id="' + esc(inputId) +
1467
+ '" type="password" data-credential-input="' +
1468
+ esc(c.id) + '" aria-label="' + esc(cred.label) + '" placeholder="' +
1469
+ esc(cred.placeholder || "Paste credential") +
1470
+ '" autocomplete="new-password" autocapitalize="none" spellcheck="false">';
1471
+ }
1472
+ body += '<button class="linklike" type="button" data-credential-action="save" data-connector="' +
1473
+ esc(c.id) + '">Save</button><button class="linklike" type="button" data-credential-action="cancel" data-connector="' +
1474
+ esc(c.id) + '">Cancel</button></div>';
1475
+ el.innerHTML = body;
1476
+ list.appendChild(el);
1477
+ }
1478
+ }
1479
+
1480
+ async function credentialRequest(connector, method, action, body, generation) {
1066
1481
  const token = await sessionToken();
1482
+ if (generation !== SESSION_GENERATION) {
1483
+ throw new Error("The operator session changed.");
1484
+ }
1067
1485
  if (!token) throw new Error("Your Clerk session has expired.");
1068
1486
  const suffix = action ? "/" + action : "";
1069
1487
  const res = await fetch(
@@ -1089,12 +1507,13 @@ function credentialForm(connector) {
1089
1507
  CSS.escape(connector) + '"]');
1090
1508
  }
1091
1509
 
1092
- $("list").onclick = async (event) => {
1510
+ $("credentialList").onclick = async (event) => {
1093
1511
  const button = event.target.closest("[data-credential-action]");
1094
1512
  if (!button) return;
1095
1513
  const connector = button.dataset.connector;
1096
1514
  const action = button.dataset.credentialAction;
1097
1515
  const form = credentialForm(connector);
1516
+ const generation = SESSION_GENERATION;
1098
1517
 
1099
1518
  if (action === "edit") {
1100
1519
  form.classList.remove("hidden");
@@ -1104,6 +1523,10 @@ $("list").onclick = async (event) => {
1104
1523
  if (action === "cancel") {
1105
1524
  form.querySelectorAll("input").forEach((input) => { input.value = ""; });
1106
1525
  form.classList.add("hidden");
1526
+ document.querySelector(
1527
+ '[data-credential-action="edit"][data-connector="' +
1528
+ CSS.escape(connector) + '"]'
1529
+ )?.focus();
1107
1530
  return;
1108
1531
  }
1109
1532
  if (action === "remove" && !window.confirm(
@@ -1111,6 +1534,7 @@ $("list").onclick = async (event) => {
1111
1534
  )) return;
1112
1535
 
1113
1536
  setNotice("");
1537
+ $("credentialList").setAttribute("aria-busy", "true");
1114
1538
  const buttons = [...document.querySelectorAll(
1115
1539
  '[data-connector="' + CSS.escape(connector) + '"]'
1116
1540
  )];
@@ -1125,42 +1549,65 @@ $("list").onclick = async (event) => {
1125
1549
  if (!value) throw new Error("Complete every credential field before saving.");
1126
1550
  values[input.dataset.credentialField] = value;
1127
1551
  }
1128
- await credentialRequest(connector, "PUT", "", { values });
1552
+ await credentialRequest(connector, "PUT", "", { values }, generation);
1129
1553
  } else {
1130
1554
  const input = form.querySelector("[data-credential-input]");
1131
1555
  const value = input.value.trim();
1132
1556
  if (!value) throw new Error("Paste a credential before saving.");
1133
- await credentialRequest(connector, "PUT", "", { value });
1557
+ await credentialRequest(connector, "PUT", "", { value }, generation);
1134
1558
  }
1559
+ if (generation !== SESSION_GENERATION) return;
1135
1560
  form.querySelectorAll("input").forEach((input) => { input.value = ""; });
1136
1561
  setNotice("Credential saved.");
1137
1562
  await load();
1563
+ if (generation !== SESSION_GENERATION) return;
1564
+ $("credentialNotice").focus();
1138
1565
  } else if (action === "remove") {
1139
- await credentialRequest(connector, "DELETE");
1566
+ await credentialRequest(connector, "DELETE", "", null, generation);
1567
+ if (generation !== SESSION_GENERATION) return;
1140
1568
  setNotice("Credential removed.");
1141
1569
  await load();
1570
+ if (generation !== SESSION_GENERATION) return;
1571
+ $("credentialNotice").focus();
1142
1572
  } else if (action === "test") {
1143
- const result = await credentialRequest(connector, "POST", "test");
1573
+ const result = await credentialRequest(
1574
+ connector,
1575
+ "POST",
1576
+ "test",
1577
+ null,
1578
+ generation,
1579
+ );
1580
+ if (generation !== SESSION_GENERATION) return;
1144
1581
  setNotice(
1145
1582
  result.message || (result.ok ? "Credential is valid." : "Credential test failed."),
1146
1583
  !result.ok,
1147
1584
  );
1148
1585
  }
1149
1586
  } catch (e) {
1587
+ if (generation !== SESSION_GENERATION) return;
1150
1588
  setNotice(e.message || "Credential action failed.", true);
1151
1589
  } finally {
1152
- buttons.forEach((item) => { item.disabled = false; });
1590
+ if (generation === SESSION_GENERATION) {
1591
+ $("credentialList").setAttribute("aria-busy", "false");
1592
+ buttons.forEach((item) => { item.disabled = false; });
1593
+ }
1153
1594
  }
1154
1595
  };
1155
1596
 
1156
- $("save").onclick = () => {
1597
+ $("save").onclick = async () => {
1157
1598
  const v = $("token").value.trim();
1158
1599
  if (!v) return;
1600
+ clearIdentityState();
1159
1601
  localStorage.setItem(KEY, v);
1160
1602
  $("token").value = "";
1161
- load();
1603
+ await load();
1604
+ if (DATA) $(CURRENT_PAGE + "Heading").focus();
1605
+ };
1606
+ $("change").onclick = () => {
1607
+ localStorage.removeItem(KEY);
1608
+ showGate("");
1609
+ $("token").focus();
1162
1610
  };
1163
- $("change").onclick = () => { localStorage.removeItem(KEY); showGate(""); };
1164
1611
  $("copyMcpUrl").onclick = async () => {
1165
1612
  const button = $("copyMcpUrl");
1166
1613
  try {
@@ -1175,14 +1622,35 @@ $("signin").onclick = () => Clerk.redirectToSignIn({
1175
1622
  signInFallbackRedirectUrl: window.location.href,
1176
1623
  signUpFallbackRedirectUrl: window.location.href,
1177
1624
  });
1178
- $("gateSignout").onclick = () => Clerk.signOut({ redirectUrl: window.location.href });
1179
- $("signout").onclick = () => Clerk.signOut({ redirectUrl: window.location.href });
1180
- $("filter").oninput = () => { if (DATA) render(); };
1181
- $("configTab").onclick = () => showView("config");
1182
- $("activityTab").onclick = () => showView("activity");
1625
+ function signOut() {
1626
+ clearIdentityState();
1627
+ return Clerk.signOut({ redirectUrl: window.location.href });
1628
+ }
1629
+ $("gateSignout").onclick = signOut;
1630
+ $("signout").onclick = signOut;
1631
+ $("filter").oninput = () => { if (DATA) renderConnections(); };
1183
1632
  $("refreshActivity").onclick = () => loadActivity(true);
1184
1633
  $("moreActivity").onclick = () => loadActivity(false);
1185
1634
  $("activitySearch").oninput = () => renderActivity();
1635
+ document.addEventListener("click", (event) => {
1636
+ const link = event.target.closest("a[data-operator-page]");
1637
+ if (
1638
+ !link ||
1639
+ event.defaultPrevented ||
1640
+ event.button !== 0 ||
1641
+ event.metaKey ||
1642
+ event.ctrlKey ||
1643
+ event.shiftKey ||
1644
+ event.altKey
1645
+ ) return;
1646
+ const target = new URL(link.href, window.location.href);
1647
+ if (target.origin !== window.location.origin) return;
1648
+ event.preventDefault();
1649
+ navigateTo(link.dataset.operatorPage, target.pathname + target.search + target.hash);
1650
+ });
1651
+ window.addEventListener("popstate", () => {
1652
+ activatePage(pageForPath(window.location.pathname), { focus: true });
1653
+ });
1186
1654
 
1187
1655
  async function init() {
1188
1656
  if (AUTH.kind === "clerk") {
@@ -1206,6 +1674,7 @@ async function init() {
1206
1674
  $("tokenGate").classList.remove("hidden");
1207
1675
  $("change").classList.remove("hidden");
1208
1676
  }
1677
+ activatePage(pageForPath(window.location.pathname));
1209
1678
  await load();
1210
1679
  }
1211
1680