@zackbart/connecta 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +31 -55
  3. package/dist/activity.d.ts +0 -8
  4. package/dist/activity.d.ts.map +1 -1
  5. package/dist/activity.js +0 -1
  6. package/dist/activity.js.map +1 -1
  7. package/dist/auth/bearer.d.ts +1 -7
  8. package/dist/auth/bearer.d.ts.map +1 -1
  9. package/dist/auth/bearer.js +2 -23
  10. package/dist/auth/bearer.js.map +1 -1
  11. package/dist/auth/clerk.d.ts +2 -11
  12. package/dist/auth/clerk.d.ts.map +1 -1
  13. package/dist/auth/clerk.js +31 -46
  14. package/dist/auth/clerk.js.map +1 -1
  15. package/dist/call-admission.d.ts.map +1 -1
  16. package/dist/call-admission.js +0 -1
  17. package/dist/call-admission.js.map +1 -1
  18. package/dist/catalog-service.d.ts +1 -2
  19. package/dist/catalog-service.d.ts.map +1 -1
  20. package/dist/catalog-service.js +12 -18
  21. package/dist/catalog-service.js.map +1 -1
  22. package/dist/catalog.d.ts +6 -0
  23. package/dist/catalog.d.ts.map +1 -1
  24. package/dist/catalog.js +51 -0
  25. package/dist/catalog.js.map +1 -1
  26. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  27. package/dist/connectors/remote-mcp.js +9 -22
  28. package/dist/connectors/remote-mcp.js.map +1 -1
  29. package/dist/errors.d.ts +18 -0
  30. package/dist/errors.d.ts.map +1 -1
  31. package/dist/errors.js.map +1 -1
  32. package/dist/execute.d.ts +1 -8
  33. package/dist/execute.d.ts.map +1 -1
  34. package/dist/execute.js +3 -13
  35. package/dist/execute.js.map +1 -1
  36. package/dist/executor-result.js +1 -1
  37. package/dist/executor-result.js.map +1 -1
  38. package/dist/index.d.ts +3 -82
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +17 -90
  41. package/dist/index.js.map +1 -1
  42. package/dist/invocation.d.ts +5 -0
  43. package/dist/invocation.d.ts.map +1 -1
  44. package/dist/invocation.js +46 -10
  45. package/dist/invocation.js.map +1 -1
  46. package/dist/meta-tools.d.ts +3 -11
  47. package/dist/meta-tools.d.ts.map +1 -1
  48. package/dist/meta-tools.js +105 -157
  49. package/dist/meta-tools.js.map +1 -1
  50. package/dist/operator-ui/generated.d.ts +2 -2
  51. package/dist/operator-ui/generated.d.ts.map +1 -1
  52. package/dist/operator-ui/generated.js +2 -2
  53. package/dist/operator-ui/generated.js.map +1 -1
  54. package/dist/operator-ui/model.d.ts +1 -22
  55. package/dist/operator-ui/model.d.ts.map +1 -1
  56. package/dist/operator-ui/model.js.map +1 -1
  57. package/dist/registry.d.ts +11 -178
  58. package/dist/registry.d.ts.map +1 -1
  59. package/dist/registry.js +52 -330
  60. package/dist/registry.js.map +1 -1
  61. package/dist/retired-toolkits.d.ts +3 -0
  62. package/dist/retired-toolkits.d.ts.map +1 -0
  63. package/dist/retired-toolkits.js +9 -0
  64. package/dist/retired-toolkits.js.map +1 -0
  65. package/dist/routes/activity.d.ts.map +1 -1
  66. package/dist/routes/activity.js +2 -5
  67. package/dist/routes/activity.js.map +1 -1
  68. package/dist/routes/credentials.d.ts.map +1 -1
  69. package/dist/routes/credentials.js +1 -12
  70. package/dist/routes/credentials.js.map +1 -1
  71. package/dist/routes/mcp.d.ts.map +1 -1
  72. package/dist/routes/mcp.js +32 -145
  73. package/dist/routes/mcp.js.map +1 -1
  74. package/dist/routes/oauth.d.ts.map +1 -1
  75. package/dist/routes/oauth.js +2 -7
  76. package/dist/routes/oauth.js.map +1 -1
  77. package/dist/routes/shared.d.ts +7 -28
  78. package/dist/routes/shared.d.ts.map +1 -1
  79. package/dist/routes/shared.js +10 -69
  80. package/dist/routes/shared.js.map +1 -1
  81. package/dist/routes/ui.d.ts.map +1 -1
  82. package/dist/routes/ui.js +4 -10
  83. package/dist/routes/ui.js.map +1 -1
  84. package/dist/server.d.ts.map +1 -1
  85. package/dist/server.js +1 -31
  86. package/dist/server.js.map +1 -1
  87. package/dist/skills.d.ts +3 -6
  88. package/dist/skills.d.ts.map +1 -1
  89. package/dist/skills.js +9 -38
  90. package/dist/skills.js.map +1 -1
  91. package/dist/timeout.d.ts.map +1 -1
  92. package/dist/timeout.js +2 -4
  93. package/dist/timeout.js.map +1 -1
  94. package/dist/types.d.ts +2 -59
  95. package/dist/types.d.ts.map +1 -1
  96. package/dist/ui.d.ts +1 -2
  97. package/dist/ui.d.ts.map +1 -1
  98. package/dist/ui.js +5 -35
  99. package/dist/ui.js.map +1 -1
  100. package/dist/version.d.ts +1 -1
  101. package/dist/version.js +1 -1
  102. package/package.json +1 -1
  103. package/src/activity.ts +0 -9
  104. package/src/auth/bearer.ts +3 -32
  105. package/src/auth/clerk.ts +35 -48
  106. package/src/call-admission.ts +0 -1
  107. package/src/catalog-service.ts +23 -23
  108. package/src/catalog.ts +55 -0
  109. package/src/connectors/remote-mcp.ts +11 -23
  110. package/src/errors.ts +20 -0
  111. package/src/execute.ts +3 -18
  112. package/src/executor-result.ts +1 -1
  113. package/src/index.ts +24 -208
  114. package/src/invocation.ts +63 -10
  115. package/src/meta-tools.ts +122 -174
  116. package/src/operator-ui/browser.css +1 -54
  117. package/src/operator-ui/browser.ts +16 -150
  118. package/src/operator-ui/generated.ts +2 -2
  119. package/src/operator-ui/model.ts +1 -23
  120. package/src/registry.ts +59 -429
  121. package/src/retired-toolkits.ts +14 -0
  122. package/src/routes/activity.ts +1 -6
  123. package/src/routes/credentials.ts +0 -12
  124. package/src/routes/mcp.ts +40 -186
  125. package/src/routes/oauth.ts +1 -7
  126. package/src/routes/shared.ts +8 -98
  127. package/src/routes/ui.ts +2 -12
  128. package/src/server.ts +1 -31
  129. package/src/skills.ts +9 -38
  130. package/src/timeout.ts +2 -4
  131. package/src/types.ts +1 -60
  132. package/src/ui.ts +9 -46
  133. package/src/version.ts +1 -1
  134. package/dist/credential-health.d.ts +0 -205
  135. package/dist/credential-health.d.ts.map +0 -1
  136. package/dist/credential-health.js +0 -610
  137. package/dist/credential-health.js.map +0 -1
  138. package/dist/toolkits.d.ts +0 -124
  139. package/dist/toolkits.d.ts.map +0 -1
  140. package/dist/toolkits.js +0 -322
  141. package/dist/toolkits.js.map +0 -1
  142. package/src/credential-health.ts +0 -835
  143. package/src/toolkits.ts +0 -461
@@ -1,835 +0,0 @@
1
- // Proactive liveness checks for the credentials connecta itself stores —
2
- // downstream-OAuth tokens and operator-managed vault credentials (issue #24).
3
- //
4
- // The problem this solves: a connector's auth state used to flip only when
5
- // something *observed* a failure, so an expired or revoked token surfaced
6
- // mid-task as a failed agent call. A liveness check asks the connector whether
7
- // the credential it holds still works, records the verdict, and lets the cached
8
- // status surfaces (`list_connectors({ probe: false })`, operator pages) report
9
- // `auth_required` BEFORE a real call discovers it.
10
- //
11
- // Runtime-agnostic on purpose: nothing here schedules itself. The core exposes a
12
- // due-gated sweep (piggybacked on inbound traffic by the server) and an awaited
13
- // entry point (`Connecta.checkCredentials()`) an operator wires to whatever
14
- // scheduler their runtime has — a Worker cron trigger, a Node `setInterval`.
15
- // There is no background daemon and no long-lived timer, so Workers and Node run
16
- // the same code.
17
-
18
- import {
19
- credentialTestRule,
20
- STORED_CREDENTIAL_SHAPE_MISMATCH_ERROR,
21
- storedCredentialShape,
22
- } from "./credentials.js";
23
- import type { CredentialVault } from "./credentials.js";
24
- import {
25
- closeConnectorScope,
26
- type DeferredWork,
27
- } from "./connector-scope.js";
28
- import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs, withTimeout } from "./timeout.js";
29
- import type {
30
- Connector,
31
- ConnectorContext,
32
- ConnectorCredentialValues,
33
- ConnectorStatusState,
34
- CredentialTestResult,
35
- KVStorage,
36
- Logger,
37
- } from "./types.js";
38
-
39
- /** Verdict of one liveness check. Same vocabulary as `ConnectorStatus.state`. */
40
- export type CredentialCheckState = ConnectorStatusState;
41
-
42
- /** The stored verdict of the most recent liveness check of one connector. */
43
- export interface CredentialHealthRecord {
44
- state: CredentialCheckState;
45
- /** ISO timestamp of the check that produced this record. */
46
- checkedAt: string;
47
- /** Why, for a non-ok state — the connector's own reason, verbatim. */
48
- message?: string;
49
- /** Consent URL to open, when the connector reported one. */
50
- authorizationUrl?: string;
51
- }
52
-
53
- /**
54
- * Why a connector was not checked.
55
- *
56
- * - `not_found` — no connector with that id is registered. Only reachable
57
- * through an explicit `ids` request, and reported rather than dropped so a
58
- * typo in a scheduled check is visible instead of silent.
59
- * - `not_checkable` — it stores no credential connecta manages, or exposes no
60
- * usable way to ask: neither `status()` nor a credential test hook the
61
- * declared credential shape can use (`credentialTestRule`), against a value
62
- * actually stored under it.
63
- * - `no_credential` — checkable, but nothing is stored yet: there is no
64
- * credential whose liveness could be in question, and probing would start an
65
- * OAuth flow nobody asked for.
66
- * - `fresh` — checked less than `intervalSeconds` ago (by any isolate — the
67
- * record is persisted), so this is the rate limit doing its job.
68
- * - `in_flight` — another check of this connector is already running.
69
- */
70
- export type CredentialCheckSkip =
71
- | "not_found"
72
- | "not_checkable"
73
- | "no_credential"
74
- | "fresh"
75
- | "in_flight";
76
-
77
- /** One connector's outcome in a sweep. */
78
- export interface CredentialCheckResult {
79
- connectorId: string;
80
- /**
81
- * The record now in force. Present for a completed check, and for a `fresh`
82
- * skip (where the still-valid record is what the skip deferred to).
83
- */
84
- record?: CredentialHealthRecord;
85
- /** Set when no check ran; `record` is then whatever was already stored. */
86
- skipped?: CredentialCheckSkip;
87
- /**
88
- * The check ran, but its verdict was thrown away: the credential it judged
89
- * was replaced or removed while it was in flight (see `clear`). `record` is
90
- * what the check saw, not what is stored — nothing is.
91
- */
92
- discarded?: true;
93
- /** How long the check took, when one ran. */
94
- latencyMs?: number;
95
- }
96
-
97
- /** Deployment-wide tuning for credential liveness checks. */
98
- export interface CredentialHealthConfig {
99
- /**
100
- * Minimum seconds between checks of the same connector, across isolates (the
101
- * verdict is persisted, so a Worker cron isolate and a request isolate share
102
- * one clock). Default 900 (15 minutes). This is the bound on downstream cost:
103
- * repeated status reads never each trigger a check.
104
- */
105
- intervalSeconds?: number;
106
- /** Max checks in flight at once during one sweep. Default 4. */
107
- concurrency?: number;
108
- /** Per-check deadline. Default 30 000, the probe default. */
109
- timeoutMs?: number;
110
- /**
111
- * Let inbound authenticated `/mcp` and `/ui/data` traffic trigger a *due*
112
- * sweep in the background (`ctx.waitUntil` where the runtime has it). Default
113
- * true — it is the trigger that makes stale-credential detection work with no
114
- * scheduler wired at all, and it cannot slow a request down or change a
115
- * result. Set false to check only from `Connecta.checkCredentials()`.
116
- */
117
- onRequest?: boolean;
118
- }
119
-
120
- const DEFAULT_CREDENTIAL_CHECK_INTERVAL_SECONDS = 900;
121
- const DEFAULT_CREDENTIAL_CHECK_CONCURRENCY = 4;
122
-
123
- /**
124
- * How long a read of one connector's record is served from memory before going
125
- * back to storage. Short: it exists so a burst of `list_connectors` calls costs
126
- * one storage read rather than one per call, not to cache a verdict.
127
- */
128
- const MIRROR_TTL_MS = 5_000;
129
-
130
- /**
131
- * Minimum gap between storage WRITES of an unchanged verdict. A liveness
132
- * observation arrives from more than one place (the sweep, and every
133
- * `list_connectors({ probe: true })`), and re-persisting "still ok" on each one
134
- * would spend a KV write per probe for no new information. A verdict whose state
135
- * or message CHANGED is always written immediately.
136
- */
137
- const MIN_WRITE_GAP_MS = 60_000;
138
-
139
- function msg(err: unknown): string {
140
- return err instanceof Error ? err.message : String(err);
141
- }
142
-
143
- function storageKey(connectorId: string): string {
144
- return `credhealth:${connectorId}`;
145
- }
146
-
147
- /**
148
- * Generation counter key. Connector ids are `[a-z0-9_-]+`, so the extra colon
149
- * puts this outside the space `storageKey` can produce — no id can collide with
150
- * another id's counter.
151
- */
152
- function generationKey(connectorId: string): string {
153
- return `credhealth:gen:${connectorId}`;
154
- }
155
-
156
- function validRecord(raw: string | null): CredentialHealthRecord | null {
157
- if (!raw) return null;
158
- try {
159
- const value = JSON.parse(raw) as Partial<CredentialHealthRecord>;
160
- if (
161
- (value.state !== "ok" &&
162
- value.state !== "auth_required" &&
163
- value.state !== "error") ||
164
- typeof value.checkedAt !== "string" ||
165
- Number.isNaN(Date.parse(value.checkedAt))
166
- ) {
167
- return null;
168
- }
169
- const stamped = Date.parse(value.checkedAt);
170
- const now = Date.now();
171
- return {
172
- state: value.state,
173
- // A verdict from the future is a clock-skewed isolate, and left alone it
174
- // would be permanently fresh (never re-checked) AND permanently newer than
175
- // any real-call success (never retired) — a wrong answer that cannot age
176
- // out. Clamping to now costs at most one early re-check.
177
- checkedAt: stamped > now ? new Date(now).toISOString() : value.checkedAt,
178
- ...(typeof value.message === "string" ? { message: value.message } : {}),
179
- ...(typeof value.authorizationUrl === "string"
180
- ? { authorizationUrl: value.authorizationUrl }
181
- : {}),
182
- };
183
- } catch {
184
- return null;
185
- }
186
- }
187
-
188
- /**
189
- * The stored verdicts, in the deployment's own KVStorage under
190
- * `credhealth:<connectorId>`.
191
- *
192
- * Persisted rather than held in memory because the two runtimes disagree about
193
- * what "in memory" means: a Cloudflare cron trigger runs in a different isolate
194
- * from the fetch handlers, so a verdict only reaches `list_connectors` if it
195
- * goes through storage. The in-memory mirror is a read cache over that, not the
196
- * source of truth.
197
- *
198
- * Never throws: a check that cannot be persisted (or read back) must degrade to
199
- * "no verdict" rather than break the status surface that reads it.
200
- */
201
- class CredentialHealthStore {
202
- private readonly mirror = new Map<
203
- string,
204
- { record: CredentialHealthRecord | null; readAt: number }
205
- >();
206
-
207
- constructor(
208
- private readonly storage: KVStorage,
209
- private readonly logger: Logger,
210
- ) {}
211
-
212
- async get(connectorId: string): Promise<CredentialHealthRecord | undefined> {
213
- const cached = this.mirror.get(connectorId);
214
- if (cached && Date.now() - cached.readAt < MIRROR_TTL_MS) {
215
- return cached.record ?? undefined;
216
- }
217
- let record: CredentialHealthRecord | null = null;
218
- try {
219
- record = validRecord(await this.storage.get(storageKey(connectorId)));
220
- } catch (err) {
221
- this.logger.warn(
222
- `[connecta] connector "${connectorId}" credential-health read failed: ${msg(err)}`,
223
- );
224
- return cached?.record ?? undefined;
225
- }
226
- this.mirror.set(connectorId, { record, readAt: Date.now() });
227
- return record ?? undefined;
228
- }
229
-
230
- /**
231
- * Monotonic per-connector counter, advanced by {@link clear}. Read straight
232
- * from storage, never from the mirror: its whole job is to notice a change
233
- * another isolate made, which a read cache would hide.
234
- *
235
- * A read failure answers 0. Paired with the fence in `put`, that fails
236
- * *closed* — a mismatched generation drops the verdict — because losing one
237
- * verdict costs a re-check, while resurrecting one costs an operator a
238
- * connector that reports dead after they just fixed it.
239
- */
240
- async generation(connectorId: string): Promise<number> {
241
- try {
242
- const raw = await this.storage.get(generationKey(connectorId));
243
- const value = raw ? Number(raw) : 0;
244
- return Number.isFinite(value) ? value : 0;
245
- } catch {
246
- return 0;
247
- }
248
- }
249
-
250
- /**
251
- * Write a verdict. `expectedGeneration` fences the write against a `clear`
252
- * that landed while the check was in flight: pass the generation captured
253
- * before the check started, and the write is dropped if it has since advanced.
254
- * Omit it for a verdict observed synchronously (a live probe, an operator's
255
- * Test), where there is no window to race.
256
- *
257
- * Returns whether the verdict was actually stored.
258
- */
259
- async put(
260
- connectorId: string,
261
- record: CredentialHealthRecord,
262
- expectedGeneration?: number,
263
- ): Promise<boolean> {
264
- if (
265
- expectedGeneration !== undefined &&
266
- (await this.generation(connectorId)) !== expectedGeneration
267
- ) {
268
- // The credential this verdict judged was replaced or removed mid-check.
269
- // Drop the mirror too: this isolate's idea of the verdict is as stale as
270
- // the write it just declined to make.
271
- this.mirror.delete(connectorId);
272
- return false;
273
- }
274
- const current = await this.get(connectorId);
275
- const unchanged =
276
- current !== undefined &&
277
- current.state === record.state &&
278
- current.message === record.message &&
279
- current.authorizationUrl === record.authorizationUrl;
280
- if (
281
- unchanged &&
282
- Date.parse(record.checkedAt) - Date.parse(current.checkedAt) <
283
- MIN_WRITE_GAP_MS
284
- ) {
285
- return true;
286
- }
287
- this.mirror.set(connectorId, { record, readAt: Date.now() });
288
- try {
289
- await this.storage.set(storageKey(connectorId), JSON.stringify(record));
290
- } catch (err) {
291
- this.logger.warn(
292
- `[connecta] connector "${connectorId}" credential-health persistence failed: ${msg(err)}`,
293
- );
294
- }
295
- return true;
296
- }
297
-
298
- /**
299
- * Forget a connector's verdict, and advance its generation so a check already
300
- * in flight — in this isolate or any other — cannot write the verdict it
301
- * formed about the credential that was just replaced.
302
- *
303
- * Bump BEFORE the delete, the same ordering the OAuth force path uses: a
304
- * racing writer must see the advance rather than land between the two writes.
305
- */
306
- async clear(connectorId: string): Promise<void> {
307
- this.mirror.delete(connectorId);
308
- try {
309
- const next = (await this.generation(connectorId)) + 1;
310
- await this.storage.set(generationKey(connectorId), String(next));
311
- await this.storage.delete(storageKey(connectorId));
312
- } catch (err) {
313
- this.logger.warn(
314
- `[connecta] connector "${connectorId}" credential-health reset failed: ${msg(err)}`,
315
- );
316
- }
317
- }
318
- }
319
-
320
- /** What the checker needs from the registry, without depending on it. */
321
- export interface CredentialHealthDeps {
322
- listConnectors(): Connector[];
323
- getConnector(id: string): Connector | undefined;
324
- contextFor(
325
- id: string,
326
- baseUrl: string,
327
- requestScope?: object,
328
- ): ConnectorContext;
329
- storage: KVStorage;
330
- logger: Logger;
331
- credentialVault?: CredentialVault;
332
- }
333
-
334
- export interface CredentialCheckOptions {
335
- /** Check even connectors whose verdict is still fresh. */
336
- force?: boolean;
337
- /** Restrict the sweep to these connector ids. Default: every connector. */
338
- ids?: string[];
339
- /**
340
- * @deprecated Ignored. Credential checks always create and close their own
341
- * probe scope; no core path supplies an existing request scope.
342
- */
343
- requestScope?: object;
344
- }
345
-
346
- /**
347
- * Whether a connector holds a credential connecta stores AND exposes a way to
348
- * ask whether it still works.
349
- *
350
- * Deliberately narrow. `listTools`/`callTool` are NOT liveness probes here: a
351
- * tool call may mutate downstream state, and the catalog path is already covered
352
- * by the existing probe. So a connector is checkable only through the two hooks
353
- * that exist to answer exactly this question — `testCredential(s)` (what the
354
- * Test button runs) and `status()` — and only when it has a credential of ours
355
- * to be asked about: an operator-managed `credential`, or a stored downstream
356
- * grant it reports via `hasStoredCredential`. A static-token connector stores
357
- * nothing here and is never probed on a timer.
358
- *
359
- * Whether a test hook counts is `credentialTestRule`'s call, not this function's
360
- * Credentials Test button and the credential API read (issue #55), so
361
- * a credential the operator cannot test by hand is not one a sweep tests behind
362
- * their back. A connector whose only hook cannot test its declared shape is
363
- * checkable only if it also implements `status()`.
364
- */
365
- function isCheckableConnector(connector: Connector): boolean {
366
- const hasCredentialStore = Boolean(
367
- connector.credential || connector.hasStoredCredential,
368
- );
369
- const canAsk = Boolean(
370
- credentialTestRule(connector).mode !== null || connector.status,
371
- );
372
- return hasCredentialStore && canAsk;
373
- }
374
-
375
- /**
376
- * The credential test the connector's DECLARED shape selects, bound to what is
377
- * actually stored — or undefined when there is no honest question to put.
378
- *
379
- * `isCheckableConnector` answers the static question ("could this connector be
380
- * asked at all"); this answers it against the vault. The hook itself is picked
381
- * by `credentialTestRule` (src/credentials.ts, issue #55), the one rule the UI's
382
- * `testable` flag and `POST /ui/credentials/<id>/test` also read: named
383
- * `credential.fields` are tested as a set by `testCredentials`, a single-value
384
- * `credential` by `testCredential` on the vault's reserved `value` field, and
385
- * the other hook is never substituted. Substituting it is what a sweep must not
386
- * do quietly — handing `testCredential` a `values.value` that named fields never
387
- * wrote would test the empty string and record a confident `auth_required` about
388
- * a credential nothing examined, and handing `testCredentials` the reserved
389
- * `{ value }` map would call a hook with a shape its connector never declared.
390
- * Either way the connector is skipped (`not_checkable`) rather than given an
391
- * invented verdict, and `createConnecta` already warned about the mismatch at
392
- * construction.
393
- */
394
- function testHookFor(
395
- connector: Connector,
396
- values: ConnectorCredentialValues | null,
397
- ): ((ctx: ConnectorContext) => Promise<CredentialTestResult>) | undefined {
398
- if (
399
- !connector.credential ||
400
- storedCredentialShape(connector.credential, values).state !== "valid"
401
- ) {
402
- return undefined;
403
- }
404
- const storedValues = values!;
405
- const { mode } = credentialTestRule(connector);
406
- if (mode === "multiple") {
407
- return (ctx) => connector.testCredentials!(storedValues, ctx);
408
- }
409
- // A single-value shape with nothing under the reserved field is still nothing
410
- // to test, so the stored value gets the last word even when the rule fits.
411
- const storedValue = storedValues.value;
412
- if (mode === "single" && typeof storedValue === "string") {
413
- return (ctx) => connector.testCredential!(storedValue, ctx);
414
- }
415
- return undefined;
416
- }
417
-
418
- /** Run `fn` over `items` with at most `limit` in flight, preserving order. */
419
- async function mapWithConcurrency<T, R>(
420
- items: T[],
421
- limit: number,
422
- fn: (item: T) => Promise<R>,
423
- ): Promise<R[]> {
424
- const out = Array<R>(items.length);
425
- const remaining = items.entries();
426
- const workers = Array.from(
427
- { length: Math.min(limit, items.length) },
428
- async () => {
429
- for (;;) {
430
- const next = remaining.next();
431
- if (next.done) return;
432
- const [index, item] = next.value;
433
- out[index] = await fn(item);
434
- }
435
- },
436
- );
437
- await Promise.all(workers);
438
- return out;
439
- }
440
-
441
- /**
442
- * Runs and caches credential liveness checks. One instance per `Registry`.
443
- *
444
- * Cost is bounded four ways, because a status surface an agent polls must never
445
- * become a way to hammer a downstream auth endpoint:
446
- *
447
- * 1. **Eligibility** — only connectors holding a credential of ours are probed
448
- * at all (`isCheckableConnector`), and only when something is actually stored.
449
- * 2. **Freshness (cross-isolate)** — a persisted verdict younger than
450
- * `intervalSeconds` short-circuits the check, so every isolate and every
451
- * trigger share one budget.
452
- * 3. **Sweep gate (per isolate)** — `sweepIfDue` runs at most one traffic-
453
- * triggered sweep per interval per isolate, and never two at once, so a burst
454
- * of requests costs one sweep, not one per request.
455
- * 4. **Deadline + fan-out bound** — each check is bounded by `timeoutMs` and at
456
- * most `concurrency` run together (the same shape as the
457
- * `discovery.probeTimeoutMs` bound on the discovery fan-out, issue #19).
458
- */
459
- export class CredentialHealthChecker {
460
- private readonly store: CredentialHealthStore;
461
- private readonly intervalMs: number;
462
- private readonly concurrency: number;
463
- private readonly timeoutMs: number;
464
- private readonly onRequest: boolean;
465
- /** Per-connector checks in flight in THIS isolate. */
466
- private readonly inFlight = new Map<string, Promise<unknown>>();
467
- /** Earliest a traffic-triggered sweep may run again in this isolate. */
468
- private nextSweepAt = 0;
469
- private sweeping: Promise<CredentialCheckResult[]> | undefined;
470
-
471
- constructor(
472
- private readonly deps: CredentialHealthDeps,
473
- config: CredentialHealthConfig = {},
474
- ) {
475
- this.store = new CredentialHealthStore(deps.storage, deps.logger);
476
- // Out-of-range tuning falls back to the default rather than being coerced:
477
- // a zero or negative interval would turn the rate limit off, which is the
478
- // one thing this class is for.
479
- const seconds = config.intervalSeconds;
480
- this.intervalMs =
481
- seconds !== undefined && Number.isFinite(seconds) && seconds > 0
482
- ? seconds * 1000
483
- : DEFAULT_CREDENTIAL_CHECK_INTERVAL_SECONDS * 1000;
484
- this.concurrency =
485
- config.concurrency !== undefined &&
486
- Number.isInteger(config.concurrency) &&
487
- config.concurrency > 0
488
- ? config.concurrency
489
- : DEFAULT_CREDENTIAL_CHECK_CONCURRENCY;
490
- this.timeoutMs =
491
- normalizeTimeoutMs(config.timeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
492
- this.onRequest = config.onRequest ?? true;
493
- }
494
-
495
- /**
496
- * The stored verdict, if any. No downstream I/O — and no storage read at all
497
- * for a connector that stores no credential of ours, since only a checkable
498
- * connector can ever have had a verdict written. That keeps
499
- * `list_connectors({ probe: false })` exactly as cheap as it was for the
500
- * deployments this feature does not apply to.
501
- */
502
- healthFor(connectorId: string): Promise<CredentialHealthRecord | undefined> {
503
- const connector = this.deps.getConnector(connectorId);
504
- if (!connector || !isCheckableConnector(connector)) {
505
- return Promise.resolve(undefined);
506
- }
507
- return this.store.get(connectorId);
508
- }
509
-
510
- /**
511
- * Record a liveness verdict observed elsewhere — today, the live status a
512
- * `list_connectors({ probe: true })` just performed. Filtered by the same
513
- * eligibility rule as a check, so this stays a record of *credential* health
514
- * rather than a general status cache, and so it also counts against the
515
- * freshness budget: an operator who just probed live does not get swept again
516
- * moments later.
517
- */
518
- async record(
519
- connectorId: string,
520
- record: CredentialHealthRecord,
521
- ): Promise<void> {
522
- const connector = this.deps.getConnector(connectorId);
523
- if (!connector || !isCheckableConnector(connector)) return;
524
- await this.store.put(connectorId, record);
525
- }
526
-
527
- /** Forget a connector's verdict — its credential just changed under us. */
528
- clear(connectorId: string): Promise<void> {
529
- return this.store.clear(connectorId);
530
- }
531
-
532
- /** Whether any connector in this deployment could be checked at all. */
533
- hasCheckableConnectors(): boolean {
534
- return this.deps.listConnectors().some(isCheckableConnector);
535
- }
536
-
537
- /**
538
- * Check every (or the named) connector's stored credential and return one
539
- * outcome per connector considered. Never rejects: a connector that throws,
540
- * hangs past `timeoutMs`, or cannot be persisted becomes an `error` verdict.
541
- */
542
- async check(
543
- baseUrl: string,
544
- opts: CredentialCheckOptions = {},
545
- defer?: DeferredWork,
546
- ): Promise<CredentialCheckResult[]> {
547
- // An id naming no connector is reported, not dropped: a typo in a scheduled
548
- // check would otherwise return an empty list that looks exactly like a
549
- // deployment with nothing to check.
550
- const targets: Array<Connector | string> = opts.ids
551
- ? opts.ids.map((id) => this.deps.getConnector(id) ?? id)
552
- : this.deps.listConnectors();
553
- return mapWithConcurrency(targets, this.concurrency, (target) =>
554
- typeof target === "string"
555
- ? Promise.resolve({ connectorId: target, skipped: "not_found" as const })
556
- : this.checkOne(target, baseUrl, opts, defer),
557
- );
558
- }
559
-
560
- /**
561
- * The traffic-triggered sweep: a promise to hand to `ctx.waitUntil`, or
562
- * `undefined` when nothing is due (the common case, and free — no I/O). The
563
- * gate is armed BEFORE the sweep starts, so a burst of concurrent requests
564
- * produces one sweep.
565
- */
566
- sweepIfDue(
567
- baseUrl: string,
568
- defer?: DeferredWork,
569
- ): Promise<CredentialCheckResult[]> | undefined {
570
- if (!this.onRequest || this.sweeping) return undefined;
571
- const now = Date.now();
572
- if (now < this.nextSweepAt) return undefined;
573
- if (!this.hasCheckableConnectors()) return undefined;
574
- this.nextSweepAt = now + this.intervalMs;
575
- const sweep = this.check(baseUrl, {}, defer).finally(() => {
576
- this.sweeping = undefined;
577
- });
578
- this.sweeping = sweep;
579
- return sweep;
580
- }
581
-
582
- private async checkOne(
583
- connector: Connector,
584
- baseUrl: string,
585
- opts: CredentialCheckOptions,
586
- defer?: DeferredWork,
587
- ): Promise<CredentialCheckResult> {
588
- const connectorId = connector.id;
589
- if (!isCheckableConnector(connector)) {
590
- return { connectorId, skipped: "not_checkable" };
591
- }
592
- if (this.inFlight.has(connectorId)) {
593
- // Report rather than join: the caller wants to know a check happened, not
594
- // to be blocked behind one someone else already pays for.
595
- return {
596
- connectorId,
597
- skipped: "in_flight",
598
- ...(await this.recordOrNothing(connectorId)),
599
- };
600
- }
601
- const run = this.runCheck(
602
- connector,
603
- baseUrl,
604
- opts.force ?? false,
605
- defer,
606
- );
607
- this.inFlight.set(connectorId, run);
608
- try {
609
- return await run;
610
- } finally {
611
- this.inFlight.delete(connectorId);
612
- }
613
- }
614
-
615
- private async recordOrNothing(
616
- connectorId: string,
617
- ): Promise<{ record?: CredentialHealthRecord }> {
618
- const record = await this.store.get(connectorId);
619
- return record ? { record } : {};
620
- }
621
-
622
- private async runCheck(
623
- connector: Connector,
624
- baseUrl: string,
625
- force: boolean,
626
- defer?: DeferredWork,
627
- ): Promise<CredentialCheckResult> {
628
- const connectorId = connector.id;
629
- const started = Date.now();
630
- // Captured BEFORE anything downstream happens — including the vault read
631
- // below: everything after this point is a window in which the operator may
632
- // replace the very credential being judged, and `settle` fences the write
633
- // against exactly that. It cannot move later to save a read on the fresh
634
- // path; a generation sampled AFTER the values would miss a `clear` that
635
- // landed between the two and let a verdict about the replaced credential
636
- // through the fence.
637
- const generation = await this.store.generation(connectorId);
638
- let values: ConnectorCredentialValues | null = null;
639
- let credentialReadError: unknown;
640
- if (connector.credential && this.deps.credentialVault) {
641
- try {
642
- values = await this.deps.credentialVault.getAll(connectorId);
643
- } catch (err) {
644
- credentialReadError = err;
645
- }
646
- const shape = storedCredentialShape(connector.credential, values);
647
- if (shape.state === "mismatch") {
648
- // Drift is a persistent operator-reconfiguration state, not an event:
649
- // outside the freshness gate it would spend a write on every sweep in
650
- // every isolate, forever, against exactly the deployments this feature
651
- // is meant to help (and on Cloudflare KV those writes are metered).
652
- // Once the SAME drift verdict is already stored and still fresh, this
653
- // is the rate limit doing its job, same as a fresh `ok`. A stored
654
- // verdict that says anything else — a pre-redeploy `ok`, a different
655
- // message — is still replaced immediately, which is the whole point of
656
- // checking the shape before the gate.
657
- if (!force) {
658
- const current = await this.store.get(connectorId);
659
- if (
660
- current &&
661
- current.state === "auth_required" &&
662
- current.message === shape.message &&
663
- Date.now() - Date.parse(current.checkedAt) < this.intervalMs
664
- ) {
665
- return { connectorId, skipped: "fresh", record: current };
666
- }
667
- }
668
- return this.settle(connectorId, started, generation, {
669
- // Unlike a failed check, this is a completed static classification:
670
- // the current declaration cannot consume what the vault holds.
671
- state: "auth_required",
672
- checkedAt: new Date().toISOString(),
673
- message: shape.message,
674
- });
675
- }
676
- }
677
- // Shape drift is checked before this shortcut so an old, still-fresh `ok`
678
- // cannot survive a redeploy that changed the credential declaration.
679
- if (!force) {
680
- const current = await this.store.get(connectorId);
681
- if (
682
- current &&
683
- Date.now() - Date.parse(current.checkedAt) < this.intervalMs
684
- ) {
685
- return { connectorId, skipped: "fresh", record: current };
686
- }
687
- }
688
- // Credential checks are always probe owners. No caller may lend them an
689
- // ordinary request scope and thereby suppress the teardown below.
690
- const scope = {};
691
- const ctx = this.deps.contextFor(connectorId, baseUrl, scope);
692
- try {
693
- if (credentialReadError) {
694
- // A stored credential that cannot be decrypted (rotated key, corrupt
695
- // envelope) is exactly the kind of dead credential this feature
696
- // exists to surface early, so it is a verdict rather than a skip.
697
- return this.settle(connectorId, started, generation, {
698
- state: "auth_required",
699
- checkedAt: new Date().toISOString(),
700
- message: msg(credentialReadError),
701
- });
702
- }
703
- const stored = connector.hasStoredCredential
704
- ? await connector
705
- .hasStoredCredential(ctx)
706
- .catch(() => values !== null)
707
- : values !== null;
708
- if (!stored) return { connectorId, skipped: "no_credential" };
709
- if (!this.canAsk(connector, values)) {
710
- return { connectorId, skipped: "not_checkable" };
711
- }
712
-
713
- try {
714
- const verdict = await withTimeout(
715
- this.probe(connector, ctx, values),
716
- this.timeoutMs,
717
- `credential check of "${connectorId}"`,
718
- );
719
- return await this.settle(connectorId, started, generation, {
720
- ...verdict,
721
- checkedAt: new Date().toISOString(),
722
- });
723
- } catch (err) {
724
- return await this.settle(connectorId, started, generation, {
725
- state: "error",
726
- checkedAt: new Date().toISOString(),
727
- message: msg(err),
728
- });
729
- }
730
- } finally {
731
- await closeConnectorScope(connector, ctx, defer);
732
- }
733
- }
734
-
735
- /**
736
- * `isCheckableConnector` re-asked against what is actually stored: the hook
737
- * the declared shape selects, bound to a value that fits it (see
738
- * {@link testHookFor}), or a `status()` to fall back on. Neither ⇒ there is no
739
- * honest question to put to this connector.
740
- */
741
- private canAsk(
742
- connector: Connector,
743
- values: ConnectorCredentialValues | null,
744
- ): boolean {
745
- return Boolean(testHookFor(connector, values) || connector.status);
746
- }
747
-
748
- /**
749
- * Ask the connector whether the credential it holds still works — with no
750
- * downstream mutation and no tool call. A credential test is preferred for a
751
- * vault credential because it validates the stored value itself; `status()` is
752
- * the downstream-OAuth answer (it refreshes the grant, which is the liveness
753
- * question for a token).
754
- */
755
- private async probe(
756
- connector: Connector,
757
- ctx: ConnectorContext,
758
- values: ConnectorCredentialValues | null,
759
- ): Promise<Omit<CredentialHealthRecord, "checkedAt">> {
760
- const test = testHookFor(connector, values);
761
- if (test) {
762
- const result = await test(ctx);
763
- if (result.ok) {
764
- return { state: "ok", ...(result.message ? { message: result.message } : {}) };
765
- }
766
- // A rejected stored credential needs an operator, not a retry — the same
767
- // actionable state a revoked OAuth grant reports. There is no consent URL
768
- // for a vault credential; /credentials is where it is replaced.
769
- return {
770
- state: "auth_required",
771
- message:
772
- result.message ??
773
- "Stored credential was rejected by the connector — replace it in /credentials.",
774
- };
775
- }
776
- const status = await connector.status!(ctx);
777
- return {
778
- state: status.state,
779
- ...(status.message ? { message: status.message } : {}),
780
- ...(status.authorizationUrl
781
- ? { authorizationUrl: status.authorizationUrl }
782
- : {}),
783
- };
784
- }
785
-
786
- private async settle(
787
- connectorId: string,
788
- started: number,
789
- generation: number,
790
- record: CredentialHealthRecord,
791
- ): Promise<CredentialCheckResult> {
792
- const stored = await this.store.put(connectorId, record, generation);
793
- return {
794
- connectorId,
795
- record,
796
- ...(stored ? {} : { discarded: true as const }),
797
- latencyMs: Date.now() - started,
798
- };
799
- }
800
- }
801
-
802
- /**
803
- * Whether a liveness verdict may DECIDE a connector's cached status.
804
- *
805
- * Only `auth_required` ever does, and only while nothing better has happened
806
- * since. Two separate judgements:
807
- *
808
- * 1. **`error` is not credential evidence.** A check that timed out, threw, or
809
- * got a 502 from the provider's status endpoint failed to *complete* — it
810
- * learned nothing about the credential. Letting it set the status would flip
811
- * a connector whose calls are fine to `error` for a whole interval on a DNS
812
- * blip. Error verdicts stay visible in `credentialCheck` (an operator wants
813
- * to know checks are failing) but the status keeps coming from observed real
814
- * calls, which is evidence.
815
- * 2. **A successful real call retires the verdict, except static shape drift.**
816
- * Traffic beats a background probe, so a `lastSuccessAt` at or after
817
- * `checkedAt` normally means the credential demonstrably works. Stored-shape
818
- * drift is different: a credential-independent tool can succeed without
819
- * making a missing declared field appear, so only replacement/removal clears
820
- * that verdict.
821
- *
822
- * `auth_required` deliberately outranks an observed real-call *failure*: both
823
- * say something is wrong, and only one of them carries the URL that fixes it.
824
- * The failure stays visible as `lastError`.
825
- */
826
- export function credentialVerdictApplies(
827
- record: CredentialHealthRecord | undefined,
828
- lastSuccessAt: string | undefined,
829
- ): boolean {
830
- if (!record || record.state !== "auth_required") return false;
831
- if (record.message === STORED_CREDENTIAL_SHAPE_MISMATCH_ERROR) return true;
832
- if (!lastSuccessAt) return true;
833
- const success = Date.parse(lastSuccessAt);
834
- return Number.isNaN(success) || success < Date.parse(record.checkedAt);
835
- }