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