@zackbart/connecta 0.38.0 → 0.40.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 +91 -0
- package/README.md +3 -2
- package/bin/connecta.mjs +4 -8
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.js +6 -4
- package/dist/agent-routes.js +231 -0
- package/dist/auth/downstream-oauth/consent.js +519 -0
- package/dist/auth/downstream-oauth/grant-store.js +439 -0
- package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
- package/dist/auth/downstream-oauth/refresh-records.js +447 -0
- package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
- package/dist/auth/downstream-oauth.js +66 -2382
- package/dist/auth/native.js +37 -0
- package/dist/auth/oauth-grant.js +13 -0
- package/dist/auth/static-oauth.js +153 -131
- package/dist/auth-elicitation.js +7 -1
- package/dist/authorization-handoff.js +11 -5
- package/dist/catalog-cache.js +219 -59
- package/dist/catalog-policy.js +18 -0
- package/dist/catalog-service.js +284 -47
- package/dist/catalog.js +18 -2
- package/dist/config.d.ts +16 -11
- package/dist/config.js +9 -6
- package/dist/connector-caller.d.ts +1 -1
- package/dist/connector-health.js +59 -0
- package/dist/connector-status.js +117 -0
- package/dist/connectors/api-connector.d.ts +7 -2
- package/dist/connectors/api-connector.js +7 -4
- package/dist/connectors/negotiation-cache.js +22 -4
- package/dist/connectors/remote-mcp/call.js +164 -0
- package/dist/connectors/remote-mcp/catalog.js +278 -0
- package/dist/connectors/remote-mcp/config.js +263 -0
- package/dist/connectors/remote-mcp/connection.js +682 -0
- package/dist/connectors/remote-mcp/errors.js +488 -0
- package/dist/connectors/remote-mcp/oauth.js +473 -0
- package/dist/connectors/remote-mcp/resources.js +473 -0
- package/dist/connectors/remote-mcp/runtime.js +1 -0
- package/dist/connectors/remote-mcp/transport.js +555 -0
- package/dist/connectors/remote-mcp.js +62 -2863
- package/dist/credential-contract.d.ts +23 -0
- package/dist/credential-reads.js +17 -0
- package/dist/credential-verification.js +53 -0
- package/dist/credentials.d.ts +7 -0
- package/dist/credentials.js +60 -25
- package/dist/describe-config.d.ts +12 -2
- package/dist/describe-config.js +2 -0
- package/dist/discovery-health.js +199 -0
- package/dist/downstream-input.js +2 -0
- package/dist/errors.d.ts +55 -17
- package/dist/errors.js +95 -41
- package/dist/execute.js +152 -114
- package/dist/executor-result.js +3 -1
- package/dist/guest-functions.js +83 -0
- package/dist/guest-runtime.js +60 -19
- package/dist/guest-types.d.ts +19 -5
- package/dist/http/argument-problems.js +35 -0
- package/dist/http/errors.js +5 -4
- package/dist/http/fields.js +192 -13
- package/dist/http/hatch-response.js +73 -0
- package/dist/http/hatch.js +88 -23
- package/dist/http/oauth.js +3 -2
- package/dist/http/provider.js +32 -4
- package/dist/http/safety.js +8 -0
- package/dist/http/tools.js +44 -23
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/dist/invocation.js +26 -8
- package/dist/membership.d.ts +13 -5
- package/dist/membership.js +34 -2
- package/dist/meta-tools.js +54 -31
- package/dist/native-recovery.js +4 -16
- package/dist/native-tools.js +5 -2
- package/dist/oauth-sealing.js +10 -0
- package/dist/operation-vocabulary.js +135 -13
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +1 -0
- package/dist/operator-ui/contract.d.ts +10 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +22 -1
- package/dist/provider-call-facts.js +6 -2
- package/dist/providers/_shared/rest/operation-index.js +48 -50
- package/dist/providers/_shared/rest/value-safety.js +80 -0
- package/dist/providers/breeze/api.js +7 -1
- package/dist/providers/breeze/skill.generated.js +1 -1
- package/dist/providers/breeze/tools.js +4 -3
- package/dist/providers/ccb/api.js +2 -1
- package/dist/providers/ccb/index.js +5 -0
- package/dist/providers/ccb/skill.generated.js +1 -1
- package/dist/providers/cloudflare/api.js +8 -1
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/docs/lib/schemas.js +24 -22
- package/dist/providers/docs/safety.js +2 -0
- package/dist/providers/docs/skill.generated.js +1 -1
- package/dist/providers/docs/tools.js +2 -4
- package/dist/providers/drive/api.js +1 -0
- package/dist/providers/drive/lib/contracts.js +8 -5
- package/dist/providers/drive/skill.generated.js +1 -1
- package/dist/providers/drive/tools.js +11 -16
- package/dist/providers/forms/api.js +1 -0
- package/dist/providers/forms/skill.generated.js +1 -1
- package/dist/providers/forms/tools.js +10 -7
- package/dist/providers/github/api.js +1 -0
- package/dist/providers/github/lib/discovery.js +1 -1
- package/dist/providers/github/skill.generated.js +1 -1
- package/dist/providers/github/tools.js +61 -17
- package/dist/providers/gmail/lib/schemas.js +60 -50
- package/dist/providers/gmail/skill.generated.js +1 -1
- package/dist/providers/gmail/tools.js +7 -5
- package/dist/providers/granola/index.js +2 -0
- package/dist/providers/granola/skill.generated.js +1 -1
- package/dist/providers/overflow/api.js +7 -1
- package/dist/providers/overflow/skill.generated.js +1 -1
- package/dist/providers/planning-center/api.js +3 -1
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/skill.generated.js +1 -1
- package/dist/providers/quickbooks/safety.js +80 -39
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/api.js +1 -0
- package/dist/providers/sheets/skill.generated.js +1 -1
- package/dist/providers/sheets/tools.js +13 -14
- package/dist/providers/slides/skill.generated.js +1 -1
- package/dist/providers/slides/tools.js +15 -13
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/tithely/api.js +3 -1
- package/dist/providers/tithely/skill.generated.js +1 -1
- package/dist/providers/vercel/api.js +1 -1
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/providers/vercel/tools.js +4 -3
- package/dist/registry.d.ts +10 -4
- package/dist/registry.js +41 -13
- package/dist/routes/credentials.js +129 -60
- package/dist/routes/dispatch.js +5 -1
- package/dist/routes/mcp.js +30 -14
- package/dist/routes/operator.js +2 -2
- package/dist/routes/shared.d.ts +6 -1
- package/dist/routes/shared.js +7 -1
- package/dist/routes/ui-config.js +2 -2
- package/dist/routes/ui-data.js +126 -20
- package/dist/routes/ui.js +16 -0
- package/dist/runtime/call.js +7 -2
- package/dist/runtime/native-storage.js +12 -0
- package/dist/runtime/request-reads.js +207 -0
- package/dist/runtime/request-scope.js +16 -0
- package/dist/runtime/storage.js +12 -0
- package/dist/skills.js +37 -20
- package/dist/storage/keys.js +53 -3
- package/dist/storage/memory.js +8 -2
- package/dist/storage/sql-native.js +53 -1
- package/dist/storage/sql.js +12 -2
- package/dist/storage-digest.js +32 -0
- package/dist/timeout.js +8 -0
- package/dist/types.d.ts +33 -0
- package/dist/usage-guide.js +16 -35
- package/dist/validate.d.ts +3 -0
- package/dist/validate.js +220 -158
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/write-outcome.js +0 -3
- package/documentation/README.md +13 -13
- package/documentation/architecture.md +385 -20
- package/documentation/auth.md +233 -64
- package/documentation/code-mode.md +122 -39
- package/documentation/deploying.md +6 -3
- package/documentation/integrating.md +16 -2
- package/documentation/meta-tools.md +240 -49
- package/documentation/migration-0.33.md +5 -1
- package/documentation/migration-0.34.md +3 -1
- package/documentation/operator-tests.md +1 -1
- package/documentation/operator-ui.md +105 -12
- package/documentation/pool-membership-upgrade.md +44 -0
- package/documentation/providers.md +105 -0
- package/examples/worker/AGENTS.md +10 -10
- package/examples/worker/README.md +4 -3
- package/examples/worker/src/connecta.config.ts +1 -0
- package/guides/connecting-agents.md +2 -1
- package/guides/how-it-works.md +4 -2
- package/package.json +1 -1
- package/templates/node/AGENTS.md +13 -12
- package/templates/node/package.json +1 -1
- package/templates/node/src/connecta.config.ts +1 -0
|
@@ -10,6 +10,12 @@ export interface CredentialMetadata {
|
|
|
10
10
|
/** Backward-compatible metadata for the reserved single credential field. */
|
|
11
11
|
lastFour?: string;
|
|
12
12
|
updatedAt: string;
|
|
13
|
+
/**
|
|
14
|
+
* Opaque and unique to the save this metadata describes. Credential
|
|
15
|
+
* verification is recorded only against it, so a vault that omits it
|
|
16
|
+
* stores credentials that are never shown as verified.
|
|
17
|
+
*/
|
|
18
|
+
revision?: string;
|
|
13
19
|
/** Per-field masked metadata for named multi-value credentials. */
|
|
14
20
|
fields?: Record<string, CredentialFieldMetadata>;
|
|
15
21
|
}
|
|
@@ -20,6 +26,15 @@ export interface CredentialVault {
|
|
|
20
26
|
set(connectorId: string, value: string, updatedBy: string, owner?: string): Promise<CredentialMetadata>;
|
|
21
27
|
setAll(connectorId: string, values: ConnectorCredentialValues, updatedBy: string, owner?: string): Promise<CredentialMetadata>;
|
|
22
28
|
delete(connectorId: string, owner?: string): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* The values and metadata of one save, read together so neither can come
|
|
31
|
+
* from a later save. Without it, a Test of the stored credential records
|
|
32
|
+
* no verification.
|
|
33
|
+
*/
|
|
34
|
+
snapshot?(connectorId: string, owner?: string): Promise<{
|
|
35
|
+
values: ConnectorCredentialValues;
|
|
36
|
+
metadata: CredentialMetadata;
|
|
37
|
+
} | null>;
|
|
23
38
|
/**
|
|
24
39
|
* Encrypt downstream OAuth state (tokens, a registered client, a PKCE
|
|
25
40
|
* verifier) before it enters storage. `purpose` is the storage key the value
|
|
@@ -37,6 +52,14 @@ export interface CredentialVault {
|
|
|
37
52
|
* exposes the vault or this key through a connector context or guest API.
|
|
38
53
|
*/
|
|
39
54
|
requestStateKey?(): Promise<Uint8Array>;
|
|
55
|
+
/**
|
|
56
|
+
* Host-only HMAC-SHA-256 signing key for stored remote catalog,
|
|
57
|
+
* negotiation-verdict and connector-health keys, so those partitions are
|
|
58
|
+
* deployment-keyed. Must be stable across the deployment's processes,
|
|
59
|
+
* purpose-specific, and never stored. Without it, those keys use a random
|
|
60
|
+
* per-process key, and such entries are reused only within one process.
|
|
61
|
+
*/
|
|
62
|
+
storageDigestKey?(): Promise<CryptoKey>;
|
|
40
63
|
/** Reverse `seal` for the same connector, purpose, and owner; rejects otherwise. */
|
|
41
64
|
open?(connectorId: string, purpose: string, sealed: string, owner?: string): Promise<string>;
|
|
42
65
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const readers = new WeakMap();
|
|
2
|
+
/** `canonical` is the base class's own prototype methods, never the instance's. */
|
|
3
|
+
export function registerCredentialReads(vault, canonical, memo) {
|
|
4
|
+
readers.set(vault, { canonical, memo });
|
|
5
|
+
}
|
|
6
|
+
/** A `fresh` reader always reads storage and records what it saw. */
|
|
7
|
+
export function credentialReads(vault, reads, fresh = false) {
|
|
8
|
+
const reader = readers.get(vault);
|
|
9
|
+
return {
|
|
10
|
+
getAll: (connectorId, owner) => reader && vault.getAll === reader.canonical.getAll
|
|
11
|
+
? reader.memo.getAll(connectorId, owner, reads, fresh)
|
|
12
|
+
: vault.getAll(connectorId, owner),
|
|
13
|
+
metadata: (connectorId, owner) => reader && vault.metadata === reader.canonical.metadata
|
|
14
|
+
? reader.memo.metadata(connectorId, owner, reads, fresh)
|
|
15
|
+
: vault.metadata(connectorId, owner),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The last verification of a connector's operator-managed credential: a typed,
|
|
2
|
+
// payload-free observation stored per connector and owner (the connector's
|
|
3
|
+
// `ctx.storage` is already partitioned by both). Credential tests and saves
|
|
4
|
+
// write it; the operator page only reads it (INV-10).
|
|
5
|
+
import { classifyCallError } from "./errors.js";
|
|
6
|
+
import { verificationKeys } from "./storage/keys.js";
|
|
7
|
+
const FAILURES = ["rejected", "scope", "unreachable", "timeout", "failed"];
|
|
8
|
+
const OUTCOMES = ["ok", "unverified", "rejected", "scope", "unreachable", "timeout", "failed"];
|
|
9
|
+
/** Each typed code that names a class; every other code is `failed`. */
|
|
10
|
+
const CLASS_OF = {
|
|
11
|
+
auth_required: "rejected",
|
|
12
|
+
downstream_oauth_required: "rejected",
|
|
13
|
+
provider_permission_denied: "scope",
|
|
14
|
+
timeout: "timeout",
|
|
15
|
+
unavailable: "unreachable",
|
|
16
|
+
rate_limited: "unreachable",
|
|
17
|
+
};
|
|
18
|
+
/** A thrown failure's class, read from its typed code alone, never its text. */
|
|
19
|
+
export const credentialFailureClass = (error) => CLASS_OF[classifyCallError(error).code] ?? "failed";
|
|
20
|
+
/** A hook's answer as an outcome. Only its typed fields count; `message` never does. */
|
|
21
|
+
export const credentialTestOutcome = (result) => result?.ok === true ? "ok" : FAILURES.includes(result?.failure) ? result.failure : "failed";
|
|
22
|
+
/** A checked copy, or undefined: nothing here trusts stored bytes. */
|
|
23
|
+
function checkedVerification(value) {
|
|
24
|
+
const { outcome, observedAt } = (value ?? {});
|
|
25
|
+
return OUTCOMES.includes(outcome) &&
|
|
26
|
+
typeof observedAt === "string" &&
|
|
27
|
+
observedAt.length < 64 &&
|
|
28
|
+
!Number.isNaN(Date.parse(observedAt))
|
|
29
|
+
? { outcome: outcome, observedAt }
|
|
30
|
+
: undefined;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The stored observation, or undefined when there is none or it cannot be
|
|
34
|
+
* read. With `credential`, only one made about that vault credential (its
|
|
35
|
+
* `updatedAt`) counts: a replacement saved since is unverified.
|
|
36
|
+
*/
|
|
37
|
+
export async function readVerification(storage, credential) {
|
|
38
|
+
try {
|
|
39
|
+
const stored = JSON.parse((await storage.get(verificationKeys.record)) ?? "null");
|
|
40
|
+
return credential === undefined || stored?.credential === credential ? checkedVerification(stored) : undefined;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Record an outcome, bound to the vault credential's `updatedAt` when it is about one; `null` removes the record. */
|
|
47
|
+
export async function writeVerification(storage, outcome, credential) {
|
|
48
|
+
if (!outcome)
|
|
49
|
+
return void (await storage.delete(verificationKeys.record));
|
|
50
|
+
const record = { outcome, observedAt: new Date().toISOString() };
|
|
51
|
+
await storage.set(verificationKeys.record, JSON.stringify({ ...record, credential }));
|
|
52
|
+
return record;
|
|
53
|
+
}
|
package/dist/credentials.d.ts
CHANGED
|
@@ -11,15 +11,22 @@ export declare class CredentialVault implements Vault {
|
|
|
11
11
|
private readonly key;
|
|
12
12
|
private readonly handoffKey;
|
|
13
13
|
private readonly retryKey;
|
|
14
|
+
private readonly digestKey;
|
|
14
15
|
constructor(storage: KVStorage, encryptionKey: string);
|
|
15
16
|
signOAuthHandoff(payload: string): Promise<string>;
|
|
16
17
|
requestStateKey(): Promise<Uint8Array>;
|
|
18
|
+
storageDigestKey(): Promise<CryptoKey>;
|
|
17
19
|
verifyOAuthHandoff(payload: string, signature: string): Promise<boolean>;
|
|
18
20
|
private additionalData;
|
|
19
21
|
private read;
|
|
20
22
|
get(connectorId: string, field?: string, owner?: string): Promise<string | null>;
|
|
21
23
|
getAll(connectorId: string, owner?: string): Promise<ConnectorCredentialValues | null>;
|
|
22
24
|
metadata(connectorId: string, owner?: string): Promise<CredentialMetadata | null>;
|
|
25
|
+
/** The values and metadata of one save, from a single envelope read. */
|
|
26
|
+
snapshot(connectorId: string, owner?: string): Promise<{
|
|
27
|
+
values: ConnectorCredentialValues;
|
|
28
|
+
metadata: CredentialMetadata;
|
|
29
|
+
} | null>;
|
|
23
30
|
set(connectorId: string, value: string, updatedBy: string, owner?: string): Promise<CredentialMetadata>;
|
|
24
31
|
setAll(connectorId: string, values: ConnectorCredentialValues, updatedBy: string, owner?: string): Promise<CredentialMetadata>;
|
|
25
32
|
delete(connectorId: string, owner?: string): Promise<void>;
|
package/dist/credentials.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { advanceCatalogRevision } from "./catalog-revision.js";
|
|
2
|
-
import { deriveOAuthHandoffKey, deriveRequestStateKey } from "./oauth-sealing.js";
|
|
2
|
+
import { deriveOAuthHandoffKey, deriveRequestStateKey, deriveStorageDigestKey } from "./oauth-sealing.js";
|
|
3
3
|
import { credentialKeys } from "./storage/keys.js";
|
|
4
|
+
import { registerCredentialReads } from "./credential-reads.js";
|
|
4
5
|
const KEY_BYTES = 32;
|
|
5
6
|
const IV_BYTES = 12;
|
|
6
7
|
const MAX_CREDENTIAL_BYTES = 16_384;
|
|
@@ -50,7 +51,7 @@ function parseEnvelope(raw) {
|
|
|
50
51
|
throw new Error("Stored credential is invalid or corrupted");
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
|
-
function parsePlaintext(raw) {
|
|
54
|
+
function parsePlaintext(raw, iv) {
|
|
54
55
|
try {
|
|
55
56
|
const parsed = JSON.parse(raw);
|
|
56
57
|
const values = typeof parsed.value === "string" ? { value: parsed.value } : parsed.values;
|
|
@@ -66,6 +67,7 @@ function parsePlaintext(raw) {
|
|
|
66
67
|
values: { ...values },
|
|
67
68
|
updatedAt: parsed.updatedAt,
|
|
68
69
|
updatedBy: parsed.updatedBy,
|
|
70
|
+
revision: typeof parsed.revision === "string" ? parsed.revision : iv,
|
|
69
71
|
};
|
|
70
72
|
}
|
|
71
73
|
catch {
|
|
@@ -91,6 +93,28 @@ function validateValues(values) {
|
|
|
91
93
|
}
|
|
92
94
|
return normalized;
|
|
93
95
|
}
|
|
96
|
+
function metadataOf(credential) {
|
|
97
|
+
if (!credential)
|
|
98
|
+
return null;
|
|
99
|
+
const fields = Object.fromEntries(Object.entries(credential.values).map(([field, value]) => [
|
|
100
|
+
field,
|
|
101
|
+
{
|
|
102
|
+
configured: true,
|
|
103
|
+
// Only reveal the tail on values comfortably longer than four chars;
|
|
104
|
+
// for a short secret those four would be half of it.
|
|
105
|
+
...(value.length >= 12 ? { lastFour: value.slice(-4) } : {}),
|
|
106
|
+
updatedAt: credential.updatedAt,
|
|
107
|
+
},
|
|
108
|
+
]));
|
|
109
|
+
const single = fields.value;
|
|
110
|
+
return {
|
|
111
|
+
configured: true,
|
|
112
|
+
...(single?.lastFour ? { lastFour: single.lastFour } : {}),
|
|
113
|
+
updatedAt: credential.updatedAt,
|
|
114
|
+
revision: credential.revision,
|
|
115
|
+
fields,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
94
118
|
/**
|
|
95
119
|
* Encrypted, connector-scoped credential vault over the deployment's existing
|
|
96
120
|
* KVStorage. Only ciphertext enters storage; the AES-GCM key remains an environment
|
|
@@ -102,6 +126,7 @@ export class CredentialVault {
|
|
|
102
126
|
key;
|
|
103
127
|
handoffKey;
|
|
104
128
|
retryKey;
|
|
129
|
+
digestKey;
|
|
105
130
|
constructor(storage, encryptionKey) {
|
|
106
131
|
this.storage = storage;
|
|
107
132
|
const raw = base64ToBytes(encryptionKey.trim());
|
|
@@ -110,7 +135,16 @@ export class CredentialVault {
|
|
|
110
135
|
}
|
|
111
136
|
this.handoffKey = deriveOAuthHandoffKey(raw);
|
|
112
137
|
this.retryKey = deriveRequestStateKey(raw);
|
|
138
|
+
this.digestKey = deriveStorageDigestKey(raw);
|
|
113
139
|
this.key = crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
|
140
|
+
// The base class's original methods, so an override is never replaced.
|
|
141
|
+
registerCredentialReads(this, canonicalReads, {
|
|
142
|
+
getAll: async (connectorId, owner, reads, fresh) => {
|
|
143
|
+
const credential = await this.read(connectorId, owner, { reads, fresh });
|
|
144
|
+
return credential ? { ...credential.values } : null;
|
|
145
|
+
},
|
|
146
|
+
metadata: async (connectorId, owner, reads, fresh) => metadataOf(await this.read(connectorId, owner, { reads, fresh })),
|
|
147
|
+
});
|
|
114
148
|
}
|
|
115
149
|
async signOAuthHandoff(payload) {
|
|
116
150
|
return bytesToBase64(new Uint8Array(await crypto.subtle.sign("HMAC", await this.handoffKey, encoder.encode(payload))));
|
|
@@ -118,6 +152,9 @@ export class CredentialVault {
|
|
|
118
152
|
async requestStateKey() {
|
|
119
153
|
return new Uint8Array(await this.retryKey);
|
|
120
154
|
}
|
|
155
|
+
storageDigestKey() {
|
|
156
|
+
return this.digestKey;
|
|
157
|
+
}
|
|
121
158
|
async verifyOAuthHandoff(payload, signature) {
|
|
122
159
|
try {
|
|
123
160
|
return await crypto.subtle.verify("HMAC", await this.handoffKey, base64ToBytes(signature), encoder.encode(payload));
|
|
@@ -129,8 +166,11 @@ export class CredentialVault {
|
|
|
129
166
|
additionalData(connectorId, owner) {
|
|
130
167
|
return encoder.encode(owner ? `connecta:credential:principal:${owner}:${connectorId}:v1` : `connecta:credential:${connectorId}:v1`);
|
|
131
168
|
}
|
|
132
|
-
async read(connectorId, owner) {
|
|
133
|
-
const
|
|
169
|
+
async read(connectorId, owner, memo) {
|
|
170
|
+
const key = storageKey(connectorId, owner);
|
|
171
|
+
const raw = memo
|
|
172
|
+
? await memo.reads.readPromise(this.storage, key, () => this.storage.get(key), memo.fresh)
|
|
173
|
+
: await this.storage.get(key);
|
|
134
174
|
if (!raw)
|
|
135
175
|
return null;
|
|
136
176
|
const envelope = parseEnvelope(raw);
|
|
@@ -140,7 +180,7 @@ export class CredentialVault {
|
|
|
140
180
|
iv: base64ToBytes(envelope.iv),
|
|
141
181
|
additionalData: this.additionalData(connectorId, owner),
|
|
142
182
|
}, await this.key, base64ToBytes(envelope.ciphertext));
|
|
143
|
-
return parsePlaintext(decoder.decode(plaintext));
|
|
183
|
+
return parsePlaintext(decoder.decode(plaintext), envelope.iv);
|
|
144
184
|
}
|
|
145
185
|
catch {
|
|
146
186
|
throw new Error("Stored credential could not be decrypted");
|
|
@@ -155,26 +195,12 @@ export class CredentialVault {
|
|
|
155
195
|
return credential ? { ...credential.values } : null;
|
|
156
196
|
}
|
|
157
197
|
async metadata(connectorId, owner) {
|
|
198
|
+
return metadataOf(await this.read(connectorId, owner));
|
|
199
|
+
}
|
|
200
|
+
/** The values and metadata of one save, from a single envelope read. */
|
|
201
|
+
async snapshot(connectorId, owner) {
|
|
158
202
|
const credential = await this.read(connectorId, owner);
|
|
159
|
-
|
|
160
|
-
return null;
|
|
161
|
-
const fields = Object.fromEntries(Object.entries(credential.values).map(([field, value]) => [
|
|
162
|
-
field,
|
|
163
|
-
{
|
|
164
|
-
configured: true,
|
|
165
|
-
// Only reveal the tail on values comfortably longer than four chars;
|
|
166
|
-
// for a short secret those four would be half of it.
|
|
167
|
-
...(value.length >= 12 ? { lastFour: value.slice(-4) } : {}),
|
|
168
|
-
updatedAt: credential.updatedAt,
|
|
169
|
-
},
|
|
170
|
-
]));
|
|
171
|
-
const single = fields.value;
|
|
172
|
-
return {
|
|
173
|
-
configured: true,
|
|
174
|
-
...(single?.lastFour ? { lastFour: single.lastFour } : {}),
|
|
175
|
-
updatedAt: credential.updatedAt,
|
|
176
|
-
fields,
|
|
177
|
-
};
|
|
203
|
+
return credential && { values: { ...credential.values }, metadata: metadataOf(credential) };
|
|
178
204
|
}
|
|
179
205
|
async set(connectorId, value, updatedBy, owner) {
|
|
180
206
|
// `await` (not a bare promise return) so a validation throw inside setAll
|
|
@@ -188,6 +214,7 @@ export class CredentialVault {
|
|
|
188
214
|
values: normalized,
|
|
189
215
|
updatedAt: new Date().toISOString(),
|
|
190
216
|
updatedBy,
|
|
217
|
+
revision: crypto.randomUUID(),
|
|
191
218
|
};
|
|
192
219
|
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
193
220
|
const ciphertext = await crypto.subtle.encrypt({
|
|
@@ -203,7 +230,9 @@ export class CredentialVault {
|
|
|
203
230
|
};
|
|
204
231
|
await this.storage.set(storageKey(connectorId, owner), JSON.stringify(envelope));
|
|
205
232
|
advanceCatalogRevision(this);
|
|
206
|
-
|
|
233
|
+
// What this write stored, never a re-read: a concurrent save may already
|
|
234
|
+
// have replaced it, and its revision must not be reported as this one's.
|
|
235
|
+
return metadataOf(plaintext);
|
|
207
236
|
}
|
|
208
237
|
async delete(connectorId, owner) {
|
|
209
238
|
await this.storage.delete(storageKey(connectorId, owner));
|
|
@@ -238,6 +267,12 @@ export class CredentialVault {
|
|
|
238
267
|
}
|
|
239
268
|
}
|
|
240
269
|
}
|
|
270
|
+
// Captured once at definition: a later subclass, instance or prototype
|
|
271
|
+
// override of either method is always called itself (credential-reads.ts).
|
|
272
|
+
const canonicalReads = {
|
|
273
|
+
getAll: CredentialVault.prototype.getAll,
|
|
274
|
+
metadata: CredentialVault.prototype.metadata,
|
|
275
|
+
};
|
|
241
276
|
export function encryptedCredentialVault(storage, encryptionKey) {
|
|
242
277
|
return new CredentialVault(storage, encryptionKey);
|
|
243
278
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CONFIG_DEFAULTS } from "./config-defaults.js";
|
|
2
2
|
import type { ConnectaConfig, ResolvedConfig } from "./config.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type ResolvedPool } from "./membership.js";
|
|
4
4
|
import type { ConnectorAuthDescription, ConnectorDescription, ConnectorToolDescription, DescribedEndpoint, IdentityReference } from "./types.js";
|
|
5
5
|
/** Where a limit's value came from. */
|
|
6
6
|
export type ConfigValueSource = "default" | "config";
|
|
@@ -100,11 +100,21 @@ export interface ConnectaConfigDescription {
|
|
|
100
100
|
};
|
|
101
101
|
/** Whether the default /mcp endpoint refuses every write, and exact classification overrides from deployment code. */
|
|
102
102
|
readOnly: boolean;
|
|
103
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* The people plain `/mcp` serves the whole catalog, resolved, in declaration
|
|
105
|
+
* order. Empty on an open deployment. Every other person reaches plain
|
|
106
|
+
* `/mcp` only through exactly one pool's `members`, which it then serves.
|
|
107
|
+
*/
|
|
104
108
|
maintainers: Array<{
|
|
105
109
|
namespace: string;
|
|
106
110
|
id: string;
|
|
107
111
|
}>;
|
|
112
|
+
/** Non-maintainer people listed in several pools, whom plain `/mcp` refuses. */
|
|
113
|
+
peopleInSeveralPools: Array<{
|
|
114
|
+
namespace: string;
|
|
115
|
+
id: string;
|
|
116
|
+
pools: string[];
|
|
117
|
+
}>;
|
|
108
118
|
classification: Record<string, Record<string, "read" | "write" | "hidden">>;
|
|
109
119
|
/** Inbound auth providers in the order they are tried. */
|
|
110
120
|
auth: Array<{
|
package/dist/describe-config.js
CHANGED
|
@@ -15,6 +15,7 @@ import { resolveBranding } from "./branding.js";
|
|
|
15
15
|
import { CONFIG_DEFAULTS } from "./config-defaults.js";
|
|
16
16
|
import { describedEndpoint, describedHref, describedOrigin, describedTools, describedUrl } from "./described.js";
|
|
17
17
|
import { connectorGuideRequired, connectorGuideSummary } from "./skills.js";
|
|
18
|
+
import { peopleInSeveralPools } from "./membership.js";
|
|
18
19
|
import { CONNECTA_VERSION } from "./version.js";
|
|
19
20
|
const str = (value) => (typeof value === "string" ? value : undefined);
|
|
20
21
|
const num = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
@@ -235,6 +236,7 @@ export function describeConfig(input) {
|
|
|
235
236
|
},
|
|
236
237
|
readOnly: config.readOnly,
|
|
237
238
|
maintainers: input.maintainers.map(({ namespace, id }) => ({ namespace: String(namespace), id: String(id) })),
|
|
239
|
+
peopleInSeveralPools: peopleInSeveralPools(input.pools, input.maintainers),
|
|
238
240
|
classification: structuredClone(config.classification ?? {}),
|
|
239
241
|
auth: config.auth.map((provider) => ({
|
|
240
242
|
kind: String(provider.kind),
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Bounded per-connector discovery health, shared across requests (INV-7).
|
|
2
|
+
//
|
|
3
|
+
// One hung or failing connector would otherwise add its whole probe deadline
|
|
4
|
+
// to every unscoped search, every time. A failed discovery leaves a record:
|
|
5
|
+
// the connector-health family's `discovery` key, a typed code, when it was
|
|
6
|
+
// seen, and when to try again (INV-6: never downstream text, tools or
|
|
7
|
+
// credentials). Unscoped search skips a connector whose record is current and
|
|
8
|
+
// reports it as unavailable in `catalogErrors` with `retryAfterMs`; it never
|
|
9
|
+
// serves tools from the failed or interrupted walk (INV-8). Scoped search,
|
|
10
|
+
// describe and calls still reach the connector.
|
|
11
|
+
//
|
|
12
|
+
// Records sit beside the catalog cache, in its store and owner partition:
|
|
13
|
+
// principal, subject, caller identity and pool, origin, the admitted endpoint,
|
|
14
|
+
// the connector's value-free described configuration and the package version
|
|
15
|
+
// (INV-4, INV-5). Each record
|
|
16
|
+
// also carries the connector's catalog invalidation generation as it was when
|
|
17
|
+
// written. A credential change, OAuth connect or disconnect, or operator
|
|
18
|
+
// invalidation rotates that generation, so every partition's record lapses at
|
|
19
|
+
// once, in every isolate. A record is a bounded observation: it grants and
|
|
20
|
+
// starts nothing (INV-10).
|
|
21
|
+
//
|
|
22
|
+
// After its retry time a record lingers, so exactly one request can claim the
|
|
23
|
+
// next probe by compare-and-set as it dispatches it; the others keep skipping
|
|
24
|
+
// until that probe settles or its lease lapses. A success, or an auth failure that proves the
|
|
25
|
+
// connector answers, clears the record. A fast failure is not recorded:
|
|
26
|
+
// asking again costs the next search nothing. Caller cancellation and a probe cut
|
|
27
|
+
// short by the discovery budget record nothing.
|
|
28
|
+
import { Effect } from "effect";
|
|
29
|
+
import { healthAddress } from "./connector-health.js";
|
|
30
|
+
export { bindDiscoveryEndpoint } from "./connector-health.js";
|
|
31
|
+
import { classificationCode, classifyCallError } from "./errors.js";
|
|
32
|
+
import { failureRecord, logFailure } from "./operator-record.js";
|
|
33
|
+
import { causeError } from "./runtime/run.js";
|
|
34
|
+
import { Storage } from "./runtime/services.js";
|
|
35
|
+
import { storageCompareAndSet, storageGetMany, storageSet } from "./runtime/storage.js";
|
|
36
|
+
import { connectorHealthKeys, CONNECTOR_HEALTH_LINGER_MS } from "./storage/keys.js";
|
|
37
|
+
/** How long unscoped search skips a connector after a failed discovery. */
|
|
38
|
+
const DISCOVERY_RETRY_MS = 60_000;
|
|
39
|
+
/**
|
|
40
|
+
* A retryable failure slower than this is recorded; a faster one is cheap to
|
|
41
|
+
* ask again. A timeout, or a downstream that names its own wait, always is.
|
|
42
|
+
*/
|
|
43
|
+
const SLOW_FAILURE_MS = 1_000;
|
|
44
|
+
function parseFact(raw) {
|
|
45
|
+
if (raw === null)
|
|
46
|
+
return undefined;
|
|
47
|
+
try {
|
|
48
|
+
const value = JSON.parse(raw);
|
|
49
|
+
const code = classificationCode(value.code);
|
|
50
|
+
if (value.v !== 1 ||
|
|
51
|
+
!code ||
|
|
52
|
+
!Number.isFinite(value.observedAt) ||
|
|
53
|
+
!Number.isFinite(value.retryAt) ||
|
|
54
|
+
!(value.generation === null || typeof value.generation === "string"))
|
|
55
|
+
return undefined;
|
|
56
|
+
return { v: 1, code, observedAt: value.observedAt, retryAt: value.retryAt, generation: value.generation };
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function encodeFact(fact) {
|
|
63
|
+
return { raw: JSON.stringify(fact), expiresAtMs: Math.ceil(fact.retryAt + CONNECTOR_HEALTH_LINGER_MS) };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Each connector's verdict for this discovery, in `ids` order. With `skip`, a
|
|
67
|
+
* record of the current generation comes back as `skip`: current until its
|
|
68
|
+
* `retryAt`, claimable at dispatch once that has passed. Without `skip` every
|
|
69
|
+
* connector is probed and the slot only settles the record afterwards. A
|
|
70
|
+
* storage failure is a probe with no slot: health never fails discovery.
|
|
71
|
+
*/
|
|
72
|
+
export function readDiscoveryHealth(registry, ids, baseUrl, requestScope, options) {
|
|
73
|
+
return Effect.gen(function* () {
|
|
74
|
+
// Authored static catalogs never hang, so discovery keeps no health for them.
|
|
75
|
+
const addresses = yield* Effect.forEach(ids, (id) => registry.getConnector(id)?.staticTools
|
|
76
|
+
? Effect.succeed(undefined)
|
|
77
|
+
: healthAddress(registry, id, baseUrl, requestScope), {
|
|
78
|
+
concurrency: "unbounded",
|
|
79
|
+
});
|
|
80
|
+
const verdicts = ids.map(() => ({}));
|
|
81
|
+
// One getMany per store: every record and its generation in one round trip.
|
|
82
|
+
const stores = new Map();
|
|
83
|
+
addresses.forEach((address, index) => {
|
|
84
|
+
if (!address)
|
|
85
|
+
return;
|
|
86
|
+
const indexes = stores.get(address.storage) ?? [];
|
|
87
|
+
indexes.push(index);
|
|
88
|
+
stores.set(address.storage, indexes);
|
|
89
|
+
});
|
|
90
|
+
for (const [storage, indexes] of stores) {
|
|
91
|
+
const keys = indexes.flatMap((index) => [
|
|
92
|
+
connectorHealthKeys.discovery(ids[index], addresses[index].digest),
|
|
93
|
+
addresses[index].generationKey,
|
|
94
|
+
]);
|
|
95
|
+
const values = yield* storageGetMany(keys).pipe(Effect.provideService(Storage, storage), Effect.catchCause((cause) => Effect.sync(() => {
|
|
96
|
+
const first = addresses[indexes[0]];
|
|
97
|
+
logFailure(first.logger, "discovery health read failed", failureRecord({}, causeError(cause)));
|
|
98
|
+
return undefined;
|
|
99
|
+
})));
|
|
100
|
+
if (!values)
|
|
101
|
+
continue;
|
|
102
|
+
for (const [position, index] of indexes.entries()) {
|
|
103
|
+
const address = addresses[index];
|
|
104
|
+
const slot = {
|
|
105
|
+
storage,
|
|
106
|
+
key: keys[position * 2],
|
|
107
|
+
logger: address.logger,
|
|
108
|
+
generation: values[position * 2 + 1] ?? null,
|
|
109
|
+
raw: values[position * 2] ?? null,
|
|
110
|
+
};
|
|
111
|
+
const fact = parseFact(slot.raw);
|
|
112
|
+
verdicts[index] =
|
|
113
|
+
!options.skip || !fact || fact.generation !== slot.generation
|
|
114
|
+
? { slot }
|
|
115
|
+
: { slot, skip: { code: fact.code, retryAt: fact.retryAt } };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return verdicts;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Claim the probe of a record whose retry time has passed, as the probe is
|
|
123
|
+
* dispatched: one compare-and-set against the record this request read, with
|
|
124
|
+
* a lease to the absolute time `leaseUntil`, past the claimant's whole
|
|
125
|
+
* discovery budget. Others keep skipping until the lease lapses or the probe
|
|
126
|
+
* settles. False when another request holds it, or the record changed.
|
|
127
|
+
*/
|
|
128
|
+
export function claimDiscoveryProbe(slot, leaseUntil) {
|
|
129
|
+
return Effect.suspend(() => {
|
|
130
|
+
const fact = parseFact(slot.raw);
|
|
131
|
+
if (!fact)
|
|
132
|
+
return Effect.succeed(slot.raw === null);
|
|
133
|
+
const claim = encodeFact({ ...fact, retryAt: leaseUntil });
|
|
134
|
+
return storageCompareAndSet(slot.key, slot.raw, claim.raw, { expiresAtMs: claim.expiresAtMs }).pipe(Effect.provideService(Storage, slot.storage), Effect.map((claimed) => {
|
|
135
|
+
if (claimed)
|
|
136
|
+
slot.raw = claim.raw;
|
|
137
|
+
return claimed;
|
|
138
|
+
}), Effect.catchCause(() => Effect.succeed(false)));
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const REACHABLE_CODES = new Set([
|
|
142
|
+
"auth_required",
|
|
143
|
+
"downstream_oauth_required",
|
|
144
|
+
"provider_permission_denied",
|
|
145
|
+
]);
|
|
146
|
+
/**
|
|
147
|
+
* Settle one probed connector's record. `ok` and an auth failure clear a
|
|
148
|
+
* record this request saw; a recordable failure writes a fresh one. A
|
|
149
|
+
* cancelled or budget-cut probe passes `undefined` and leaves the record as
|
|
150
|
+
* it is, so a claim it held lapses on its own.
|
|
151
|
+
*/
|
|
152
|
+
export function settleDiscoveryHealth(slot, outcome) {
|
|
153
|
+
return Effect.suspend(() => {
|
|
154
|
+
let fact;
|
|
155
|
+
if (!outcome.ok) {
|
|
156
|
+
const classified = outcome.timedOut ? undefined : classifyCallError(outcome.failure, "catalog_lookup_failed");
|
|
157
|
+
const code = outcome.timedOut ? "timeout" : classificationCode(classified.code);
|
|
158
|
+
if (classified && REACHABLE_CODES.has(classified.code)) {
|
|
159
|
+
fact = undefined;
|
|
160
|
+
}
|
|
161
|
+
else if (code &&
|
|
162
|
+
(outcome.timedOut ||
|
|
163
|
+
(classified.retryable && (outcome.elapsedMs >= SLOW_FAILURE_MS || classified.retryAfterMs !== undefined)))) {
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
const window = classified?.retryAfterMs !== undefined
|
|
166
|
+
? Math.min(DISCOVERY_RETRY_MS, Math.max(1_000, classified.retryAfterMs))
|
|
167
|
+
: DISCOVERY_RETRY_MS;
|
|
168
|
+
fact = { v: 1, code, observedAt: now, retryAt: now + window, generation: slot.generation };
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
// A deterministic refusal is not a health fact, and a fast failure
|
|
172
|
+
// costs the next search nothing to ask again; leave the record.
|
|
173
|
+
return Effect.succeed({ slot });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (!fact) {
|
|
177
|
+
if (slot.raw === null)
|
|
178
|
+
return Effect.succeed({ slot });
|
|
179
|
+
// Clear only the record this request saw: a newer failure stays.
|
|
180
|
+
return storageCompareAndSet(slot.key, slot.raw, null).pipe(Effect.provideService(Storage, slot.storage), Effect.map((cleared) => {
|
|
181
|
+
if (cleared)
|
|
182
|
+
slot.raw = null;
|
|
183
|
+
return { slot };
|
|
184
|
+
}), Effect.catchCause((cause) => Effect.sync(() => {
|
|
185
|
+
logFailure(slot.logger, "discovery health write failed", failureRecord({}, causeError(cause)));
|
|
186
|
+
return { slot };
|
|
187
|
+
})));
|
|
188
|
+
}
|
|
189
|
+
const encoded = encodeFact(fact);
|
|
190
|
+
const skip = { code: fact.code, retryAt: fact.retryAt };
|
|
191
|
+
return storageSet(slot.key, encoded.raw, { expiresAtMs: encoded.expiresAtMs }).pipe(Effect.provideService(Storage, slot.storage), Effect.map(() => {
|
|
192
|
+
slot.raw = encoded.raw;
|
|
193
|
+
return { slot, skip };
|
|
194
|
+
}), Effect.catchCause((cause) => Effect.sync(() => {
|
|
195
|
+
logFailure(slot.logger, "discovery health write failed", failureRecord({}, causeError(cause)));
|
|
196
|
+
return { slot, skip };
|
|
197
|
+
})));
|
|
198
|
+
});
|
|
199
|
+
}
|
package/dist/downstream-input.js
CHANGED
|
@@ -210,6 +210,7 @@ export class DownstreamElicitation {
|
|
|
210
210
|
kind: "downstream",
|
|
211
211
|
principal,
|
|
212
212
|
endpoint,
|
|
213
|
+
pool: self.options.registry.resultIdentity().pool,
|
|
213
214
|
connector: pending.connector,
|
|
214
215
|
address: String(args.address),
|
|
215
216
|
target: pending.address,
|
|
@@ -370,6 +371,7 @@ export class DownstreamElicitation {
|
|
|
370
371
|
state.kind !== "downstream" ||
|
|
371
372
|
state.principal !== principal ||
|
|
372
373
|
state.endpoint !== endpoint ||
|
|
374
|
+
state.pool !== registry.resultIdentity().pool ||
|
|
373
375
|
state.connector !== wrapper.connector ||
|
|
374
376
|
typeof state.address !== "string" ||
|
|
375
377
|
typeof state.target !== "string" ||
|