@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.
- package/CHANGELOG.md +87 -0
- package/README.md +31 -55
- package/dist/activity.d.ts +0 -8
- package/dist/activity.d.ts.map +1 -1
- package/dist/activity.js +0 -1
- package/dist/activity.js.map +1 -1
- package/dist/auth/bearer.d.ts +1 -7
- package/dist/auth/bearer.d.ts.map +1 -1
- package/dist/auth/bearer.js +2 -23
- package/dist/auth/bearer.js.map +1 -1
- package/dist/auth/clerk.d.ts +2 -11
- package/dist/auth/clerk.d.ts.map +1 -1
- package/dist/auth/clerk.js +31 -46
- package/dist/auth/clerk.js.map +1 -1
- package/dist/call-admission.d.ts.map +1 -1
- package/dist/call-admission.js +0 -1
- package/dist/call-admission.js.map +1 -1
- package/dist/catalog-service.d.ts +1 -2
- package/dist/catalog-service.d.ts.map +1 -1
- package/dist/catalog-service.js +12 -18
- package/dist/catalog-service.js.map +1 -1
- package/dist/catalog.d.ts +6 -0
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +51 -0
- package/dist/catalog.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +9 -22
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/execute.d.ts +1 -8
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +3 -13
- package/dist/execute.js.map +1 -1
- package/dist/executor-result.js +1 -1
- package/dist/executor-result.js.map +1 -1
- package/dist/index.d.ts +3 -82
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -90
- package/dist/index.js.map +1 -1
- package/dist/invocation.d.ts +5 -0
- package/dist/invocation.d.ts.map +1 -1
- package/dist/invocation.js +46 -10
- package/dist/invocation.js.map +1 -1
- package/dist/meta-tools.d.ts +3 -11
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +105 -157
- package/dist/meta-tools.js.map +1 -1
- package/dist/operator-ui/generated.d.ts +2 -2
- package/dist/operator-ui/generated.d.ts.map +1 -1
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/generated.js.map +1 -1
- package/dist/operator-ui/model.d.ts +1 -22
- package/dist/operator-ui/model.d.ts.map +1 -1
- package/dist/operator-ui/model.js.map +1 -1
- package/dist/registry.d.ts +11 -178
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +52 -330
- package/dist/registry.js.map +1 -1
- package/dist/retired-toolkits.d.ts +3 -0
- package/dist/retired-toolkits.d.ts.map +1 -0
- package/dist/retired-toolkits.js +9 -0
- package/dist/retired-toolkits.js.map +1 -0
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/activity.js +2 -5
- package/dist/routes/activity.js.map +1 -1
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/credentials.js +1 -12
- package/dist/routes/credentials.js.map +1 -1
- package/dist/routes/mcp.d.ts.map +1 -1
- package/dist/routes/mcp.js +32 -145
- package/dist/routes/mcp.js.map +1 -1
- package/dist/routes/oauth.d.ts.map +1 -1
- package/dist/routes/oauth.js +2 -7
- package/dist/routes/oauth.js.map +1 -1
- package/dist/routes/shared.d.ts +7 -28
- package/dist/routes/shared.d.ts.map +1 -1
- package/dist/routes/shared.js +10 -69
- package/dist/routes/shared.js.map +1 -1
- package/dist/routes/ui.d.ts.map +1 -1
- package/dist/routes/ui.js +4 -10
- package/dist/routes/ui.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -31
- package/dist/server.js.map +1 -1
- package/dist/skills.d.ts +3 -6
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +9 -38
- package/dist/skills.js.map +1 -1
- package/dist/timeout.d.ts.map +1 -1
- package/dist/timeout.js +2 -4
- package/dist/timeout.js.map +1 -1
- package/dist/types.d.ts +2 -59
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +1 -2
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +5 -35
- 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/activity.ts +0 -9
- package/src/auth/bearer.ts +3 -32
- package/src/auth/clerk.ts +35 -48
- package/src/call-admission.ts +0 -1
- package/src/catalog-service.ts +23 -23
- package/src/catalog.ts +55 -0
- package/src/connectors/remote-mcp.ts +11 -23
- package/src/errors.ts +20 -0
- package/src/execute.ts +3 -18
- package/src/executor-result.ts +1 -1
- package/src/index.ts +24 -208
- package/src/invocation.ts +63 -10
- package/src/meta-tools.ts +122 -174
- package/src/operator-ui/browser.css +1 -54
- package/src/operator-ui/browser.ts +16 -150
- package/src/operator-ui/generated.ts +2 -2
- package/src/operator-ui/model.ts +1 -23
- package/src/registry.ts +59 -429
- package/src/retired-toolkits.ts +14 -0
- package/src/routes/activity.ts +1 -6
- package/src/routes/credentials.ts +0 -12
- package/src/routes/mcp.ts +40 -186
- package/src/routes/oauth.ts +1 -7
- package/src/routes/shared.ts +8 -98
- package/src/routes/ui.ts +2 -12
- package/src/server.ts +1 -31
- package/src/skills.ts +9 -38
- package/src/timeout.ts +2 -4
- package/src/types.ts +1 -60
- package/src/ui.ts +9 -46
- package/src/version.ts +1 -1
- package/dist/credential-health.d.ts +0 -205
- package/dist/credential-health.d.ts.map +0 -1
- package/dist/credential-health.js +0 -610
- package/dist/credential-health.js.map +0 -1
- package/dist/toolkits.d.ts +0 -124
- package/dist/toolkits.d.ts.map +0 -1
- package/dist/toolkits.js +0 -322
- package/dist/toolkits.js.map +0 -1
- package/src/credential-health.ts +0 -835
- package/src/toolkits.ts +0 -461
package/src/credential-health.ts
DELETED
|
@@ -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
|
-
}
|