@zackbart/connecta 0.28.1 → 0.29.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 (319) hide show
  1. package/CHANGELOG.md +788 -5
  2. package/README.md +62 -42
  3. package/bin/connecta.mjs +110 -139
  4. package/bin/meta-tool-names.d.mts +2 -0
  5. package/bin/meta-tool-names.mjs +9 -0
  6. package/bin/version.mjs +3 -0
  7. package/dist/access-tokens.js +53 -63
  8. package/dist/activity-request.js +8 -0
  9. package/dist/activity.d.ts +76 -42
  10. package/dist/activity.js +110 -42
  11. package/dist/auth/clerk-sdk/client.d.ts +65 -0
  12. package/dist/auth/clerk-sdk/client.js +5 -0
  13. package/dist/auth/clerk-sdk/generated.js +1845 -0
  14. package/dist/auth/clerk-transport.js +19 -0
  15. package/dist/auth/clerk.d.ts +17 -7
  16. package/dist/auth/clerk.js +189 -107
  17. package/dist/auth/cloudflare-access.js +25 -26
  18. package/dist/auth/downstream-client-metadata.d.ts +29 -0
  19. package/dist/auth/downstream-client-metadata.js +133 -0
  20. package/dist/auth/downstream-oauth.js +1584 -1580
  21. package/dist/auth/downstream-revocation.js +106 -0
  22. package/dist/auth/oauth-token-response.js +231 -0
  23. package/dist/auth/oauth-v2-migration.js +116 -0
  24. package/dist/auth/static-oauth.js +93 -71
  25. package/dist/auth-elicitation.js +283 -0
  26. package/dist/branding.d.ts +8 -16
  27. package/dist/branding.js +26 -59
  28. package/dist/byte-read-response.js +39 -0
  29. package/dist/call-admission.js +12 -16
  30. package/dist/catalog-cache.js +739 -0
  31. package/dist/catalog-drift.d.ts +50 -15
  32. package/dist/catalog-drift.js +365 -128
  33. package/dist/catalog-fingerprint.js +2 -4
  34. package/dist/catalog-freshness.js +13 -0
  35. package/dist/catalog-limits.js +2 -2
  36. package/dist/catalog-service.js +368 -224
  37. package/dist/catalog.js +43 -84
  38. package/dist/concurrency.js +3 -4
  39. package/dist/config-defaults.d.ts +39 -0
  40. package/dist/config-defaults.js +46 -0
  41. package/dist/config-schema.d.ts +181 -0
  42. package/dist/config-schema.js +435 -0
  43. package/dist/config-value-sources.js +102 -0
  44. package/dist/config.d.ts +631 -0
  45. package/dist/config.js +503 -0
  46. package/dist/connector-access.d.ts +1 -0
  47. package/dist/connector-access.js +13 -15
  48. package/dist/connector-caller.d.ts +8 -0
  49. package/dist/connector-caller.js +15 -6
  50. package/dist/connector-scope.js +1 -1
  51. package/dist/connectors/api-connector.d.ts +59 -7
  52. package/dist/connectors/api-connector.js +106 -19
  53. package/dist/connectors/api.js +4 -1
  54. package/dist/connectors/guarded-fetch.d.ts +144 -0
  55. package/dist/connectors/guarded-fetch.js +84 -35
  56. package/dist/connectors/negotiation-cache.js +46 -0
  57. package/dist/connectors/option-shapes.js +27 -0
  58. package/dist/connectors/remote-mcp.d.ts +30 -4
  59. package/dist/connectors/remote-mcp.js +1774 -320
  60. package/dist/connectors/resource-uri.js +335 -0
  61. package/dist/credential-contract.d.ts +10 -0
  62. package/dist/credential-rules.js +1 -3
  63. package/dist/credential-url.js +108 -0
  64. package/dist/credentials.d.ts +6 -1
  65. package/dist/credentials.js +27 -18
  66. package/dist/d1.d.ts +130 -0
  67. package/dist/d1.js +290 -0
  68. package/dist/describe-config.d.ts +165 -0
  69. package/dist/describe-config.js +315 -0
  70. package/dist/described.js +106 -0
  71. package/dist/downstream-input-context.js +119 -0
  72. package/dist/downstream-input.js +323 -0
  73. package/dist/errors.d.ts +97 -20
  74. package/dist/errors.js +158 -80
  75. package/dist/execute.js +453 -305
  76. package/dist/executor-admission.d.ts +1 -0
  77. package/dist/executor-admission.js +11 -15
  78. package/dist/executor-contract.d.ts +20 -0
  79. package/dist/executor-contract.js +48 -0
  80. package/dist/executor-result.js +24 -9
  81. package/dist/executors/quickjs-child.js +3 -4
  82. package/dist/executors/quickjs-runtime.js +147 -59
  83. package/dist/executors/quickjs.js +29 -35
  84. package/dist/favicon.js +1 -2
  85. package/dist/fix-prompt.js +1 -3
  86. package/dist/guest-runtime.js +188 -0
  87. package/dist/guest-types.d.ts +176 -0
  88. package/dist/guest-types.js +1 -0
  89. package/dist/html-security.js +24 -0
  90. package/dist/identity.js +1 -3
  91. package/dist/inbound-credential.js +52 -0
  92. package/dist/index.d.ts +24 -256
  93. package/dist/index.js +65 -478
  94. package/dist/invocation-auth.js +69 -0
  95. package/dist/invocation.js +230 -73
  96. package/dist/json-schema.d.ts +1 -1
  97. package/dist/mcp-client-context.js +35 -0
  98. package/dist/mcp-result.js +11 -7
  99. package/dist/meta-output.js +76 -0
  100. package/dist/meta-tool-names.d.ts +2 -0
  101. package/dist/meta-tool-names.js +3 -0
  102. package/dist/meta-tools.js +285 -209
  103. package/dist/module-contracts.d.ts +8 -23
  104. package/dist/node.d.ts +0 -2
  105. package/dist/node.js +2 -2
  106. package/dist/oauth-callback-outcome.js +1 -3
  107. package/dist/oauth-handoff.js +117 -0
  108. package/dist/oauth-sealing.js +20 -0
  109. package/dist/operator-record.d.ts +137 -0
  110. package/dist/operator-record.js +420 -0
  111. package/dist/operator-ui/assets.js +31 -0
  112. package/dist/operator-ui/contract.d.ts +72 -0
  113. package/dist/operator-ui/contract.js +1 -0
  114. package/dist/operator-ui/fix-prompts.d.ts +18 -0
  115. package/dist/operator-ui/generated.js +5 -4
  116. package/dist/operator-ui/model.d.ts +12 -87
  117. package/dist/operator-ui/model.js +2 -9
  118. package/dist/operator-ui/setup-commands.js +1 -3
  119. package/dist/operator-ui/view.d.ts +323 -0
  120. package/dist/operator-ui/view.js +62 -189
  121. package/dist/page-styles.js +2 -3
  122. package/dist/program-source.js +14 -2
  123. package/dist/{exempt-writes.js → program-writes.js} +55 -18
  124. package/dist/provider.d.ts +142 -0
  125. package/dist/provider.js +187 -0
  126. package/dist/providers/_shared/google/result-size.js +57 -0
  127. package/dist/providers/_shared/google/service-account.d.ts +58 -0
  128. package/dist/providers/_shared/google/service-account.js +500 -0
  129. package/dist/providers/_shared/google/workspace.d.ts +276 -0
  130. package/dist/providers/_shared/google/workspace.js +518 -0
  131. package/dist/providers/{basecamp.d.ts → basecamp/index.d.ts} +6 -28
  132. package/dist/providers/basecamp/index.js +997 -0
  133. package/dist/providers/basecamp/skill.generated.js +9 -0
  134. package/dist/providers/{breeze.d.ts → breeze/index.d.ts} +2 -2
  135. package/dist/providers/{breeze.js → breeze/index.js} +184 -140
  136. package/dist/providers/breeze/skill.generated.js +9 -0
  137. package/dist/providers/{ccb.d.ts → ccb/index.d.ts} +2 -2
  138. package/dist/providers/{ccb.js → ccb/index.js} +271 -116
  139. package/dist/providers/ccb/skill.generated.js +9 -0
  140. package/dist/providers/{cloudflare.d.ts → cloudflare/index.d.ts} +8 -7
  141. package/dist/providers/{cloudflare.js → cloudflare/index.js} +171 -738
  142. package/dist/providers/cloudflare/skill.generated.js +10 -0
  143. package/dist/providers/docs/index.d.ts +54 -0
  144. package/dist/providers/docs/index.js +1237 -0
  145. package/dist/providers/docs/skill.generated.js +9 -0
  146. package/dist/providers/drive/index.d.ts +53 -0
  147. package/dist/providers/drive/index.js +1523 -0
  148. package/dist/providers/drive/skill.generated.js +9 -0
  149. package/dist/providers/forms/index.d.ts +54 -0
  150. package/dist/providers/forms/index.js +1217 -0
  151. package/dist/providers/forms/skill.generated.js +11 -0
  152. package/dist/providers/github/auth.d.ts +32 -0
  153. package/dist/providers/github/auth.js +361 -0
  154. package/dist/providers/github/hosted.js +218 -0
  155. package/dist/providers/github/index.d.ts +13 -0
  156. package/dist/providers/github/index.js +140 -0
  157. package/dist/providers/github/key.js +100 -0
  158. package/dist/providers/github/rest.js +321 -0
  159. package/dist/providers/github/scope.d.ts +29 -0
  160. package/dist/providers/github/scope.js +124 -0
  161. package/dist/providers/github/skill.generated.js +8 -0
  162. package/dist/providers/gmail/index.d.ts +51 -0
  163. package/dist/providers/gmail/index.js +1436 -0
  164. package/dist/providers/gmail/skill.generated.js +9 -0
  165. package/dist/providers/{linear.d.ts → linear/index.d.ts} +11 -14
  166. package/dist/providers/linear/index.js +352 -0
  167. package/dist/providers/linear/skill.generated.js +8 -0
  168. package/dist/providers/{mixpanel.d.ts → mixpanel/index.d.ts} +5 -10
  169. package/dist/providers/mixpanel/index.js +416 -0
  170. package/dist/providers/mixpanel/skill.generated.js +9 -0
  171. package/dist/providers/{notion.d.ts → notion/index.d.ts} +8 -8
  172. package/dist/providers/{notion.js → notion/index.js} +226 -239
  173. package/dist/providers/notion/skill.generated.js +10 -0
  174. package/dist/providers/{overflow.d.ts → overflow/index.d.ts} +2 -2
  175. package/dist/providers/{overflow.js → overflow/index.js} +74 -111
  176. package/dist/providers/overflow/skill.generated.js +8 -0
  177. package/dist/providers/{planning-center.d.ts → planning-center/index.d.ts} +2 -2
  178. package/dist/providers/{planning-center.js → planning-center/index.js} +264 -104
  179. package/dist/providers/planning-center/skill.generated.js +10 -0
  180. package/dist/providers/{revenuecat.d.ts → revenuecat/index.d.ts} +5 -10
  181. package/dist/providers/revenuecat/index.js +557 -0
  182. package/dist/providers/revenuecat/skill.generated.js +11 -0
  183. package/dist/providers/sheets/index.d.ts +54 -0
  184. package/dist/providers/sheets/index.js +1237 -0
  185. package/dist/providers/sheets/skill.generated.js +10 -0
  186. package/dist/providers/slides/index.d.ts +53 -0
  187. package/dist/providers/slides/index.js +2645 -0
  188. package/dist/providers/slides/skill.generated.js +9 -0
  189. package/dist/providers/{stripe.d.ts → stripe/index.d.ts} +5 -12
  190. package/dist/providers/stripe/index.js +266 -0
  191. package/dist/providers/stripe/skill.generated.js +11 -0
  192. package/dist/providers/{tithely.d.ts → tithely/index.d.ts} +2 -2
  193. package/dist/providers/{tithely.js → tithely/index.js} +60 -91
  194. package/dist/providers/tithely/skill.generated.js +8 -0
  195. package/dist/providers/{vercel.d.ts → vercel/index.d.ts} +8 -8
  196. package/dist/providers/vercel/index.js +1038 -0
  197. package/dist/providers/vercel/mcp-ownership.js +212 -0
  198. package/dist/providers/vercel/skill.generated.js +11 -0
  199. package/dist/registry.d.ts +108 -171
  200. package/dist/registry.js +513 -925
  201. package/dist/request-state.js +21 -0
  202. package/dist/resource-read.js +100 -0
  203. package/dist/result-shapes.js +17 -49
  204. package/dist/retired-toolkits.js +1 -1
  205. package/dist/routes/access-tokens.js +3 -6
  206. package/dist/routes/activity-context.js +21 -0
  207. package/dist/routes/activity-disclosure.js +40 -0
  208. package/dist/routes/activity.js +59 -21
  209. package/dist/routes/actor-labels.js +3 -50
  210. package/dist/routes/connect.js +221 -0
  211. package/dist/routes/credentials.js +15 -27
  212. package/dist/routes/mcp.js +356 -130
  213. package/dist/routes/oauth-client-metadata.js +15 -0
  214. package/dist/routes/oauth-management.js +45 -107
  215. package/dist/routes/oauth.js +110 -57
  216. package/dist/routes/operator.js +3 -3
  217. package/dist/routes/shared.d.ts +25 -64
  218. package/dist/routes/shared.js +160 -80
  219. package/dist/routes/skills.js +30 -0
  220. package/dist/routes/ui-config.js +225 -0
  221. package/dist/routes/ui-data.js +40 -35
  222. package/dist/routes/ui.js +66 -56
  223. package/dist/runtime/admission.js +4 -2
  224. package/dist/runtime/call-admission.js +3 -1
  225. package/dist/runtime/connector-scope.js +1 -4
  226. package/dist/runtime/run.js +10 -39
  227. package/dist/runtime/services.js +12 -117
  228. package/dist/runtime/shared-read.js +1 -3
  229. package/dist/runtime/storage.js +8 -1
  230. package/dist/sent-secrets.d.ts +52 -0
  231. package/dist/sent-secrets.js +474 -0
  232. package/dist/server.js +28 -71
  233. package/dist/skills.js +406 -174
  234. package/dist/sqlite.d.ts +48 -0
  235. package/dist/sqlite.js +166 -0
  236. package/dist/storage/keys.js +428 -0
  237. package/dist/storage/memory.js +7 -0
  238. package/dist/storage/sql.d.ts +66 -0
  239. package/dist/storage/sql.js +534 -0
  240. package/dist/timeout.js +4 -2
  241. package/dist/tool-name.js +17 -0
  242. package/dist/tool-safety.d.ts +9 -35
  243. package/dist/tool-safety.js +20 -40
  244. package/dist/types.d.ts +320 -54
  245. package/dist/types.js +0 -1
  246. package/dist/ui.d.ts +7 -19
  247. package/dist/ui.js +76 -90
  248. package/dist/url-safety.js +3 -6
  249. package/dist/usage-guide.js +137 -0
  250. package/dist/validate.d.ts +7 -0
  251. package/dist/validate.js +309 -47
  252. package/dist/version.d.ts +1 -7
  253. package/dist/version.js +3 -7
  254. package/dist/worker.d.ts +11 -0
  255. package/dist/worker.js +434 -0
  256. package/documentation/architecture.md +664 -231
  257. package/documentation/auth.md +936 -546
  258. package/documentation/code-mode.md +501 -312
  259. package/documentation/meta-tools.md +449 -200
  260. package/documentation/operator-tests.md +93 -0
  261. package/documentation/provider-migration-0.29.md +60 -0
  262. package/examples/worker/AGENTS.md +34 -12
  263. package/examples/worker/README.md +286 -255
  264. package/examples/worker/kv-to-d1.wrangler.jsonc +26 -0
  265. package/examples/worker/scripts/copy-kv-to-d1.mjs +162 -0
  266. package/examples/worker/src/connecta.config.ts +151 -0
  267. package/examples/worker/src/index.ts +14 -197
  268. package/examples/worker/wrangler.jsonc +15 -35
  269. package/package.json +120 -54
  270. package/templates/node/.dockerignore +1 -2
  271. package/templates/node/.env.example +20 -21
  272. package/templates/node/AGENTS.md +21 -17
  273. package/templates/node/Dockerfile +3 -5
  274. package/templates/node/README.md +133 -58
  275. package/templates/node/docker-compose.yml +11 -12
  276. package/templates/node/package.json +4 -2
  277. package/templates/node/src/connecta.config.ts +119 -0
  278. package/templates/node/src/index.ts +9 -154
  279. package/templates/node/src/provision-token.ts +17 -0
  280. package/AGENTS.md +0 -170
  281. package/dist/artifacts/connector.d.ts +0 -58
  282. package/dist/artifacts/connector.js +0 -730
  283. package/dist/artifacts/document.js +0 -86
  284. package/dist/artifacts/guide.js +0 -140
  285. package/dist/artifacts/html-scan.js +0 -329
  286. package/dist/artifacts/json.js +0 -78
  287. package/dist/artifacts/kv-store.d.ts +0 -20
  288. package/dist/artifacts/kv-store.js +0 -140
  289. package/dist/artifacts/markdown.js +0 -478
  290. package/dist/artifacts/module.d.ts +0 -37
  291. package/dist/artifacts/module.js +0 -66
  292. package/dist/artifacts/operations.d.ts +0 -197
  293. package/dist/artifacts/operations.js +0 -1002
  294. package/dist/artifacts/refresh.d.ts +0 -55
  295. package/dist/artifacts/refresh.js +0 -236
  296. package/dist/artifacts/routes.js +0 -206
  297. package/dist/artifacts/types.d.ts +0 -214
  298. package/dist/artifacts/types.js +0 -22
  299. package/dist/artifacts/validate.d.ts +0 -62
  300. package/dist/artifacts/validate.js +0 -651
  301. package/dist/artifacts.d.ts +0 -6
  302. package/dist/artifacts.js +0 -9
  303. package/dist/auth/bearer.d.ts +0 -11
  304. package/dist/auth/bearer.js +0 -50
  305. package/dist/providers/basecamp.js +0 -445
  306. package/dist/providers/linear.js +0 -217
  307. package/dist/providers/mixpanel.js +0 -247
  308. package/dist/providers/revenuecat.js +0 -317
  309. package/dist/providers/stripe.js +0 -253
  310. package/dist/providers/vercel.js +0 -1253
  311. package/dist/storage/file.d.ts +0 -15
  312. package/dist/storage/file.js +0 -455
  313. package/ethos.md +0 -93
  314. package/examples/worker/src/cloudflare-kv.ts +0 -43
  315. package/examples/worker/src/d1-activity-row.ts +0 -155
  316. package/examples/worker/src/d1-activity.ts +0 -147
  317. package/examples/worker/src/d1-storage.ts +0 -138
  318. package/examples/worker/src/r2-artifact-blobs.ts +0 -29
  319. package/templates/node/src/file-activity.ts +0 -182
@@ -1,55 +1,128 @@
1
1
  // Web-API only, like the rest of the core: a manifest comparison that ran on
2
2
  // Node but not on Workers would leave half the deployments unable to tell a
3
3
  // stale allowlist from a current one.
4
- import { boundedEchoText } from "./errors.js";
5
- /**
6
- * Byte budget for the one string a drift report carries. An ISO timestamp is
7
- * 24 bytes; anything near this bound is a plugin sending something else.
8
- */
9
- const MAX_OBSERVED_AT_BYTES = 64;
4
+ import { classifyTool } from "./tool-safety.js";
5
+ import { failureRecord, logFailure } from "./operator-record.js";
10
6
  /** A count, or 0 when the seam returned something that is not one. */
11
7
  function boundedCount(value) {
12
- return typeof value === "number" && Number.isFinite(value)
13
- ? Math.max(0, Math.trunc(value))
14
- : 0;
8
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
15
9
  }
10
+ /** An ISO-8601 date-time with a zone, as `Date.parse` reads it on every runtime. */
11
+ const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})$/;
16
12
  /**
17
- * Rebuild a drift report as four counts and a bounded timestamp.
13
+ * The report's time, re-serialized from the instant it names, so the string
14
+ * that leaves is always connecta's own ISO-8601 UTC form, never the seam's.
15
+ */
16
+ function observationTime(value) {
17
+ if (typeof value !== "string" || !ISO_INSTANT.test(value))
18
+ return undefined;
19
+ const time = Date.parse(value);
20
+ return Number.isFinite(time) ? new Date(time).toISOString() : undefined;
21
+ }
22
+ /**
23
+ * Rebuild a drift report as bounded counts and a canonical timestamp.
18
24
  *
19
25
  * `Connector.catalogDrift()` sits on the open plugin seam, and what it returns
20
- * lands in the body of unauthenticated `/health` and on connector status.
21
- * TypeScript constrains neither an extra enumerable property nor the length of
22
- * `observedAt` at runtime, so "counts and nothing else" is *made* true here —
23
- * at the boundary where third-party output becomes a response — rather than
24
- * trusted. The activity path reconstructs its five fields for the same reason.
26
+ * lands in the body of unauthenticated `/health`, on connector status, in the
27
+ * operator page, and in `connecta doctor`. TypeScript constrains neither an
28
+ * extra enumerable property nor what `observedAt` holds at runtime, so "counts
29
+ * and a time and nothing else" is *made* true here — at the boundary where
30
+ * third-party output becomes a response — rather than trusted. A report whose
31
+ * `observedAt` names no instant is no observation connecta can date, and is
32
+ * dropped whole (INV-6): a plugin forwarding a downstream's report could
33
+ * otherwise put any text there. The activity path reconstructs its five
34
+ * fields for the same reason.
25
35
  */
26
36
  export function boundedCatalogDrift(report) {
27
37
  if (!report || typeof report !== "object")
28
38
  return undefined;
39
+ const observedAt = observationTime(report.observedAt);
40
+ if (observedAt === undefined)
41
+ return undefined;
29
42
  return {
30
- observedAt: boundedEchoText(typeof report.observedAt === "string" ? report.observedAt : "", MAX_OBSERVED_AT_BYTES),
43
+ observedAt,
31
44
  unclassifiedTools: boundedCount(report.unclassifiedTools),
32
45
  unservedTools: boundedCount(report.unservedTools),
33
46
  annotationConflicts: boundedCount(report.annotationConflicts),
34
47
  schemaChanges: boundedCount(report.schemaChanges),
48
+ ...(boundedCount(report.droppedTools ?? 0) > 0 ? { droppedTools: boundedCount(report.droppedTools ?? 0) } : {}),
35
49
  };
36
50
  }
37
51
  const encoder = new TextEncoder();
38
- /** Deterministic JSON: object keys sorted, so key order is not a schema change. */
39
- function canonicalize(value, depth = 0) {
40
- // Drift is advisory. Beyond this bound compare an explicit marker instead
41
- // of letting a downstream schema exhaust the host stack.
42
- if (depth > 64)
43
- return "[schema depth truncated]";
44
- if (Array.isArray(value)) {
45
- return value.map((item) => canonicalize(item, depth + 1));
52
+ /**
53
+ * Most JSON values one tool's schemas may hold before its digest refuses to
54
+ * compute. A digest vouches for a whole schema or for nothing, so a schema
55
+ * past the bound is not hashed in part: the computation throws, and the
56
+ * caller serves every digested review unverified (INV-1).
57
+ */
58
+ const MAX_SCHEMA_DIGEST_NODES = 100_000;
59
+ /** Object keys in the order `JSON.stringify` emits a key-sorted object. */
60
+ function canonicalKeys(value) {
61
+ const entries = Object.entries(value).filter(([, item]) => item !== undefined && typeof item !== "function" && typeof item !== "symbol");
62
+ entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
63
+ // A plain object enumerates integer-like keys first, in numeric order.
64
+ // Recorded digests were taken from a key-sorted object, so reproduce its
65
+ // enumeration rather than the sort alone.
66
+ const sorted = Object.fromEntries(entries);
67
+ return Object.keys(sorted).map((key) => [key, sorted[key]]);
68
+ }
69
+ /**
70
+ * Deterministic JSON with object keys sorted, so key order is not a schema
71
+ * change. Iterative and complete: every leaf at every depth reaches the
72
+ * digest, and a schema deeper than the host stack cannot exhaust it. Throws
73
+ * past {@link MAX_SCHEMA_DIGEST_NODES}, and on any value JSON cannot carry.
74
+ */
75
+ function canonicalJson(root) {
76
+ const out = [];
77
+ const stack = [{ value: root }];
78
+ let nodes = 0;
79
+ while (stack.length > 0) {
80
+ const task = stack.pop();
81
+ if ("text" in task) {
82
+ out.push(task.text);
83
+ continue;
84
+ }
85
+ if (++nodes > MAX_SCHEMA_DIGEST_NODES) {
86
+ throw new Error(`schema has more than ${MAX_SCHEMA_DIGEST_NODES} values; refusing to digest it in part.`);
87
+ }
88
+ const value = task.value;
89
+ if (value === null || value === undefined) {
90
+ out.push("null");
91
+ }
92
+ else if (typeof value === "string" || typeof value === "boolean") {
93
+ out.push(JSON.stringify(value));
94
+ }
95
+ else if (typeof value === "number") {
96
+ out.push(Number.isFinite(value) ? JSON.stringify(value) : "null");
97
+ }
98
+ else if (typeof value === "function" || typeof value === "symbol") {
99
+ // Only array items reach here; object keys holding these are dropped.
100
+ out.push("null");
101
+ }
102
+ else if (typeof value !== "object") {
103
+ throw new Error(`schema holds a ${typeof value}, which JSON cannot carry.`);
104
+ }
105
+ else if (Array.isArray(value)) {
106
+ out.push("[");
107
+ stack.push({ text: "]" });
108
+ for (let index = value.length - 1; index >= 0; index -= 1) {
109
+ stack.push({ value: value[index] });
110
+ if (index > 0)
111
+ stack.push({ text: "," });
112
+ }
113
+ }
114
+ else {
115
+ out.push("{");
116
+ stack.push({ text: "}" });
117
+ const entries = canonicalKeys(value);
118
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
119
+ const [key, item] = entries[index];
120
+ stack.push({ value: item });
121
+ stack.push({ text: `${index > 0 ? "," : ""}${JSON.stringify(key)}:` });
122
+ }
123
+ }
46
124
  }
47
- if (value === null || typeof value !== "object")
48
- return value;
49
- const entries = Object.entries(value)
50
- .filter(([, item]) => item !== undefined)
51
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
52
- return Object.fromEntries(entries.map(([key, item]) => [key, canonicalize(item, depth + 1)]));
125
+ return out.join("");
53
126
  }
54
127
  /**
55
128
  * Digest the schemas of one downstream tool.
@@ -60,16 +133,17 @@ function canonicalize(value, depth = 0) {
60
133
  * maintainer's attention on a downstream that reordered its JSON keys.
61
134
  * Description and annotations are excluded — a reworded description is P1's
62
135
  * business, and an annotation change is already its own drift category.
136
+ *
137
+ * Rejects rather than digesting part of a schema: a digest that ignored some
138
+ * leaf would let a reviewed read keep its verdict after that leaf changed.
63
139
  */
64
140
  export async function vettedSchemaDigest(tool) {
65
- const bytes = encoder.encode(JSON.stringify(canonicalize({
141
+ const bytes = encoder.encode(canonicalJson({
66
142
  inputSchema: tool.inputSchema ?? null,
67
143
  outputSchema: tool.outputSchema ?? null,
68
- })));
144
+ }));
69
145
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
70
- return `sha256:${[...digest]
71
- .map((byte) => byte.toString(16).padStart(2, "0"))
72
- .join("")}`;
146
+ return `sha256:${[...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
73
147
  }
74
148
  /**
75
149
  * Build a provider's manifest from the lists it already maintains.
@@ -106,61 +180,161 @@ export function vettedCatalog(input) {
106
180
  }
107
181
  return { version: 1, tools };
108
182
  }
183
+ const VERDICTS = {
184
+ read: "read-only",
185
+ write: "additive",
186
+ destructive: "destructive",
187
+ };
188
+ const PUBLIC_VERDICTS = {
189
+ "read-only": "read",
190
+ additive: "write",
191
+ destructive: "destructive",
192
+ };
193
+ const SCHEMA_DIGEST = /^sha256:[0-9a-f]{64}$/;
194
+ const REVIEWED_TOOL_KEYS = new Set(["verdict", "reason", "schemaDigest"]);
195
+ function isRecord(value) {
196
+ return typeof value === "object" && value !== null && !Array.isArray(value);
197
+ }
109
198
  /**
110
- * Fill in downstream silence; keep reviewed destructive tools fail-closed.
199
+ * Validate a public {@link ToolClassification} once, reading every value a
200
+ * single time, into the manifest the registry classifies with and a
201
+ * deep-frozen copy of the record.
111
202
  *
112
- * Silence is what a vetted classification is for, and an explicit downstream
113
- * annotation otherwise wins in both directions. `destructiveHint: true` or
114
- * `readOnlyHint: false` on a classified read is the downstream telling us this
115
- * release's allowlist is stale; `readOnlyHint: true` on a name no release has
116
- * classified says the same thing from the other side. The single place a
117
- * vetted verdict still overrides the downstream is a name this release
118
- * reviewed and filed destructive: there connecta knows what the tool does, and
119
- * a claim to the contrary is a downstream bug rather than news
203
+ * Every structural mistake throws, naming `owner`, because a classification
204
+ * that half-applies is worse than one that refuses to boot (INV-11). Unknown
205
+ * keys throw too: a misspelled `verdict` must not quietly leave a write
206
+ * unclassified.
207
+ */
208
+ function parseClassification(classification, owner) {
209
+ function fail(detail) {
210
+ throw new Error(`[connecta] ${owner} classify ${detail}`);
211
+ }
212
+ if (!isRecord(classification))
213
+ fail("must be an object with a tools record.");
214
+ for (const key of Object.keys(classification)) {
215
+ if (key !== "tools" && key !== "unlisted")
216
+ fail(`has unknown key "${key}"; only "tools" and "unlisted" are accepted.`);
217
+ }
218
+ const unlisted = classification.unlisted;
219
+ if (unlisted !== undefined && unlisted !== "hide")
220
+ fail('unlisted must be "hide" when set.');
221
+ const input = classification.tools;
222
+ if (!isRecord(input))
223
+ fail("tools must be a record of tool name to verdict.");
224
+ const tools = new Map();
225
+ const frozen = {};
226
+ for (const [name, entry] of Object.entries(input)) {
227
+ if (!name || name.trim() !== name) {
228
+ fail(`tool name "${name}" is empty or has surrounding whitespace.`);
229
+ }
230
+ const record = typeof entry === "string" ? { verdict: entry } : isRecord(entry) ? { ...entry } : {};
231
+ for (const key of Object.keys(record)) {
232
+ if (!REVIEWED_TOOL_KEYS.has(key))
233
+ fail(`tool "${name}" has unknown key "${key}".`);
234
+ }
235
+ const { verdict: publicVerdict, reason, schemaDigest: digest } = record;
236
+ const verdict = typeof publicVerdict === "string" && Object.hasOwn(VERDICTS, publicVerdict) ? VERDICTS[publicVerdict] : undefined;
237
+ if (verdict === undefined) {
238
+ fail(`tool "${name}" needs verdict "read", "write", or "destructive".`);
239
+ }
240
+ if (reason !== undefined && (typeof reason !== "string" || !reason.trim())) {
241
+ fail(`tool "${name}" reason must be a non-empty string.`);
242
+ }
243
+ if (digest !== undefined && (typeof digest !== "string" || !SCHEMA_DIGEST.test(digest))) {
244
+ fail(`tool "${name}" schemaDigest must be "sha256:" and 64 lowercase hex digits.`);
245
+ }
246
+ tools.set(name, {
247
+ verdict,
248
+ ...(typeof digest === "string" ? { schemaDigest: digest } : {}),
249
+ });
250
+ frozen[name] =
251
+ typeof entry === "string"
252
+ ? PUBLIC_VERDICTS[verdict]
253
+ : Object.freeze({
254
+ verdict: PUBLIC_VERDICTS[verdict],
255
+ ...(typeof reason === "string" ? { reason } : {}),
256
+ ...(typeof digest === "string" ? { schemaDigest: digest } : {}),
257
+ });
258
+ }
259
+ return {
260
+ catalog: { version: 1, tools, ...(unlisted ? { unlisted } : {}) },
261
+ frozen: Object.freeze({ tools: Object.freeze(frozen), ...(unlisted ? { unlisted } : {}) }),
262
+ };
263
+ }
264
+ /** Validate a {@link ToolClassification} and build the manifest it describes. */
265
+ export function reviewedCatalog(classification, owner) {
266
+ return parseClassification(classification, owner).catalog;
267
+ }
268
+ /**
269
+ * Validate a {@link ToolClassification} and return a deep-frozen copy of it:
270
+ * what a connector carries as `classification`, so neither the caller's
271
+ * original object nor a write through the connector can change a verdict
272
+ * after review.
273
+ */
274
+ export function reviewedClassification(classification, owner) {
275
+ return parseClassification(classification, owner).frozen;
276
+ }
277
+ /**
278
+ * One reviewed tool as served: a fresh object, never one a connector, a
279
+ * cache, or an earlier read holds.
280
+ *
281
+ * A listed read fills downstream silence, and an explicit write annotation on
282
+ * it wins: `destructiveHint: true` or `readOnlyHint: false` on a classified
283
+ * read is the downstream telling us the review is stale. A listed write or
284
+ * destructive tool stays a write whatever the downstream claims; there
285
+ * connecta knows what the tool does, and a claim to the contrary is a
286
+ * downstream bug rather than news
120
287
  * ([#310](https://github.com/zackbart/connecta/issues/310),
121
- * [#315](https://github.com/zackbart/connecta/issues/315)).
288
+ * [#315](https://github.com/zackbart/connecta/issues/315)). A tool no review
289
+ * lists keeps only an explicit read annotation, the downstream's own word
290
+ * being the only evidence there is. A reviewed tool whose recorded schema
291
+ * digest the live tool no longer matches, or could not be checked against,
292
+ * keeps no reviewed verdict and is a write.
122
293
  */
123
- function applyVettedSafety(catalog, definition) {
294
+ function servedTool(catalog, fact, lapsed, override) {
295
+ const definition = structuredClone(fact);
296
+ const reviewed = catalog.tools.get(fact.name);
297
+ definition.classification = classifyTool(fact, override, reviewed
298
+ ? {
299
+ verdict: PUBLIC_VERDICTS[reviewed.verdict],
300
+ stale: lapsed.has(fact.name),
301
+ }
302
+ : undefined);
124
303
  const downstream = definition.annotations ?? {};
125
304
  const record = catalog.tools.get(definition.name);
305
+ const annotate = (annotations) => ({
306
+ ...definition,
307
+ annotations: override === undefined
308
+ ? annotations
309
+ : { ...annotations, readOnlyHint: override === "read", destructiveHint: override === "write" },
310
+ });
311
+ if (record?.verdict === "destructive") {
312
+ return annotate({ ...downstream, readOnlyHint: false, destructiveHint: true });
313
+ }
314
+ if (record && lapsed.has(definition.name)) {
315
+ // A review vouches for the schema it read. When the live schema no longer
316
+ // matches that digest, or the digest could not be checked, the verdict is
317
+ // about some other tool: the read becomes a write until a release reviews
318
+ // it again (INV-1).
319
+ return annotate({ ...downstream, readOnlyHint: false });
320
+ }
126
321
  if (record?.verdict === "read-only") {
127
- if (downstream.destructiveHint === true ||
128
- downstream.readOnlyHint === false) {
129
- return definition;
322
+ if (downstream.destructiveHint === true || downstream.readOnlyHint === false) {
323
+ return annotate(downstream);
130
324
  }
131
- return {
132
- ...definition,
133
- annotations: {
134
- ...downstream,
135
- readOnlyHint: true,
136
- destructiveHint: downstream.destructiveHint ?? false,
137
- },
138
- };
325
+ return annotate({
326
+ ...downstream,
327
+ readOnlyHint: true,
328
+ destructiveHint: downstream.destructiveHint ?? false,
329
+ });
139
330
  }
140
- if (record?.verdict === "destructive") {
141
- return {
142
- ...definition,
143
- annotations: {
144
- ...downstream,
145
- readOnlyHint: false,
146
- destructiveHint: true,
147
- },
148
- };
331
+ if (record?.verdict === "additive") {
332
+ return annotate({ ...downstream, readOnlyHint: false });
149
333
  }
150
- // Maintained additive creates and tools this release has never seen land
151
- // here alike. Fill-in only: a silent tool is not read-only, so drift still
152
- // fails closed onto `call_destructive_tool`, and neither population gets a
153
- // `destructiveHint` it has not earned. A tool that arrives explicitly
154
- // read-only keeps that annotation — on a name no release has reviewed, the
155
- // downstream's own word is the only evidence there is, and rewriting it
156
- // would be an overrule rather than a fill-in.
157
- return {
158
- ...definition,
159
- annotations: {
160
- ...downstream,
161
- readOnlyHint: downstream.readOnlyHint ?? false,
162
- },
163
- };
334
+ // Fill-in only: a silent tool is not read-only, so drift fails closed onto
335
+ // `call_destructive_tool`, and it gets no `destructiveHint` it has not
336
+ // earned. An explicit read claim on a name no review lists is believed.
337
+ return annotate({ ...downstream, readOnlyHint: downstream.readOnlyHint ?? false });
164
338
  }
165
339
  /**
166
340
  * Whether the downstream's own annotation contradicts what a release reviewed.
@@ -172,22 +346,35 @@ function applyVettedSafety(catalog, definition) {
172
346
  function contradicts(record, definition) {
173
347
  const downstream = definition.annotations ?? {};
174
348
  if (record.verdict === "read-only") {
175
- return (downstream.readOnlyHint === false || downstream.destructiveHint === true);
349
+ return downstream.readOnlyHint === false || downstream.destructiveHint === true;
176
350
  }
177
351
  return downstream.readOnlyHint === true;
178
352
  }
179
353
  /**
180
- * Compare a live catalog with the manifest and count what moved.
181
- *
182
- * Counts only, and by construction: there is nowhere here to put a tool name,
183
- * a schema, or a downstream string, so no later surface has to remember to
184
- * strip one. Reads nothing but the tools it was handed — the caller already
185
- * fetched them to serve a request, and this function never fetches anything.
354
+ * Names of served tools whose schemas no longer match the digest a release
355
+ * recorded for them. A manifest that recorded no digest for a tool cannot have
356
+ * an opinion about its schema, so it does not pay for a hash either. Throws
357
+ * when a digest cannot be computed; the caller decides what that means.
186
358
  */
187
- export async function detectCatalogDrift(catalog, tools) {
359
+ async function changedSchemas(catalog, tools) {
360
+ const changed = new Set();
361
+ for (const definition of tools) {
362
+ const recorded = catalog.tools.get(definition.name)?.schemaDigest;
363
+ if (recorded !== undefined && recorded !== (await vettedSchemaDigest(definition))) {
364
+ changed.add(definition.name);
365
+ }
366
+ }
367
+ return changed;
368
+ }
369
+ /** Every served tool whose review recorded a digest: what cannot be verified. */
370
+ function digestedTools(catalog, tools) {
371
+ return new Set(tools
372
+ .filter((definition) => catalog.tools.get(definition.name)?.schemaDigest !== undefined)
373
+ .map((definition) => definition.name));
374
+ }
375
+ function countDrift(catalog, tools, changed) {
188
376
  let unclassifiedTools = 0;
189
377
  let annotationConflicts = 0;
190
- let schemaChanges = 0;
191
378
  const served = new Set();
192
379
  for (const definition of tools) {
193
380
  served.add(definition.name);
@@ -198,12 +385,6 @@ export async function detectCatalogDrift(catalog, tools) {
198
385
  }
199
386
  if (contradicts(record, definition))
200
387
  annotationConflicts += 1;
201
- // A manifest that recorded no digest for this tool cannot have an opinion
202
- // about its schema, so it does not pay for a hash either.
203
- if (record.schemaDigest !== undefined &&
204
- record.schemaDigest !== (await vettedSchemaDigest(definition))) {
205
- schemaChanges += 1;
206
- }
207
388
  }
208
389
  let unservedTools = 0;
209
390
  for (const name of catalog.tools.keys()) {
@@ -214,43 +395,99 @@ export async function detectCatalogDrift(catalog, tools) {
214
395
  unclassifiedTools,
215
396
  unservedTools,
216
397
  annotationConflicts,
217
- schemaChanges,
398
+ schemaChanges: changed.size,
218
399
  };
219
400
  }
220
401
  /**
221
- * Wrap a hosted-MCP connector in its vetted manifest: the classification the
222
- * catalog is normalized with, and the drift check that rides the same listing.
402
+ * Compare a live catalog with the manifest and count what moved.
223
403
  *
224
- * The check happens where the tools are already in hand and still unmodified —
225
- * after the downstream answered, before the classification is applied. It adds
226
- * no request of its own, which is the whole boundary: connecta watches a
227
- * contract while it is serving a refresh the deployment asked for, and never
228
- * initiates one to go looking ([#179](https://github.com/zackbart/connecta/issues/179),
404
+ * Counts only, and by construction: there is nowhere here to put a tool name,
405
+ * a schema, or a downstream string, so no later surface has to remember to
406
+ * strip one. Reads nothing but the tools it was handed — the caller already
407
+ * fetched them to serve a request, and this function never fetches anything.
408
+ */
409
+ export async function detectCatalogDrift(catalog, tools) {
410
+ return countDrift(catalog, tools, await changedSchemas(catalog, tools));
411
+ }
412
+ /**
413
+ * Each connector object's review, validated the first time a registry reads
414
+ * it. Read once per object, so a getter or a record mutated later cannot
415
+ * hand a later registry (a principal's, say) a different review.
416
+ */
417
+ const reviews = new WeakMap();
418
+ /**
419
+ * The review `connector.classification` declares, or undefined when it
420
+ * declares none. Throws on a malformed one (INV-11).
421
+ */
422
+ export function catalogReviewOf(connector) {
423
+ let review = reviews.get(connector);
424
+ if (review === undefined) {
425
+ const classification = connector.classification;
426
+ review = classification === undefined ? null : reviewedCatalog(classification, `connector "${connector.id}"`);
427
+ reviews.set(connector, review);
428
+ }
429
+ return review ?? undefined;
430
+ }
431
+ /**
432
+ * Classify downstream facts with a review: what the registry serves on every
433
+ * read, from whichever cache layer the facts came from.
434
+ *
435
+ * Every tool returned is a fresh object, so nothing a caller does to one can
436
+ * reach the facts, `memo`, or a later read. `memo` holds digest verification
437
+ * per facts array; the registry passes one only for deep-frozen arrays it
438
+ * owns. A failed verification is not kept, so the next read tries again, and
439
+ * meanwhile no digested review vouches for a read (INV-1).
440
+ */
441
+ export async function classifyCatalog(catalog, connectorId, facts, logger, memo, overrides) {
442
+ let lapsed = memo?.get(facts);
443
+ if (!lapsed) {
444
+ try {
445
+ lapsed = await changedSchemas(catalog, facts);
446
+ memo?.set(facts, lapsed);
447
+ }
448
+ catch (error) {
449
+ lapsed = digestedTools(catalog, facts);
450
+ logFailure(logger, "schema digest check failed; serving digested reviews as writes", failureRecord({ connector: connectorId }, error));
451
+ }
452
+ }
453
+ const unverified = lapsed;
454
+ return facts
455
+ .filter((fact) => catalog.unlisted !== "hide" || catalog.tools.has(fact.name))
456
+ .map((fact) => servedTool(catalog, fact, unverified, overrides?.[fact.name]));
457
+ }
458
+ /**
459
+ * The drift each reviewed connector object showed on its last listing in this
460
+ * runtime. Keyed by the connector rather than by registry, so a principal's
461
+ * registry and the root report one observation, as they serve one connector.
462
+ */
463
+ const observations = new WeakMap();
464
+ /**
465
+ * Compare a listing a refresh just received with the connector's review, and
466
+ * keep the counts as its latest observation.
467
+ *
468
+ * The check happens where the tools are already in hand, while serving a
469
+ * refresh the deployment asked for. It adds no request of its own, which is
470
+ * the whole boundary: connecta watches a contract while it is serving a
471
+ * refresh, and never initiates one to go looking
472
+ * ([#179](https://github.com/zackbart/connecta/issues/179),
229
473
  * [#343](https://github.com/zackbart/connecta/issues/343)).
230
474
  */
231
- export function withVettedCatalog(connector, catalog) {
232
- let observed;
233
- return {
234
- ...connector,
235
- async listTools(ctx) {
236
- const downstream = await connector.listTools(ctx);
237
- try {
238
- observed = {
239
- observedAt: new Date().toISOString(),
240
- ...(await detectCatalogDrift(catalog, downstream)),
241
- };
242
- }
243
- catch (error) {
244
- // A drift check is a report about a catalog, never a condition for
245
- // serving one. Keep the last good observation rather than replacing it
246
- // with a lie, and let the refresh through.
247
- ctx.logger.warn(`[connecta] connector "${connector.id}" catalog drift check failed: ` +
248
- (error instanceof Error ? error.message : String(error)));
249
- }
250
- return downstream.map((definition) => applyVettedSafety(catalog, definition));
251
- },
252
- catalogDrift() {
253
- return observed;
254
- },
255
- };
475
+ export async function observeReviewedDrift(connector, catalog, listed, logger) {
476
+ try {
477
+ const changed = await changedSchemas(catalog, listed);
478
+ observations.set(connector, {
479
+ observedAt: new Date().toISOString(),
480
+ ...countDrift(catalog, listed, changed),
481
+ });
482
+ }
483
+ catch (error) {
484
+ // A drift check is a report about a catalog, never a condition for
485
+ // serving one. Keep the last good observation rather than replacing it
486
+ // with a lie, and let the refresh through; classification fails closed.
487
+ logFailure(logger, "catalog drift check failed", failureRecord({ connector: connector.id }, error));
488
+ }
489
+ }
490
+ /** The latest drift {@link observeReviewedDrift} kept for `connector`. */
491
+ export function observedCatalogDrift(connector) {
492
+ return observations.get(connector);
256
493
  }
@@ -1,9 +1,7 @@
1
1
  const encoder = new TextEncoder();
2
2
  async function fingerprintBytes(bytes) {
3
- const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
4
- const hex = [...digest]
5
- .map((byte) => byte.toString(16).padStart(2, "0"))
6
- .join("");
3
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new Uint8Array(bytes)));
4
+ const hex = [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");
7
5
  return `sha256:${bytes.byteLength}:${hex}`;
8
6
  }
9
7
  /** Fingerprint an already serialized catalog without parsing/reserializing it. */
@@ -0,0 +1,13 @@
1
+ // Host-only provenance follows catalog copies, never connector definitions or
2
+ // serialized listings. Unknown provenance cannot authorize post-entry recovery.
3
+ const deadlines = new WeakMap();
4
+ export function markCatalogFreshness(tools, freshUntil) {
5
+ deadlines.set(tools, freshUntil);
6
+ return tools;
7
+ }
8
+ export function carryCatalogFreshness(source, copy) {
9
+ return markCatalogFreshness(copy, deadlines.get(source) ?? 0);
10
+ }
11
+ export function catalogIsFresh(tools) {
12
+ return (deadlines.get(tools) ?? 0) > Date.now();
13
+ }
@@ -1,8 +1,8 @@
1
1
  /** Complete-catalog ceiling shared by dynamically listed connector catalogs. */
2
2
  export const MAX_CATALOG_TOOLS = 100_000;
3
3
  /**
4
- * Large enough to cross a single Workers KV value boundary while keeping the
5
- * serialized form at one quarter of a 128 MiB Worker heap.
4
+ * Large enough to span many stored chunks while keeping the serialized form
5
+ * at one quarter of a 128 MiB Worker heap.
6
6
  */
7
7
  export const MAX_SERIALIZED_CATALOG_BYTES = 32 * 1024 * 1024;
8
8
  /** Persisted values stay comfortably below platform per-value limits. */