@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.
Files changed (180) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +3 -2
  3. package/bin/connecta.mjs +4 -8
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.js +6 -4
  6. package/dist/agent-routes.js +231 -0
  7. package/dist/auth/downstream-oauth/consent.js +519 -0
  8. package/dist/auth/downstream-oauth/grant-store.js +439 -0
  9. package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
  10. package/dist/auth/downstream-oauth/refresh-records.js +447 -0
  11. package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
  12. package/dist/auth/downstream-oauth.js +66 -2382
  13. package/dist/auth/native.js +37 -0
  14. package/dist/auth/oauth-grant.js +13 -0
  15. package/dist/auth/static-oauth.js +153 -131
  16. package/dist/auth-elicitation.js +7 -1
  17. package/dist/authorization-handoff.js +11 -5
  18. package/dist/catalog-cache.js +219 -59
  19. package/dist/catalog-policy.js +18 -0
  20. package/dist/catalog-service.js +284 -47
  21. package/dist/catalog.js +18 -2
  22. package/dist/config.d.ts +16 -11
  23. package/dist/config.js +9 -6
  24. package/dist/connector-caller.d.ts +1 -1
  25. package/dist/connector-health.js +59 -0
  26. package/dist/connector-status.js +117 -0
  27. package/dist/connectors/api-connector.d.ts +7 -2
  28. package/dist/connectors/api-connector.js +7 -4
  29. package/dist/connectors/negotiation-cache.js +22 -4
  30. package/dist/connectors/remote-mcp/call.js +164 -0
  31. package/dist/connectors/remote-mcp/catalog.js +278 -0
  32. package/dist/connectors/remote-mcp/config.js +263 -0
  33. package/dist/connectors/remote-mcp/connection.js +682 -0
  34. package/dist/connectors/remote-mcp/errors.js +488 -0
  35. package/dist/connectors/remote-mcp/oauth.js +473 -0
  36. package/dist/connectors/remote-mcp/resources.js +473 -0
  37. package/dist/connectors/remote-mcp/runtime.js +1 -0
  38. package/dist/connectors/remote-mcp/transport.js +555 -0
  39. package/dist/connectors/remote-mcp.js +62 -2863
  40. package/dist/credential-contract.d.ts +23 -0
  41. package/dist/credential-reads.js +17 -0
  42. package/dist/credential-verification.js +53 -0
  43. package/dist/credentials.d.ts +7 -0
  44. package/dist/credentials.js +60 -25
  45. package/dist/describe-config.d.ts +12 -2
  46. package/dist/describe-config.js +2 -0
  47. package/dist/discovery-health.js +199 -0
  48. package/dist/downstream-input.js +2 -0
  49. package/dist/errors.d.ts +55 -17
  50. package/dist/errors.js +95 -41
  51. package/dist/execute.js +152 -114
  52. package/dist/executor-result.js +3 -1
  53. package/dist/guest-functions.js +83 -0
  54. package/dist/guest-runtime.js +60 -19
  55. package/dist/guest-types.d.ts +19 -5
  56. package/dist/http/argument-problems.js +35 -0
  57. package/dist/http/errors.js +5 -4
  58. package/dist/http/fields.js +192 -13
  59. package/dist/http/hatch-response.js +73 -0
  60. package/dist/http/hatch.js +88 -23
  61. package/dist/http/oauth.js +3 -2
  62. package/dist/http/provider.js +32 -4
  63. package/dist/http/safety.js +8 -0
  64. package/dist/http/tools.js +44 -23
  65. package/dist/index.d.ts +1 -1
  66. package/dist/index.js +9 -2
  67. package/dist/invocation.js +26 -8
  68. package/dist/membership.d.ts +13 -5
  69. package/dist/membership.js +34 -2
  70. package/dist/meta-tools.js +54 -31
  71. package/dist/native-recovery.js +4 -16
  72. package/dist/native-tools.js +5 -2
  73. package/dist/oauth-sealing.js +10 -0
  74. package/dist/operation-vocabulary.js +135 -13
  75. package/dist/operator-record.d.ts +2 -2
  76. package/dist/operator-record.js +1 -0
  77. package/dist/operator-ui/contract.d.ts +10 -0
  78. package/dist/operator-ui/generated.js +2 -2
  79. package/dist/operator-ui/model.d.ts +22 -1
  80. package/dist/provider-call-facts.js +6 -2
  81. package/dist/providers/_shared/rest/operation-index.js +48 -50
  82. package/dist/providers/_shared/rest/value-safety.js +80 -0
  83. package/dist/providers/breeze/api.js +7 -1
  84. package/dist/providers/breeze/skill.generated.js +1 -1
  85. package/dist/providers/breeze/tools.js +4 -3
  86. package/dist/providers/ccb/api.js +2 -1
  87. package/dist/providers/ccb/index.js +5 -0
  88. package/dist/providers/ccb/skill.generated.js +1 -1
  89. package/dist/providers/cloudflare/api.js +8 -1
  90. package/dist/providers/cloudflare/skill.generated.js +1 -1
  91. package/dist/providers/docs/lib/schemas.js +24 -22
  92. package/dist/providers/docs/safety.js +2 -0
  93. package/dist/providers/docs/skill.generated.js +1 -1
  94. package/dist/providers/docs/tools.js +2 -4
  95. package/dist/providers/drive/api.js +1 -0
  96. package/dist/providers/drive/lib/contracts.js +8 -5
  97. package/dist/providers/drive/skill.generated.js +1 -1
  98. package/dist/providers/drive/tools.js +11 -16
  99. package/dist/providers/forms/api.js +1 -0
  100. package/dist/providers/forms/skill.generated.js +1 -1
  101. package/dist/providers/forms/tools.js +10 -7
  102. package/dist/providers/github/api.js +1 -0
  103. package/dist/providers/github/lib/discovery.js +1 -1
  104. package/dist/providers/github/skill.generated.js +1 -1
  105. package/dist/providers/github/tools.js +61 -17
  106. package/dist/providers/gmail/lib/schemas.js +60 -50
  107. package/dist/providers/gmail/skill.generated.js +1 -1
  108. package/dist/providers/gmail/tools.js +7 -5
  109. package/dist/providers/granola/index.js +2 -0
  110. package/dist/providers/granola/skill.generated.js +1 -1
  111. package/dist/providers/overflow/api.js +7 -1
  112. package/dist/providers/overflow/skill.generated.js +1 -1
  113. package/dist/providers/planning-center/api.js +3 -1
  114. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  115. package/dist/providers/planning-center/skill.generated.js +1 -1
  116. package/dist/providers/quickbooks/safety.js +80 -39
  117. package/dist/providers/quickbooks/skill.generated.js +1 -1
  118. package/dist/providers/sheets/api.js +1 -0
  119. package/dist/providers/sheets/skill.generated.js +1 -1
  120. package/dist/providers/sheets/tools.js +13 -14
  121. package/dist/providers/slides/skill.generated.js +1 -1
  122. package/dist/providers/slides/tools.js +15 -13
  123. package/dist/providers/stripe/skill.generated.js +1 -1
  124. package/dist/providers/tithely/api.js +3 -1
  125. package/dist/providers/tithely/skill.generated.js +1 -1
  126. package/dist/providers/vercel/api.js +1 -1
  127. package/dist/providers/vercel/skill.generated.js +1 -1
  128. package/dist/providers/vercel/tools.js +4 -3
  129. package/dist/registry.d.ts +10 -4
  130. package/dist/registry.js +41 -13
  131. package/dist/routes/credentials.js +129 -60
  132. package/dist/routes/dispatch.js +5 -1
  133. package/dist/routes/mcp.js +30 -14
  134. package/dist/routes/operator.js +2 -2
  135. package/dist/routes/shared.d.ts +6 -1
  136. package/dist/routes/shared.js +7 -1
  137. package/dist/routes/ui-config.js +2 -2
  138. package/dist/routes/ui-data.js +126 -20
  139. package/dist/routes/ui.js +16 -0
  140. package/dist/runtime/call.js +7 -2
  141. package/dist/runtime/native-storage.js +12 -0
  142. package/dist/runtime/request-reads.js +207 -0
  143. package/dist/runtime/request-scope.js +16 -0
  144. package/dist/runtime/storage.js +12 -0
  145. package/dist/skills.js +37 -20
  146. package/dist/storage/keys.js +53 -3
  147. package/dist/storage/memory.js +8 -2
  148. package/dist/storage/sql-native.js +53 -1
  149. package/dist/storage/sql.js +12 -2
  150. package/dist/storage-digest.js +32 -0
  151. package/dist/timeout.js +8 -0
  152. package/dist/types.d.ts +33 -0
  153. package/dist/usage-guide.js +16 -35
  154. package/dist/validate.d.ts +3 -0
  155. package/dist/validate.js +220 -158
  156. package/dist/version.d.ts +1 -1
  157. package/dist/version.js +1 -1
  158. package/dist/write-outcome.js +0 -3
  159. package/documentation/README.md +13 -13
  160. package/documentation/architecture.md +385 -20
  161. package/documentation/auth.md +233 -64
  162. package/documentation/code-mode.md +122 -39
  163. package/documentation/deploying.md +6 -3
  164. package/documentation/integrating.md +16 -2
  165. package/documentation/meta-tools.md +240 -49
  166. package/documentation/migration-0.33.md +5 -1
  167. package/documentation/migration-0.34.md +3 -1
  168. package/documentation/operator-tests.md +1 -1
  169. package/documentation/operator-ui.md +105 -12
  170. package/documentation/pool-membership-upgrade.md +44 -0
  171. package/documentation/providers.md +105 -0
  172. package/examples/worker/AGENTS.md +10 -10
  173. package/examples/worker/README.md +4 -3
  174. package/examples/worker/src/connecta.config.ts +1 -0
  175. package/guides/connecting-agents.md +2 -1
  176. package/guides/how-it-works.md +4 -2
  177. package/package.json +1 -1
  178. package/templates/node/AGENTS.md +13 -12
  179. package/templates/node/package.json +1 -1
  180. 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
+ }
@@ -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>;
@@ -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 raw = await this.storage.get(storageKey(connectorId, owner));
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
- if (!credential)
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
- return (await this.metadata(connectorId, owner));
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 { ResolvedPool } from "./membership.js";
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
- /** The principals plain `/mcp` admits, resolved, in declaration order. Empty on an open deployment. */
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<{
@@ -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
+ }
@@ -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" ||