@zackbart/connecta 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +403 -0
  2. package/README.md +163 -308
  3. package/dist/auth/bearer.d.ts +4 -3
  4. package/dist/auth/bearer.d.ts.map +1 -1
  5. package/dist/auth/bearer.js +10 -8
  6. package/dist/auth/bearer.js.map +1 -1
  7. package/dist/auth/clerk.d.ts +8 -7
  8. package/dist/auth/clerk.d.ts.map +1 -1
  9. package/dist/auth/clerk.js +27 -8
  10. package/dist/auth/clerk.js.map +1 -1
  11. package/dist/connector-scope.d.ts +13 -0
  12. package/dist/connector-scope.d.ts.map +1 -0
  13. package/dist/connector-scope.js +35 -0
  14. package/dist/connector-scope.js.map +1 -0
  15. package/dist/connectors/api.d.ts +5 -5
  16. package/dist/connectors/api.d.ts.map +1 -1
  17. package/dist/connectors/remote-mcp.d.ts +3 -3
  18. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  19. package/dist/connectors/remote-mcp.js +309 -10
  20. package/dist/connectors/remote-mcp.js.map +1 -1
  21. package/dist/credential-health.d.ts +20 -9
  22. package/dist/credential-health.d.ts.map +1 -1
  23. package/dist/credential-health.js +127 -63
  24. package/dist/credential-health.js.map +1 -1
  25. package/dist/credentials.d.ts +84 -1
  26. package/dist/credentials.d.ts.map +1 -1
  27. package/dist/credentials.js +109 -2
  28. package/dist/credentials.js.map +1 -1
  29. package/dist/index.d.ts +83 -82
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +101 -31
  32. package/dist/index.js.map +1 -1
  33. package/dist/meta-tools.d.ts +3 -3
  34. package/dist/meta-tools.d.ts.map +1 -1
  35. package/dist/meta-tools.js +16 -7
  36. package/dist/meta-tools.js.map +1 -1
  37. package/dist/registry.d.ts +3 -2
  38. package/dist/registry.d.ts.map +1 -1
  39. package/dist/registry.js +4 -3
  40. package/dist/registry.js.map +1 -1
  41. package/dist/server.d.ts +1 -1
  42. package/dist/server.d.ts.map +1 -1
  43. package/dist/server.js +154 -52
  44. package/dist/server.js.map +1 -1
  45. package/dist/skills.js +2 -2
  46. package/dist/skills.js.map +1 -1
  47. package/dist/toolkits.js +1 -1
  48. package/dist/types.d.ts +51 -26
  49. package/dist/types.d.ts.map +1 -1
  50. package/dist/ui.d.ts +52 -21
  51. package/dist/ui.d.ts.map +1 -1
  52. package/dist/ui.js +665 -196
  53. package/dist/ui.js.map +1 -1
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/package.json +3 -2
  57. package/src/auth/bearer.ts +10 -8
  58. package/src/auth/clerk.ts +28 -9
  59. package/src/connector-scope.ts +41 -0
  60. package/src/connectors/api.ts +5 -5
  61. package/src/connectors/remote-mcp.ts +348 -25
  62. package/src/credential-health.ts +151 -71
  63. package/src/credentials.ts +166 -3
  64. package/src/index.ts +202 -113
  65. package/src/meta-tools.ts +22 -7
  66. package/src/registry.ts +4 -3
  67. package/src/server.ts +197 -71
  68. package/src/skills.ts +2 -2
  69. package/src/toolkits.ts +1 -1
  70. package/src/types.ts +51 -26
  71. package/src/ui.ts +703 -195
  72. package/src/version.ts +1 -1
package/src/ui.ts CHANGED
@@ -1,4 +1,10 @@
1
+ import {
2
+ credentialTestRule,
3
+ describeUndeclaredCredentialFields,
4
+ storedCredentialShape,
5
+ } from "./credentials.js";
1
6
  import type { CredentialVault } from "./credentials.js";
7
+ import { closeConnectorScope } from "./connector-scope.js";
2
8
  import type { Registry } from "./registry.js";
3
9
  import type { ConnectaBranding, UiAuthConfig } from "./types.js";
4
10
 
@@ -66,6 +72,20 @@ export function resolveBranding(
66
72
  };
67
73
  }
68
74
 
75
+ /**
76
+ * Whether the operator meant to supply a value here — the question every
77
+ * dropped-URL warning asks before naming a field, and one definition so the
78
+ * branding and `uiAuth` warnings cannot answer it differently. A non-string
79
+ * counts as set: the intent was there and is exactly what the warning reports
80
+ * on. A blank or whitespace-only string does not; that is indistinguishable
81
+ * from leaving the field alone, and both take the default silently.
82
+ */
83
+ function isSetUrlValue(value: unknown): boolean {
84
+ return typeof value === "string"
85
+ ? trimmedString(value) !== undefined
86
+ : value !== undefined && value !== null;
87
+ }
88
+
69
89
  /**
70
90
  * Names of the branding URLs the operator set that failed their gate and were
71
91
  * replaced by a default. Lives beside the gates so the startup warning cannot
@@ -75,17 +95,15 @@ export function resolveBranding(
75
95
  export function droppedBrandingUrls(branding?: ConnectaBranding): string[] {
76
96
  if (!branding) return [];
77
97
  const resolved = resolveBranding(branding);
78
- // A non-string still counts as "set": the operator meant to supply a URL, and
79
- // that intent is exactly what the warning reports on. A blank string does not.
80
- const isSet = (value: unknown) =>
81
- typeof value === "string"
82
- ? trimmedString(value) !== undefined
83
- : value !== undefined && value !== null;
84
98
  const faviconHref = branding.favicon?.href;
85
99
  return [
86
- ...(isSet(branding.productUrl) && !resolved.productUrl ? ["productUrl"] : []),
87
- ...(isSet(branding.ownerUrl) && !resolved.ownerUrl ? ["ownerUrl"] : []),
88
- ...(isSet(faviconHref) &&
100
+ ...(isSetUrlValue(branding.productUrl) && !resolved.productUrl
101
+ ? ["productUrl"]
102
+ : []),
103
+ ...(isSetUrlValue(branding.ownerUrl) && !resolved.ownerUrl
104
+ ? ["ownerUrl"]
105
+ : []),
106
+ ...(isSetUrlValue(faviconHref) &&
89
107
  trimmedString(faviconHref) !== resolved.faviconHref
90
108
  ? ["favicon.href"]
91
109
  : []),
@@ -135,8 +153,8 @@ const URL_STRIPPED_CHARS = /[\t\n\r]/g;
135
153
  * default href is the relative `/favicon.svg`, which `isSafeHttpUrl` alone would
136
154
  * reject — and it is kept narrow on both ends.
137
155
  *
138
- * Root-relative only, because `/ui` and `/oauth/callback/<id>` sit at different
139
- * 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.
140
158
  *
141
159
  * "Root-relative" is enforced structurally: exactly one leading `/` followed by
142
160
  * a character that is neither `/` nor `\`. Both of those would make the value an
@@ -159,20 +177,26 @@ export function isSafeIconHref(href: unknown): boolean {
159
177
  }
160
178
 
161
179
  /**
162
- * True only for an absolute `https:` URL — the gate for `uiAuth.frontendApiUrl`,
163
- * the last operator-config value that lands in a URL-valued HTML position (the
164
- * `<script src>` of `/ui`'s sign-in loader). `javascript:` in a `src` does not
165
- * execute, so this closes a hole in the *invariant* rather than a live vector:
166
- * every operator value reaching an `href`/`src` is validated, with no exception
167
- * left to remember.
180
+ * True only for an absolute `https:` URL — the gate every `uiAuth` URL passes:
181
+ * `frontendApiUrl`, which becomes the operator shell's sign-in loader source,
182
+ * and `signInUrl`/`signUpUrl`, which ClerkJS uses as *navigation targets* when
183
+ * the operator signs in. With those three gated, no operator-config value
184
+ * reaches the browser in a URL position attribute or navigation — without
185
+ * validation, and there is no exception left to remember.
168
186
  *
169
- * Stricter than `isSafeHttpUrl` on purpose. There is no `http:` carve-out and no
170
- * loopback carve-out, because nobody types this value: the shipped Clerk adapter
171
- * derives it from the publishable key and Clerk's Frontend API is always https.
172
- * A cleartext script source on the dashboard would be a downgrade even where a
173
- * browser's mixed-content rules had not already blocked it.
187
+ * Stricter than `isSafeHttpUrl` on purpose: no `http:` carve-out, no loopback
188
+ * carve-out, and no relative form. Nobody types `frontendApiUrl` the shipped
189
+ * Clerk adapter derives it from the publishable key, and Clerk's Frontend API is
190
+ * always https — and a cleartext script source on an operator page would be a
191
+ * downgrade even where a browser's mixed-content rules had not already blocked
192
+ * it. `signInUrl`/`signUpUrl` *are* typed by the operator, but what belongs
193
+ * there is a hosted Account Portal address (`https://accounts.<domain>` or
194
+ * `https://<slug>.accounts.dev`), which is https as well; `http:` would carry a
195
+ * sign-in over cleartext, and a path relative to this origin is meaningless
196
+ * because this server hosts no sign-in page of its own. So the looser gate would
197
+ * buy nothing real, and the same strictness holds for all three.
174
198
  */
175
- export function isSafeScriptSrcUrl(url: unknown): boolean {
199
+ export function isSafeHttpsUrl(url: unknown): boolean {
176
200
  if (typeof url !== "string") return false;
177
201
  try {
178
202
  return new URL(url).protocol === "https:";
@@ -186,14 +210,31 @@ export function isSafeScriptSrcUrl(url: unknown): boolean {
186
210
  * gate. Lives beside the gate for the same reason `droppedBrandingUrls` does: the
187
211
  * startup warning cannot then drift from what rendering actually drops. Every
188
212
  * field is read defensively rather than trusted, because a custom `InboundAuth`
189
- * is untyped at a JS call site — `isSafeScriptSrcUrl` takes `unknown`, and a
213
+ * is untyped at a JS call site — `isSafeHttpsUrl` takes `unknown`, and a
190
214
  * `uiAuth` that is not the clerk shape is reported as nothing to warn about.
215
+ *
216
+ * `frontendApiUrl` is required, so anything that fails its gate is a drop.
217
+ * `signInUrl` and `signUpUrl` are optional, so only a value the operator
218
+ * *supplied* and the gate then rejected is worth a warning — an unset field
219
+ * took no default away from anyone. `isSetUrlValue` decides that, the same way
220
+ * and for the same reasons it decides it for the branding URLs: a warning that
221
+ * fires for one and not the other would be reporting on the field rather than
222
+ * on the operator's intent. Rendering is not consulted for this: it drops on
223
+ * the gate alone, and a blank string fails that gate too — it is simply not
224
+ * *reported*, because a blank is indistinguishable from leaving the field
225
+ * alone.
191
226
  */
192
227
  export function droppedUiAuthUrls(uiAuth?: UiAuthConfig): string[] {
193
228
  if (!uiAuth || uiAuth.kind !== "clerk") return [];
194
- return isSafeScriptSrcUrl(uiAuth.frontendApiUrl)
195
- ? []
196
- : ["uiAuth.frontendApiUrl"];
229
+ return [
230
+ ...(isSafeHttpsUrl(uiAuth.frontendApiUrl) ? [] : ["uiAuth.frontendApiUrl"]),
231
+ ...(isSetUrlValue(uiAuth.signInUrl) && !isSafeHttpsUrl(uiAuth.signInUrl)
232
+ ? ["uiAuth.signInUrl"]
233
+ : []),
234
+ ...(isSetUrlValue(uiAuth.signUpUrl) && !isSafeHttpsUrl(uiAuth.signUpUrl)
235
+ ? ["uiAuth.signUpUrl"]
236
+ : []),
237
+ ];
197
238
  }
198
239
 
199
240
  export interface UiTool {
@@ -243,6 +284,12 @@ export interface UiConnector {
243
284
  updatedAt?: string;
244
285
  testable: boolean;
245
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;
246
293
  };
247
294
  }
248
295
 
@@ -250,6 +297,46 @@ export interface UiData {
250
297
  serverInfo: { name: string; version: string };
251
298
  connectors: UiConnector[];
252
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";
253
340
  }
254
341
 
255
342
  export interface FilteredUiConnector {
@@ -301,10 +388,14 @@ export async function buildUiData(
301
388
  serverInfo: { name: string; version: string },
302
389
  credentialVault?: CredentialVault,
303
390
  activityEnabled = false,
391
+ credentialManagement: CredentialManagementCapability = credentialVault
392
+ ? "available"
393
+ : "requires_clerk",
304
394
  ): Promise<UiData> {
305
395
  const requestScope = {};
396
+ const connectorSet = registry.listConnectors();
306
397
  const connectors = await Promise.all(
307
- registry.listConnectors().map(async (c): Promise<UiConnector> => {
398
+ connectorSet.map(async (c): Promise<UiConnector> => {
308
399
  const status = await registry.statusFor(c.id, baseUrl, requestScope);
309
400
  const credentialCheck = await registry.credentialHealthFor(c.id);
310
401
  let tools: UiTool[] = [];
@@ -328,6 +419,10 @@ export async function buildUiData(
328
419
  }
329
420
  let credential: UiConnector["credential"];
330
421
  if (c.credential && credentialVault) {
422
+ // One rule, shared with the test route: only the hook matching the
423
+ // declared credential shape can run, so the button is offered only
424
+ // where a click can succeed (src/credentials.ts).
425
+ const testRule = credentialTestRule(c);
331
426
  const credentialFields = (
332
427
  metadata?: Awaited<ReturnType<CredentialVault["metadata"]>>,
333
428
  ) =>
@@ -355,6 +450,10 @@ export async function buildUiData(
355
450
  try {
356
451
  const metadata = await credentialVault.metadata(c.id);
357
452
  const fields = credentialFields(metadata);
453
+ const shape = storedCredentialShape(
454
+ c.credential,
455
+ metadata?.fields ?? null,
456
+ );
358
457
  credential = {
359
458
  label: c.credential.label,
360
459
  ...(c.credential.description
@@ -364,9 +463,7 @@ export async function buildUiData(
364
463
  ? { placeholder: c.credential.placeholder }
365
464
  : {}),
366
465
  ...(fields?.length ? { fields } : {}),
367
- configured: fields?.length
368
- ? fields.every((field) => field.configured)
369
- : Boolean(metadata),
466
+ configured: shape.state === "valid",
370
467
  removable: Boolean(metadata),
371
468
  ...(metadata
372
469
  ? {
@@ -374,7 +471,21 @@ export async function buildUiData(
374
471
  updatedAt: metadata.updatedAt,
375
472
  }
376
473
  : {}),
377
- testable: Boolean(c.testCredential || c.testCredentials),
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
+ : {}),
378
489
  };
379
490
  } catch {
380
491
  const fields = credentialFields();
@@ -389,7 +500,7 @@ export async function buildUiData(
389
500
  ...(fields?.length ? { fields } : {}),
390
501
  configured: false,
391
502
  removable: true,
392
- testable: Boolean(c.testCredential || c.testCredentials),
503
+ testable: testRule.mode !== null,
393
504
  error: "Stored credential could not be read.",
394
505
  };
395
506
  }
@@ -419,8 +530,22 @@ export async function buildUiData(
419
530
  ...(credential ? { credential } : {}),
420
531
  };
421
532
  }),
422
- );
423
- 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
+ };
424
549
  }
425
550
 
426
551
  function escapeHtmlAttr(value: string): string {
@@ -439,16 +564,15 @@ function jsonForInlineScript(value: unknown): string {
439
564
  }
440
565
 
441
566
  /**
442
- * The `/ui` shell carries no connector data: everything comes client-side from
443
- * the auth-gated `/ui/data`. A configured Clerk provider gives operators a
444
- * normal sign-in flow and a short-lived session token; bearer-only deployments
445
- * 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.
446
569
  */
447
570
  export function renderUiHtml(
448
571
  uiAuth?: UiAuthConfig,
449
572
  mcpUrl = "/mcp",
450
573
  branding?: ConnectaBranding,
451
574
  nonce?: string,
575
+ page: OperatorPage = "connections",
452
576
  ): string {
453
577
  const clerk = uiAuth?.kind === "clerk" ? uiAuth : undefined;
454
578
  // The Clerk loader's origin. A value that fails the gate is dropped rather
@@ -457,24 +581,31 @@ export function renderUiHtml(
457
581
  // the same fallback-and-warn posture the branding URLs take, with the drop
458
582
  // named in a startup warning (see `droppedUiAuthUrls`).
459
583
  const clerkScriptOrigin =
460
- clerk && isSafeScriptSrcUrl(clerk.frontendApiUrl)
584
+ clerk && isSafeHttpsUrl(clerk.frontendApiUrl)
461
585
  ? clerk.frontendApiUrl
462
586
  : undefined;
463
587
  // Enumerated field by field, because this object is serialized into the page's
464
588
  // inline script: a rejected frontendApiUrl must not reach the document through
465
- // `AUTH` after being kept out of the `<script src>`.
589
+ // `AUTH` after being kept out of the `<script src>`, and a rejected
590
+ // signInUrl/signUpUrl — which `AUTH` is the only path into the page for — must
591
+ // not reach it at all. Dropping one leaves the key absent, so `Clerk.load`
592
+ // falls back to its own default the same way it does for an unset value.
466
593
  const auth = clerk
467
594
  ? {
468
595
  kind: clerk.kind,
469
596
  publishableKey: clerk.publishableKey,
470
597
  ...(clerkScriptOrigin ? { frontendApiUrl: clerkScriptOrigin } : {}),
471
- ...(clerk.signInUrl ? { signInUrl: clerk.signInUrl } : {}),
472
- ...(clerk.signUpUrl ? { signUpUrl: clerk.signUpUrl } : {}),
598
+ ...(isSafeHttpsUrl(clerk.signInUrl)
599
+ ? { signInUrl: clerk.signInUrl }
600
+ : {}),
601
+ ...(isSafeHttpsUrl(clerk.signUpUrl)
602
+ ? { signUpUrl: clerk.signUpUrl }
603
+ : {}),
473
604
  }
474
605
  : (uiAuth ?? { kind: "bearer" as const });
475
606
  const brand = resolveBranding(branding);
476
- const title = brand.pageTitle;
477
- // 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
478
609
  // carry that nonce to run; without a nonce the markup is unchanged.
479
610
  const nonceAttr = nonce ? ` nonce="${nonce}"` : "";
480
611
  // Top-left corner. With an owner set it reads "<owner> <product>"; without
@@ -515,6 +646,7 @@ ${clerkScript}
515
646
  --paper: #fff;
516
647
  --rule: #ccc;
517
648
  --muted: #666;
649
+ --trace: #f5f5f5;
518
650
  --shell: 70rem;
519
651
  --pad: 1rem;
520
652
  --gap: 1.5rem;
@@ -561,6 +693,15 @@ ${clerkScript}
561
693
  outline: 1px solid var(--ink);
562
694
  }
563
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); }
564
705
 
565
706
  .shell {
566
707
  margin: 0 auto;
@@ -579,6 +720,15 @@ ${clerkScript}
579
720
  .cap, .meta { color: var(--muted); font-size: .9em; }
580
721
  .mono { font-family: var(--mono); font-size: .78rem; }
581
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
+ }
582
732
 
583
733
  .masthead {
584
734
  align-items: start;
@@ -603,6 +753,17 @@ ${clerkScript}
603
753
  justify-content: flex-end;
604
754
  min-width: 0;
605
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
+ }
606
767
  .navlink,
607
768
  .linklike {
608
769
  text-decoration: underline;
@@ -612,7 +773,7 @@ ${clerkScript}
612
773
  .navlink { text-decoration-color: transparent; }
613
774
  .navlink:hover,
614
775
  .navlink:focus-visible,
615
- .navlink.active { text-decoration-color: currentColor; }
776
+ .navlink[aria-current="page"] { text-decoration-color: currentColor; }
616
777
  .linklike { text-decoration-color: currentColor; }
617
778
  .linklike:hover,
618
779
  .linklike:focus-visible { text-decoration-color: transparent; }
@@ -660,7 +821,24 @@ ${clerkScript}
660
821
  #notice:empty { display: none; }
661
822
  #notice:not(:empty) { text-decoration: underline; }
662
823
  .error-notice, .msg { text-decoration: underline; }
663
- .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; }
664
842
  .connector-head {
665
843
  display: grid;
666
844
  gap: var(--gap);
@@ -671,14 +849,25 @@ ${clerkScript}
671
849
  display: flex;
672
850
  gap: .5rem;
673
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
+ }
674
861
  .card h2 { overflow-wrap: anywhere; }
675
862
  .connector-state { text-align: right; }
676
863
  .dot {
864
+ background: var(--paper);
677
865
  border: 1px solid var(--ink);
678
866
  display: inline-block;
679
867
  flex: none;
680
868
  height: .5rem;
681
869
  width: .5rem;
870
+ z-index: 1;
682
871
  }
683
872
  .dot.ok { background: var(--ink); }
684
873
  .dot.auth_required {
@@ -688,7 +877,8 @@ ${clerkScript}
688
877
  .connector-message,
689
878
  .connector-auth { margin-top: .75rem; }
690
879
 
691
- .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; }
692
882
  .credential-head {
693
883
  align-items: baseline;
694
884
  display: flex;
@@ -697,7 +887,27 @@ ${clerkScript}
697
887
  justify-content: space-between;
698
888
  }
699
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
+ }
700
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
+ }
701
911
  .credential-form {
702
912
  align-items: center;
703
913
  display: flex;
@@ -741,7 +951,8 @@ ${clerkScript}
741
951
  display: grid;
742
952
  gap: .25rem var(--gap);
743
953
  grid-template-columns: minmax(9rem, .85fr) minmax(12rem, 1.4fr) minmax(8rem, .9fr);
744
- padding: .75rem 0;
954
+ padding-bottom: .75rem;
955
+ padding-top: .75rem;
745
956
  }
746
957
  .activity-time,
747
958
  .activity-actor,
@@ -752,6 +963,16 @@ ${clerkScript}
752
963
  .activity-item.timeout .activity-outcome { text-decoration: underline; }
753
964
  .activity-empty { border-top: 1px solid var(--rule); padding: .75rem 0; }
754
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
+ }
755
976
 
756
977
  @media (max-width: 36.99rem) {
757
978
  .pgrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -760,7 +981,12 @@ ${clerkScript}
760
981
  .masthead .brand { grid-column: 1; }
761
982
  .mast-nav { grid-column: 1 / -1; grid-row: 2; justify-content: flex-start; }
762
983
  .product { display: none; }
763
- .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
+ }
764
990
  .lead { margin-top: 4rem; }
765
991
  .section,
766
992
  .section + .section { margin-top: 2.5rem; }
@@ -769,49 +995,58 @@ ${clerkScript}
769
995
  .activity-item { grid-template-columns: 1fr; }
770
996
  .connector-state { text-align: left; }
771
997
  .credential-field { align-items: start; grid-template-columns: 1fr; gap: .25rem; }
998
+ input { min-height: 2.75rem; }
772
999
  }
773
1000
  </style>
774
1001
  </head>
775
1002
  <body>
1003
+ <a class="skip-link" href="#operatorContent">Skip to operator page</a>
776
1004
  <header class="masthead shell pgrid">
777
1005
  ${owner}
778
1006
  <div class="mast-nav">
779
1007
  ${product}
780
- <nav id="appNav" class="mast-actions hidden" aria-label="Dashboard views">
781
- <button id="configTab" class="navlink active" type="button"
782
- aria-pressed="true">Connections</button>
783
- <button id="activityTab" class="navlink hidden" type="button"
784
- aria-pressed="false">Activity</button>
785
- <button id="change" class="navlink hidden" type="button">Change token</button>
786
- <button id="signout" class="navlink hidden" type="button">Sign out</button>
787
- </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>
788
1022
  </div>
789
1023
  </header>
790
1024
 
791
- <main id="gate" class="page shell hidden">
792
- <section class="lead pgrid">
793
- <h1 class="pcap">Tool connections</h1>
794
- <div class="pbody lead-copy">
795
- <p>${escapeHtmlAttr(brand.description)}</p>
796
- <p id="gateCopy" class="meta"></p>
797
- <div id="tokenGate" class="row gate-actions hidden">
798
- <input id="token" type="password" placeholder="Bearer token" autocomplete="off"
799
- aria-label="Bearer token">
800
- <button id="save" class="linklike" type="button">Open dashboard</button>
801
- </div>
802
- <div id="clerkGate" class="actions gate-actions hidden">
803
- <button id="signin" class="linklike" type="button">Team sign in</button>
804
- <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>
805
1042
  </div>
806
- <p id="err"></p>
807
1043
  </div>
808
1044
  </section>
809
- </main>
810
1045
 
811
- <main id="app" class="page shell hidden">
812
- <section id="configView">
1046
+ <div id="app" class="hidden">
1047
+ <section id="connectionsView"${page === "connections" ? "" : ' class="hidden"'}>
813
1048
  <div class="lead pgrid">
814
- <h1 class="pcap">MCP connection</h1>
1049
+ <h1 id="connectionsHeading" class="pcap" tabindex="-1">Connections</h1>
815
1050
  <div class="pbody lead-copy">
816
1051
  <p>Use this endpoint to give an MCP client access to the tools below.</p>
817
1052
  <div class="endpoint">
@@ -820,50 +1055,80 @@ ${clerkScript}
820
1055
  <button id="copyMcpUrl" class="linklike" type="button">Copy URL</button>
821
1056
  </div>
822
1057
  </div>
823
- <p class="cap" id="serverInfo">${escapeHtmlAttr(brand.productName)} status dashboard</p>
1058
+ <p class="cap" id="serverInfo">${escapeHtmlAttr(brand.productName)} operator</p>
824
1059
  </div>
825
1060
  </div>
826
- <section class="section pgrid" aria-labelledby="connectorsHeading">
827
- <h2 class="pcap" id="connectorsHeading">Connectors</h2>
1061
+ <section class="section pgrid" aria-labelledby="connectorLedgerHeading">
1062
+ <h2 class="pcap" id="connectorLedgerHeading">Connectors</h2>
828
1063
  <div class="pbody">
829
1064
  <div class="row toolbar">
830
1065
  <input id="filter" type="search" placeholder="Filter connectors or tools…"
831
1066
  aria-label="Filter connectors or tools">
832
1067
  </div>
833
- <p id="notice" role="status" aria-live="polite"></p>
834
- <div id="list" class="connector-tools"></div>
1068
+ <div id="list" class="connector-tools" aria-busy="false"></div>
835
1069
  </div>
836
1070
  </section>
837
1071
  </section>
838
- <section id="activityView" class="hidden">
1072
+
1073
+ <section id="credentialsView"${page === "credentials" ? "" : ' class="hidden"'}>
1074
+ <div class="lead pgrid">
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"'}>
839
1087
  <div class="lead pgrid">
840
- <h1 class="pcap">Tool activity</h1>
1088
+ <h1 id="activityHeading" class="pcap" tabindex="-1">Activity</h1>
841
1089
  <div class="pbody">
842
1090
  <p class="activity-copy" id="activitySummary">Arguments and results are never stored.</p>
843
- <div class="row activity-controls">
844
- <input id="activitySearch" type="search"
845
- placeholder="Search user, tool, or outcome…"
846
- aria-label="Search loaded activity">
847
- <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>
848
1105
  </div>
849
- <p id="activityNotice" class="meta" role="status" aria-live="polite"></p>
850
- <div id="activityList" class="activity-ledger"></div>
851
- <button id="moreActivity" class="linklike activity-more hidden" type="button">Load older</button>
852
1106
  </div>
853
1107
  </div>
854
1108
  </section>
1109
+ </div>
855
1110
  </main>
856
1111
 
857
1112
  <script${nonceAttr}>
858
1113
  const AUTH = ${jsonForInlineScript(auth)};
859
1114
  const MCP_URL = ${jsonForInlineScript(mcpUrl)};
1115
+ const INITIAL_PAGE = ${jsonForInlineScript(page)};
1116
+ const TITLE_SUFFIX = ${jsonForInlineScript(brand.pageTitle)};
860
1117
  const filterUiConnectors = ${filterUiConnectors.toString()};
861
1118
  const KEY = "connecta:token";
862
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
+ };
863
1125
  let DATA = null;
864
1126
  let ACTIVITY = [];
865
1127
  let ACTIVITY_CURSOR = null;
866
1128
  let ACTIVITY_LOADED = false;
1129
+ let CURRENT_PAGE = INITIAL_PAGE;
1130
+ let SESSION_GENERATION = 0;
1131
+ let ACTIVITY_GENERATION = 0;
867
1132
  $("mcpUrl").textContent = MCP_URL;
868
1133
 
869
1134
  function esc(s) {
@@ -887,8 +1152,9 @@ function formatDate(value) {
887
1152
  }
888
1153
 
889
1154
  function setNotice(message, isError) {
890
- $("notice").textContent = message || "";
891
- $("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");
892
1158
  }
893
1159
 
894
1160
  async function sessionToken() {
@@ -897,7 +1163,45 @@ async function sessionToken() {
897
1163
  : localStorage.getItem(KEY);
898
1164
  }
899
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
+
900
1203
  function showGate(msg) {
1204
+ clearIdentityState();
901
1205
  $("app").classList.add("hidden");
902
1206
  $("appNav").classList.add("hidden");
903
1207
  $("gate").classList.remove("hidden");
@@ -905,8 +1209,8 @@ function showGate(msg) {
905
1209
  if (AUTH.kind === "clerk") {
906
1210
  const signedIn = Boolean(window.Clerk && Clerk.user);
907
1211
  $("gateCopy").textContent = signedIn
908
- ? "Signed in with Clerk, but this account cannot open the dashboard."
909
- : "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.";
910
1214
  $("signin").classList.toggle("hidden", signedIn);
911
1215
  $("gateSignout").classList.toggle("hidden", !signedIn);
912
1216
  } else {
@@ -914,20 +1218,87 @@ function showGate(msg) {
914
1218
  }
915
1219
  }
916
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
+
917
1283
  async function load() {
1284
+ const generation = SESSION_GENERATION;
918
1285
  let token;
919
1286
  try {
920
1287
  token = await sessionToken();
921
1288
  } catch (e) {
1289
+ if (generation !== SESSION_GENERATION) return;
922
1290
  return showGate("Could not read the Clerk session: " + e.message);
923
1291
  }
1292
+ if (generation !== SESSION_GENERATION) return;
924
1293
  if (!token) return showGate("");
925
1294
  let res;
926
1295
  try {
927
1296
  res = await fetch("/ui/data", { headers: { Authorization: "Bearer " + token } });
928
1297
  } catch (e) {
1298
+ if (generation !== SESSION_GENERATION) return;
929
1299
  return showGate("Network error: " + e.message);
930
1300
  }
1301
+ if (generation !== SESSION_GENERATION) return;
931
1302
  if (res.status === 401 || res.status === 403) {
932
1303
  if (AUTH.kind === "clerk") {
933
1304
  return showGate(
@@ -940,25 +1311,22 @@ async function load() {
940
1311
  return showGate("Token rejected — enter a valid bearer token.");
941
1312
  }
942
1313
  if (!res.ok) return showGate("Error " + res.status);
943
- 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;
944
1323
  $("gate").classList.add("hidden");
945
1324
  $("app").classList.remove("hidden");
946
1325
  $("appNav").classList.remove("hidden");
947
1326
  const si = DATA.serverInfo || {};
948
1327
  $("serverInfo").textContent = (si.name || ${escapeScriptString(brand.productName)}) + " v" + (si.version || "?");
949
- $("activityTab").classList.toggle("hidden", !DATA.activityEnabled);
950
- render();
951
- }
952
-
953
- function showView(view) {
954
- const activity = view === "activity";
955
- $("configView").classList.toggle("hidden", activity);
956
- $("activityView").classList.toggle("hidden", !activity);
957
- $("configTab").classList.toggle("active", !activity);
958
- $("activityTab").classList.toggle("active", activity);
959
- $("configTab").setAttribute("aria-pressed", String(!activity));
960
- $("activityTab").setAttribute("aria-pressed", String(activity));
961
- if (activity && !ACTIVITY_LOADED) loadActivity(true);
1328
+ updateCapabilities();
1329
+ activatePage(CURRENT_PAGE);
962
1330
  }
963
1331
 
964
1332
  function actorLabel(actor) {
@@ -997,12 +1365,21 @@ function renderActivity() {
997
1365
  }
998
1366
  for (const event of visible) {
999
1367
  const item = document.createElement("article");
1000
- item.className = "activity-item " + esc(event.outcome);
1001
- 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
+ : "";
1002
1375
  const errorCopy = event.errorCode ? " · " + esc(event.errorCode) : "";
1003
1376
  item.innerHTML =
1004
- '<div><div class="activity-time">' + esc(formatDate(event.occurredAt)) +
1005
- '</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>' +
1006
1383
  '<div><div class="activity-address">' + esc(event.address) +
1007
1384
  '</div><div class="activity-detail">' + esc(event.source) + retryCopy +
1008
1385
  errorCopy + '</div></div>' +
@@ -1015,22 +1392,45 @@ function renderActivity() {
1015
1392
 
1016
1393
  async function loadActivity(reset) {
1017
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;
1018
1401
  $("activityNotice").textContent = "Loading activity…";
1402
+ $("activityNotice").setAttribute("role", "status");
1403
+ $("activityList").setAttribute("aria-busy", "true");
1019
1404
  $("refreshActivity").disabled = true;
1020
1405
  $("moreActivity").disabled = true;
1021
1406
  try {
1022
1407
  const token = await sessionToken();
1023
- if (!token) throw new Error("Your session has expired.");
1408
+ if (!isCurrent()) return;
1409
+ if (!token) return showGate("Your session has expired.");
1024
1410
  const cursor = reset ? null : ACTIVITY_CURSOR;
1025
1411
  const params = new URLSearchParams({ limit: "50" });
1026
1412
  if (cursor) params.set("cursor", cursor);
1027
1413
  const res = await fetch("/ui/activity?" + params, {
1028
1414
  headers: { Authorization: "Bearer " + token },
1029
1415
  });
1416
+ if (!isCurrent()) return;
1030
1417
  let payload = {};
1031
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
+ }
1032
1430
  if (!res.ok) {
1033
- 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
+ );
1034
1434
  }
1035
1435
  ACTIVITY = reset
1036
1436
  ? (payload.events || [])
@@ -1040,14 +1440,19 @@ async function loadActivity(reset) {
1040
1440
  $("activityNotice").textContent = "";
1041
1441
  renderActivity();
1042
1442
  } catch (e) {
1443
+ if (!isCurrent()) return;
1444
+ $("activityNotice").setAttribute("role", "alert");
1043
1445
  $("activityNotice").textContent = e.message || "Activity could not be loaded.";
1044
1446
  } finally {
1045
- $("refreshActivity").disabled = false;
1046
- $("moreActivity").disabled = false;
1447
+ if (isCurrent()) {
1448
+ $("activityList").setAttribute("aria-busy", "false");
1449
+ $("refreshActivity").disabled = false;
1450
+ $("moreActivity").disabled = false;
1451
+ }
1047
1452
  }
1048
1453
  }
1049
1454
 
1050
- function render() {
1455
+ function renderConnections() {
1051
1456
  const q = $("filter").value.trim().toLowerCase();
1052
1457
  const list = $("list");
1053
1458
  list.innerHTML = "";
@@ -1095,66 +1500,8 @@ function render() {
1095
1500
  esc(c.authorizationUrl) + "</p>";
1096
1501
  }
1097
1502
  if (c.credential) {
1098
- const cred = c.credential;
1099
- const configured = Boolean(cred.configured);
1100
- const removable = configured || Boolean(cred.removable);
1101
- const state = configured
1102
- ? "configured · ••••" + esc(cred.lastFour || "")
1103
- : "not configured";
1104
- const updated = configured && cred.updatedAt
1105
- ? " · updated " + esc(formatDate(cred.updatedAt))
1106
- : "";
1107
- head += '<section class="credential" aria-label="' + esc(cred.label) + '">';
1108
- head += '<div class="credential-head"><span class="credential-label">' +
1109
- esc(cred.label) + '</span><span class="credential-state">' + state +
1110
- updated + "</span></div>";
1111
- if (cred.description) {
1112
- head += '<p class="credential-copy meta">' + esc(cred.description) + "</p>";
1113
- }
1114
- if (cred.error) {
1115
- head += '<div class="msg">' + esc(cred.error) + "</div>";
1116
- }
1117
- if (AUTH.kind === "clerk") {
1118
- head += '<div class="credential-actions">';
1119
- head += '<button class="linklike" type="button" data-credential-action="edit" data-connector="' +
1120
- esc(c.id) + '">' + (removable ? "Replace" : "Add credential") + "</button>";
1121
- if (configured && cred.testable) {
1122
- head += '<button class="linklike" type="button" data-credential-action="test" data-connector="' +
1123
- esc(c.id) + '">Test</button>';
1124
- }
1125
- if (removable) {
1126
- head += '<button type="button" class="linklike danger" data-credential-action="remove" data-connector="' +
1127
- esc(c.id) + '">Remove</button>';
1128
- }
1129
- head += "</div>";
1130
- head += '<div class="credential-form hidden" data-credential-form="' +
1131
- esc(c.id) + '">';
1132
- if (cred.fields && cred.fields.length) {
1133
- head += '<div class="credential-fields">';
1134
- for (const field of cred.fields) {
1135
- head += '<div class="credential-field"><label>' +
1136
- esc(field.label) + '</label><input type="' +
1137
- esc(field.inputType || "password") + '" data-credential-field="' +
1138
- esc(field.name) + '" aria-label="' + esc(field.label) +
1139
- '" placeholder="' + esc(field.placeholder || field.label) +
1140
- '" autocomplete="' +
1141
- (field.inputType === "password" ? "new-password" : "off") +
1142
- '" autocapitalize="none" spellcheck="false"></div>';
1143
- }
1144
- head += "</div>";
1145
- } else {
1146
- head += '<input type="password" data-credential-input="' +
1147
- esc(c.id) + '" aria-label="' + esc(cred.label) + '" placeholder="' +
1148
- esc(cred.placeholder || "Paste credential") +
1149
- '" autocomplete="new-password" autocapitalize="none" spellcheck="false">';
1150
- }
1151
- head += '<button class="linklike" type="button" data-credential-action="save" data-connector="' +
1152
- esc(c.id) + '">Save</button><button class="linklike" type="button" data-credential-action="cancel" data-connector="' +
1153
- esc(c.id) + '">Cancel</button></div>';
1154
- } else {
1155
- head += '<p class="credential-copy meta">Team sign in is required to manage this credential.</p>';
1156
- }
1157
- head += "</section>";
1503
+ head += '<p class="connector-auth"><a class="linklike" href="/credentials" ' +
1504
+ 'data-operator-page="credentials">Manage credential →</a></p>';
1158
1505
  }
1159
1506
  let body = "";
1160
1507
  if (tools.length) {
@@ -1172,12 +1519,122 @@ function render() {
1172
1519
  list.appendChild(el);
1173
1520
  }
1174
1521
  if (!list.children.length) {
1175
- 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>";
1176
1527
  }
1177
1528
  }
1178
1529
 
1179
- async function credentialRequest(connector, method, action, body) {
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);
1630
+ }
1631
+ }
1632
+
1633
+ async function credentialRequest(connector, method, action, body, generation) {
1180
1634
  const token = await sessionToken();
1635
+ if (generation !== SESSION_GENERATION) {
1636
+ throw new Error("The operator session changed.");
1637
+ }
1181
1638
  if (!token) throw new Error("Your Clerk session has expired.");
1182
1639
  const suffix = action ? "/" + action : "";
1183
1640
  const res = await fetch(
@@ -1203,12 +1660,13 @@ function credentialForm(connector) {
1203
1660
  CSS.escape(connector) + '"]');
1204
1661
  }
1205
1662
 
1206
- $("list").onclick = async (event) => {
1663
+ $("credentialList").onclick = async (event) => {
1207
1664
  const button = event.target.closest("[data-credential-action]");
1208
1665
  if (!button) return;
1209
1666
  const connector = button.dataset.connector;
1210
1667
  const action = button.dataset.credentialAction;
1211
1668
  const form = credentialForm(connector);
1669
+ const generation = SESSION_GENERATION;
1212
1670
 
1213
1671
  if (action === "edit") {
1214
1672
  form.classList.remove("hidden");
@@ -1218,6 +1676,10 @@ $("list").onclick = async (event) => {
1218
1676
  if (action === "cancel") {
1219
1677
  form.querySelectorAll("input").forEach((input) => { input.value = ""; });
1220
1678
  form.classList.add("hidden");
1679
+ document.querySelector(
1680
+ '[data-credential-action="edit"][data-connector="' +
1681
+ CSS.escape(connector) + '"]'
1682
+ )?.focus();
1221
1683
  return;
1222
1684
  }
1223
1685
  if (action === "remove" && !window.confirm(
@@ -1225,6 +1687,7 @@ $("list").onclick = async (event) => {
1225
1687
  )) return;
1226
1688
 
1227
1689
  setNotice("");
1690
+ $("credentialList").setAttribute("aria-busy", "true");
1228
1691
  const buttons = [...document.querySelectorAll(
1229
1692
  '[data-connector="' + CSS.escape(connector) + '"]'
1230
1693
  )];
@@ -1239,42 +1702,65 @@ $("list").onclick = async (event) => {
1239
1702
  if (!value) throw new Error("Complete every credential field before saving.");
1240
1703
  values[input.dataset.credentialField] = value;
1241
1704
  }
1242
- await credentialRequest(connector, "PUT", "", { values });
1705
+ await credentialRequest(connector, "PUT", "", { values }, generation);
1243
1706
  } else {
1244
1707
  const input = form.querySelector("[data-credential-input]");
1245
1708
  const value = input.value.trim();
1246
1709
  if (!value) throw new Error("Paste a credential before saving.");
1247
- await credentialRequest(connector, "PUT", "", { value });
1710
+ await credentialRequest(connector, "PUT", "", { value }, generation);
1248
1711
  }
1712
+ if (generation !== SESSION_GENERATION) return;
1249
1713
  form.querySelectorAll("input").forEach((input) => { input.value = ""; });
1250
1714
  setNotice("Credential saved.");
1251
1715
  await load();
1716
+ if (generation !== SESSION_GENERATION) return;
1717
+ $("credentialNotice").focus();
1252
1718
  } else if (action === "remove") {
1253
- await credentialRequest(connector, "DELETE");
1719
+ await credentialRequest(connector, "DELETE", "", null, generation);
1720
+ if (generation !== SESSION_GENERATION) return;
1254
1721
  setNotice("Credential removed.");
1255
1722
  await load();
1723
+ if (generation !== SESSION_GENERATION) return;
1724
+ $("credentialNotice").focus();
1256
1725
  } else if (action === "test") {
1257
- 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;
1258
1734
  setNotice(
1259
1735
  result.message || (result.ok ? "Credential is valid." : "Credential test failed."),
1260
1736
  !result.ok,
1261
1737
  );
1262
1738
  }
1263
1739
  } catch (e) {
1740
+ if (generation !== SESSION_GENERATION) return;
1264
1741
  setNotice(e.message || "Credential action failed.", true);
1265
1742
  } finally {
1266
- 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
+ }
1267
1747
  }
1268
1748
  };
1269
1749
 
1270
- $("save").onclick = () => {
1750
+ $("save").onclick = async () => {
1271
1751
  const v = $("token").value.trim();
1272
1752
  if (!v) return;
1753
+ clearIdentityState();
1273
1754
  localStorage.setItem(KEY, v);
1274
1755
  $("token").value = "";
1275
- load();
1756
+ await load();
1757
+ if (DATA) $(CURRENT_PAGE + "Heading").focus();
1758
+ };
1759
+ $("change").onclick = () => {
1760
+ localStorage.removeItem(KEY);
1761
+ showGate("");
1762
+ $("token").focus();
1276
1763
  };
1277
- $("change").onclick = () => { localStorage.removeItem(KEY); showGate(""); };
1278
1764
  $("copyMcpUrl").onclick = async () => {
1279
1765
  const button = $("copyMcpUrl");
1280
1766
  try {
@@ -1289,14 +1775,35 @@ $("signin").onclick = () => Clerk.redirectToSignIn({
1289
1775
  signInFallbackRedirectUrl: window.location.href,
1290
1776
  signUpFallbackRedirectUrl: window.location.href,
1291
1777
  });
1292
- $("gateSignout").onclick = () => Clerk.signOut({ redirectUrl: window.location.href });
1293
- $("signout").onclick = () => Clerk.signOut({ redirectUrl: window.location.href });
1294
- $("filter").oninput = () => { if (DATA) render(); };
1295
- $("configTab").onclick = () => showView("config");
1296
- $("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(); };
1297
1785
  $("refreshActivity").onclick = () => loadActivity(true);
1298
1786
  $("moreActivity").onclick = () => loadActivity(false);
1299
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
+ });
1300
1807
 
1301
1808
  async function init() {
1302
1809
  if (AUTH.kind === "clerk") {
@@ -1320,6 +1827,7 @@ async function init() {
1320
1827
  $("tokenGate").classList.remove("hidden");
1321
1828
  $("change").classList.remove("hidden");
1322
1829
  }
1830
+ activatePage(pageForPath(window.location.pathname));
1323
1831
  await load();
1324
1832
  }
1325
1833