@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
@@ -5,7 +5,7 @@
5
5
  // something *observed* a failure, so an expired or revoked token surfaced
6
6
  // mid-task as a failed agent call. A liveness check asks the connector whether
7
7
  // the credential it holds still works, records the verdict, and lets the cached
8
- // status surfaces (`list_connectors({ probe: false })`, `/ui`) report
8
+ // status surfaces (`list_connectors({ probe: false })`, operator pages) report
9
9
  // `auth_required` BEFORE a real call discovers it.
10
10
  //
11
11
  // Runtime-agnostic on purpose: nothing here schedules itself. The core exposes a
@@ -15,7 +15,12 @@
15
15
  // There is no background daemon and no long-lived timer, so Workers and Node run
16
16
  // the same code.
17
17
 
18
+ import {
19
+ credentialTestRule,
20
+ storedCredentialShape,
21
+ } from "./credentials.js";
18
22
  import type { CredentialVault } from "./credentials.js";
23
+ import { closeConnectorScope } from "./connector-scope.js";
19
24
  import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs, withTimeout } from "./timeout.js";
20
25
  import type {
21
26
  Connector,
@@ -48,8 +53,9 @@ export interface CredentialHealthRecord {
48
53
  * through an explicit `ids` request, and reported rather than dropped so a
49
54
  * typo in a scheduled check is visible instead of silent.
50
55
  * - `not_checkable` — it stores no credential connecta manages, or exposes no
51
- * usable way to ask: neither `status()` nor a credential test hook that fits
52
- * the stored value's shape.
56
+ * usable way to ask: neither `status()` nor a credential test hook the
57
+ * declared credential shape can use (`credentialTestRule`), against a value
58
+ * actually stored under it.
53
59
  * - `no_credential` — checkable, but nothing is stored yet: there is no
54
60
  * credential whose liveness could be in question, and probing would start an
55
61
  * OAuth flow nobody asked for.
@@ -326,7 +332,10 @@ export interface CredentialCheckOptions {
326
332
  force?: boolean;
327
333
  /** Restrict the sweep to these connector ids. Default: every connector. */
328
334
  ids?: string[];
329
- /** Request-scope identity to reuse a connector's per-request resources. */
335
+ /**
336
+ * Internal scope identity supplied by an existing owner. When omitted, the
337
+ * check creates and ends its own probe scope.
338
+ */
330
339
  requestScope?: object;
331
340
  }
332
341
 
@@ -337,46 +346,66 @@ export interface CredentialCheckOptions {
337
346
  * Deliberately narrow. `listTools`/`callTool` are NOT liveness probes here: a
338
347
  * tool call may mutate downstream state, and the catalog path is already covered
339
348
  * by the existing probe. So a connector is checkable only through the two hooks
340
- * that exist to answer exactly this question — `testCredential(s)` (what /ui's
349
+ * that exist to answer exactly this question — `testCredential(s)` (what the
341
350
  * Test button runs) and `status()` — and only when it has a credential of ours
342
351
  * to be asked about: an operator-managed `credential`, or a stored downstream
343
352
  * grant it reports via `hasStoredCredential`. A static-token connector stores
344
353
  * nothing here and is never probed on a timer.
354
+ *
355
+ * Whether a test hook counts is `credentialTestRule`'s call, not this function's
356
+ * Credentials Test button and the credential API read (issue #55), so
357
+ * a credential the operator cannot test by hand is not one a sweep tests behind
358
+ * their back. A connector whose only hook cannot test its declared shape is
359
+ * checkable only if it also implements `status()`.
345
360
  */
346
361
  export function isCheckableConnector(connector: Connector): boolean {
347
362
  const hasCredentialStore = Boolean(
348
363
  connector.credential || connector.hasStoredCredential,
349
364
  );
350
365
  const canAsk = Boolean(
351
- (connector.credential &&
352
- (connector.testCredentials || connector.testCredential)) ||
353
- connector.status,
366
+ credentialTestRule(connector).mode !== null || connector.status,
354
367
  );
355
368
  return hasCredentialStore && canAsk;
356
369
  }
357
370
 
358
371
  /**
359
- * The credential test that fits the STORED value's shape, or undefined.
372
+ * The credential test the connector's DECLARED shape selects, bound to what is
373
+ * actually stored — or undefined when there is no honest question to put.
360
374
  *
361
375
  * `isCheckableConnector` answers the static question ("could this connector be
362
- * asked at all"); this answers it against what is actually in the vault. The gap
363
- * that matters is a connector with named fields but only the single-value
364
- * `testCredential` hook: handing it `values.value` the reserved single-value
365
- * field, absent here would test the empty string and record a confident
366
- * `auth_required` about a credential nothing examined. The credential API
367
- * refuses that same shape with a 409 rather than testing it; here the connector
368
- * is skipped rather than given an invented verdict.
376
+ * asked at all"); this answers it against the vault. The hook itself is picked
377
+ * by `credentialTestRule` (src/credentials.ts, issue #55), the one rule the UI's
378
+ * `testable` flag and `POST /ui/credentials/<id>/test` also read: named
379
+ * `credential.fields` are tested as a set by `testCredentials`, a single-value
380
+ * `credential` by `testCredential` on the vault's reserved `value` field, and
381
+ * the other hook is never substituted. Substituting it is what a sweep must not
382
+ * do quietly handing `testCredential` a `values.value` that named fields never
383
+ * wrote would test the empty string and record a confident `auth_required` about
384
+ * a credential nothing examined, and handing `testCredentials` the reserved
385
+ * `{ value }` map would call a hook with a shape its connector never declared.
386
+ * Either way the connector is skipped (`not_checkable`) rather than given an
387
+ * invented verdict, and `createConnecta` already warned about the mismatch at
388
+ * construction.
369
389
  */
370
390
  function testHookFor(
371
391
  connector: Connector,
372
392
  values: ConnectorCredentialValues | null,
373
393
  ): ((ctx: ConnectorContext) => Promise<CredentialTestResult>) | undefined {
374
- if (!values) return undefined;
375
- if (connector.testCredentials) {
376
- return (ctx) => connector.testCredentials!(values, ctx);
394
+ if (
395
+ !connector.credential ||
396
+ storedCredentialShape(connector.credential, values).state !== "valid"
397
+ ) {
398
+ return undefined;
399
+ }
400
+ const storedValues = values!;
401
+ const { mode } = credentialTestRule(connector);
402
+ if (mode === "multiple") {
403
+ return (ctx) => connector.testCredentials!(storedValues, ctx);
377
404
  }
378
- if (connector.testCredential && typeof values.value === "string") {
379
- return (ctx) => connector.testCredential!(values.value, ctx);
405
+ // A single-value shape with nothing under the reserved field is still nothing
406
+ // to test, so the stored value gets the last word even when the rule fits.
407
+ if (mode === "single" && typeof storedValues.value === "string") {
408
+ return (ctx) => connector.testCredential!(storedValues.value, ctx);
380
409
  }
381
410
  return undefined;
382
411
  }
@@ -417,8 +446,8 @@ async function mapWithConcurrency<T, R>(
417
446
  * triggered sweep per interval per isolate, and never two at once, so a burst
418
447
  * of requests costs one sweep, not one per request.
419
448
  * 4. **Deadline + fan-out bound** — each check is bounded by `timeoutMs` and at
420
- * most `concurrency` run together (the same shape as the `probeTimeoutMs`
421
- * bound on the discovery fan-out, issue #19).
449
+ * most `concurrency` run together (the same shape as the
450
+ * `discovery.probeTimeoutMs` bound on the discovery fan-out, issue #19).
422
451
  */
423
452
  export class CredentialHealthChecker {
424
453
  private readonly store: CredentialHealthStore;
@@ -557,16 +586,12 @@ export class CredentialHealthChecker {
557
586
  ...(await this.recordOrNothing(connectorId)),
558
587
  };
559
588
  }
560
- if (!opts.force) {
561
- const current = await this.store.get(connectorId);
562
- if (
563
- current &&
564
- Date.now() - Date.parse(current.checkedAt) < this.intervalMs
565
- ) {
566
- return { connectorId, skipped: "fresh", record: current };
567
- }
568
- }
569
- const run = this.runCheck(connector, baseUrl, opts.requestScope);
589
+ const run = this.runCheck(
590
+ connector,
591
+ baseUrl,
592
+ opts.force ?? false,
593
+ opts.requestScope,
594
+ );
570
595
  this.inFlight.set(connectorId, run);
571
596
  try {
572
597
  return await run;
@@ -585,63 +610,118 @@ export class CredentialHealthChecker {
585
610
  private async runCheck(
586
611
  connector: Connector,
587
612
  baseUrl: string,
613
+ force: boolean,
588
614
  requestScope?: object,
589
615
  ): Promise<CredentialCheckResult> {
590
616
  const connectorId = connector.id;
591
617
  const started = Date.now();
592
- // Captured BEFORE anything downstream happens: everything after this point
593
- // is a window in which the operator may replace the very credential being
594
- // judged, and `settle` fences the write against exactly that.
618
+ // Captured BEFORE anything downstream happens including the vault read
619
+ // below: everything after this point is a window in which the operator may
620
+ // replace the very credential being judged, and `settle` fences the write
621
+ // against exactly that. It cannot move later to save a read on the fresh
622
+ // path; a generation sampled AFTER the values would miss a `clear` that
623
+ // landed between the two and let a verdict about the replaced credential
624
+ // through the fence.
595
625
  const generation = await this.store.generation(connectorId);
596
- const ctx = this.deps.contextFor(connectorId, baseUrl, requestScope);
597
626
  let values: ConnectorCredentialValues | null = null;
627
+ let credentialReadError: unknown;
598
628
  if (connector.credential && this.deps.credentialVault) {
599
629
  try {
600
630
  values = await this.deps.credentialVault.getAll(connectorId);
601
631
  } catch (err) {
602
- // A stored credential that cannot be decrypted (rotated key, corrupt
603
- // envelope) is exactly the kind of dead credential this feature exists
604
- // to surface early, so it is a verdict rather than a skip.
632
+ credentialReadError = err;
633
+ }
634
+ const shape = storedCredentialShape(connector.credential, values);
635
+ if (shape.state === "mismatch") {
636
+ // Drift is a persistent operator-error state, not an event: left
637
+ // outside the freshness gate it would spend a write on every sweep in
638
+ // every isolate, forever, against exactly the deployments this feature
639
+ // is meant to help (and on Cloudflare KV those writes are metered).
640
+ // Once the SAME drift verdict is already stored and still fresh, this
641
+ // is the rate limit doing its job, same as a fresh `ok`. A stored
642
+ // verdict that says anything else — a pre-redeploy `ok`, a different
643
+ // message — is still replaced immediately, which is the whole point of
644
+ // checking the shape before the gate.
645
+ if (!force) {
646
+ const current = await this.store.get(connectorId);
647
+ if (
648
+ current &&
649
+ current.state === "error" &&
650
+ current.message === shape.message &&
651
+ Date.now() - Date.parse(current.checkedAt) < this.intervalMs
652
+ ) {
653
+ return { connectorId, skipped: "fresh", record: current };
654
+ }
655
+ }
605
656
  return this.settle(connectorId, started, generation, {
606
- state: "auth_required",
657
+ state: "error",
607
658
  checkedAt: new Date().toISOString(),
608
- message: msg(err),
659
+ message: shape.message,
609
660
  });
610
661
  }
611
662
  }
612
- const stored = connector.hasStoredCredential
613
- ? await connector
614
- .hasStoredCredential(ctx)
615
- .catch(() => values !== null)
616
- : values !== null;
617
- if (!stored) return { connectorId, skipped: "no_credential" };
618
- if (!this.canAsk(connector, values)) {
619
- return { connectorId, skipped: "not_checkable" };
663
+ // Shape drift is checked before this shortcut so an old, still-fresh `ok`
664
+ // cannot survive a redeploy that changed the credential declaration.
665
+ if (!force) {
666
+ const current = await this.store.get(connectorId);
667
+ if (
668
+ current &&
669
+ Date.now() - Date.parse(current.checkedAt) < this.intervalMs
670
+ ) {
671
+ return { connectorId, skipped: "fresh", record: current };
672
+ }
620
673
  }
621
-
674
+ const ownsScope = requestScope === undefined;
675
+ const scope = requestScope ?? {};
676
+ const ctx = this.deps.contextFor(connectorId, baseUrl, scope);
622
677
  try {
623
- const verdict = await withTimeout(
624
- this.probe(connector, ctx, values),
625
- this.timeoutMs,
626
- `credential check of "${connectorId}"`,
627
- );
628
- return await this.settle(connectorId, started, generation, {
629
- ...verdict,
630
- checkedAt: new Date().toISOString(),
631
- });
632
- } catch (err) {
633
- return await this.settle(connectorId, started, generation, {
634
- state: "error",
635
- checkedAt: new Date().toISOString(),
636
- message: msg(err),
637
- });
678
+ if (credentialReadError) {
679
+ // A stored credential that cannot be decrypted (rotated key, corrupt
680
+ // envelope) is exactly the kind of dead credential this feature
681
+ // exists to surface early, so it is a verdict rather than a skip.
682
+ return this.settle(connectorId, started, generation, {
683
+ state: "auth_required",
684
+ checkedAt: new Date().toISOString(),
685
+ message: msg(credentialReadError),
686
+ });
687
+ }
688
+ const stored = connector.hasStoredCredential
689
+ ? await connector
690
+ .hasStoredCredential(ctx)
691
+ .catch(() => values !== null)
692
+ : values !== null;
693
+ if (!stored) return { connectorId, skipped: "no_credential" };
694
+ if (!this.canAsk(connector, values)) {
695
+ return { connectorId, skipped: "not_checkable" };
696
+ }
697
+
698
+ try {
699
+ const verdict = await withTimeout(
700
+ this.probe(connector, ctx, values),
701
+ this.timeoutMs,
702
+ `credential check of "${connectorId}"`,
703
+ );
704
+ return await this.settle(connectorId, started, generation, {
705
+ ...verdict,
706
+ checkedAt: new Date().toISOString(),
707
+ });
708
+ } catch (err) {
709
+ return await this.settle(connectorId, started, generation, {
710
+ state: "error",
711
+ checkedAt: new Date().toISOString(),
712
+ message: msg(err),
713
+ });
714
+ }
715
+ } finally {
716
+ if (ownsScope) await closeConnectorScope(connector, ctx);
638
717
  }
639
718
  }
640
719
 
641
720
  /**
642
- * `isCheckableConnector` re-asked against what is actually stored: a hook that
643
- * fits the value's shape (see {@link testHookFor}), or a `status()` to fall
644
- * back on. Neither ⇒ there is no honest question to put to this connector.
721
+ * `isCheckableConnector` re-asked against what is actually stored: the hook
722
+ * the declared shape selects, bound to a value that fits it (see
723
+ * {@link testHookFor}), or a `status()` to fall back on. Neither ⇒ there is no
724
+ * honest question to put to this connector.
645
725
  */
646
726
  private canAsk(
647
727
  connector: Connector,
@@ -670,12 +750,12 @@ export class CredentialHealthChecker {
670
750
  }
671
751
  // A rejected stored credential needs an operator, not a retry — the same
672
752
  // actionable state a revoked OAuth grant reports. There is no consent URL
673
- // for a vault credential; /ui's credential form is where it is replaced.
753
+ // for a vault credential; /credentials is where it is replaced.
674
754
  return {
675
755
  state: "auth_required",
676
756
  message:
677
757
  result.message ??
678
- "Stored credential was rejected by the connector — replace it in /ui.",
758
+ "Stored credential was rejected by the connector — replace it in /credentials.",
679
759
  };
680
760
  }
681
761
  const status = await connector.status!(ctx);
@@ -1,4 +1,9 @@
1
- import type { ConnectorCredentialValues, KVStorage } from "./types.js";
1
+ import type {
2
+ Connector,
3
+ ConnectorCredentialConfig,
4
+ ConnectorCredentialValues,
5
+ KVStorage,
6
+ } from "./types.js";
2
7
 
3
8
  const KEY_BYTES = 32;
4
9
  const IV_BYTES = 12;
@@ -35,6 +40,164 @@ export interface CredentialMetadata {
35
40
  fields?: Record<string, CredentialFieldMetadata>;
36
41
  }
37
42
 
43
+ /** Which hook a testable credential is checked with. */
44
+ export type CredentialTestMode = "single" | "multiple";
45
+
46
+ /** Operator-safe explanation shared by every surface that detects shape drift. */
47
+ export const STORED_CREDENTIAL_SHAPE_MISMATCH_ERROR =
48
+ "Stored credential fields do not match this connector's current declaration. Replace the credential before using or testing this connector.";
49
+
50
+ export type StoredCredentialShape =
51
+ | { state: "missing" }
52
+ | {
53
+ state: "valid";
54
+ mode: CredentialTestMode;
55
+ /**
56
+ * Stored keys the connector no longer declares, sorted. Harmless — the
57
+ * credential works — but worth telling an operator about, since nothing
58
+ * else in `/ui` can show a field the declaration has stopped naming.
59
+ */
60
+ undeclared: string[];
61
+ }
62
+ | {
63
+ state: "mismatch";
64
+ mode: CredentialTestMode;
65
+ message: typeof STORED_CREDENTIAL_SHAPE_MISMATCH_ERROR;
66
+ };
67
+
68
+ /**
69
+ * Compare a connector's current declaration with the keys in its stored
70
+ * credential. Values are deliberately ignored: callers may pass decrypted
71
+ * values or `/credentials`' masked field metadata and get the same answer.
72
+ *
73
+ * The test is CONTAINMENT, not equality: the stored key set is compatible when
74
+ * it holds every field currently declared — the reserved `value` key for a
75
+ * single-value declaration, every declared name for a named one. Anything the
76
+ * declaration asks for and the vault does not have is `mismatch`, which is
77
+ * precisely what a renamed field, a newly added field, or a swap between the
78
+ * two shapes produces. The swap needs no special case: `value` is never one of
79
+ * the declared names, so single→named and named→single each leave the declared
80
+ * side unsatisfied. An empty stored map (reachable through a hand-written
81
+ * plaintext) satisfies nothing and is a mismatch too.
82
+ *
83
+ * Extra keys are NOT drift. They are what *dropping* a field leaves behind, and
84
+ * every accessor a connector actually uses — `ctx.credential.get("apiKey")`,
85
+ * `getAll().apiKey` — keeps returning the right secret across that redeploy.
86
+ * Calling it drift would order an operator to re-enter a working secret that
87
+ * many providers will not reissue in readable form. The leftovers come back as
88
+ * `undeclared` instead, for a surface to mention without blocking anything.
89
+ */
90
+ export function storedCredentialShape(
91
+ config: ConnectorCredentialConfig,
92
+ stored: Readonly<Record<string, unknown>> | null,
93
+ ): StoredCredentialShape {
94
+ if (!stored) return { state: "missing" };
95
+ const mode: CredentialTestMode = config.fields?.length
96
+ ? "multiple"
97
+ : "single";
98
+ const declared = new Set(
99
+ mode === "multiple" ? config.fields!.map((field) => field.name) : ["value"],
100
+ );
101
+ const actual = Object.keys(stored);
102
+ const present = new Set(actual);
103
+ for (const field of declared) {
104
+ if (!present.has(field)) {
105
+ return {
106
+ state: "mismatch",
107
+ mode,
108
+ message: STORED_CREDENTIAL_SHAPE_MISMATCH_ERROR,
109
+ };
110
+ }
111
+ }
112
+ return {
113
+ state: "valid",
114
+ mode,
115
+ undeclared: actual.filter((field) => !declared.has(field)).sort(),
116
+ };
117
+ }
118
+
119
+ /** How many leftover field names an advisory names before it summarizes. */
120
+ const UNDECLARED_SAMPLE = 5;
121
+
122
+ /**
123
+ * The one sentence describing leftover stored fields, so every surface words it
124
+ * the same way. Names only — the values stay in the vault, and a field name from
125
+ * a previous declaration is not a secret. Deliberately reassuring: nothing is
126
+ * broken, and the only thing an operator gains by acting is that a connector
127
+ * iterating `getAll()` stops seeing a field its code no longer knows about.
128
+ */
129
+ export function describeUndeclaredCredentialFields(fields: string[]): string {
130
+ const shown = fields.slice(0, UNDECLARED_SAMPLE);
131
+ const rest = fields.length - shown.length;
132
+ const named = shown.join(", ") + (rest > 0 ? `, and ${rest} more` : "");
133
+ return fields.length === 1
134
+ ? `Stored credential also holds a field this connector no longer declares (${named}). It keeps working; replace the credential to drop it.`
135
+ : `Stored credential also holds fields this connector no longer declares (${named}). It keeps working; replace the credential to drop them.`;
136
+ }
137
+
138
+ /** A declared credential shape whose only test hook cannot test it. */
139
+ export interface CredentialTestMismatch {
140
+ /** The shape the connector declared. */
141
+ shape: CredentialTestMode;
142
+ /** The hook it implements, which that shape cannot use. */
143
+ hook: "testCredential" | "testCredentials";
144
+ }
145
+
146
+ export interface CredentialTestRule {
147
+ /** The hook to call, or null when this credential cannot be tested at all. */
148
+ mode: CredentialTestMode | null;
149
+ /** Set only when the sole implemented hook is the one the shape cannot use. */
150
+ mismatch?: CredentialTestMismatch;
151
+ }
152
+
153
+ /**
154
+ * The one rule deciding whether a connector's credential can be tested — read
155
+ * by /credentials' `testable` flag, by `POST /ui/credentials/<id>/test` when
156
+ * it picks a hook, and by the construction-time mismatch warning, so those
157
+ * three cannot drift apart.
158
+ *
159
+ * The declared credential *shape* selects the hook: named `credential.fields`
160
+ * are tested as a set by `testCredentials`, a single-value `credential` by
161
+ * `testCredential` on the vault's reserved `value` field. The other hook is
162
+ * never substituted — it would be handed a shape the connector never declared —
163
+ * so a connector implementing only the mismatched hook is not testable, and
164
+ * says so at construction rather than under an operator's click.
165
+ */
166
+ export function credentialTestRule(
167
+ connector: Pick<
168
+ Connector,
169
+ "credential" | "testCredential" | "testCredentials"
170
+ >,
171
+ ): CredentialTestRule {
172
+ if (!connector.credential) return { mode: null };
173
+ if (connector.credential.fields?.length) {
174
+ if (connector.testCredentials) return { mode: "multiple" };
175
+ return connector.testCredential
176
+ ? { mode: null, mismatch: { shape: "multiple", hook: "testCredential" } }
177
+ : { mode: null };
178
+ }
179
+ if (connector.testCredential) return { mode: "single" };
180
+ return connector.testCredentials
181
+ ? { mode: null, mismatch: { shape: "single", hook: "testCredentials" } }
182
+ : { mode: null };
183
+ }
184
+
185
+ /**
186
+ * One clause naming a mismatch, shared by the startup warning and the test
187
+ * route's 400 so an operator reads the same explanation in both places.
188
+ */
189
+ export function describeCredentialTestMismatch(
190
+ mismatch: CredentialTestMismatch,
191
+ ): string {
192
+ return mismatch.shape === "multiple"
193
+ ? "it declares named credential fields, which only " +
194
+ "`testCredentials(values, ctx)` can test, but implements " +
195
+ "`testCredential`"
196
+ : "it declares a single-value credential, which only " +
197
+ "`testCredential(value, ctx)` can test, but implements " +
198
+ "`testCredentials`";
199
+ }
200
+
38
201
  function storageKey(connectorId: string): string {
39
202
  return `conn:${connectorId}:credential:v1`;
40
203
  }
@@ -51,7 +214,7 @@ function base64ToBytes(value: string): Uint8Array {
51
214
  binary = atob(value);
52
215
  } catch {
53
216
  throw new Error(
54
- "credentialEncryptionKey must be a base64-encoded 32-byte key",
217
+ "credentials.encryptionKey must be a base64-encoded 32-byte key",
55
218
  );
56
219
  }
57
220
  return Uint8Array.from(binary, (char) => char.charCodeAt(0));
@@ -146,7 +309,7 @@ export class CredentialVault {
146
309
  const raw = base64ToBytes(encryptionKey.trim());
147
310
  if (raw.byteLength !== KEY_BYTES) {
148
311
  throw new Error(
149
- "credentialEncryptionKey must be a base64-encoded 32-byte key",
312
+ "credentials.encryptionKey must be a base64-encoded 32-byte key",
150
313
  );
151
314
  }
152
315
  this.key = crypto.subtle.importKey(