@zackbart/connecta 0.6.1 → 0.7.1

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 (73) hide show
  1. package/CHANGELOG.md +294 -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 +27 -4
  18. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  19. package/dist/connectors/remote-mcp.js +400 -19
  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/execute.d.ts.map +1 -1
  30. package/dist/execute.js +10 -8
  31. package/dist/execute.js.map +1 -1
  32. package/dist/index.d.ts +84 -83
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +74 -24
  35. package/dist/index.js.map +1 -1
  36. package/dist/meta-tools.d.ts +28 -3
  37. package/dist/meta-tools.d.ts.map +1 -1
  38. package/dist/meta-tools.js +131 -16
  39. package/dist/meta-tools.js.map +1 -1
  40. package/dist/registry.d.ts +3 -2
  41. package/dist/registry.d.ts.map +1 -1
  42. package/dist/registry.js +4 -3
  43. package/dist/registry.js.map +1 -1
  44. package/dist/server.d.ts +1 -1
  45. package/dist/server.d.ts.map +1 -1
  46. package/dist/server.js +152 -52
  47. package/dist/server.js.map +1 -1
  48. package/dist/toolkits.js +1 -1
  49. package/dist/types.d.ts +41 -27
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/ui.d.ts +24 -10
  52. package/dist/ui.d.ts.map +1 -1
  53. package/dist/ui.js +594 -172
  54. package/dist/ui.js.map +1 -1
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.js +1 -1
  57. package/package.json +3 -2
  58. package/src/auth/bearer.ts +10 -8
  59. package/src/auth/clerk.ts +28 -9
  60. package/src/connector-scope.ts +41 -0
  61. package/src/connectors/api.ts +5 -5
  62. package/src/connectors/remote-mcp.ts +489 -35
  63. package/src/credential-health.ts +120 -57
  64. package/src/credentials.ts +96 -3
  65. package/src/execute.ts +18 -7
  66. package/src/index.ts +174 -107
  67. package/src/meta-tools.ts +173 -24
  68. package/src/registry.ts +4 -3
  69. package/src/server.ts +191 -70
  70. package/src/toolkits.ts +1 -1
  71. package/src/types.ts +41 -27
  72. package/src/ui.ts +631 -170
  73. package/src/version.ts +1 -1
package/src/ui.ts CHANGED
@@ -1,5 +1,10 @@
1
- import { credentialTestRule } from "./credentials.js";
1
+ import {
2
+ credentialTestRule,
3
+ describeUndeclaredCredentialFields,
4
+ storedCredentialShape,
5
+ } from "./credentials.js";
2
6
  import type { CredentialVault } from "./credentials.js";
7
+ import { closeConnectorScope } from "./connector-scope.js";
3
8
  import type { Registry } from "./registry.js";
4
9
  import type { ConnectaBranding, UiAuthConfig } from "./types.js";
5
10
 
@@ -148,8 +153,8 @@ const URL_STRIPPED_CHARS = /[\t\n\r]/g;
148
153
  * default href is the relative `/favicon.svg`, which `isSafeHttpUrl` alone would
149
154
  * reject — and it is kept narrow on both ends.
150
155
  *
151
- * Root-relative only, because `/ui` and `/oauth/callback/<id>` sit at different
152
- * depths and a document-relative path would resolve differently on each.
156
+ * Root-relative only, because operator and OAuth callback pages sit at
157
+ * different depths and a document-relative path would resolve differently.
153
158
  *
154
159
  * "Root-relative" is enforced structurally: exactly one leading `/` followed by
155
160
  * a character that is neither `/` nor `\`. Both of those would make the value an
@@ -173,7 +178,7 @@ export function isSafeIconHref(href: unknown): boolean {
173
178
 
174
179
  /**
175
180
  * True only for an absolute `https:` URL — the gate every `uiAuth` URL passes:
176
- * `frontendApiUrl`, which becomes the `<script src>` of `/ui`'s sign-in loader,
181
+ * `frontendApiUrl`, which becomes the operator shell's sign-in loader source,
177
182
  * and `signInUrl`/`signUpUrl`, which ClerkJS uses as *navigation targets* when
178
183
  * the operator signs in. With those three gated, no operator-config value
179
184
  * reaches the browser in a URL position — attribute or navigation — without
@@ -182,7 +187,7 @@ export function isSafeIconHref(href: unknown): boolean {
182
187
  * Stricter than `isSafeHttpUrl` on purpose: no `http:` carve-out, no loopback
183
188
  * carve-out, and no relative form. Nobody types `frontendApiUrl` — the shipped
184
189
  * Clerk adapter derives it from the publishable key, and Clerk's Frontend API is
185
- * always https — and a cleartext script source on the dashboard would be a
190
+ * always https — and a cleartext script source on an operator page would be a
186
191
  * downgrade even where a browser's mixed-content rules had not already blocked
187
192
  * it. `signInUrl`/`signUpUrl` *are* typed by the operator, but what belongs
188
193
  * there is a hosted Account Portal address (`https://accounts.<domain>` or
@@ -279,6 +284,12 @@ export interface UiConnector {
279
284
  updatedAt?: string;
280
285
  testable: boolean;
281
286
  error?: string;
287
+ /**
288
+ * Something true and non-blocking about a working credential — today, that
289
+ * the vault still holds fields the connector has stopped declaring.
290
+ * Distinct from `error`, which means the credential cannot be used.
291
+ */
292
+ notice?: string;
282
293
  };
283
294
  }
284
295
 
@@ -286,6 +297,46 @@ export interface UiData {
286
297
  serverInfo: { name: string; version: string };
287
298
  connectors: UiConnector[];
288
299
  activityEnabled: boolean;
300
+ credentialManagement: CredentialManagementCapability;
301
+ }
302
+
303
+ export type CredentialManagementCapability =
304
+ | "available"
305
+ | "requires_clerk"
306
+ | "vault_not_configured"
307
+ | "no_slots";
308
+
309
+ export type OperatorPage = "connections" | "credentials" | "activity";
310
+
311
+ const OPERATOR_PAGE_LABELS: Readonly<Record<OperatorPage, string>> = {
312
+ connections: "Connections",
313
+ credentials: "Credentials",
314
+ activity: "Activity",
315
+ };
316
+
317
+ export function operatorPageForPath(path: string): OperatorPage | undefined {
318
+ if (path === "/") return "connections";
319
+ if (path === "/credentials") return "credentials";
320
+ if (path === "/activity") return "activity";
321
+ return undefined;
322
+ }
323
+
324
+ export function operatorPageTitle(
325
+ page: OperatorPage,
326
+ configuredTitle: string,
327
+ ): string {
328
+ return `${OPERATOR_PAGE_LABELS[page]} — ${configuredTitle}`;
329
+ }
330
+
331
+ export function credentialManagementCapability(input: {
332
+ eligibleClerkOperator: boolean;
333
+ hasCredentialSlots: boolean;
334
+ hasCredentialVault: boolean;
335
+ }): CredentialManagementCapability {
336
+ if (!input.eligibleClerkOperator) return "requires_clerk";
337
+ if (!input.hasCredentialSlots) return "no_slots";
338
+ if (!input.hasCredentialVault) return "vault_not_configured";
339
+ return "available";
289
340
  }
290
341
 
291
342
  export interface FilteredUiConnector {
@@ -337,10 +388,14 @@ export async function buildUiData(
337
388
  serverInfo: { name: string; version: string },
338
389
  credentialVault?: CredentialVault,
339
390
  activityEnabled = false,
391
+ credentialManagement: CredentialManagementCapability = credentialVault
392
+ ? "available"
393
+ : "requires_clerk",
340
394
  ): Promise<UiData> {
341
395
  const requestScope = {};
396
+ const connectorSet = registry.listConnectors();
342
397
  const connectors = await Promise.all(
343
- registry.listConnectors().map(async (c): Promise<UiConnector> => {
398
+ connectorSet.map(async (c): Promise<UiConnector> => {
344
399
  const status = await registry.statusFor(c.id, baseUrl, requestScope);
345
400
  const credentialCheck = await registry.credentialHealthFor(c.id);
346
401
  let tools: UiTool[] = [];
@@ -367,7 +422,7 @@ export async function buildUiData(
367
422
  // One rule, shared with the test route: only the hook matching the
368
423
  // declared credential shape can run, so the button is offered only
369
424
  // where a click can succeed (src/credentials.ts).
370
- const testable = credentialTestRule(c).mode !== null;
425
+ const testRule = credentialTestRule(c);
371
426
  const credentialFields = (
372
427
  metadata?: Awaited<ReturnType<CredentialVault["metadata"]>>,
373
428
  ) =>
@@ -395,6 +450,10 @@ export async function buildUiData(
395
450
  try {
396
451
  const metadata = await credentialVault.metadata(c.id);
397
452
  const fields = credentialFields(metadata);
453
+ const shape = storedCredentialShape(
454
+ c.credential,
455
+ metadata?.fields ?? null,
456
+ );
398
457
  credential = {
399
458
  label: c.credential.label,
400
459
  ...(c.credential.description
@@ -404,9 +463,7 @@ export async function buildUiData(
404
463
  ? { placeholder: c.credential.placeholder }
405
464
  : {}),
406
465
  ...(fields?.length ? { fields } : {}),
407
- configured: fields?.length
408
- ? fields.every((field) => field.configured)
409
- : Boolean(metadata),
466
+ configured: shape.state === "valid",
410
467
  removable: Boolean(metadata),
411
468
  ...(metadata
412
469
  ? {
@@ -414,7 +471,21 @@ export async function buildUiData(
414
471
  updatedAt: metadata.updatedAt,
415
472
  }
416
473
  : {}),
417
- testable,
474
+ testable:
475
+ testRule.mode !== null && shape.state !== "mismatch",
476
+ ...(shape.state === "mismatch"
477
+ ? { error: shape.message }
478
+ : {}),
479
+ // A dropped field leaves its secret in the vault, and the field
480
+ // list below only renders fields the connector still declares —
481
+ // so without this line there is nowhere an operator could see it.
482
+ ...(shape.state === "valid" && shape.undeclared.length
483
+ ? {
484
+ notice: describeUndeclaredCredentialFields(
485
+ shape.undeclared,
486
+ ),
487
+ }
488
+ : {}),
418
489
  };
419
490
  } catch {
420
491
  const fields = credentialFields();
@@ -429,7 +500,7 @@ export async function buildUiData(
429
500
  ...(fields?.length ? { fields } : {}),
430
501
  configured: false,
431
502
  removable: true,
432
- testable,
503
+ testable: testRule.mode !== null,
433
504
  error: "Stored credential could not be read.",
434
505
  };
435
506
  }
@@ -459,8 +530,22 @@ export async function buildUiData(
459
530
  ...(credential ? { credential } : {}),
460
531
  };
461
532
  }),
462
- );
463
- return { serverInfo, connectors, activityEnabled };
533
+ ).finally(async () => {
534
+ await Promise.all(
535
+ connectorSet.map((connector) =>
536
+ closeConnectorScope(
537
+ connector,
538
+ registry.contextFor(connector.id, baseUrl, requestScope),
539
+ ),
540
+ ),
541
+ );
542
+ });
543
+ return {
544
+ serverInfo,
545
+ connectors,
546
+ activityEnabled,
547
+ credentialManagement,
548
+ };
464
549
  }
465
550
 
466
551
  function escapeHtmlAttr(value: string): string {
@@ -479,16 +564,15 @@ function jsonForInlineScript(value: unknown): string {
479
564
  }
480
565
 
481
566
  /**
482
- * The `/ui` shell carries no connector data: everything comes client-side from
483
- * the auth-gated `/ui/data`. A configured Clerk provider gives operators a
484
- * normal sign-in flow and a short-lived session token; bearer-only deployments
485
- * retain the manual-token fallback.
567
+ * Every operator page serves this same data-free shell. Connector, credential,
568
+ * and activity data arrives only through the authenticated `/ui/*` APIs.
486
569
  */
487
570
  export function renderUiHtml(
488
571
  uiAuth?: UiAuthConfig,
489
572
  mcpUrl = "/mcp",
490
573
  branding?: ConnectaBranding,
491
574
  nonce?: string,
575
+ page: OperatorPage = "connections",
492
576
  ): string {
493
577
  const clerk = uiAuth?.kind === "clerk" ? uiAuth : undefined;
494
578
  // The Clerk loader's origin. A value that fails the gate is dropped rather
@@ -520,8 +604,8 @@ export function renderUiHtml(
520
604
  }
521
605
  : (uiAuth ?? { kind: "bearer" as const });
522
606
  const brand = resolveBranding(branding);
523
- const title = brand.pageTitle;
524
- // When the /ui response ships a nonce-based CSP, every <script> it emits must
607
+ const title = operatorPageTitle(page, brand.pageTitle);
608
+ // When an operator shell ships a nonce-based CSP, every script it emits must
525
609
  // carry that nonce to run; without a nonce the markup is unchanged.
526
610
  const nonceAttr = nonce ? ` nonce="${nonce}"` : "";
527
611
  // Top-left corner. With an owner set it reads "<owner> <product>"; without
@@ -562,6 +646,7 @@ ${clerkScript}
562
646
  --paper: #fff;
563
647
  --rule: #ccc;
564
648
  --muted: #666;
649
+ --trace: #f5f5f5;
565
650
  --shell: 70rem;
566
651
  --pad: 1rem;
567
652
  --gap: 1.5rem;
@@ -608,6 +693,15 @@ ${clerkScript}
608
693
  outline: 1px solid var(--ink);
609
694
  }
610
695
  :is(a, button, summary):focus-visible { outline-offset: 2px; }
696
+ .skip-link {
697
+ background: var(--paper);
698
+ left: var(--pad);
699
+ padding: .5rem;
700
+ position: fixed;
701
+ top: -4rem;
702
+ z-index: 10;
703
+ }
704
+ .skip-link:focus { top: var(--pad); }
611
705
 
612
706
  .shell {
613
707
  margin: 0 auto;
@@ -626,6 +720,15 @@ ${clerkScript}
626
720
  .cap, .meta { color: var(--muted); font-size: .9em; }
627
721
  .mono { font-family: var(--mono); font-size: .78rem; }
628
722
  .hidden { display: none !important; }
723
+ .visually-hidden {
724
+ clip: rect(0 0 0 0);
725
+ clip-path: inset(50%);
726
+ height: 1px;
727
+ overflow: hidden;
728
+ position: absolute;
729
+ white-space: nowrap;
730
+ width: 1px;
731
+ }
629
732
 
630
733
  .masthead {
631
734
  align-items: start;
@@ -650,6 +753,17 @@ ${clerkScript}
650
753
  justify-content: flex-end;
651
754
  min-width: 0;
652
755
  }
756
+ .page-nav,
757
+ .session-actions {
758
+ display: flex;
759
+ flex-wrap: wrap;
760
+ gap: .5rem var(--gap);
761
+ }
762
+ .mast-actions :is(a, button) {
763
+ align-items: center;
764
+ display: inline-flex;
765
+ min-height: 2rem;
766
+ }
653
767
  .navlink,
654
768
  .linklike {
655
769
  text-decoration: underline;
@@ -659,7 +773,7 @@ ${clerkScript}
659
773
  .navlink { text-decoration-color: transparent; }
660
774
  .navlink:hover,
661
775
  .navlink:focus-visible,
662
- .navlink.active { text-decoration-color: currentColor; }
776
+ .navlink[aria-current="page"] { text-decoration-color: currentColor; }
663
777
  .linklike { text-decoration-color: currentColor; }
664
778
  .linklike:hover,
665
779
  .linklike:focus-visible { text-decoration-color: transparent; }
@@ -707,7 +821,24 @@ ${clerkScript}
707
821
  #notice:empty { display: none; }
708
822
  #notice:not(:empty) { text-decoration: underline; }
709
823
  .error-notice, .msg { text-decoration: underline; }
710
- .card { border-top: 1px solid var(--rule); padding: .75rem 0; }
824
+ .card,
825
+ .credential-card,
826
+ .activity-item {
827
+ padding-left: 1.25rem;
828
+ position: relative;
829
+ }
830
+ .card::before,
831
+ .credential-card::before,
832
+ .activity-item::before {
833
+ background: var(--rule);
834
+ bottom: 0;
835
+ content: "";
836
+ left: .25rem;
837
+ position: absolute;
838
+ top: 0;
839
+ width: 1px;
840
+ }
841
+ .card { border-top: 1px solid var(--rule); padding-bottom: .75rem; padding-top: .75rem; }
711
842
  .connector-head {
712
843
  display: grid;
713
844
  gap: var(--gap);
@@ -718,14 +849,25 @@ ${clerkScript}
718
849
  display: flex;
719
850
  gap: .5rem;
720
851
  }
852
+ .connector-title .dot,
853
+ .activity-stamp .dot {
854
+ margin-left: -1.25rem;
855
+ }
856
+ .activity-stamp {
857
+ align-items: baseline;
858
+ display: flex;
859
+ gap: .75rem;
860
+ }
721
861
  .card h2 { overflow-wrap: anywhere; }
722
862
  .connector-state { text-align: right; }
723
863
  .dot {
864
+ background: var(--paper);
724
865
  border: 1px solid var(--ink);
725
866
  display: inline-block;
726
867
  flex: none;
727
868
  height: .5rem;
728
869
  width: .5rem;
870
+ z-index: 1;
729
871
  }
730
872
  .dot.ok { background: var(--ink); }
731
873
  .dot.auth_required {
@@ -735,7 +877,8 @@ ${clerkScript}
735
877
  .connector-message,
736
878
  .connector-auth { margin-top: .75rem; }
737
879
 
738
- .credential { border-top: 1px solid var(--rule); margin-top: .75rem; padding-top: .75rem; }
880
+ .credential-ledger { border-bottom: 1px solid var(--rule); }
881
+ .credential-card { border-top: 1px solid var(--rule); padding-bottom: .75rem; padding-top: .75rem; }
739
882
  .credential-head {
740
883
  align-items: baseline;
741
884
  display: flex;
@@ -744,7 +887,27 @@ ${clerkScript}
744
887
  justify-content: space-between;
745
888
  }
746
889
  .credential-copy { margin-top: .25rem; max-width: 40rem; }
890
+ .credential-field-summary {
891
+ border-top: 1px solid var(--rule);
892
+ margin-top: .75rem;
893
+ }
894
+ .credential-field-summary > div {
895
+ border-bottom: 1px solid var(--rule);
896
+ display: flex;
897
+ flex-wrap: wrap;
898
+ gap: .25rem var(--gap);
899
+ justify-content: space-between;
900
+ padding: .5rem 0;
901
+ }
747
902
  .credential-actions { display: flex; flex-wrap: wrap; gap: var(--gap); margin-top: .75rem; }
903
+ .credential-actions button,
904
+ .credential-form button,
905
+ .activity-controls button,
906
+ .activity-more {
907
+ align-items: center;
908
+ display: inline-flex;
909
+ min-height: 2.75rem;
910
+ }
748
911
  .credential-form {
749
912
  align-items: center;
750
913
  display: flex;
@@ -788,7 +951,8 @@ ${clerkScript}
788
951
  display: grid;
789
952
  gap: .25rem var(--gap);
790
953
  grid-template-columns: minmax(9rem, .85fr) minmax(12rem, 1.4fr) minmax(8rem, .9fr);
791
- padding: .75rem 0;
954
+ padding-bottom: .75rem;
955
+ padding-top: .75rem;
792
956
  }
793
957
  .activity-time,
794
958
  .activity-actor,
@@ -799,6 +963,16 @@ ${clerkScript}
799
963
  .activity-item.timeout .activity-outcome { text-decoration: underline; }
800
964
  .activity-empty { border-top: 1px solid var(--rule); padding: .75rem 0; }
801
965
  .activity-more { margin-top: .75rem; }
966
+ .unavailable {
967
+ background: var(--trace);
968
+ border-bottom: 1px solid var(--rule);
969
+ border-top: 1px solid var(--rule);
970
+ padding: .75rem;
971
+ }
972
+
973
+ @media (prefers-reduced-motion: reduce) {
974
+ html:focus-within { scroll-behavior: auto; }
975
+ }
802
976
 
803
977
  @media (max-width: 36.99rem) {
804
978
  .pgrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -807,7 +981,12 @@ ${clerkScript}
807
981
  .masthead .brand { grid-column: 1; }
808
982
  .mast-nav { grid-column: 1 / -1; grid-row: 2; justify-content: flex-start; }
809
983
  .product { display: none; }
810
- .mast-actions { font-size: .875rem; gap: .5rem; white-space: nowrap; }
984
+ .mast-actions {
985
+ align-items: flex-start;
986
+ flex-direction: column;
987
+ font-size: .875rem;
988
+ gap: .25rem;
989
+ }
811
990
  .lead { margin-top: 4rem; }
812
991
  .section,
813
992
  .section + .section { margin-top: 2.5rem; }
@@ -816,49 +995,58 @@ ${clerkScript}
816
995
  .activity-item { grid-template-columns: 1fr; }
817
996
  .connector-state { text-align: left; }
818
997
  .credential-field { align-items: start; grid-template-columns: 1fr; gap: .25rem; }
998
+ input { min-height: 2.75rem; }
819
999
  }
820
1000
  </style>
821
1001
  </head>
822
1002
  <body>
1003
+ <a class="skip-link" href="#operatorContent">Skip to operator page</a>
823
1004
  <header class="masthead shell pgrid">
824
1005
  ${owner}
825
1006
  <div class="mast-nav">
826
1007
  ${product}
827
- <nav id="appNav" class="mast-actions hidden" aria-label="Dashboard views">
828
- <button id="configTab" class="navlink active" type="button"
829
- aria-pressed="true">Connections</button>
830
- <button id="activityTab" class="navlink hidden" type="button"
831
- aria-pressed="false">Activity</button>
832
- <button id="change" class="navlink hidden" type="button">Change token</button>
833
- <button id="signout" class="navlink hidden" type="button">Sign out</button>
834
- </nav>
1008
+ <div id="appNav" class="mast-actions hidden">
1009
+ <nav class="page-nav" aria-label="Operator pages">
1010
+ <a id="connectionsNav" class="navlink" href="/"
1011
+ data-operator-page="connections"${page === "connections" ? ' aria-current="page"' : ""}>Connections</a>
1012
+ <a id="credentialsNav" class="navlink hidden" href="/credentials"
1013
+ data-operator-page="credentials"${page === "credentials" ? ' aria-current="page"' : ""}>Credentials</a>
1014
+ <a id="activityNav" class="navlink hidden" href="/activity"
1015
+ data-operator-page="activity"${page === "activity" ? ' aria-current="page"' : ""}>Activity</a>
1016
+ </nav>
1017
+ <div class="session-actions" aria-label="Session actions">
1018
+ <button id="change" class="navlink hidden" type="button">Change token</button>
1019
+ <button id="signout" class="navlink hidden" type="button">Sign out</button>
1020
+ </div>
1021
+ </div>
835
1022
  </div>
836
1023
  </header>
837
1024
 
838
- <main id="gate" class="page shell hidden">
839
- <section class="lead pgrid">
840
- <h1 class="pcap">Tool connections</h1>
841
- <div class="pbody lead-copy">
842
- <p>${escapeHtmlAttr(brand.description)}</p>
843
- <p id="gateCopy" class="meta"></p>
844
- <div id="tokenGate" class="row gate-actions hidden">
845
- <input id="token" type="password" placeholder="Bearer token" autocomplete="off"
846
- aria-label="Bearer token">
847
- <button id="save" class="linklike" type="button">Open dashboard</button>
848
- </div>
849
- <div id="clerkGate" class="actions gate-actions hidden">
850
- <button id="signin" class="linklike" type="button">Team sign in</button>
851
- <button id="gateSignout" class="linklike hidden" type="button">Sign out</button>
1025
+ <main id="operatorContent" class="page shell" tabindex="-1">
1026
+ <section id="gate" class="hidden">
1027
+ <div class="lead pgrid">
1028
+ <h1 id="gateHeading" class="pcap" tabindex="-1">${OPERATOR_PAGE_LABELS[page]}</h1>
1029
+ <div class="pbody lead-copy">
1030
+ <p>${escapeHtmlAttr(brand.description)}</p>
1031
+ <p id="gateCopy" class="meta"></p>
1032
+ <div id="tokenGate" class="row gate-actions hidden">
1033
+ <input id="token" type="password" placeholder="Bearer token" autocomplete="off"
1034
+ aria-label="Bearer token">
1035
+ <button id="save" class="linklike" type="button">Open operator pages</button>
1036
+ </div>
1037
+ <div id="clerkGate" class="actions gate-actions hidden">
1038
+ <button id="signin" class="linklike" type="button">Team sign in</button>
1039
+ <button id="gateSignout" class="linklike hidden" type="button">Sign out</button>
1040
+ </div>
1041
+ <p id="err" role="alert"></p>
852
1042
  </div>
853
- <p id="err"></p>
854
1043
  </div>
855
1044
  </section>
856
- </main>
857
1045
 
858
- <main id="app" class="page shell hidden">
859
- <section id="configView">
1046
+ <div id="app" class="hidden">
1047
+ <section id="connectionsView"${page === "connections" ? "" : ' class="hidden"'}>
860
1048
  <div class="lead pgrid">
861
- <h1 class="pcap">MCP connection</h1>
1049
+ <h1 id="connectionsHeading" class="pcap" tabindex="-1">Connections</h1>
862
1050
  <div class="pbody lead-copy">
863
1051
  <p>Use this endpoint to give an MCP client access to the tools below.</p>
864
1052
  <div class="endpoint">
@@ -867,50 +1055,80 @@ ${clerkScript}
867
1055
  <button id="copyMcpUrl" class="linklike" type="button">Copy URL</button>
868
1056
  </div>
869
1057
  </div>
870
- <p class="cap" id="serverInfo">${escapeHtmlAttr(brand.productName)} status dashboard</p>
1058
+ <p class="cap" id="serverInfo">${escapeHtmlAttr(brand.productName)} operator</p>
871
1059
  </div>
872
1060
  </div>
873
- <section class="section pgrid" aria-labelledby="connectorsHeading">
874
- <h2 class="pcap" id="connectorsHeading">Connectors</h2>
1061
+ <section class="section pgrid" aria-labelledby="connectorLedgerHeading">
1062
+ <h2 class="pcap" id="connectorLedgerHeading">Connectors</h2>
875
1063
  <div class="pbody">
876
1064
  <div class="row toolbar">
877
1065
  <input id="filter" type="search" placeholder="Filter connectors or tools…"
878
1066
  aria-label="Filter connectors or tools">
879
1067
  </div>
880
- <p id="notice" role="status" aria-live="polite"></p>
881
- <div id="list" class="connector-tools"></div>
1068
+ <div id="list" class="connector-tools" aria-busy="false"></div>
882
1069
  </div>
883
1070
  </section>
884
1071
  </section>
885
- <section id="activityView" class="hidden">
1072
+
1073
+ <section id="credentialsView"${page === "credentials" ? "" : ' class="hidden"'}>
886
1074
  <div class="lead pgrid">
887
- <h1 class="pcap">Tool activity</h1>
1075
+ <h1 id="credentialsHeading" class="pcap" tabindex="-1">Credentials</h1>
1076
+ <div class="pbody">
1077
+ <p class="activity-copy">Rotate operator-managed connector credentials. Stored values are never returned or displayed.</p>
1078
+ <p id="credentialNotice" class="meta" role="status" aria-live="polite"
1079
+ tabindex="-1"></p>
1080
+ <div id="credentialUnavailable" class="unavailable hidden"></div>
1081
+ <div id="credentialList" class="credential-ledger" aria-busy="false"></div>
1082
+ </div>
1083
+ </div>
1084
+ </section>
1085
+
1086
+ <section id="activityView"${page === "activity" ? "" : ' class="hidden"'}>
1087
+ <div class="lead pgrid">
1088
+ <h1 id="activityHeading" class="pcap" tabindex="-1">Activity</h1>
888
1089
  <div class="pbody">
889
1090
  <p class="activity-copy" id="activitySummary">Arguments and results are never stored.</p>
890
- <div class="row activity-controls">
891
- <input id="activitySearch" type="search"
892
- placeholder="Search user, tool, or outcome…"
893
- aria-label="Search loaded activity">
894
- <button id="refreshActivity" class="linklike" type="button">Refresh</button>
1091
+ <div id="activityUnavailable" class="unavailable hidden">
1092
+ Activity history is not configured. Add an <span class="mono">activity.store</span>
1093
+ with a list reader to enable this page.
1094
+ </div>
1095
+ <div id="activityAvailable">
1096
+ <div class="row activity-controls">
1097
+ <input id="activitySearch" type="search"
1098
+ placeholder="Search user, tool, or outcome…"
1099
+ aria-label="Search loaded activity">
1100
+ <button id="refreshActivity" class="linklike" type="button">Refresh</button>
1101
+ </div>
1102
+ <p id="activityNotice" class="meta" role="status" aria-live="polite"></p>
1103
+ <div id="activityList" class="activity-ledger" aria-busy="false"></div>
1104
+ <button id="moreActivity" class="linklike activity-more hidden" type="button">Load older</button>
895
1105
  </div>
896
- <p id="activityNotice" class="meta" role="status" aria-live="polite"></p>
897
- <div id="activityList" class="activity-ledger"></div>
898
- <button id="moreActivity" class="linklike activity-more hidden" type="button">Load older</button>
899
1106
  </div>
900
1107
  </div>
901
1108
  </section>
1109
+ </div>
902
1110
  </main>
903
1111
 
904
1112
  <script${nonceAttr}>
905
1113
  const AUTH = ${jsonForInlineScript(auth)};
906
1114
  const MCP_URL = ${jsonForInlineScript(mcpUrl)};
1115
+ const INITIAL_PAGE = ${jsonForInlineScript(page)};
1116
+ const TITLE_SUFFIX = ${jsonForInlineScript(brand.pageTitle)};
907
1117
  const filterUiConnectors = ${filterUiConnectors.toString()};
908
1118
  const KEY = "connecta:token";
909
1119
  const $ = (id) => document.getElementById(id);
1120
+ const PAGE_META = {
1121
+ connections: { path: "/", label: "Connections" },
1122
+ credentials: { path: "/credentials", label: "Credentials" },
1123
+ activity: { path: "/activity", label: "Activity" },
1124
+ };
910
1125
  let DATA = null;
911
1126
  let ACTIVITY = [];
912
1127
  let ACTIVITY_CURSOR = null;
913
1128
  let ACTIVITY_LOADED = false;
1129
+ let CURRENT_PAGE = INITIAL_PAGE;
1130
+ let SESSION_GENERATION = 0;
1131
+ let ACTIVITY_GENERATION = 0;
914
1132
  $("mcpUrl").textContent = MCP_URL;
915
1133
 
916
1134
  function esc(s) {
@@ -934,8 +1152,9 @@ function formatDate(value) {
934
1152
  }
935
1153
 
936
1154
  function setNotice(message, isError) {
937
- $("notice").textContent = message || "";
938
- $("notice").classList.toggle("error-notice", Boolean(isError));
1155
+ $("credentialNotice").textContent = message || "";
1156
+ $("credentialNotice").classList.toggle("error-notice", Boolean(isError));
1157
+ $("credentialNotice").setAttribute("role", isError ? "alert" : "status");
939
1158
  }
940
1159
 
941
1160
  async function sessionToken() {
@@ -944,7 +1163,45 @@ async function sessionToken() {
944
1163
  : localStorage.getItem(KEY);
945
1164
  }
946
1165
 
1166
+ function clearActivityState() {
1167
+ ACTIVITY_GENERATION += 1;
1168
+ ACTIVITY = [];
1169
+ ACTIVITY_CURSOR = null;
1170
+ ACTIVITY_LOADED = false;
1171
+ $("activityList").innerHTML = "";
1172
+ $("activityList").setAttribute("aria-busy", "false");
1173
+ $("activityNotice").textContent = "";
1174
+ $("activityNotice").setAttribute("role", "status");
1175
+ $("activitySummary").textContent = "Arguments and results are never stored.";
1176
+ $("activitySearch").value = "";
1177
+ $("refreshActivity").disabled = false;
1178
+ $("moreActivity").disabled = false;
1179
+ $("moreActivity").classList.add("hidden");
1180
+ }
1181
+
1182
+ function clearIdentityState() {
1183
+ SESSION_GENERATION += 1;
1184
+ DATA = null;
1185
+ clearActivityState();
1186
+ $("list").innerHTML = "";
1187
+ $("filter").value = "";
1188
+ $("credentialList").innerHTML = "";
1189
+ $("credentialList").setAttribute("aria-busy", "false");
1190
+ $("credentialNotice").textContent = "";
1191
+ $("credentialNotice").setAttribute("role", "status");
1192
+ $("credentialNotice").classList.remove("error-notice");
1193
+ $("credentialUnavailable").textContent = "";
1194
+ $("credentialUnavailable").classList.add("hidden");
1195
+ $("credentialList").classList.add("hidden");
1196
+ $("activityUnavailable").classList.add("hidden");
1197
+ $("activityAvailable").classList.add("hidden");
1198
+ $("credentialsNav").classList.add("hidden");
1199
+ $("activityNav").classList.add("hidden");
1200
+ $("serverInfo").textContent = ${escapeScriptString(brand.productName + " operator")};
1201
+ }
1202
+
947
1203
  function showGate(msg) {
1204
+ clearIdentityState();
948
1205
  $("app").classList.add("hidden");
949
1206
  $("appNav").classList.add("hidden");
950
1207
  $("gate").classList.remove("hidden");
@@ -952,8 +1209,8 @@ function showGate(msg) {
952
1209
  if (AUTH.kind === "clerk") {
953
1210
  const signedIn = Boolean(window.Clerk && Clerk.user);
954
1211
  $("gateCopy").textContent = signedIn
955
- ? "Signed in with Clerk, but this account cannot open the dashboard."
956
- : "Sign in with Clerk to open the dashboard.";
1212
+ ? "Signed in with Clerk, but this account cannot open deployment-wide operator pages."
1213
+ : "Sign in with Clerk to open this operator page.";
957
1214
  $("signin").classList.toggle("hidden", signedIn);
958
1215
  $("gateSignout").classList.toggle("hidden", !signedIn);
959
1216
  } else {
@@ -961,20 +1218,87 @@ function showGate(msg) {
961
1218
  }
962
1219
  }
963
1220
 
1221
+ function pageForPath(path) {
1222
+ if (path === "/credentials") return "credentials";
1223
+ if (path === "/activity") return "activity";
1224
+ return "connections";
1225
+ }
1226
+
1227
+ function credentialUnavailableCopy(capability) {
1228
+ if (capability === "no_slots") {
1229
+ return "No connectors declare operator-managed credential slots. Connector credentials remain configuration-as-code until a slot is declared.";
1230
+ }
1231
+ if (capability === "vault_not_configured") {
1232
+ return "Credential storage is not configured. Set credentials.encryptionKey before managing connector credentials here.";
1233
+ }
1234
+ return "Credential management requires an eligible Clerk operator. Bearer-authenticated sessions can inspect connections but cannot manage stored credentials.";
1235
+ }
1236
+
1237
+ function updateCapabilities() {
1238
+ const credentialsAvailable =
1239
+ DATA?.credentialManagement === "available";
1240
+ $("credentialsNav").classList.toggle("hidden", !credentialsAvailable);
1241
+ $("activityNav").classList.toggle("hidden", !DATA?.activityEnabled);
1242
+ $("credentialUnavailable").classList.toggle("hidden", credentialsAvailable);
1243
+ $("credentialList").classList.toggle("hidden", !credentialsAvailable);
1244
+ $("credentialUnavailable").textContent = credentialsAvailable
1245
+ ? ""
1246
+ : credentialUnavailableCopy(DATA?.credentialManagement);
1247
+ $("activityUnavailable").classList.toggle("hidden", Boolean(DATA?.activityEnabled));
1248
+ $("activityAvailable").classList.toggle("hidden", !DATA?.activityEnabled);
1249
+ }
1250
+
1251
+ function activatePage(page, options) {
1252
+ const next = PAGE_META[page] ? page : "connections";
1253
+ CURRENT_PAGE = next;
1254
+ for (const name of Object.keys(PAGE_META)) {
1255
+ $(name + "View").classList.toggle("hidden", name !== next);
1256
+ const link = $(name + "Nav");
1257
+ if (name === next) link.setAttribute("aria-current", "page");
1258
+ else link.removeAttribute("aria-current");
1259
+ }
1260
+ $("gateHeading").textContent = PAGE_META[next].label;
1261
+ document.title = PAGE_META[next].label + " — " + TITLE_SUFFIX;
1262
+ if (DATA) {
1263
+ updateCapabilities();
1264
+ if (next === "connections") renderConnections();
1265
+ if (next === "credentials") renderCredentials();
1266
+ if (next === "activity" && DATA.activityEnabled && !ACTIVITY_LOADED) {
1267
+ loadActivity(true);
1268
+ }
1269
+ }
1270
+ if (next !== "credentials") $("credentialList").innerHTML = "";
1271
+ // Focus what is actually on screen. While gated the page views are hidden, so
1272
+ // focusing their heading is a silent no-op that drops focus to <body> and
1273
+ // restarts the next Tab from the top of the document — the gate's own h1 is
1274
+ // the visible heading, and activatePage has just relabelled it.
1275
+ if (options?.focus) $(DATA ? next + "Heading" : "gateHeading").focus();
1276
+ }
1277
+
1278
+ function navigateTo(page, href) {
1279
+ history.pushState({ operatorPage: page }, "", href);
1280
+ activatePage(page, { focus: true });
1281
+ }
1282
+
964
1283
  async function load() {
1284
+ const generation = SESSION_GENERATION;
965
1285
  let token;
966
1286
  try {
967
1287
  token = await sessionToken();
968
1288
  } catch (e) {
1289
+ if (generation !== SESSION_GENERATION) return;
969
1290
  return showGate("Could not read the Clerk session: " + e.message);
970
1291
  }
1292
+ if (generation !== SESSION_GENERATION) return;
971
1293
  if (!token) return showGate("");
972
1294
  let res;
973
1295
  try {
974
1296
  res = await fetch("/ui/data", { headers: { Authorization: "Bearer " + token } });
975
1297
  } catch (e) {
1298
+ if (generation !== SESSION_GENERATION) return;
976
1299
  return showGate("Network error: " + e.message);
977
1300
  }
1301
+ if (generation !== SESSION_GENERATION) return;
978
1302
  if (res.status === 401 || res.status === 403) {
979
1303
  if (AUTH.kind === "clerk") {
980
1304
  return showGate(
@@ -987,25 +1311,22 @@ async function load() {
987
1311
  return showGate("Token rejected — enter a valid bearer token.");
988
1312
  }
989
1313
  if (!res.ok) return showGate("Error " + res.status);
990
- DATA = await res.json();
1314
+ let data;
1315
+ try {
1316
+ data = await res.json();
1317
+ } catch (e) {
1318
+ if (generation !== SESSION_GENERATION) return;
1319
+ return showGate("Operator data could not be read.");
1320
+ }
1321
+ if (generation !== SESSION_GENERATION) return;
1322
+ DATA = data;
991
1323
  $("gate").classList.add("hidden");
992
1324
  $("app").classList.remove("hidden");
993
1325
  $("appNav").classList.remove("hidden");
994
1326
  const si = DATA.serverInfo || {};
995
1327
  $("serverInfo").textContent = (si.name || ${escapeScriptString(brand.productName)}) + " v" + (si.version || "?");
996
- $("activityTab").classList.toggle("hidden", !DATA.activityEnabled);
997
- render();
998
- }
999
-
1000
- function showView(view) {
1001
- const activity = view === "activity";
1002
- $("configView").classList.toggle("hidden", activity);
1003
- $("activityView").classList.toggle("hidden", !activity);
1004
- $("configTab").classList.toggle("active", !activity);
1005
- $("activityTab").classList.toggle("active", activity);
1006
- $("configTab").setAttribute("aria-pressed", String(!activity));
1007
- $("activityTab").setAttribute("aria-pressed", String(activity));
1008
- if (activity && !ACTIVITY_LOADED) loadActivity(true);
1328
+ updateCapabilities();
1329
+ activatePage(CURRENT_PAGE);
1009
1330
  }
1010
1331
 
1011
1332
  function actorLabel(actor) {
@@ -1044,12 +1365,21 @@ function renderActivity() {
1044
1365
  }
1045
1366
  for (const event of visible) {
1046
1367
  const item = document.createElement("article");
1047
- item.className = "activity-item " + esc(event.outcome);
1048
- const retryCopy = event.attempts > 1 ? " · " + event.attempts + " attempts" : "";
1368
+ const outcomeClass = ["success", "error", "timeout"].includes(event.outcome)
1369
+ ? event.outcome
1370
+ : "error";
1371
+ item.className = "activity-item " + outcomeClass;
1372
+ const retryCopy = event.attempts > 1
1373
+ ? " · " + esc(event.attempts) + " attempts"
1374
+ : "";
1049
1375
  const errorCopy = event.errorCode ? " · " + esc(event.errorCode) : "";
1050
1376
  item.innerHTML =
1051
- '<div><div class="activity-time">' + esc(formatDate(event.occurredAt)) +
1052
- '</div><div class="activity-actor">' + esc(actorLabel(event.actor)) + '</div></div>' +
1377
+ '<div class="activity-stamp"><span class="dot ' +
1378
+ (outcomeClass === "success" ? "ok" : "") +
1379
+ '" aria-hidden="true"></span><div><time class="activity-time" datetime="' +
1380
+ esc(event.occurredAt) + '">' +
1381
+ esc(formatDate(event.occurredAt)) +
1382
+ '</time><div class="activity-actor">' + esc(actorLabel(event.actor)) + '</div></div></div>' +
1053
1383
  '<div><div class="activity-address">' + esc(event.address) +
1054
1384
  '</div><div class="activity-detail">' + esc(event.source) + retryCopy +
1055
1385
  errorCopy + '</div></div>' +
@@ -1062,22 +1392,45 @@ function renderActivity() {
1062
1392
 
1063
1393
  async function loadActivity(reset) {
1064
1394
  if (!DATA?.activityEnabled) return;
1395
+ const sessionGeneration = SESSION_GENERATION;
1396
+ if (reset) ACTIVITY_GENERATION += 1;
1397
+ const activityGeneration = ACTIVITY_GENERATION;
1398
+ const isCurrent = () =>
1399
+ sessionGeneration === SESSION_GENERATION &&
1400
+ activityGeneration === ACTIVITY_GENERATION;
1065
1401
  $("activityNotice").textContent = "Loading activity…";
1402
+ $("activityNotice").setAttribute("role", "status");
1403
+ $("activityList").setAttribute("aria-busy", "true");
1066
1404
  $("refreshActivity").disabled = true;
1067
1405
  $("moreActivity").disabled = true;
1068
1406
  try {
1069
1407
  const token = await sessionToken();
1070
- if (!token) throw new Error("Your session has expired.");
1408
+ if (!isCurrent()) return;
1409
+ if (!token) return showGate("Your session has expired.");
1071
1410
  const cursor = reset ? null : ACTIVITY_CURSOR;
1072
1411
  const params = new URLSearchParams({ limit: "50" });
1073
1412
  if (cursor) params.set("cursor", cursor);
1074
1413
  const res = await fetch("/ui/activity?" + params, {
1075
1414
  headers: { Authorization: "Bearer " + token },
1076
1415
  });
1416
+ if (!isCurrent()) return;
1077
1417
  let payload = {};
1078
1418
  try { payload = await res.json(); } catch (e) {}
1419
+ if (!isCurrent()) return;
1420
+ if (res.status === 401) {
1421
+ return showGate("Your session was not accepted. Sign in again.");
1422
+ }
1423
+ if (res.status === 403) {
1424
+ clearActivityState();
1425
+ $("activityNotice").setAttribute("role", "alert");
1426
+ $("activityNotice").textContent =
1427
+ "This identity may not read activity history.";
1428
+ return;
1429
+ }
1079
1430
  if (!res.ok) {
1080
- throw new Error(payload.error || "Activity could not be loaded (" + res.status + ").");
1431
+ throw new Error(
1432
+ payload.error || "Activity could not be loaded (" + res.status + ")."
1433
+ );
1081
1434
  }
1082
1435
  ACTIVITY = reset
1083
1436
  ? (payload.events || [])
@@ -1087,14 +1440,19 @@ async function loadActivity(reset) {
1087
1440
  $("activityNotice").textContent = "";
1088
1441
  renderActivity();
1089
1442
  } catch (e) {
1443
+ if (!isCurrent()) return;
1444
+ $("activityNotice").setAttribute("role", "alert");
1090
1445
  $("activityNotice").textContent = e.message || "Activity could not be loaded.";
1091
1446
  } finally {
1092
- $("refreshActivity").disabled = false;
1093
- $("moreActivity").disabled = false;
1447
+ if (isCurrent()) {
1448
+ $("activityList").setAttribute("aria-busy", "false");
1449
+ $("refreshActivity").disabled = false;
1450
+ $("moreActivity").disabled = false;
1451
+ }
1094
1452
  }
1095
1453
  }
1096
1454
 
1097
- function render() {
1455
+ function renderConnections() {
1098
1456
  const q = $("filter").value.trim().toLowerCase();
1099
1457
  const list = $("list");
1100
1458
  list.innerHTML = "";
@@ -1142,66 +1500,8 @@ function render() {
1142
1500
  esc(c.authorizationUrl) + "</p>";
1143
1501
  }
1144
1502
  if (c.credential) {
1145
- const cred = c.credential;
1146
- const configured = Boolean(cred.configured);
1147
- const removable = configured || Boolean(cred.removable);
1148
- const state = configured
1149
- ? "configured · ••••" + esc(cred.lastFour || "")
1150
- : "not configured";
1151
- const updated = configured && cred.updatedAt
1152
- ? " · updated " + esc(formatDate(cred.updatedAt))
1153
- : "";
1154
- head += '<section class="credential" aria-label="' + esc(cred.label) + '">';
1155
- head += '<div class="credential-head"><span class="credential-label">' +
1156
- esc(cred.label) + '</span><span class="credential-state">' + state +
1157
- updated + "</span></div>";
1158
- if (cred.description) {
1159
- head += '<p class="credential-copy meta">' + esc(cred.description) + "</p>";
1160
- }
1161
- if (cred.error) {
1162
- head += '<div class="msg">' + esc(cred.error) + "</div>";
1163
- }
1164
- if (AUTH.kind === "clerk") {
1165
- head += '<div class="credential-actions">';
1166
- head += '<button class="linklike" type="button" data-credential-action="edit" data-connector="' +
1167
- esc(c.id) + '">' + (removable ? "Replace" : "Add credential") + "</button>";
1168
- if (configured && cred.testable) {
1169
- head += '<button class="linklike" type="button" data-credential-action="test" data-connector="' +
1170
- esc(c.id) + '">Test</button>';
1171
- }
1172
- if (removable) {
1173
- head += '<button type="button" class="linklike danger" data-credential-action="remove" data-connector="' +
1174
- esc(c.id) + '">Remove</button>';
1175
- }
1176
- head += "</div>";
1177
- head += '<div class="credential-form hidden" data-credential-form="' +
1178
- esc(c.id) + '">';
1179
- if (cred.fields && cred.fields.length) {
1180
- head += '<div class="credential-fields">';
1181
- for (const field of cred.fields) {
1182
- head += '<div class="credential-field"><label>' +
1183
- esc(field.label) + '</label><input type="' +
1184
- esc(field.inputType || "password") + '" data-credential-field="' +
1185
- esc(field.name) + '" aria-label="' + esc(field.label) +
1186
- '" placeholder="' + esc(field.placeholder || field.label) +
1187
- '" autocomplete="' +
1188
- (field.inputType === "password" ? "new-password" : "off") +
1189
- '" autocapitalize="none" spellcheck="false"></div>';
1190
- }
1191
- head += "</div>";
1192
- } else {
1193
- head += '<input type="password" data-credential-input="' +
1194
- esc(c.id) + '" aria-label="' + esc(cred.label) + '" placeholder="' +
1195
- esc(cred.placeholder || "Paste credential") +
1196
- '" autocomplete="new-password" autocapitalize="none" spellcheck="false">';
1197
- }
1198
- head += '<button class="linklike" type="button" data-credential-action="save" data-connector="' +
1199
- esc(c.id) + '">Save</button><button class="linklike" type="button" data-credential-action="cancel" data-connector="' +
1200
- esc(c.id) + '">Cancel</button></div>';
1201
- } else {
1202
- head += '<p class="credential-copy meta">Team sign in is required to manage this credential.</p>';
1203
- }
1204
- head += "</section>";
1503
+ head += '<p class="connector-auth"><a class="linklike" href="/credentials" ' +
1504
+ 'data-operator-page="credentials">Manage credential →</a></p>';
1205
1505
  }
1206
1506
  let body = "";
1207
1507
  if (tools.length) {
@@ -1219,12 +1519,122 @@ function render() {
1219
1519
  list.appendChild(el);
1220
1520
  }
1221
1521
  if (!list.children.length) {
1222
- list.innerHTML = '<p class="empty">No connectors or tools match this filter.</p>';
1522
+ list.innerHTML = '<p class="empty">' +
1523
+ (q
1524
+ ? "No connectors or tools match this filter."
1525
+ : "No connectors are declared in this deployment.") +
1526
+ "</p>";
1527
+ }
1528
+ }
1529
+
1530
+ function renderCredentials() {
1531
+ const list = $("credentialList");
1532
+ list.innerHTML = "";
1533
+ if (DATA.credentialManagement !== "available") return;
1534
+ for (const c of DATA.connectors.filter((connector) => connector.credential)) {
1535
+ const cred = c.credential;
1536
+ const configured = Boolean(cred.configured);
1537
+ const removable = configured || Boolean(cred.removable);
1538
+ const state = configured
1539
+ ? cred.fields?.length
1540
+ ? "configured"
1541
+ : "configured · ••••" + esc(cred.lastFour || "")
1542
+ : "not configured";
1543
+ const updated = configured && cred.updatedAt
1544
+ ? " · updated " + esc(formatDate(cred.updatedAt))
1545
+ : "";
1546
+ const el = document.createElement("section");
1547
+ el.className = "credential-card";
1548
+ el.id = "credential-" + c.id;
1549
+ el.setAttribute("aria-labelledby", "credential-title-" + c.id);
1550
+ let body = '<div class="credential-head"><div class="connector-title">' +
1551
+ '<span class="dot ' + (configured ? "ok" : "auth_required") +
1552
+ '" aria-hidden="true"></span><h2 id="credential-title-' + esc(c.id) + '">' +
1553
+ esc(c.title || c.id) + '</h2></div><span class="credential-state">' +
1554
+ state + updated + "</span></div>";
1555
+ body += '<p class="mono">' + esc(c.id) + " · " + esc(cred.label) + "</p>";
1556
+ if (cred.description) {
1557
+ body += '<p class="credential-copy meta">' + esc(cred.description) + "</p>";
1558
+ }
1559
+ if (cred.fields?.length) {
1560
+ body += '<div class="credential-field-summary">';
1561
+ for (const field of cred.fields) {
1562
+ const fieldState = field.configured
1563
+ ? "configured · ••••" + esc(field.lastFour || "") +
1564
+ (field.updatedAt ? " · updated " + esc(formatDate(field.updatedAt)) : "")
1565
+ : "not configured";
1566
+ body += '<div><span>' + esc(field.label) +
1567
+ '</span><span class="meta">' + fieldState + "</span></div>";
1568
+ }
1569
+ body += "</div>";
1570
+ }
1571
+ if (c.credentialCheck) {
1572
+ const check = c.credentialCheck;
1573
+ const verdict = check.state === "ok"
1574
+ ? "healthy"
1575
+ : check.state === "auth_required"
1576
+ ? "needs authorization"
1577
+ : "check failed";
1578
+ body += '<p class="connector-check meta">Liveness: ' + esc(verdict) +
1579
+ " · " + esc(formatDate(check.checkedAt)) +
1580
+ (check.message ? " — " + esc(check.message) : "") + "</p>";
1581
+ }
1582
+ if (cred.error) body += '<div class="msg">' + esc(cred.error) + "</div>";
1583
+ // Leftover stored fields are not an error — the credential still works, so
1584
+ // this stays muted copy rather than the msg block an actual failure earns.
1585
+ if (cred.notice) {
1586
+ body += '<p class="credential-copy meta">' + esc(cred.notice) + "</p>";
1587
+ }
1588
+ body += '<div class="credential-actions">';
1589
+ body += '<button class="linklike" type="button" data-credential-action="edit" data-connector="' +
1590
+ esc(c.id) + '">' + (removable ? "Replace" : "Add credential") + "</button>";
1591
+ if (configured && cred.testable) {
1592
+ body += '<button class="linklike" type="button" data-credential-action="test" data-connector="' +
1593
+ esc(c.id) + '">Test</button>';
1594
+ }
1595
+ if (removable) {
1596
+ body += '<button type="button" class="linklike danger" data-credential-action="remove" data-connector="' +
1597
+ esc(c.id) + '">Remove</button>';
1598
+ }
1599
+ body += "</div>";
1600
+ body += '<div class="credential-form hidden" data-credential-form="' +
1601
+ esc(c.id) + '">';
1602
+ if (cred.fields && cred.fields.length) {
1603
+ body += '<div class="credential-fields">';
1604
+ for (let index = 0; index < cred.fields.length; index += 1) {
1605
+ const field = cred.fields[index];
1606
+ const inputId = "credential-input-" + c.id + "-" + index;
1607
+ body += '<div class="credential-field"><label for="' + esc(inputId) + '">' +
1608
+ esc(field.label) + '</label><input id="' + esc(inputId) + '" type="' +
1609
+ esc(field.inputType || "password") + '" data-credential-field="' +
1610
+ esc(field.name) + '" placeholder="' + esc(field.placeholder || field.label) +
1611
+ '" autocomplete="' +
1612
+ (field.inputType === "password" ? "new-password" : "off") +
1613
+ '" autocapitalize="none" spellcheck="false"></div>';
1614
+ }
1615
+ body += "</div>";
1616
+ } else {
1617
+ const inputId = "credential-input-" + c.id;
1618
+ body += '<label class="visually-hidden" for="' + esc(inputId) + '">' +
1619
+ esc(cred.label) + '</label><input id="' + esc(inputId) +
1620
+ '" type="password" data-credential-input="' +
1621
+ esc(c.id) + '" aria-label="' + esc(cred.label) + '" placeholder="' +
1622
+ esc(cred.placeholder || "Paste credential") +
1623
+ '" autocomplete="new-password" autocapitalize="none" spellcheck="false">';
1624
+ }
1625
+ body += '<button class="linklike" type="button" data-credential-action="save" data-connector="' +
1626
+ esc(c.id) + '">Save</button><button class="linklike" type="button" data-credential-action="cancel" data-connector="' +
1627
+ esc(c.id) + '">Cancel</button></div>';
1628
+ el.innerHTML = body;
1629
+ list.appendChild(el);
1223
1630
  }
1224
1631
  }
1225
1632
 
1226
- async function credentialRequest(connector, method, action, body) {
1633
+ async function credentialRequest(connector, method, action, body, generation) {
1227
1634
  const token = await sessionToken();
1635
+ if (generation !== SESSION_GENERATION) {
1636
+ throw new Error("The operator session changed.");
1637
+ }
1228
1638
  if (!token) throw new Error("Your Clerk session has expired.");
1229
1639
  const suffix = action ? "/" + action : "";
1230
1640
  const res = await fetch(
@@ -1250,12 +1660,13 @@ function credentialForm(connector) {
1250
1660
  CSS.escape(connector) + '"]');
1251
1661
  }
1252
1662
 
1253
- $("list").onclick = async (event) => {
1663
+ $("credentialList").onclick = async (event) => {
1254
1664
  const button = event.target.closest("[data-credential-action]");
1255
1665
  if (!button) return;
1256
1666
  const connector = button.dataset.connector;
1257
1667
  const action = button.dataset.credentialAction;
1258
1668
  const form = credentialForm(connector);
1669
+ const generation = SESSION_GENERATION;
1259
1670
 
1260
1671
  if (action === "edit") {
1261
1672
  form.classList.remove("hidden");
@@ -1265,6 +1676,10 @@ $("list").onclick = async (event) => {
1265
1676
  if (action === "cancel") {
1266
1677
  form.querySelectorAll("input").forEach((input) => { input.value = ""; });
1267
1678
  form.classList.add("hidden");
1679
+ document.querySelector(
1680
+ '[data-credential-action="edit"][data-connector="' +
1681
+ CSS.escape(connector) + '"]'
1682
+ )?.focus();
1268
1683
  return;
1269
1684
  }
1270
1685
  if (action === "remove" && !window.confirm(
@@ -1272,6 +1687,7 @@ $("list").onclick = async (event) => {
1272
1687
  )) return;
1273
1688
 
1274
1689
  setNotice("");
1690
+ $("credentialList").setAttribute("aria-busy", "true");
1275
1691
  const buttons = [...document.querySelectorAll(
1276
1692
  '[data-connector="' + CSS.escape(connector) + '"]'
1277
1693
  )];
@@ -1286,42 +1702,65 @@ $("list").onclick = async (event) => {
1286
1702
  if (!value) throw new Error("Complete every credential field before saving.");
1287
1703
  values[input.dataset.credentialField] = value;
1288
1704
  }
1289
- await credentialRequest(connector, "PUT", "", { values });
1705
+ await credentialRequest(connector, "PUT", "", { values }, generation);
1290
1706
  } else {
1291
1707
  const input = form.querySelector("[data-credential-input]");
1292
1708
  const value = input.value.trim();
1293
1709
  if (!value) throw new Error("Paste a credential before saving.");
1294
- await credentialRequest(connector, "PUT", "", { value });
1710
+ await credentialRequest(connector, "PUT", "", { value }, generation);
1295
1711
  }
1712
+ if (generation !== SESSION_GENERATION) return;
1296
1713
  form.querySelectorAll("input").forEach((input) => { input.value = ""; });
1297
1714
  setNotice("Credential saved.");
1298
1715
  await load();
1716
+ if (generation !== SESSION_GENERATION) return;
1717
+ $("credentialNotice").focus();
1299
1718
  } else if (action === "remove") {
1300
- await credentialRequest(connector, "DELETE");
1719
+ await credentialRequest(connector, "DELETE", "", null, generation);
1720
+ if (generation !== SESSION_GENERATION) return;
1301
1721
  setNotice("Credential removed.");
1302
1722
  await load();
1723
+ if (generation !== SESSION_GENERATION) return;
1724
+ $("credentialNotice").focus();
1303
1725
  } else if (action === "test") {
1304
- const result = await credentialRequest(connector, "POST", "test");
1726
+ const result = await credentialRequest(
1727
+ connector,
1728
+ "POST",
1729
+ "test",
1730
+ null,
1731
+ generation,
1732
+ );
1733
+ if (generation !== SESSION_GENERATION) return;
1305
1734
  setNotice(
1306
1735
  result.message || (result.ok ? "Credential is valid." : "Credential test failed."),
1307
1736
  !result.ok,
1308
1737
  );
1309
1738
  }
1310
1739
  } catch (e) {
1740
+ if (generation !== SESSION_GENERATION) return;
1311
1741
  setNotice(e.message || "Credential action failed.", true);
1312
1742
  } finally {
1313
- buttons.forEach((item) => { item.disabled = false; });
1743
+ if (generation === SESSION_GENERATION) {
1744
+ $("credentialList").setAttribute("aria-busy", "false");
1745
+ buttons.forEach((item) => { item.disabled = false; });
1746
+ }
1314
1747
  }
1315
1748
  };
1316
1749
 
1317
- $("save").onclick = () => {
1750
+ $("save").onclick = async () => {
1318
1751
  const v = $("token").value.trim();
1319
1752
  if (!v) return;
1753
+ clearIdentityState();
1320
1754
  localStorage.setItem(KEY, v);
1321
1755
  $("token").value = "";
1322
- load();
1756
+ await load();
1757
+ if (DATA) $(CURRENT_PAGE + "Heading").focus();
1758
+ };
1759
+ $("change").onclick = () => {
1760
+ localStorage.removeItem(KEY);
1761
+ showGate("");
1762
+ $("token").focus();
1323
1763
  };
1324
- $("change").onclick = () => { localStorage.removeItem(KEY); showGate(""); };
1325
1764
  $("copyMcpUrl").onclick = async () => {
1326
1765
  const button = $("copyMcpUrl");
1327
1766
  try {
@@ -1336,14 +1775,35 @@ $("signin").onclick = () => Clerk.redirectToSignIn({
1336
1775
  signInFallbackRedirectUrl: window.location.href,
1337
1776
  signUpFallbackRedirectUrl: window.location.href,
1338
1777
  });
1339
- $("gateSignout").onclick = () => Clerk.signOut({ redirectUrl: window.location.href });
1340
- $("signout").onclick = () => Clerk.signOut({ redirectUrl: window.location.href });
1341
- $("filter").oninput = () => { if (DATA) render(); };
1342
- $("configTab").onclick = () => showView("config");
1343
- $("activityTab").onclick = () => showView("activity");
1778
+ function signOut() {
1779
+ clearIdentityState();
1780
+ return Clerk.signOut({ redirectUrl: window.location.href });
1781
+ }
1782
+ $("gateSignout").onclick = signOut;
1783
+ $("signout").onclick = signOut;
1784
+ $("filter").oninput = () => { if (DATA) renderConnections(); };
1344
1785
  $("refreshActivity").onclick = () => loadActivity(true);
1345
1786
  $("moreActivity").onclick = () => loadActivity(false);
1346
1787
  $("activitySearch").oninput = () => renderActivity();
1788
+ document.addEventListener("click", (event) => {
1789
+ const link = event.target.closest("a[data-operator-page]");
1790
+ if (
1791
+ !link ||
1792
+ event.defaultPrevented ||
1793
+ event.button !== 0 ||
1794
+ event.metaKey ||
1795
+ event.ctrlKey ||
1796
+ event.shiftKey ||
1797
+ event.altKey
1798
+ ) return;
1799
+ const target = new URL(link.href, window.location.href);
1800
+ if (target.origin !== window.location.origin) return;
1801
+ event.preventDefault();
1802
+ navigateTo(link.dataset.operatorPage, target.pathname + target.search + target.hash);
1803
+ });
1804
+ window.addEventListener("popstate", () => {
1805
+ activatePage(pageForPath(window.location.pathname), { focus: true });
1806
+ });
1347
1807
 
1348
1808
  async function init() {
1349
1809
  if (AUTH.kind === "clerk") {
@@ -1367,6 +1827,7 @@ async function init() {
1367
1827
  $("tokenGate").classList.remove("hidden");
1368
1828
  $("change").classList.remove("hidden");
1369
1829
  }
1830
+ activatePage(pageForPath(window.location.pathname));
1370
1831
  await load();
1371
1832
  }
1372
1833