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