@zackbart/connecta 0.6.1 → 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 (69) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/README.md +24 -20
  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 +8 -5
  22. package/dist/credential-health.d.ts.map +1 -1
  23. package/dist/credential-health.js +99 -51
  24. package/dist/credential-health.js.map +1 -1
  25. package/dist/credentials.d.ts +51 -2
  26. package/dist/credentials.d.ts.map +1 -1
  27. package/dist/credentials.js +68 -3
  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 +74 -24
  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 +144 -52
  44. package/dist/server.js.map +1 -1
  45. package/dist/toolkits.js +1 -1
  46. package/dist/types.d.ts +41 -27
  47. package/dist/types.d.ts.map +1 -1
  48. package/dist/ui.d.ts +24 -10
  49. package/dist/ui.d.ts.map +1 -1
  50. package/dist/ui.js +594 -172
  51. package/dist/ui.js.map +1 -1
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/package.json +3 -2
  55. package/src/auth/bearer.ts +10 -8
  56. package/src/auth/clerk.ts +28 -9
  57. package/src/connector-scope.ts +41 -0
  58. package/src/connectors/api.ts +5 -5
  59. package/src/connectors/remote-mcp.ts +348 -25
  60. package/src/credential-health.ts +120 -57
  61. package/src/credentials.ts +96 -3
  62. package/src/index.ts +169 -106
  63. package/src/meta-tools.ts +22 -7
  64. package/src/registry.ts +4 -3
  65. package/src/server.ts +183 -70
  66. package/src/toolkits.ts +1 -1
  67. package/src/types.ts +41 -27
  68. package/src/ui.ts +631 -170
  69. package/src/version.ts +1 -1
package/dist/ui.js CHANGED
@@ -1,4 +1,5 @@
1
- import { credentialTestRule } from "./credentials.js";
1
+ import { credentialTestRule, describeUndeclaredCredentialFields, storedCredentialShape, } from "./credentials.js";
2
+ import { closeConnectorScope } from "./connector-scope.js";
2
3
  /** Connecta's default monochrome "C" mark. */
3
4
  export const CONNECTA_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
4
5
  <style>
@@ -119,8 +120,8 @@ const URL_STRIPPED_CHARS = /[\t\n\r]/g;
119
120
  * default href is the relative `/favicon.svg`, which `isSafeHttpUrl` alone would
120
121
  * reject — and it is kept narrow on both ends.
121
122
  *
122
- * Root-relative only, because `/ui` and `/oauth/callback/<id>` sit at different
123
- * 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.
124
125
  *
125
126
  * "Root-relative" is enforced structurally: exactly one leading `/` followed by
126
127
  * a character that is neither `/` nor `\`. Both of those would make the value an
@@ -147,7 +148,7 @@ export function isSafeIconHref(href) {
147
148
  }
148
149
  /**
149
150
  * True only for an absolute `https:` URL — the gate every `uiAuth` URL passes:
150
- * `frontendApiUrl`, which becomes the `<script src>` of `/ui`'s sign-in loader,
151
+ * `frontendApiUrl`, which becomes the operator shell's sign-in loader source,
151
152
  * and `signInUrl`/`signUpUrl`, which ClerkJS uses as *navigation targets* when
152
153
  * the operator signs in. With those three gated, no operator-config value
153
154
  * reaches the browser in a URL position — attribute or navigation — without
@@ -156,7 +157,7 @@ export function isSafeIconHref(href) {
156
157
  * Stricter than `isSafeHttpUrl` on purpose: no `http:` carve-out, no loopback
157
158
  * carve-out, and no relative form. Nobody types `frontendApiUrl` — the shipped
158
159
  * Clerk adapter derives it from the publishable key, and Clerk's Frontend API is
159
- * always https — and a cleartext script source on the dashboard would be a
160
+ * always https — and a cleartext script source on an operator page would be a
160
161
  * downgrade even where a browser's mixed-content rules had not already blocked
161
162
  * it. `signInUrl`/`signUpUrl` *are* typed by the operator, but what belongs
162
163
  * there is a hosted Account Portal address (`https://accounts.<domain>` or
@@ -207,6 +208,32 @@ export function droppedUiAuthUrls(uiAuth) {
207
208
  : []),
208
209
  ];
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";
236
+ }
210
237
  /**
211
238
  * Filter by connector identity/description or tool name/description. A
212
239
  * connector-level match stays visible even when it currently exposes no tools
@@ -239,9 +266,12 @@ export function filterUiConnectors(connectors, query) {
239
266
  * isolated: they surface status "error" with an empty tool list rather than
240
267
  * failing the whole payload.
241
268
  */
242
- 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") {
243
272
  const requestScope = {};
244
- 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) => {
245
275
  const status = await registry.statusFor(c.id, baseUrl, requestScope);
246
276
  const credentialCheck = await registry.credentialHealthFor(c.id);
247
277
  let tools = [];
@@ -267,7 +297,7 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
267
297
  // One rule, shared with the test route: only the hook matching the
268
298
  // declared credential shape can run, so the button is offered only
269
299
  // where a click can succeed (src/credentials.ts).
270
- const testable = credentialTestRule(c).mode !== null;
300
+ const testRule = credentialTestRule(c);
271
301
  const credentialFields = (metadata) => c.credential?.fields?.map((field) => {
272
302
  const fieldMetadata = metadata?.fields?.[field.name];
273
303
  return {
@@ -292,6 +322,7 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
292
322
  try {
293
323
  const metadata = await credentialVault.metadata(c.id);
294
324
  const fields = credentialFields(metadata);
325
+ const shape = storedCredentialShape(c.credential, metadata?.fields ?? null);
295
326
  credential = {
296
327
  label: c.credential.label,
297
328
  ...(c.credential.description
@@ -301,9 +332,7 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
301
332
  ? { placeholder: c.credential.placeholder }
302
333
  : {}),
303
334
  ...(fields?.length ? { fields } : {}),
304
- configured: fields?.length
305
- ? fields.every((field) => field.configured)
306
- : Boolean(metadata),
335
+ configured: shape.state === "valid",
307
336
  removable: Boolean(metadata),
308
337
  ...(metadata
309
338
  ? {
@@ -311,7 +340,18 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
311
340
  updatedAt: metadata.updatedAt,
312
341
  }
313
342
  : {}),
314
- testable,
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
+ : {}),
315
355
  };
316
356
  }
317
357
  catch {
@@ -327,7 +367,7 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
327
367
  ...(fields?.length ? { fields } : {}),
328
368
  configured: false,
329
369
  removable: true,
330
- testable,
370
+ testable: testRule.mode !== null,
331
371
  error: "Stored credential could not be read.",
332
372
  };
333
373
  }
@@ -356,8 +396,15 @@ export async function buildUiData(registry, baseUrl, serverInfo, credentialVault
356
396
  : {}),
357
397
  ...(credential ? { credential } : {}),
358
398
  };
359
- }));
360
- 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
+ };
361
408
  }
362
409
  function escapeHtmlAttr(value) {
363
410
  return value
@@ -373,12 +420,10 @@ function jsonForInlineScript(value) {
373
420
  .replaceAll("&", "\\u0026");
374
421
  }
375
422
  /**
376
- * The `/ui` shell carries no connector data: everything comes client-side from
377
- * the auth-gated `/ui/data`. A configured Clerk provider gives operators a
378
- * normal sign-in flow and a short-lived session token; bearer-only deployments
379
- * 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.
380
425
  */
381
- export function renderUiHtml(uiAuth, mcpUrl = "/mcp", branding, nonce) {
426
+ export function renderUiHtml(uiAuth, mcpUrl = "/mcp", branding, nonce, page = "connections") {
382
427
  const clerk = uiAuth?.kind === "clerk" ? uiAuth : undefined;
383
428
  // The Clerk loader's origin. A value that fails the gate is dropped rather
384
429
  // than escaped into the page: the loader tag is simply not emitted, the gate
@@ -408,8 +453,8 @@ export function renderUiHtml(uiAuth, mcpUrl = "/mcp", branding, nonce) {
408
453
  }
409
454
  : (uiAuth ?? { kind: "bearer" });
410
455
  const brand = resolveBranding(branding);
411
- const title = brand.pageTitle;
412
- // 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
413
458
  // carry that nonce to run; without a nonce the markup is unchanged.
414
459
  const nonceAttr = nonce ? ` nonce="${nonce}"` : "";
415
460
  // Top-left corner. With an owner set it reads "<owner> <product>"; without
@@ -448,6 +493,7 @@ ${clerkScript}
448
493
  --paper: #fff;
449
494
  --rule: #ccc;
450
495
  --muted: #666;
496
+ --trace: #f5f5f5;
451
497
  --shell: 70rem;
452
498
  --pad: 1rem;
453
499
  --gap: 1.5rem;
@@ -494,6 +540,15 @@ ${clerkScript}
494
540
  outline: 1px solid var(--ink);
495
541
  }
496
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); }
497
552
 
498
553
  .shell {
499
554
  margin: 0 auto;
@@ -512,6 +567,15 @@ ${clerkScript}
512
567
  .cap, .meta { color: var(--muted); font-size: .9em; }
513
568
  .mono { font-family: var(--mono); font-size: .78rem; }
514
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
+ }
515
579
 
516
580
  .masthead {
517
581
  align-items: start;
@@ -536,6 +600,17 @@ ${clerkScript}
536
600
  justify-content: flex-end;
537
601
  min-width: 0;
538
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
+ }
539
614
  .navlink,
540
615
  .linklike {
541
616
  text-decoration: underline;
@@ -545,7 +620,7 @@ ${clerkScript}
545
620
  .navlink { text-decoration-color: transparent; }
546
621
  .navlink:hover,
547
622
  .navlink:focus-visible,
548
- .navlink.active { text-decoration-color: currentColor; }
623
+ .navlink[aria-current="page"] { text-decoration-color: currentColor; }
549
624
  .linklike { text-decoration-color: currentColor; }
550
625
  .linklike:hover,
551
626
  .linklike:focus-visible { text-decoration-color: transparent; }
@@ -593,7 +668,24 @@ ${clerkScript}
593
668
  #notice:empty { display: none; }
594
669
  #notice:not(:empty) { text-decoration: underline; }
595
670
  .error-notice, .msg { text-decoration: underline; }
596
- .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; }
597
689
  .connector-head {
598
690
  display: grid;
599
691
  gap: var(--gap);
@@ -604,14 +696,25 @@ ${clerkScript}
604
696
  display: flex;
605
697
  gap: .5rem;
606
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
+ }
607
708
  .card h2 { overflow-wrap: anywhere; }
608
709
  .connector-state { text-align: right; }
609
710
  .dot {
711
+ background: var(--paper);
610
712
  border: 1px solid var(--ink);
611
713
  display: inline-block;
612
714
  flex: none;
613
715
  height: .5rem;
614
716
  width: .5rem;
717
+ z-index: 1;
615
718
  }
616
719
  .dot.ok { background: var(--ink); }
617
720
  .dot.auth_required {
@@ -621,7 +724,8 @@ ${clerkScript}
621
724
  .connector-message,
622
725
  .connector-auth { margin-top: .75rem; }
623
726
 
624
- .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; }
625
729
  .credential-head {
626
730
  align-items: baseline;
627
731
  display: flex;
@@ -630,7 +734,27 @@ ${clerkScript}
630
734
  justify-content: space-between;
631
735
  }
632
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
+ }
633
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
+ }
634
758
  .credential-form {
635
759
  align-items: center;
636
760
  display: flex;
@@ -674,7 +798,8 @@ ${clerkScript}
674
798
  display: grid;
675
799
  gap: .25rem var(--gap);
676
800
  grid-template-columns: minmax(9rem, .85fr) minmax(12rem, 1.4fr) minmax(8rem, .9fr);
677
- padding: .75rem 0;
801
+ padding-bottom: .75rem;
802
+ padding-top: .75rem;
678
803
  }
679
804
  .activity-time,
680
805
  .activity-actor,
@@ -685,6 +810,16 @@ ${clerkScript}
685
810
  .activity-item.timeout .activity-outcome { text-decoration: underline; }
686
811
  .activity-empty { border-top: 1px solid var(--rule); padding: .75rem 0; }
687
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
+ }
688
823
 
689
824
  @media (max-width: 36.99rem) {
690
825
  .pgrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -693,7 +828,12 @@ ${clerkScript}
693
828
  .masthead .brand { grid-column: 1; }
694
829
  .mast-nav { grid-column: 1 / -1; grid-row: 2; justify-content: flex-start; }
695
830
  .product { display: none; }
696
- .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
+ }
697
837
  .lead { margin-top: 4rem; }
698
838
  .section,
699
839
  .section + .section { margin-top: 2.5rem; }
@@ -702,49 +842,58 @@ ${clerkScript}
702
842
  .activity-item { grid-template-columns: 1fr; }
703
843
  .connector-state { text-align: left; }
704
844
  .credential-field { align-items: start; grid-template-columns: 1fr; gap: .25rem; }
845
+ input { min-height: 2.75rem; }
705
846
  }
706
847
  </style>
707
848
  </head>
708
849
  <body>
850
+ <a class="skip-link" href="#operatorContent">Skip to operator page</a>
709
851
  <header class="masthead shell pgrid">
710
852
  ${owner}
711
853
  <div class="mast-nav">
712
854
  ${product}
713
- <nav id="appNav" class="mast-actions hidden" aria-label="Dashboard views">
714
- <button id="configTab" class="navlink active" type="button"
715
- aria-pressed="true">Connections</button>
716
- <button id="activityTab" class="navlink hidden" type="button"
717
- aria-pressed="false">Activity</button>
718
- <button id="change" class="navlink hidden" type="button">Change token</button>
719
- <button id="signout" class="navlink hidden" type="button">Sign out</button>
720
- </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>
721
869
  </div>
722
870
  </header>
723
871
 
724
- <main id="gate" class="page shell hidden">
725
- <section class="lead pgrid">
726
- <h1 class="pcap">Tool connections</h1>
727
- <div class="pbody lead-copy">
728
- <p>${escapeHtmlAttr(brand.description)}</p>
729
- <p id="gateCopy" class="meta"></p>
730
- <div id="tokenGate" class="row gate-actions hidden">
731
- <input id="token" type="password" placeholder="Bearer token" autocomplete="off"
732
- aria-label="Bearer token">
733
- <button id="save" class="linklike" type="button">Open dashboard</button>
734
- </div>
735
- <div id="clerkGate" class="actions gate-actions hidden">
736
- <button id="signin" class="linklike" type="button">Team sign in</button>
737
- <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>
738
889
  </div>
739
- <p id="err"></p>
740
890
  </div>
741
891
  </section>
742
- </main>
743
892
 
744
- <main id="app" class="page shell hidden">
745
- <section id="configView">
893
+ <div id="app" class="hidden">
894
+ <section id="connectionsView"${page === "connections" ? "" : ' class="hidden"'}>
746
895
  <div class="lead pgrid">
747
- <h1 class="pcap">MCP connection</h1>
896
+ <h1 id="connectionsHeading" class="pcap" tabindex="-1">Connections</h1>
748
897
  <div class="pbody lead-copy">
749
898
  <p>Use this endpoint to give an MCP client access to the tools below.</p>
750
899
  <div class="endpoint">
@@ -753,50 +902,80 @@ ${clerkScript}
753
902
  <button id="copyMcpUrl" class="linklike" type="button">Copy URL</button>
754
903
  </div>
755
904
  </div>
756
- <p class="cap" id="serverInfo">${escapeHtmlAttr(brand.productName)} status dashboard</p>
905
+ <p class="cap" id="serverInfo">${escapeHtmlAttr(brand.productName)} operator</p>
757
906
  </div>
758
907
  </div>
759
- <section class="section pgrid" aria-labelledby="connectorsHeading">
760
- <h2 class="pcap" id="connectorsHeading">Connectors</h2>
908
+ <section class="section pgrid" aria-labelledby="connectorLedgerHeading">
909
+ <h2 class="pcap" id="connectorLedgerHeading">Connectors</h2>
761
910
  <div class="pbody">
762
911
  <div class="row toolbar">
763
912
  <input id="filter" type="search" placeholder="Filter connectors or tools…"
764
913
  aria-label="Filter connectors or tools">
765
914
  </div>
766
- <p id="notice" role="status" aria-live="polite"></p>
767
- <div id="list" class="connector-tools"></div>
915
+ <div id="list" class="connector-tools" aria-busy="false"></div>
768
916
  </div>
769
917
  </section>
770
918
  </section>
771
- <section id="activityView" class="hidden">
919
+
920
+ <section id="credentialsView"${page === "credentials" ? "" : ' class="hidden"'}>
921
+ <div class="lead pgrid">
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"'}>
772
934
  <div class="lead pgrid">
773
- <h1 class="pcap">Tool activity</h1>
935
+ <h1 id="activityHeading" class="pcap" tabindex="-1">Activity</h1>
774
936
  <div class="pbody">
775
937
  <p class="activity-copy" id="activitySummary">Arguments and results are never stored.</p>
776
- <div class="row activity-controls">
777
- <input id="activitySearch" type="search"
778
- placeholder="Search user, tool, or outcome…"
779
- aria-label="Search loaded activity">
780
- <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>
781
952
  </div>
782
- <p id="activityNotice" class="meta" role="status" aria-live="polite"></p>
783
- <div id="activityList" class="activity-ledger"></div>
784
- <button id="moreActivity" class="linklike activity-more hidden" type="button">Load older</button>
785
953
  </div>
786
954
  </div>
787
955
  </section>
956
+ </div>
788
957
  </main>
789
958
 
790
959
  <script${nonceAttr}>
791
960
  const AUTH = ${jsonForInlineScript(auth)};
792
961
  const MCP_URL = ${jsonForInlineScript(mcpUrl)};
962
+ const INITIAL_PAGE = ${jsonForInlineScript(page)};
963
+ const TITLE_SUFFIX = ${jsonForInlineScript(brand.pageTitle)};
793
964
  const filterUiConnectors = ${filterUiConnectors.toString()};
794
965
  const KEY = "connecta:token";
795
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
+ };
796
972
  let DATA = null;
797
973
  let ACTIVITY = [];
798
974
  let ACTIVITY_CURSOR = null;
799
975
  let ACTIVITY_LOADED = false;
976
+ let CURRENT_PAGE = INITIAL_PAGE;
977
+ let SESSION_GENERATION = 0;
978
+ let ACTIVITY_GENERATION = 0;
800
979
  $("mcpUrl").textContent = MCP_URL;
801
980
 
802
981
  function esc(s) {
@@ -820,8 +999,9 @@ function formatDate(value) {
820
999
  }
821
1000
 
822
1001
  function setNotice(message, isError) {
823
- $("notice").textContent = message || "";
824
- $("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");
825
1005
  }
826
1006
 
827
1007
  async function sessionToken() {
@@ -830,7 +1010,45 @@ async function sessionToken() {
830
1010
  : localStorage.getItem(KEY);
831
1011
  }
832
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
+
833
1050
  function showGate(msg) {
1051
+ clearIdentityState();
834
1052
  $("app").classList.add("hidden");
835
1053
  $("appNav").classList.add("hidden");
836
1054
  $("gate").classList.remove("hidden");
@@ -838,8 +1056,8 @@ function showGate(msg) {
838
1056
  if (AUTH.kind === "clerk") {
839
1057
  const signedIn = Boolean(window.Clerk && Clerk.user);
840
1058
  $("gateCopy").textContent = signedIn
841
- ? "Signed in with Clerk, but this account cannot open the dashboard."
842
- : "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.";
843
1061
  $("signin").classList.toggle("hidden", signedIn);
844
1062
  $("gateSignout").classList.toggle("hidden", !signedIn);
845
1063
  } else {
@@ -847,20 +1065,87 @@ function showGate(msg) {
847
1065
  }
848
1066
  }
849
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
+
850
1130
  async function load() {
1131
+ const generation = SESSION_GENERATION;
851
1132
  let token;
852
1133
  try {
853
1134
  token = await sessionToken();
854
1135
  } catch (e) {
1136
+ if (generation !== SESSION_GENERATION) return;
855
1137
  return showGate("Could not read the Clerk session: " + e.message);
856
1138
  }
1139
+ if (generation !== SESSION_GENERATION) return;
857
1140
  if (!token) return showGate("");
858
1141
  let res;
859
1142
  try {
860
1143
  res = await fetch("/ui/data", { headers: { Authorization: "Bearer " + token } });
861
1144
  } catch (e) {
1145
+ if (generation !== SESSION_GENERATION) return;
862
1146
  return showGate("Network error: " + e.message);
863
1147
  }
1148
+ if (generation !== SESSION_GENERATION) return;
864
1149
  if (res.status === 401 || res.status === 403) {
865
1150
  if (AUTH.kind === "clerk") {
866
1151
  return showGate(
@@ -873,25 +1158,22 @@ async function load() {
873
1158
  return showGate("Token rejected — enter a valid bearer token.");
874
1159
  }
875
1160
  if (!res.ok) return showGate("Error " + res.status);
876
- 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;
877
1170
  $("gate").classList.add("hidden");
878
1171
  $("app").classList.remove("hidden");
879
1172
  $("appNav").classList.remove("hidden");
880
1173
  const si = DATA.serverInfo || {};
881
1174
  $("serverInfo").textContent = (si.name || ${escapeScriptString(brand.productName)}) + " v" + (si.version || "?");
882
- $("activityTab").classList.toggle("hidden", !DATA.activityEnabled);
883
- render();
884
- }
885
-
886
- function showView(view) {
887
- const activity = view === "activity";
888
- $("configView").classList.toggle("hidden", activity);
889
- $("activityView").classList.toggle("hidden", !activity);
890
- $("configTab").classList.toggle("active", !activity);
891
- $("activityTab").classList.toggle("active", activity);
892
- $("configTab").setAttribute("aria-pressed", String(!activity));
893
- $("activityTab").setAttribute("aria-pressed", String(activity));
894
- if (activity && !ACTIVITY_LOADED) loadActivity(true);
1175
+ updateCapabilities();
1176
+ activatePage(CURRENT_PAGE);
895
1177
  }
896
1178
 
897
1179
  function actorLabel(actor) {
@@ -930,12 +1212,21 @@ function renderActivity() {
930
1212
  }
931
1213
  for (const event of visible) {
932
1214
  const item = document.createElement("article");
933
- item.className = "activity-item " + esc(event.outcome);
934
- 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
+ : "";
935
1222
  const errorCopy = event.errorCode ? " · " + esc(event.errorCode) : "";
936
1223
  item.innerHTML =
937
- '<div><div class="activity-time">' + esc(formatDate(event.occurredAt)) +
938
- '</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>' +
939
1230
  '<div><div class="activity-address">' + esc(event.address) +
940
1231
  '</div><div class="activity-detail">' + esc(event.source) + retryCopy +
941
1232
  errorCopy + '</div></div>' +
@@ -948,22 +1239,45 @@ function renderActivity() {
948
1239
 
949
1240
  async function loadActivity(reset) {
950
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;
951
1248
  $("activityNotice").textContent = "Loading activity…";
1249
+ $("activityNotice").setAttribute("role", "status");
1250
+ $("activityList").setAttribute("aria-busy", "true");
952
1251
  $("refreshActivity").disabled = true;
953
1252
  $("moreActivity").disabled = true;
954
1253
  try {
955
1254
  const token = await sessionToken();
956
- if (!token) throw new Error("Your session has expired.");
1255
+ if (!isCurrent()) return;
1256
+ if (!token) return showGate("Your session has expired.");
957
1257
  const cursor = reset ? null : ACTIVITY_CURSOR;
958
1258
  const params = new URLSearchParams({ limit: "50" });
959
1259
  if (cursor) params.set("cursor", cursor);
960
1260
  const res = await fetch("/ui/activity?" + params, {
961
1261
  headers: { Authorization: "Bearer " + token },
962
1262
  });
1263
+ if (!isCurrent()) return;
963
1264
  let payload = {};
964
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
+ }
965
1277
  if (!res.ok) {
966
- 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
+ );
967
1281
  }
968
1282
  ACTIVITY = reset
969
1283
  ? (payload.events || [])
@@ -973,14 +1287,19 @@ async function loadActivity(reset) {
973
1287
  $("activityNotice").textContent = "";
974
1288
  renderActivity();
975
1289
  } catch (e) {
1290
+ if (!isCurrent()) return;
1291
+ $("activityNotice").setAttribute("role", "alert");
976
1292
  $("activityNotice").textContent = e.message || "Activity could not be loaded.";
977
1293
  } finally {
978
- $("refreshActivity").disabled = false;
979
- $("moreActivity").disabled = false;
1294
+ if (isCurrent()) {
1295
+ $("activityList").setAttribute("aria-busy", "false");
1296
+ $("refreshActivity").disabled = false;
1297
+ $("moreActivity").disabled = false;
1298
+ }
980
1299
  }
981
1300
  }
982
1301
 
983
- function render() {
1302
+ function renderConnections() {
984
1303
  const q = $("filter").value.trim().toLowerCase();
985
1304
  const list = $("list");
986
1305
  list.innerHTML = "";
@@ -1028,66 +1347,8 @@ function render() {
1028
1347
  esc(c.authorizationUrl) + "</p>";
1029
1348
  }
1030
1349
  if (c.credential) {
1031
- const cred = c.credential;
1032
- const configured = Boolean(cred.configured);
1033
- const removable = configured || Boolean(cred.removable);
1034
- const state = configured
1035
- ? "configured · ••••" + esc(cred.lastFour || "")
1036
- : "not configured";
1037
- const updated = configured && cred.updatedAt
1038
- ? " · updated " + esc(formatDate(cred.updatedAt))
1039
- : "";
1040
- head += '<section class="credential" aria-label="' + esc(cred.label) + '">';
1041
- head += '<div class="credential-head"><span class="credential-label">' +
1042
- esc(cred.label) + '</span><span class="credential-state">' + state +
1043
- updated + "</span></div>";
1044
- if (cred.description) {
1045
- head += '<p class="credential-copy meta">' + esc(cred.description) + "</p>";
1046
- }
1047
- if (cred.error) {
1048
- head += '<div class="msg">' + esc(cred.error) + "</div>";
1049
- }
1050
- if (AUTH.kind === "clerk") {
1051
- head += '<div class="credential-actions">';
1052
- head += '<button class="linklike" type="button" data-credential-action="edit" data-connector="' +
1053
- esc(c.id) + '">' + (removable ? "Replace" : "Add credential") + "</button>";
1054
- if (configured && cred.testable) {
1055
- head += '<button class="linklike" type="button" data-credential-action="test" data-connector="' +
1056
- esc(c.id) + '">Test</button>';
1057
- }
1058
- if (removable) {
1059
- head += '<button type="button" class="linklike danger" data-credential-action="remove" data-connector="' +
1060
- esc(c.id) + '">Remove</button>';
1061
- }
1062
- head += "</div>";
1063
- head += '<div class="credential-form hidden" data-credential-form="' +
1064
- esc(c.id) + '">';
1065
- if (cred.fields && cred.fields.length) {
1066
- head += '<div class="credential-fields">';
1067
- for (const field of cred.fields) {
1068
- head += '<div class="credential-field"><label>' +
1069
- esc(field.label) + '</label><input type="' +
1070
- esc(field.inputType || "password") + '" data-credential-field="' +
1071
- esc(field.name) + '" aria-label="' + esc(field.label) +
1072
- '" placeholder="' + esc(field.placeholder || field.label) +
1073
- '" autocomplete="' +
1074
- (field.inputType === "password" ? "new-password" : "off") +
1075
- '" autocapitalize="none" spellcheck="false"></div>';
1076
- }
1077
- head += "</div>";
1078
- } else {
1079
- head += '<input type="password" data-credential-input="' +
1080
- esc(c.id) + '" aria-label="' + esc(cred.label) + '" placeholder="' +
1081
- esc(cred.placeholder || "Paste credential") +
1082
- '" autocomplete="new-password" autocapitalize="none" spellcheck="false">';
1083
- }
1084
- head += '<button class="linklike" type="button" data-credential-action="save" data-connector="' +
1085
- esc(c.id) + '">Save</button><button class="linklike" type="button" data-credential-action="cancel" data-connector="' +
1086
- esc(c.id) + '">Cancel</button></div>';
1087
- } else {
1088
- head += '<p class="credential-copy meta">Team sign in is required to manage this credential.</p>';
1089
- }
1090
- head += "</section>";
1350
+ head += '<p class="connector-auth"><a class="linklike" href="/credentials" ' +
1351
+ 'data-operator-page="credentials">Manage credential →</a></p>';
1091
1352
  }
1092
1353
  let body = "";
1093
1354
  if (tools.length) {
@@ -1105,12 +1366,122 @@ function render() {
1105
1366
  list.appendChild(el);
1106
1367
  }
1107
1368
  if (!list.children.length) {
1108
- 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>";
1109
1374
  }
1110
1375
  }
1111
1376
 
1112
- 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) {
1113
1481
  const token = await sessionToken();
1482
+ if (generation !== SESSION_GENERATION) {
1483
+ throw new Error("The operator session changed.");
1484
+ }
1114
1485
  if (!token) throw new Error("Your Clerk session has expired.");
1115
1486
  const suffix = action ? "/" + action : "";
1116
1487
  const res = await fetch(
@@ -1136,12 +1507,13 @@ function credentialForm(connector) {
1136
1507
  CSS.escape(connector) + '"]');
1137
1508
  }
1138
1509
 
1139
- $("list").onclick = async (event) => {
1510
+ $("credentialList").onclick = async (event) => {
1140
1511
  const button = event.target.closest("[data-credential-action]");
1141
1512
  if (!button) return;
1142
1513
  const connector = button.dataset.connector;
1143
1514
  const action = button.dataset.credentialAction;
1144
1515
  const form = credentialForm(connector);
1516
+ const generation = SESSION_GENERATION;
1145
1517
 
1146
1518
  if (action === "edit") {
1147
1519
  form.classList.remove("hidden");
@@ -1151,6 +1523,10 @@ $("list").onclick = async (event) => {
1151
1523
  if (action === "cancel") {
1152
1524
  form.querySelectorAll("input").forEach((input) => { input.value = ""; });
1153
1525
  form.classList.add("hidden");
1526
+ document.querySelector(
1527
+ '[data-credential-action="edit"][data-connector="' +
1528
+ CSS.escape(connector) + '"]'
1529
+ )?.focus();
1154
1530
  return;
1155
1531
  }
1156
1532
  if (action === "remove" && !window.confirm(
@@ -1158,6 +1534,7 @@ $("list").onclick = async (event) => {
1158
1534
  )) return;
1159
1535
 
1160
1536
  setNotice("");
1537
+ $("credentialList").setAttribute("aria-busy", "true");
1161
1538
  const buttons = [...document.querySelectorAll(
1162
1539
  '[data-connector="' + CSS.escape(connector) + '"]'
1163
1540
  )];
@@ -1172,42 +1549,65 @@ $("list").onclick = async (event) => {
1172
1549
  if (!value) throw new Error("Complete every credential field before saving.");
1173
1550
  values[input.dataset.credentialField] = value;
1174
1551
  }
1175
- await credentialRequest(connector, "PUT", "", { values });
1552
+ await credentialRequest(connector, "PUT", "", { values }, generation);
1176
1553
  } else {
1177
1554
  const input = form.querySelector("[data-credential-input]");
1178
1555
  const value = input.value.trim();
1179
1556
  if (!value) throw new Error("Paste a credential before saving.");
1180
- await credentialRequest(connector, "PUT", "", { value });
1557
+ await credentialRequest(connector, "PUT", "", { value }, generation);
1181
1558
  }
1559
+ if (generation !== SESSION_GENERATION) return;
1182
1560
  form.querySelectorAll("input").forEach((input) => { input.value = ""; });
1183
1561
  setNotice("Credential saved.");
1184
1562
  await load();
1563
+ if (generation !== SESSION_GENERATION) return;
1564
+ $("credentialNotice").focus();
1185
1565
  } else if (action === "remove") {
1186
- await credentialRequest(connector, "DELETE");
1566
+ await credentialRequest(connector, "DELETE", "", null, generation);
1567
+ if (generation !== SESSION_GENERATION) return;
1187
1568
  setNotice("Credential removed.");
1188
1569
  await load();
1570
+ if (generation !== SESSION_GENERATION) return;
1571
+ $("credentialNotice").focus();
1189
1572
  } else if (action === "test") {
1190
- 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;
1191
1581
  setNotice(
1192
1582
  result.message || (result.ok ? "Credential is valid." : "Credential test failed."),
1193
1583
  !result.ok,
1194
1584
  );
1195
1585
  }
1196
1586
  } catch (e) {
1587
+ if (generation !== SESSION_GENERATION) return;
1197
1588
  setNotice(e.message || "Credential action failed.", true);
1198
1589
  } finally {
1199
- 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
+ }
1200
1594
  }
1201
1595
  };
1202
1596
 
1203
- $("save").onclick = () => {
1597
+ $("save").onclick = async () => {
1204
1598
  const v = $("token").value.trim();
1205
1599
  if (!v) return;
1600
+ clearIdentityState();
1206
1601
  localStorage.setItem(KEY, v);
1207
1602
  $("token").value = "";
1208
- load();
1603
+ await load();
1604
+ if (DATA) $(CURRENT_PAGE + "Heading").focus();
1605
+ };
1606
+ $("change").onclick = () => {
1607
+ localStorage.removeItem(KEY);
1608
+ showGate("");
1609
+ $("token").focus();
1209
1610
  };
1210
- $("change").onclick = () => { localStorage.removeItem(KEY); showGate(""); };
1211
1611
  $("copyMcpUrl").onclick = async () => {
1212
1612
  const button = $("copyMcpUrl");
1213
1613
  try {
@@ -1222,14 +1622,35 @@ $("signin").onclick = () => Clerk.redirectToSignIn({
1222
1622
  signInFallbackRedirectUrl: window.location.href,
1223
1623
  signUpFallbackRedirectUrl: window.location.href,
1224
1624
  });
1225
- $("gateSignout").onclick = () => Clerk.signOut({ redirectUrl: window.location.href });
1226
- $("signout").onclick = () => Clerk.signOut({ redirectUrl: window.location.href });
1227
- $("filter").oninput = () => { if (DATA) render(); };
1228
- $("configTab").onclick = () => showView("config");
1229
- $("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(); };
1230
1632
  $("refreshActivity").onclick = () => loadActivity(true);
1231
1633
  $("moreActivity").onclick = () => loadActivity(false);
1232
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
+ });
1233
1654
 
1234
1655
  async function init() {
1235
1656
  if (AUTH.kind === "clerk") {
@@ -1253,6 +1674,7 @@ async function init() {
1253
1674
  $("tokenGate").classList.remove("hidden");
1254
1675
  $("change").classList.remove("hidden");
1255
1676
  }
1677
+ activatePage(pageForPath(window.location.pathname));
1256
1678
  await load();
1257
1679
  }
1258
1680