dsh-keys-manager 0.2.1 → 0.2.3

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.
package/lib/client.js CHANGED
@@ -44,9 +44,20 @@ var zh = {
44
44
  title: "\u5BC6\u94A5\u7BA1\u7406",
45
45
  subtitle: "\u4E3A\u540C\u4E00\u4E2A Provider \u914D\u7F6E\u591A\u4E2A API Key\u3002\u5355\u4F1A\u8BDD\u56FA\u5B9A\u4F7F\u7528\u4E00\u4E2A Key\uFF0C\u65B0\u4F1A\u8BDD\u8F6E\u6362\uFF1B\u5931\u8D25\u540E\u81EA\u52A8\u51B7\u5374\u5E76\u6062\u590D\u3002",
46
46
  search: "\u641C\u7D22 Provider\uFF08\u540D\u79F0\u3001ID \u6216\u73AF\u5883\u53D8\u91CF\uFF09",
47
+ filterAll: "\u5168\u90E8",
48
+ filterBuiltin: "\u5185\u7F6E",
49
+ filterPlugin: "\u63D2\u4EF6",
50
+ filterOauth: "\u652F\u6301 OAuth",
51
+ filterTransport: "\u4F20\u8F93",
52
+ filterGroup: "\u7B5B\u9009",
53
+ // Unattributed requests (I-000120, D8): the compass's fixed copy, shared
54
+ // with the filter plan's dictionary task — one pair, never a second.
55
+ unattributed: "{n} \u65E0\u5F52\u5C5E",
56
+ unattributedHint: "\u8BE5\u8DEF\u7531\u5728\u5F52\u5C5E\u8BFB\u53D6\u5C1A\u672A\u843D\u5730\uFF08\u6216\u5DF2\u5931\u8D25\uFF09\u671F\u95F4\u5E94\u7B54\u7684\u6D41\u5C1D\u8BD5\u6570\uFF1B\u5931\u8D25\u8F6C\u79FB\u91CD\u8BD5\u4F1A\u5404\u8BA1\u4E00\u6B21\uFF0C\u6545\u5355\u4E2A\u8BF7\u6C42\u53EF\u80FD\u88AB\u8BA1\u591A\u6B21\u3002\u8BFB\u53D6\u5B8C\u6210\u5F52\u5C5E\u540E\u6E05\u96F6\u3002",
47
57
  managed: "\u5DF2\u63A5\u7BA1",
48
58
  available: "\u53EF\u6DFB\u52A0 Providers",
49
59
  managedEmpty: "\u8FD8\u6CA1\u6709\u63A5\u7BA1\u7684 Provider\u3002\u4ECE\u4E0B\u65B9\u9009\u62E9\u4E00\u4E2A\u5F00\u59CB\u3002",
60
+ managedFilteredEmpty: "\u5F53\u524D\u7B5B\u9009\u4E0B\u6CA1\u6709\u5DF2\u63A5\u7BA1\u7684 Provider\u3002",
50
61
  availableEmpty: "\u6CA1\u6709\u5339\u914D\u7684 Provider\u3002",
51
62
  keys: "\u5BC6\u94A5",
52
63
  healthy: "\u53EF\u7528",
@@ -175,6 +186,12 @@ var zh = {
175
186
  accountProjected: "\u670D\u52A1\u4E2D",
176
187
  accountReauth: "\u91CD\u65B0\u8BA4\u8BC1",
177
188
  accountActivate: "\u8BBE\u4E3A\u5F53\u524D",
189
+ testKey: "\u6D4B\u8BD5\u8FDE\u63A5",
190
+ testRunning: "\u6D4B\u8BD5\u4E2D\u2026",
191
+ testPassed: "\u8FDE\u63A5\u6B63\u5E38 \xB7 {ms} ms",
192
+ testFailed: "\u8FDE\u63A5\u5931\u8D25\uFF1A{error}",
193
+ testTimedOut: "\u6D4B\u8BD5\u8D85\u65F6\uFF0C\u672A\u6536\u5230\u7ED3\u679C",
194
+ testFailedUnknown: "\u6D4B\u8BD5\u672A\u5B8C\u6210\uFF0C\u672A\u8FD4\u56DE\u539F\u56E0",
178
195
  loginTitle: "\u8D26\u6237\u767B\u5F55",
179
196
  loginPending: "\u7B49\u5F85\u6388\u6743\u2026",
180
197
  loginBrowserOpen: "\u5728\u6D4F\u89C8\u5668\u4E2D\u6253\u5F00",
@@ -185,16 +202,38 @@ var zh = {
185
202
  loginReady: "\u767B\u5F55\u6210\u529F\u3002",
186
203
  deleteAccountTitle: "\u5220\u9664\u8D26\u6237\u201C{alias}\u201D\uFF1F",
187
204
  deleteAccountBody: "\u8FD9\u4F1A\u4ECE\u51ED\u8BC1\u5E93\u4E2D\u5220\u9664\u8BE5\u8D26\u6237\u4FDD\u5B58\u7684 OAuth \u6388\u6743\u3002\u5982\u679C\u5B83\u662F\u5F53\u524D\u8D26\u6237\uFF0C\u8BE5 Provider \u8DEF\u7531\u5C06\u505C\u7528\u5B83\u5E76\u56DE\u843D\u5230 API-key \u6C60\uFF1BProvider \u4ECD\u4FDD\u6301\u63A5\u7BA1\u72B6\u6001\u3002",
188
- deleteAccountBodyNoKeys: "\u8FD9\u4F1A\u4ECE\u51ED\u8BC1\u5E93\u4E2D\u5220\u9664\u8BE5\u8D26\u6237\u4FDD\u5B58\u7684 OAuth \u6388\u6743\u3002\u8BE5\u8DEF\u7531\u6CA1\u6709 API-key \u6C60\uFF0C\u5220\u9664\u540E\u5C06\u505C\u6B62\u670D\u52A1\uFF0C\u76F4\u5230\u91CD\u65B0\u767B\u5F55\u8D26\u6237\u3002"
205
+ deleteAccountBodyNoKeys: "\u8FD9\u4F1A\u4ECE\u51ED\u8BC1\u5E93\u4E2D\u5220\u9664\u8BE5\u8D26\u6237\u4FDD\u5B58\u7684 OAuth \u6388\u6743\u3002\u8BE5\u8DEF\u7531\u6CA1\u6709 API-key \u6C60\uFF0C\u5220\u9664\u540E\u5C06\u505C\u6B62\u670D\u52A1\uFF0C\u76F4\u5230\u91CD\u65B0\u767B\u5F55\u8D26\u6237\u3002",
206
+ // Transport accounts (llm-omp-transports/<route>): managed beside the
207
+ // pi-ai pool, so the copy never promises a key-pool fallback.
208
+ transportEmpty: "\u8FD8\u6CA1\u6709\u4F20\u8F93\u8D26\u6237\u3002",
209
+ transportProviderHint: "\u8BE5\u8DEF\u7531\u7531\u4F20\u8F93\u63D2\u4EF6\u63D0\u4F9B\uFF0C\u51ED\u8BC1\u5C31\u5728\u672C\u5361\u7247\u4E2D\u7BA1\u7406\u3002",
210
+ transportTag: "\u4F20\u8F93",
211
+ transportSignIn: "\u767B\u5F55",
212
+ transportExpires: "\u6709\u6548\u671F\u81F3 {time}",
213
+ transportStatusActive: "\u6B63\u5E38",
214
+ transportStatusExpired: "\u4EE4\u724C\u5DF2\u8FC7\u671F",
215
+ transportStatusNeedsReauth: "\u9700\u91CD\u65B0\u767B\u5F55",
216
+ transportStatusSignedOut: "\u672A\u767B\u5F55",
217
+ transportRemoveTitle: "\u79FB\u9664 {name} \u4F20\u8F93\u8D26\u6237\uFF1F",
218
+ transportRemoveBody: "\u8FD9\u4F1A\u4ECE\u51ED\u8BC1\u5E93\u4E2D\u5220\u9664\u8BE5\u4F20\u8F93\u8DEF\u7531\u4FDD\u5B58\u7684\u6388\u6743\uFF0C\u5E76\u53D6\u6D88\u4ECD\u5728\u8FDB\u884C\u7684\u767B\u5F55\uFF1B\u8DEF\u7531\u81EA\u8EAB\u4E0D\u4F1A\u91CD\u5EFA\u5B83\u3002\u8981\u6062\u590D\u670D\u52A1\u9700\u8981\u91CD\u65B0\u767B\u5F55\u3002"
189
219
  };
190
220
  var en = {
191
221
  nav: "Keys Manager",
192
222
  title: "Keys Manager",
193
223
  subtitle: "Give one provider several API keys. A session keeps one key; new sessions rotate; failures cool down and recover automatically.",
194
224
  search: "Search providers by name, id, or environment variable",
225
+ filterAll: "All",
226
+ filterBuiltin: "Built-in",
227
+ filterPlugin: "Plugin",
228
+ filterOauth: "OAuth-capable",
229
+ filterTransport: "Transport",
230
+ filterGroup: "Filters",
231
+ unattributed: "{n} unattributed",
232
+ unattributedHint: "Stream attempts this route served with no account owner because its ownership read had not landed (or had failed). A failover retry is a new attempt, so one request can be counted more than once. The count clears when a read attributes the route.",
195
233
  managed: "Managed",
196
234
  available: "Available providers",
197
235
  managedEmpty: "No managed providers yet. Adopt one below to start.",
236
+ managedFilteredEmpty: "No managed providers match the current filter.",
198
237
  availableEmpty: "No matching providers.",
199
238
  keys: "keys",
200
239
  healthy: "healthy",
@@ -323,6 +362,12 @@ var en = {
323
362
  accountProjected: "Serving provider",
324
363
  accountReauth: "Re-authenticate",
325
364
  accountActivate: "Set active",
365
+ testKey: "Test",
366
+ testRunning: "Testing\u2026",
367
+ testPassed: "Test passed \xB7 {ms} ms",
368
+ testFailed: "Test failed: {error}",
369
+ testTimedOut: "The test timed out without a result",
370
+ testFailedUnknown: "The test did not complete and reported no reason",
326
371
  loginTitle: "Account sign-in",
327
372
  loginPending: "Waiting for authorization\u2026",
328
373
  loginBrowserOpen: "Open in browser",
@@ -333,7 +378,19 @@ var en = {
333
378
  loginReady: "Signed in.",
334
379
  deleteAccountTitle: "Delete account \u201C{alias}\u201D?",
335
380
  deleteAccountBody: "This removes the account\u2019s stored OAuth grant from the credential store. If it is the active account, the provider route stops serving it and falls back to its API-key pool; the provider stays managed.",
336
- deleteAccountBodyNoKeys: "This removes the account\u2019s stored OAuth grant from the credential store. This route has no API-key pool, so it stops serving requests until an account is signed in again."
381
+ deleteAccountBodyNoKeys: "This removes the account\u2019s stored OAuth grant from the credential store. This route has no API-key pool, so it stops serving requests until an account is signed in again.",
382
+ // Transport accounts — same values as locale/en.json.
383
+ transportEmpty: "No transport accounts yet.",
384
+ transportProviderHint: "Served by a transport plugin: its credential is managed inside this card.",
385
+ transportTag: "Transport",
386
+ transportSignIn: "Sign in",
387
+ transportExpires: "Expires {time}",
388
+ transportStatusActive: "Active",
389
+ transportStatusExpired: "Token expired",
390
+ transportStatusNeedsReauth: "Sign in again",
391
+ transportStatusSignedOut: "Not signed in",
392
+ transportRemoveTitle: "Remove the {name} transport account?",
393
+ transportRemoveBody: "This removes the grant stored for this transport route from the credential store and cancels a sign-in still in progress; the route does not recreate it. Sign in again to restore service."
337
394
  };
338
395
  var LOCALE_DICTS = { zh, en };
339
396
 
@@ -351,6 +408,13 @@ var CSS = `
351
408
  .km-stat{display:flex;flex-direction:column;gap:2px;padding:11px 14px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1)}
352
409
  .km-stat strong{font-size:19px;line-height:1.15;font-weight:650;font-variant-numeric:tabular-nums}
353
410
  .km-stat span{color:var(--dsw-alias-label-secondary);font-size:12px}
411
+ .km-filters{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 12px}
412
+ .km-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font:inherit;font-size:12.5px;cursor:pointer}
413
+ .km-chip:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
414
+ .km-chip:focus-visible{border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 2px color-mix(in srgb,var(--dsw-alias-state-business-primary) 20%,transparent)}
415
+ .km-chip-on{border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 55%,var(--dsw-alias-border-l1));background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary)}
416
+ .km-chip-count{font-size:11.5px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}
417
+ .km-chip-on .km-chip-count{color:inherit}
354
418
  .km-search{position:relative;display:flex;align-items:center;margin:0 0 20px}
355
419
  .km-search-icon{position:absolute;left:12px;color:var(--dsw-alias-label-secondary);pointer-events:none}
356
420
  .km-search-input{width:100%;box-sizing:border-box;padding:10px 38px 10px 36px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;outline:none}
@@ -584,6 +648,18 @@ function messageOf(error) {
584
648
  if (error instanceof Error && error.message.length > 0) return error.message;
585
649
  return String(error);
586
650
  }
651
+ var TEST_KEY_DEADLINE_MS = 3e4;
652
+ async function withDeadline(work, ms, label) {
653
+ let timer;
654
+ const deadline = new Promise((_, reject) => {
655
+ timer = setTimeout(() => reject(new Error(`${label} did not answer within ${ms / 1e3}s`)), ms);
656
+ });
657
+ try {
658
+ return await Promise.race([work, deadline]);
659
+ } finally {
660
+ if (timer !== void 0) clearTimeout(timer);
661
+ }
662
+ }
587
663
  function createStore(ctx) {
588
664
  let snapshot = {
589
665
  status: "idle",
@@ -595,6 +671,10 @@ function createStore(ctx) {
595
671
  let loadSequence = 0;
596
672
  let mutationTail = Promise.resolve();
597
673
  let queuedMutations = 0;
674
+ let activeTests = 0;
675
+ let testGeneration = 0;
676
+ let mutationGeneration = 0;
677
+ let stateDropped = false;
598
678
  const listeners = /* @__PURE__ */ new Set();
599
679
  const emit = () => {
600
680
  for (const listener of [...listeners]) {
@@ -614,6 +694,7 @@ function createStore(ctx) {
614
694
  update({ busy: method, error: null });
615
695
  const run = async () => {
616
696
  ++loadSequence;
697
+ mutationGeneration = testGeneration;
617
698
  try {
618
699
  const result = await rpc(method, args);
619
700
  if (!result.ok) throw new Error(result.error.message);
@@ -627,6 +708,7 @@ function createStore(ctx) {
627
708
  return { ok: false, error: message };
628
709
  } finally {
629
710
  queuedMutations -= 1;
711
+ if (queuedMutations === 0) refreshDroppedState();
630
712
  }
631
713
  };
632
714
  const next = mutationTail.then(run, run);
@@ -635,10 +717,21 @@ function createStore(ctx) {
635
717
  };
636
718
  const stateOutcome = (value) => {
637
719
  const state = value;
720
+ const borrowed = activeTests > 0 || testGeneration !== mutationGeneration;
721
+ if (borrowed) {
722
+ if (queuedMutations === 1) update({ busy: null, error: null });
723
+ else update({ error: null });
724
+ stateDropped = true;
725
+ return { ok: true, value: state };
726
+ }
638
727
  if (queuedMutations === 1) update({ status: "ready", state, now: Date.now(), busy: null, error: null });
639
728
  else update({ status: "ready", state, now: Date.now(), error: null });
640
729
  return { ok: true, value: state };
641
730
  };
731
+ const refreshDroppedState = () => {
732
+ if (!stateDropped || activeTests > 0 || snapshot.busy !== null) return;
733
+ void store.load({ keepError: true });
734
+ };
642
735
  const rpcRead = async (method, args) => {
643
736
  try {
644
737
  return await rpc(method, args);
@@ -649,7 +742,14 @@ function createStore(ctx) {
649
742
  const attemptOutcome = (value) => {
650
743
  return { ok: true, value: value ?? null };
651
744
  };
652
- return {
745
+ const testOutcome = (value) => {
746
+ const result = value ?? {};
747
+ return {
748
+ ok: true,
749
+ value: { ok: result.ok === true, latencyMs: result.latencyMs ?? 0, ...result.error === void 0 ? {} : { error: result.error } }
750
+ };
751
+ };
752
+ const store = {
653
753
  getSnapshot: () => snapshot,
654
754
  subscribe(listener) {
655
755
  listeners.add(listener);
@@ -658,12 +758,18 @@ function createStore(ctx) {
658
758
  async load(options = {}) {
659
759
  if (snapshot.busy !== null) return;
660
760
  const request = ++loadSequence;
761
+ const generation = testGeneration;
661
762
  update({ busy: "load", error: options.keepError === true ? snapshot.error : null });
662
763
  try {
663
764
  const result = await rpc("getState", {});
664
765
  if (request !== loadSequence) return;
766
+ if (activeTests > 0 || testGeneration !== generation) {
767
+ update({ busy: null });
768
+ return;
769
+ }
665
770
  if (!result.ok) throw new Error(result.error.message);
666
771
  update({ status: "ready", state: result.value, now: Date.now(), busy: null, error: null });
772
+ stateDropped = false;
667
773
  } catch (error) {
668
774
  if (request !== loadSequence) return;
669
775
  update({ status: snapshot.state !== null ? snapshot.status : "error", busy: null, error: messageOf(error) });
@@ -694,6 +800,36 @@ function createStore(ctx) {
694
800
  removeAccount(provider, accountId) {
695
801
  return send("removeAccount", { provider, accountId }, stateOutcome);
696
802
  },
803
+ async testKey(provider, keyId) {
804
+ activeTests += 1;
805
+ ++testGeneration;
806
+ const request = rpc("testKey", { provider, keyId });
807
+ let released = false;
808
+ const release = () => {
809
+ if (released) return;
810
+ released = true;
811
+ activeTests -= 1;
812
+ testGeneration += 1;
813
+ refreshDroppedState();
814
+ };
815
+ void request.then(release, release);
816
+ try {
817
+ const result = await withDeadline(request, TEST_KEY_DEADLINE_MS, "testKey");
818
+ if (!result.ok) throw new Error(result.error.message);
819
+ return testOutcome(result.value);
820
+ } catch (error) {
821
+ return { ok: false, error: messageOf(error) };
822
+ }
823
+ },
824
+ beginTransportLogin(route) {
825
+ return send("beginTransportLogin", { provider: route }, attemptOutcome);
826
+ },
827
+ activateTransportAccount(route) {
828
+ return send("activateTransportAccount", { provider: route }, stateOutcome);
829
+ },
830
+ removeTransportAccount(route) {
831
+ return send("removeTransportAccount", { provider: route }, stateOutcome);
832
+ },
697
833
  tick() {
698
834
  update({ now: Date.now() });
699
835
  if (snapshot.status === "ready" && snapshot.busy === null) void this.load({ keepError: true });
@@ -702,6 +838,7 @@ function createStore(ctx) {
702
838
  update({ error: null });
703
839
  }
704
840
  };
841
+ return store;
705
842
  }
706
843
 
707
844
  // src/client/ui.ts
@@ -765,6 +902,37 @@ function providerInitial(provider) {
765
902
  function oauthCapable(provider) {
766
903
  return provider.nativeAuth?.oauth === true && provider.accountSupported !== false;
767
904
  }
905
+ function originOf(provider) {
906
+ return provider.origin ?? (provider.extra ? "plugin" : "dsh");
907
+ }
908
+ var FILTER_CHIPS = [
909
+ { value: "all", label: "filterAll" },
910
+ { value: "builtin", label: "filterBuiltin" },
911
+ { value: "plugin", label: "filterPlugin" },
912
+ { value: "oauth", label: "filterOauth" },
913
+ { value: "transport", label: "filterTransport" }
914
+ ];
915
+ function matchesFilter(provider, filter) {
916
+ switch (filter) {
917
+ case "transport":
918
+ return provider.transport === true;
919
+ case "builtin":
920
+ return provider.transport !== true && originOf(provider) === "dsh";
921
+ case "plugin":
922
+ return provider.transport !== true && originOf(provider) === "plugin";
923
+ case "oauth":
924
+ return provider.transport !== true && oauthCapable(provider);
925
+ default:
926
+ return true;
927
+ }
928
+ }
929
+ function orphanTransportAccounts(providers, transportAccounts) {
930
+ return transportAccounts.filter((account) => !providers.some((provider) => provider.transport === true && provider.provider === account.id));
931
+ }
932
+ function managedEntryCount(providers, transportAccounts) {
933
+ const cards = providers.filter((provider) => provider.managed || provider.transport === true).length;
934
+ return cards + orphanTransportAccounts(providers, transportAccounts).length;
935
+ }
768
936
  function accountIdentityOf(key, provider) {
769
937
  const oauth = key.oauth;
770
938
  if (oauth !== null && oauth !== void 0) {
@@ -791,6 +959,30 @@ function statusKey(status) {
791
959
  return "keyStatusUnmanaged";
792
960
  }
793
961
  }
962
+ function transportStatusKey(status) {
963
+ switch (status) {
964
+ case "active":
965
+ return "transportStatusActive";
966
+ case "expired":
967
+ return "transportStatusExpired";
968
+ case "needs-reauth":
969
+ return "transportStatusNeedsReauth";
970
+ default:
971
+ return "transportStatusSignedOut";
972
+ }
973
+ }
974
+ function transportStatusTone(status) {
975
+ switch (status) {
976
+ case "active":
977
+ return "success";
978
+ case "expired":
979
+ return "warn";
980
+ case "needs-reauth":
981
+ return "error";
982
+ default:
983
+ return "idle";
984
+ }
985
+ }
794
986
  function statusTone(status) {
795
987
  switch (status) {
796
988
  case "active":
@@ -836,6 +1028,7 @@ function Icon({ name, size = 16, className }) {
836
1028
  if (name === "warning") return h("svg", common, h("path", { d: "M12 3l9.5 17h-19L12 3zM12 9v5M12 17.2v.1" }));
837
1029
  if (name === "shield") return h("svg", common, h("path", { d: "M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6l8-3zM9 12l2 2 4-4" }));
838
1030
  if (name === "user") return h("svg", common, h("circle", { cx: 12, cy: 8, r: 3.6 }), h("path", { d: "M5 20a7 7 0 0114 0" }));
1031
+ if (name === "activity") return h("svg", common, h("path", { d: "M3 12h4l2.5-7 4 14 2.5-7h5" }));
839
1032
  return h("svg", common);
840
1033
  }
841
1034
  function Button({ variant = "secondary", size = "normal", type = "button", disabled, onClick, children, title }) {
@@ -1039,6 +1232,35 @@ function KeyForm({ provider, onDone, autoFocus = false }) {
1039
1232
  )
1040
1233
  );
1041
1234
  }
1235
+ var ROW_TEST_STYLE = { gridColumn: "1 / -1", marginTop: "-2px" };
1236
+ var ROW_TEST_DEADLINE_MS = 3e4;
1237
+ async function requestRowTest(store, provider, keyId) {
1238
+ let timer;
1239
+ const deadline = new Promise((resolve) => {
1240
+ timer = setTimeout(() => resolve({ kind: "failed", timedOut: true }), ROW_TEST_DEADLINE_MS);
1241
+ });
1242
+ const answer = store.testKey(provider, keyId).then((outcome) => {
1243
+ if (!outcome.ok) return { kind: "failed", error: outcome.error };
1244
+ if (outcome.value.ok) return { kind: "passed", latencyMs: outcome.value.latencyMs };
1245
+ return { kind: "failed", ...outcome.value.error === void 0 ? {} : { error: outcome.value.error } };
1246
+ });
1247
+ try {
1248
+ return await Promise.race([answer, deadline]);
1249
+ } finally {
1250
+ if (timer !== void 0) clearTimeout(timer);
1251
+ }
1252
+ }
1253
+ function testLine(test, t) {
1254
+ if (test === null) return null;
1255
+ if (test.kind === "running") {
1256
+ return h("div", { className: "km-key-time km-muted", role: "status", style: ROW_TEST_STYLE }, t("testRunning"));
1257
+ }
1258
+ if (test.kind === "passed") {
1259
+ return h("div", { className: "km-key-time", role: "status", style: ROW_TEST_STYLE }, t("testPassed", { ms: test.latencyMs ?? 0 }));
1260
+ }
1261
+ const detail = test.error !== void 0 && test.error.length > 0 ? test.error : test.timedOut === true ? t("testTimedOut") : t("testFailedUnknown");
1262
+ return h("div", { className: "km-key-time km-warn", role: "status", style: ROW_TEST_STYLE }, t("testFailed", { error: detail }));
1263
+ }
1042
1264
  function KeyRow({ provider, keyRecord, now }) {
1043
1265
  const key = keyRecord;
1044
1266
  const runtime = useRuntime();
@@ -1050,6 +1272,7 @@ function KeyRow({ provider, keyRecord, now }) {
1050
1272
  const [confirm, setConfirm] = import_react2.default.useState(false);
1051
1273
  const [busy, setBusy] = import_react2.default.useState(false);
1052
1274
  const [error, setError] = import_react2.default.useState(null);
1275
+ const [test, setTest] = import_react2.default.useState(null);
1053
1276
  const rootRef = import_react2.default.useRef(null);
1054
1277
  import_react2.default.useEffect(() => {
1055
1278
  setAlias(key.alias);
@@ -1094,6 +1317,10 @@ function KeyRow({ provider, keyRecord, now }) {
1094
1317
  if (!result.ok) setError(result.error);
1095
1318
  else setConfirm(false);
1096
1319
  }
1320
+ async function runTest() {
1321
+ setTest({ kind: "running" });
1322
+ setTest(await requestRowTest(runtime.store, provider.provider, key.id));
1323
+ }
1097
1324
  const cold = key.status === "cooling" && key.cooldownUntil > now;
1098
1325
  const meta = key.status === "missing" ? t("keyStatusMissing") : key.totalUses > 0 ? t("uses", { n: key.totalUses }) : "";
1099
1326
  return h(
@@ -1169,6 +1396,16 @@ function KeyRow({ provider, keyRecord, now }) {
1169
1396
  setMenu(false);
1170
1397
  setEditing(true);
1171
1398
  } }, h(Icon, { name: "key", size: 15 }), t("rename")),
1399
+ h("button", {
1400
+ type: "button",
1401
+ className: "km-menu-item",
1402
+ role: "menuitem",
1403
+ onClick: () => {
1404
+ setMenu(false);
1405
+ void runTest();
1406
+ },
1407
+ disabled: busy || test?.kind === "running"
1408
+ }, h(Icon, { name: "activity", size: 15 }), t("testKey")),
1172
1409
  h("button", {
1173
1410
  type: "button",
1174
1411
  className: "km-menu-item",
@@ -1187,6 +1424,7 @@ function KeyRow({ provider, keyRecord, now }) {
1187
1424
  ) : null
1188
1425
  ),
1189
1426
  error !== null ? h("div", { className: "km-key-error", role: "alert" }, error) : null,
1427
+ testLine(test, t),
1190
1428
  confirm ? h(Modal, {
1191
1429
  title: t("deleteTitle", { alias: key.alias }),
1192
1430
  onClose: () => setConfirm(false),
@@ -1208,6 +1446,7 @@ function AccountRow({ provider, keyRecord, now, onReauthenticate }) {
1208
1446
  const [confirm, setConfirm] = import_react2.default.useState(false);
1209
1447
  const [busy, setBusy] = import_react2.default.useState(false);
1210
1448
  const [error, setError] = import_react2.default.useState(null);
1449
+ const [test, setTest] = import_react2.default.useState(null);
1211
1450
  const rootRef = import_react2.default.useRef(null);
1212
1451
  import_react2.default.useEffect(() => {
1213
1452
  if (!menu) return void 0;
@@ -1239,6 +1478,10 @@ function AccountRow({ provider, keyRecord, now, onReauthenticate }) {
1239
1478
  if (!result.ok) setError(result.error);
1240
1479
  else setConfirm(false);
1241
1480
  }
1481
+ async function runTest() {
1482
+ setTest({ kind: "running" });
1483
+ setTest(await requestRowTest(runtime.store, provider.provider, key.id));
1484
+ }
1242
1485
  const cold = key.status === "cooling" && key.cooldownUntil > now;
1243
1486
  const metaParts = [
1244
1487
  key.totalUses > 0 ? t("uses", { n: key.totalUses }) : null,
@@ -1313,6 +1556,16 @@ function AccountRow({ provider, keyRecord, now, onReauthenticate }) {
1313
1556
  // active again is a no-op, so the entry stays disabled.
1314
1557
  disabled: busy || key.projected === true
1315
1558
  }, h(Icon, { name: "check", size: 15 }), t("accountActivate")),
1559
+ h("button", {
1560
+ type: "button",
1561
+ className: "km-menu-item",
1562
+ role: "menuitem",
1563
+ onClick: () => {
1564
+ setMenu(false);
1565
+ void runTest();
1566
+ },
1567
+ disabled: busy || test?.kind === "running"
1568
+ }, h(Icon, { name: "activity", size: 15 }), t("testKey")),
1316
1569
  h("button", {
1317
1570
  type: "button",
1318
1571
  className: "km-menu-item",
@@ -1331,6 +1584,7 @@ function AccountRow({ provider, keyRecord, now, onReauthenticate }) {
1331
1584
  ) : null
1332
1585
  ),
1333
1586
  error !== null ? h("div", { className: "km-key-error", role: "alert" }, error) : null,
1587
+ testLine(test, t),
1334
1588
  confirm ? h(Modal, {
1335
1589
  // SF4: account-specific copy — the grant removal consequence, not a
1336
1590
  // key-deletion/provider-release promise. Step 8: the key-pool
@@ -1346,7 +1600,115 @@ function AccountRow({ provider, keyRecord, now, onReauthenticate }) {
1346
1600
  }, h("p", { className: "km-modal-copy" }, t(provider.supported === true ? "deleteAccountBody" : "deleteAccountBodyNoKeys"))) : null
1347
1601
  );
1348
1602
  }
1349
- function ProviderCard({ provider, expanded, onToggle, now }) {
1603
+ function TransportAccountRow({ account }) {
1604
+ const runtime = useRuntime();
1605
+ const t = useT();
1606
+ const locale = useLocaleId();
1607
+ const [busy, setBusy] = import_react2.default.useState(false);
1608
+ const [error, setError] = import_react2.default.useState(null);
1609
+ const [confirm, setConfirm] = import_react2.default.useState(false);
1610
+ const [login, setLogin] = import_react2.default.useState(null);
1611
+ async function signIn() {
1612
+ setBusy(true);
1613
+ setError(null);
1614
+ const result = await runtime.store.beginTransportLogin(account.id);
1615
+ setBusy(false);
1616
+ if (!result.ok) {
1617
+ setError(result.error);
1618
+ return;
1619
+ }
1620
+ if (result.value !== null) setLogin({ attemptId: result.value.id });
1621
+ }
1622
+ async function remove() {
1623
+ setBusy(true);
1624
+ setError(null);
1625
+ const result = await runtime.store.removeTransportAccount(account.id);
1626
+ setBusy(false);
1627
+ if (!result.ok) setError(result.error);
1628
+ else setConfirm(false);
1629
+ }
1630
+ const identity = account.email ?? account.accountId ?? account.displayName;
1631
+ const tone = transportStatusTone(account.status);
1632
+ return h(
1633
+ "div",
1634
+ { className: "km-account-row km-transport-row" },
1635
+ h(
1636
+ "div",
1637
+ { className: "km-key-identity" },
1638
+ h(
1639
+ "div",
1640
+ { className: "km-key-alias-line" },
1641
+ h("span", { className: "km-account-glyph", "aria-hidden": true }, h(Icon, { name: "user", size: 13 })),
1642
+ h("span", { className: "km-key-alias" }, identity),
1643
+ h("span", { className: "km-tag km-tag-accent" }, t("transportTag"))
1644
+ ),
1645
+ // The credential scope, never the payload: the record is the account.
1646
+ h(
1647
+ "div",
1648
+ { className: "km-key-sub" },
1649
+ h("span", { className: "km-mono km-muted" }, account.scope)
1650
+ )
1651
+ ),
1652
+ h(
1653
+ "div",
1654
+ { className: "km-key-status" },
1655
+ h(
1656
+ "span",
1657
+ { className: cx("km-status", `km-status-${tone}`) },
1658
+ h("span", { className: cx("km-dot", `km-dot-${tone}`) }),
1659
+ h("span", null, t(transportStatusKey(account.status)))
1660
+ )
1661
+ ),
1662
+ // The expiry column stays a clock, not a countdown: the route's adapter
1663
+ // renews the grant itself, so "expired" here means "renews on next use".
1664
+ h(
1665
+ "div",
1666
+ { className: "km-key-last" },
1667
+ account.expiresAt === void 0 ? h("div", { className: "km-key-time km-muted" }, t("never")) : h("div", { className: "km-key-time" }, t("transportExpires", { time: formatClock(account.expiresAt, locale, t) }))
1668
+ ),
1669
+ h(
1670
+ "div",
1671
+ { className: "km-key-actions" },
1672
+ h(
1673
+ Button,
1674
+ {
1675
+ size: "small",
1676
+ disabled: busy,
1677
+ onClick: signIn,
1678
+ title: account.active ? t("accountReauth") : t("transportSignIn")
1679
+ },
1680
+ busy ? h(Icon, { name: "refresh", size: 14, className: "km-spin" }) : h(Icon, { name: "refresh", size: 14 }),
1681
+ t(account.active ? "accountReauth" : "transportSignIn")
1682
+ ),
1683
+ // No "Set active" entry: activation would write nothing (the stored grant
1684
+ // already IS the route's live credential), so the host endpoint stays for
1685
+ // API symmetry with the account surface and the row omits a no-effect
1686
+ // control.
1687
+ h(Button, {
1688
+ size: "small",
1689
+ variant: "danger",
1690
+ disabled: busy,
1691
+ onClick: () => setConfirm(true)
1692
+ }, h(Icon, { name: "trash", size: 14 }), t("delete"))
1693
+ ),
1694
+ error !== null ? h("div", { className: "km-key-error", role: "alert" }, error) : null,
1695
+ confirm ? h(Modal, {
1696
+ title: t("transportRemoveTitle", { name: account.displayName }),
1697
+ onClose: () => setConfirm(false),
1698
+ busy,
1699
+ footer: [
1700
+ h(Button, { key: "cancel", onClick: () => setConfirm(false), disabled: busy }, t("cancel")),
1701
+ h(Button, { key: "delete", variant: "danger", onClick: remove, disabled: busy }, busy ? t("deleting") : t("deleteConfirm"))
1702
+ ]
1703
+ }, h("p", { className: "km-modal-copy" }, t("transportRemoveBody"))) : null,
1704
+ login !== null ? h(LoginDialog, {
1705
+ provider: { displayName: account.displayName },
1706
+ attemptId: login.attemptId,
1707
+ onClose: () => setLogin(null)
1708
+ }) : null
1709
+ );
1710
+ }
1711
+ function ProviderCard({ provider, expanded, onToggle, now, transportAccount }) {
1350
1712
  const runtime = useRuntime();
1351
1713
  const t = useT();
1352
1714
  const [adding, setAdding] = import_react2.default.useState(false);
@@ -1361,11 +1723,15 @@ function ProviderCard({ provider, expanded, onToggle, now }) {
1361
1723
  const keyCount = provider.keyCount;
1362
1724
  const warning = provider.warning;
1363
1725
  const isExtra = provider.extra;
1364
- const origin = provider.origin ?? (provider.extra ? "plugin" : "dsh");
1365
- const originLabel = origin === "plugin" ? t("pluginBuiltin") : origin === "dsh" ? t("dshBuiltin") : t("customProvider");
1726
+ const transport = provider.transport === true;
1727
+ const origin = originOf(provider);
1728
+ const originLabel = transport || origin === "plugin" ? t("pluginBuiltin") : origin === "dsh" ? t("dshBuiltin") : t("customProvider");
1366
1729
  const providerMeta = [
1367
1730
  provider.provider,
1368
- provider.active ? t("active") : provider.configured ? t("configured") : t("notConfigured"),
1731
+ // `active`/`configured` are read off the route's profile or the host's
1732
+ // provider list, neither of which describes a transport route: its state
1733
+ // is the account row it carries (signed out when the host lists none).
1734
+ transport ? t(transportStatusKey(transportAccount?.status ?? "signed-out")) : provider.active ? t("active") : provider.configured ? t("configured") : t("notConfigured"),
1369
1735
  provider.api ?? null
1370
1736
  ].filter((value) => Boolean(value));
1371
1737
  async function adopt() {
@@ -1435,8 +1801,9 @@ function ProviderCard({ provider, expanded, onToggle, now }) {
1435
1801
  { className: "km-provider-title-line" },
1436
1802
  h("span", { className: "km-provider-name" }, provider.displayName || provider.provider),
1437
1803
  h("span", { className: "km-tag" }, originLabel),
1804
+ transport ? h("span", { className: "km-tag km-tag-accent" }, t("transportTag")) : null,
1438
1805
  managed ? h("span", { className: "km-tag km-tag-accent" }, t("managedBadge")) : null,
1439
- !managed && !provider.supported && !accountAdoptable ? h("span", { className: "km-tag km-tag-warn" }, t("unsupported")) : null
1806
+ !transport && !managed && !provider.supported && !accountAdoptable ? h("span", { className: "km-tag km-tag-warn" }, t("unsupported")) : null
1440
1807
  ),
1441
1808
  h(
1442
1809
  "span",
@@ -1450,7 +1817,14 @@ function ProviderCard({ provider, expanded, onToggle, now }) {
1450
1817
  h(
1451
1818
  "span",
1452
1819
  { className: "km-provider-side" },
1453
- managed ? h("span", { className: "km-count" }, countText(t, keyCount, "providerKeyOne", "providerKeys")) : h("span", { className: "km-count km-muted" }, adoptable ? t("unmanaged") : t("unsupported")),
1820
+ // I-000120 / D8: requests this route served without an attributed
1821
+ // account. Rendered only above zero, and the visible label carries the
1822
+ // count so it is the accessible name; the hint stays a description.
1823
+ (provider.unattributedRequests ?? 0) > 0 ? h("span", {
1824
+ className: "km-tag km-tag-warn km-unattributed",
1825
+ title: t("unattributedHint")
1826
+ }, t("unattributed", { n: provider.unattributedRequests ?? 0 })) : null,
1827
+ transport ? null : managed ? h("span", { className: "km-count" }, countText(t, keyCount, "providerKeyOne", "providerKeys")) : h("span", { className: "km-count km-muted" }, adoptable ? t("unmanaged") : t("unsupported")),
1454
1828
  managed ? h(StatusPill, { status: provider.status, t }) : null,
1455
1829
  h(Icon, { name: "chevron", size: 16, className: cx("km-chevron", expanded && "km-chevron-open") })
1456
1830
  )
@@ -1460,7 +1834,9 @@ function ProviderCard({ provider, expanded, onToggle, now }) {
1460
1834
  { className: "km-provider-body" },
1461
1835
  error !== null ? h("div", { className: "km-inline-error", role: "alert" }, error) : null,
1462
1836
  warning !== void 0 || broken ? h(Notice, { tone: "warn", title: t("warning") }, broken ? t("bindingBroken") : warning) : null,
1463
- !managed ? h(
1837
+ // A transport route has no adopt step and no key pool: its whole
1838
+ // credential surface is the one account row its own plugin serves.
1839
+ transport ? transportAccount !== void 0 ? h(TransportAccountRow, { account: transportAccount }) : h("div", { className: "km-muted" }, t("transportProviderHint")) : !managed ? h(
1464
1840
  "div",
1465
1841
  { className: "km-adopt" },
1466
1842
  h(
@@ -1876,6 +2252,7 @@ function KeysManagerBody() {
1876
2252
  const state = snapshot.state;
1877
2253
  const now = snapshot.now;
1878
2254
  const [query, setQuery] = import_react2.default.useState("");
2255
+ const [filter, setFilter] = import_react2.default.useState("all");
1879
2256
  const [showAllAvailable, setShowAllAvailable] = import_react2.default.useState(false);
1880
2257
  const [expanded, setExpanded] = import_react2.default.useState(() => /* @__PURE__ */ new Set());
1881
2258
  const loadedRef = import_react2.default.useRef(false);
@@ -1885,6 +2262,7 @@ function KeysManagerBody() {
1885
2262
  if (snapshot.status === "idle") void runtime.store.load();
1886
2263
  }, [runtime, snapshot.status]);
1887
2264
  const providers = state?.providers ?? [];
2265
+ const transportAccounts = state?.transportAccounts ?? [];
1888
2266
  const normalizedQuery = query.trim().toLowerCase();
1889
2267
  const matches = (provider) => {
1890
2268
  if (normalizedQuery.length === 0) return true;
@@ -1898,9 +2276,23 @@ function KeysManagerBody() {
1898
2276
  ].filter((value) => Boolean(value)).join(" ").toLowerCase();
1899
2277
  return haystack.includes(normalizedQuery);
1900
2278
  };
1901
- const managed = providers.filter((provider) => provider.managed && matches(provider));
1902
- const available = providers.filter((provider) => !provider.managed && matches(provider));
2279
+ const managed = providers.filter((provider) => (provider.managed || provider.transport === true) && matchesFilter(provider, filter) && matches(provider));
2280
+ const available = providers.filter((provider) => !provider.managed && provider.transport !== true && matchesFilter(provider, filter) && matches(provider));
1903
2281
  const visibleAvailable = showAllAvailable || normalizedQuery.length > 0 ? available : available.slice(0, 12);
2282
+ const matchesAccount = (account) => {
2283
+ if (normalizedQuery.length === 0) return true;
2284
+ const haystack = [account.id, account.scope, account.displayName].join(" ").toLowerCase();
2285
+ return haystack.includes(normalizedQuery);
2286
+ };
2287
+ const orphanAccounts = orphanTransportAccounts(providers, transportAccounts).filter(matchesAccount);
2288
+ const visibleOrphans = filter === "all" || filter === "transport" ? orphanAccounts : [];
2289
+ const filterCount = (value) => {
2290
+ const searchable = providers.filter(matches);
2291
+ if (value === "all") return searchable.length + orphanAccounts.length;
2292
+ if (value === "transport") return searchable.filter((provider) => provider.transport === true).length + orphanAccounts.length;
2293
+ return searchable.filter((provider) => matchesFilter(provider, value)).length;
2294
+ };
2295
+ const managedHiddenByChip = filter !== "all" && providers.some((provider) => provider.managed || provider.transport === true);
1904
2296
  function toggle(provider) {
1905
2297
  setExpanded((current) => {
1906
2298
  const next = new Set(current);
@@ -1939,7 +2331,7 @@ function KeysManagerBody() {
1939
2331
  h(
1940
2332
  "div",
1941
2333
  { className: "km-summary" },
1942
- h("div", { className: "km-stat" }, h("strong", null, loading ? "\u2014" : state?.managedCount ?? 0), h("span", null, t("managed"))),
2334
+ h("div", { className: "km-stat" }, h("strong", null, loading ? "\u2014" : managedEntryCount(providers, transportAccounts)), h("span", null, t("managed"))),
1943
2335
  h("div", { className: "km-stat" }, h("strong", null, loading ? "\u2014" : state?.keyCount ?? 0), h("span", null, t("keys"))),
1944
2336
  h("div", { className: "km-stat" }, h("strong", null, loading ? "\u2014" : state?.healthyKeyCount ?? 0), h("span", null, t("healthy"))),
1945
2337
  h("div", { className: "km-stat" }, h("strong", null, loading ? "\u2014" : state?.coolingKeyCount ?? 0), h("span", null, t("cooling")))
@@ -1956,6 +2348,26 @@ function KeysManagerBody() {
1956
2348
  }, h(Icon, { name: "refresh", size: 14 }), t("refresh"))
1957
2349
  )
1958
2350
  ),
2351
+ // Quick filter: single select, view state only, composed with the text
2352
+ // search below. Counts are derived per predicate from the snapshot.
2353
+ !loading && !failed ? h("div", {
2354
+ className: "km-filters",
2355
+ role: "group",
2356
+ "aria-label": t("filterGroup")
2357
+ }, FILTER_CHIPS.map(
2358
+ (chip) => h(
2359
+ "button",
2360
+ {
2361
+ key: chip.value,
2362
+ type: "button",
2363
+ className: cx("km-chip", `km-chip-${chip.value}`, filter === chip.value && "km-chip-on"),
2364
+ "aria-pressed": filter === chip.value,
2365
+ onClick: () => setFilter(chip.value)
2366
+ },
2367
+ h("span", { className: "km-chip-label" }, t(chip.label)),
2368
+ h("span", { className: "km-chip-count" }, filterCount(chip.value))
2369
+ )
2370
+ )) : null,
1959
2371
  h(
1960
2372
  "div",
1961
2373
  { className: "km-search" },
@@ -1985,15 +2397,29 @@ function KeysManagerBody() {
1985
2397
  "div",
1986
2398
  { className: "km-section-head" },
1987
2399
  h("h2", { className: "km-section-title" }, t("managed")),
1988
- h("span", { className: "km-section-count" }, managed.length)
2400
+ // What is below it, like the available section: the tile above keeps
2401
+ // the global total.
2402
+ h("span", { className: "km-section-count" }, managed.length + visibleOrphans.length)
1989
2403
  ),
1990
- managed.length === 0 ? h("div", { className: "km-empty" }, h(Icon, { name: "key", size: 20 }), h("span", null, t("managedEmpty"))) : h("div", { className: "km-provider-list" }, managed.map((provider) => h(ProviderCard, {
2404
+ // The placeholder only when the section renders neither a card nor a row
2405
+ // the active chip selects.
2406
+ managed.length === 0 && visibleOrphans.length === 0 ? h("div", { className: "km-empty" }, h(Icon, { name: "key", size: 20 }), h(
2407
+ "span",
2408
+ null,
2409
+ normalizedQuery.length > 0 ? t("noSearch") : filter === "transport" ? t("transportEmpty") : managedHiddenByChip ? t("managedFilteredEmpty") : t("managedEmpty")
2410
+ )) : null,
2411
+ managed.length > 0 ? h("div", { className: "km-provider-list" }, managed.map((provider) => h(ProviderCard, {
1991
2412
  key: provider.provider,
1992
2413
  provider,
1993
2414
  now,
2415
+ transportAccount: provider.transport === true ? transportAccounts.find((account) => account.id === provider.provider) : void 0,
1994
2416
  expanded: expanded.has(provider.provider),
1995
2417
  onToggle: () => toggle(provider.provider)
1996
- })))
2418
+ }))) : null,
2419
+ visibleOrphans.length > 0 ? h("div", { className: "km-provider-list" }, visibleOrphans.map((account) => h(TransportAccountRow, {
2420
+ key: account.scope,
2421
+ account
2422
+ }))) : null
1997
2423
  ) : null,
1998
2424
  !loading && !failed ? h(
1999
2425
  "section",
@@ -2036,8 +2462,9 @@ function KeysManagerCard({ view }) {
2036
2462
  if (view === "summary") {
2037
2463
  const state = snapshot.state;
2038
2464
  if (state === null) return h("span", { className: "km-summary-line" }, snapshot.status === "error" ? t("loadError") : t("loading"));
2039
- if (state.managedCount === 0) return h("span", { className: "km-summary-line" }, t("summaryEmpty"));
2040
- return h("span", { className: "km-summary-line" }, t("summaryText", { providers: state.managedCount, keys: state.keyCount }));
2465
+ const managed = managedEntryCount(state.providers, state.transportAccounts ?? []);
2466
+ if (managed === 0) return h("span", { className: "km-summary-line" }, t("summaryEmpty"));
2467
+ return h("span", { className: "km-summary-line" }, t("summaryText", { providers: managed, keys: state.keyCount }));
2041
2468
  }
2042
2469
  return h(KeysManagerBody);
2043
2470
  }