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