@zackbart/connecta 0.40.0 → 0.42.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 (195) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/bin/connecta.mjs +26 -17
  3. package/bin/meta-tool-names.d.mts +3 -1
  4. package/bin/meta-tool-names.mjs +4 -1
  5. package/bin/version.mjs +1 -1
  6. package/dist/activity.d.ts +32 -5
  7. package/dist/activity.js +32 -6
  8. package/dist/advertised-schema.js +15 -0
  9. package/dist/agent-observation.js +24 -2
  10. package/dist/agent-routes.js +33 -44
  11. package/dist/auth/clerk.d.ts +6 -1
  12. package/dist/auth/clerk.js +16 -0
  13. package/dist/auth/cloudflare-access.js +49 -7
  14. package/dist/auth/downstream-oauth/consent.js +79 -29
  15. package/dist/auth/downstream-oauth/grant-store.js +58 -7
  16. package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
  17. package/dist/auth/downstream-oauth/refresh-records.js +6 -5
  18. package/dist/auth/downstream-oauth.js +15 -4
  19. package/dist/auth/oauth-request-not-sent.js +4 -0
  20. package/dist/auth/static-oauth.js +77 -39
  21. package/dist/auth-elicitation.js +118 -61
  22. package/dist/authorization-handoff.js +36 -3
  23. package/dist/catalog-cache.js +352 -43
  24. package/dist/catalog-freshness.js +25 -0
  25. package/dist/catalog-service.js +408 -78
  26. package/dist/catalog.js +216 -62
  27. package/dist/config-defaults.d.ts +2 -1
  28. package/dist/config-defaults.js +4 -1
  29. package/dist/config.d.ts +53 -21
  30. package/dist/config.js +56 -17
  31. package/dist/connectors/remote-mcp/call.js +1 -1
  32. package/dist/connectors/remote-mcp/catalog.js +52 -28
  33. package/dist/connectors/remote-mcp/connection.js +113 -22
  34. package/dist/connectors/remote-mcp/oauth.js +2 -2
  35. package/dist/connectors/remote-mcp/resources.js +5 -2
  36. package/dist/connectors/remote-mcp/transport.js +24 -6
  37. package/dist/connectors/remote-mcp.js +14 -5
  38. package/dist/credential-contract.d.ts +3 -1
  39. package/dist/describe-config.d.ts +4 -2
  40. package/dist/describe-config.js +1 -0
  41. package/dist/discovery-health.js +26 -11
  42. package/dist/downstream-input.js +11 -2
  43. package/dist/errors.d.ts +60 -4
  44. package/dist/errors.js +88 -3
  45. package/dist/execute.js +491 -202
  46. package/dist/executor-admission.js +2 -0
  47. package/dist/executor-contract.js +6 -0
  48. package/dist/executor-result.js +3 -3
  49. package/dist/executors/quickjs-child.js +14 -5
  50. package/dist/executors/quickjs-runtime.d.ts +2 -0
  51. package/dist/executors/quickjs-runtime.js +37 -20
  52. package/dist/executors/quickjs.js +24 -7
  53. package/dist/guarded-transport.js +2 -0
  54. package/dist/guest-functions.js +5 -4
  55. package/dist/guest-runtime.js +37 -1
  56. package/dist/guest-types.d.ts +18 -0
  57. package/dist/http/client.js +14 -2
  58. package/dist/http/errors.js +9 -4
  59. package/dist/http/fields.js +17 -5
  60. package/dist/http/hatch.js +11 -3
  61. package/dist/http/node-transport.js +110 -4
  62. package/dist/http/oauth.js +17 -6
  63. package/dist/http/paging.js +1 -1
  64. package/dist/http/provider.js +2 -6
  65. package/dist/http/vendor.js +6 -1
  66. package/dist/index.d.ts +1 -1
  67. package/dist/index.js +15 -21
  68. package/dist/invocation-failure.js +28 -0
  69. package/dist/invocation.js +167 -64
  70. package/dist/membership.d.ts +1 -1
  71. package/dist/meta-output.js +24 -18
  72. package/dist/meta-tool-names.d.ts +1 -1
  73. package/dist/meta-tool-names.js +1 -1
  74. package/dist/meta-tools.js +286 -73
  75. package/dist/module-contracts.d.ts +3 -1
  76. package/dist/native-recovery.js +4 -1
  77. package/dist/native-tools.js +177 -55
  78. package/dist/node.d.ts +7 -0
  79. package/dist/node.js +85 -4
  80. package/dist/oauth-handoff.js +53 -43
  81. package/dist/operation-activity.js +104 -0
  82. package/dist/operation-vocabulary.js +6 -0
  83. package/dist/operator-record.d.ts +9 -1
  84. package/dist/operator-record.js +16 -0
  85. package/dist/operator-ui/generated.js +2 -2
  86. package/dist/program-result.js +2 -0
  87. package/dist/program-source.js +1 -1
  88. package/dist/program-writes.js +112 -19
  89. package/dist/provider.d.ts +1 -14
  90. package/dist/provider.js +0 -3
  91. package/dist/providers/_shared/google/kit.js +2 -2
  92. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  93. package/dist/providers/breeze/api.js +2 -2
  94. package/dist/providers/breeze/index.js +1 -1
  95. package/dist/providers/breeze/skill.generated.js +1 -1
  96. package/dist/providers/ccb/api.js +2 -2
  97. package/dist/providers/ccb/index.js +1 -1
  98. package/dist/providers/ccb/skill.generated.js +1 -1
  99. package/dist/providers/cloudflare/api.js +2 -2
  100. package/dist/providers/cloudflare/index.js +3 -1
  101. package/dist/providers/cloudflare/skill.generated.js +1 -1
  102. package/dist/providers/docs/index.js +1 -1
  103. package/dist/providers/docs/skill.generated.js +1 -1
  104. package/dist/providers/drive/index.js +1 -1
  105. package/dist/providers/drive/skill.generated.js +1 -1
  106. package/dist/providers/forms/index.js +2 -1
  107. package/dist/providers/forms/skill.generated.js +1 -1
  108. package/dist/providers/github/api.js +98 -6
  109. package/dist/providers/github/index.js +8 -3
  110. package/dist/providers/github/lib/auth.d.ts +3 -1
  111. package/dist/providers/github/lib/auth.js +43 -12
  112. package/dist/providers/github/lib/discovery.js +11 -1
  113. package/dist/providers/github/lib/operations.js +8 -0
  114. package/dist/providers/github/safety.js +39 -0
  115. package/dist/providers/github/skill.generated.js +1 -1
  116. package/dist/providers/github/tools.js +12 -5
  117. package/dist/providers/gmail/index.js +2 -1
  118. package/dist/providers/gmail/skill.generated.js +1 -1
  119. package/dist/providers/granola/index.js +1 -1
  120. package/dist/providers/granola/skill.generated.js +1 -1
  121. package/dist/providers/overflow/index.js +3 -1
  122. package/dist/providers/overflow/skill.generated.js +1 -1
  123. package/dist/providers/planning-center/index.js +12 -6
  124. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  125. package/dist/providers/planning-center/skill.generated.js +1 -1
  126. package/dist/providers/quickbooks/api.js +28 -25
  127. package/dist/providers/quickbooks/index.js +3 -6
  128. package/dist/providers/quickbooks/lib/writes.js +55 -0
  129. package/dist/providers/quickbooks/safety.js +17 -5
  130. package/dist/providers/quickbooks/skill.generated.js +1 -1
  131. package/dist/providers/sheets/index.js +2 -1
  132. package/dist/providers/sheets/skill.generated.js +1 -1
  133. package/dist/providers/slides/index.js +3 -3
  134. package/dist/providers/slides/skill.generated.js +1 -1
  135. package/dist/providers/stripe/index.js +2 -2
  136. package/dist/providers/stripe/skill.generated.js +1 -1
  137. package/dist/providers/tithely/index.js +2 -1
  138. package/dist/providers/tithely/skill.generated.js +1 -1
  139. package/dist/providers/vercel/api.js +3 -2
  140. package/dist/providers/vercel/skill.generated.js +1 -1
  141. package/dist/registry.d.ts +36 -4
  142. package/dist/registry.js +142 -30
  143. package/dist/resource-read.js +21 -2
  144. package/dist/result-preview.js +210 -0
  145. package/dist/result-seal.js +168 -0
  146. package/dist/route-names.js +53 -0
  147. package/dist/routes/activity-context.js +1 -0
  148. package/dist/routes/activity-disclosure.js +26 -4
  149. package/dist/routes/activity.js +2 -1
  150. package/dist/routes/connect.js +21 -13
  151. package/dist/routes/mcp.js +83 -35
  152. package/dist/routes/shared.d.ts +6 -0
  153. package/dist/routes/ui-config.js +1 -1
  154. package/dist/routes/ui-data.js +3 -2
  155. package/dist/runtime/call.js +15 -10
  156. package/dist/runtime/native-storage.js +12 -0
  157. package/dist/runtime/request-reads.js +66 -14
  158. package/dist/runtime/request-scope.js +38 -2
  159. package/dist/runtime/run.js +4 -1
  160. package/dist/sent-secrets.d.ts +20 -0
  161. package/dist/sent-secrets.js +49 -1
  162. package/dist/skill-origin.js +16 -0
  163. package/dist/skills.js +213 -57
  164. package/dist/storage/d1-driver.js +33 -5
  165. package/dist/storage/keys.js +16 -7
  166. package/dist/storage/sql-native.js +60 -3
  167. package/dist/storage/sql.js +6 -0
  168. package/dist/timing-trace.js +84 -0
  169. package/dist/tool-safety.d.ts +33 -0
  170. package/dist/tool-safety.js +51 -15
  171. package/dist/types.d.ts +36 -0
  172. package/dist/url-safety.js +38 -20
  173. package/dist/usage-guide.js +14 -13
  174. package/dist/utility-recovery.js +33 -0
  175. package/dist/validate.js +29 -10
  176. package/dist/version.d.ts +1 -1
  177. package/dist/version.js +1 -1
  178. package/dist/worker.js +60 -15
  179. package/documentation/architecture.md +152 -43
  180. package/documentation/auth.md +160 -53
  181. package/documentation/code-mode.md +396 -119
  182. package/documentation/deploying.md +35 -10
  183. package/documentation/hosted-mcp-migration.md +9 -3
  184. package/documentation/integrating.md +27 -5
  185. package/documentation/meta-tools.md +761 -157
  186. package/documentation/operating.md +91 -4
  187. package/documentation/operator-ui.md +51 -2
  188. package/documentation/providers.md +34 -20
  189. package/examples/worker/README.md +3 -3
  190. package/guides/how-it-works.md +2 -1
  191. package/guides/safety-and-trust.md +1 -3
  192. package/package.json +10 -8
  193. package/templates/node/AGENTS.md +3 -3
  194. package/templates/node/README.md +1 -1
  195. package/templates/node/package.json +1 -1
@@ -1,3 +1,5 @@
1
+ import { traceFor } from "./timing-trace.js";
2
+ import { markCatalogGeneration } from "./catalog-freshness.js";
1
3
  import { detach, runEdge } from "./runtime/run.js";
2
4
  import { Deferred, Effect } from "effect";
3
5
  import { authFlow, authCall, authInvoke, authValue, authMethod, authProgram, registerAuth, authStorage, authStorageMany, authMap, authAwait, } from "./auth/native.js";
@@ -236,6 +238,21 @@ export function observeCatalogExpiry(ctx, method, ttlMs, expiresAt, staleFallbac
236
238
  // failure for five minutes. Resources never use stale inventories. A fallback
237
239
  // carries its original expired deadline and cannot authorize auth recovery.
238
240
  const STALE_FALLBACK_MS = 300_000;
241
+ /**
242
+ * How long past its expiry a complete tool catalog stays listable when its
243
+ * connector does not answer discovery in time. The listing only: a call always
244
+ * resolves through STALE_FALLBACK_MS at most. One day matches the longest TTL a
245
+ * deployment can configure, so this retains nothing it could not already hold.
246
+ * A refusal retires it sooner, for that credential: see `fenceTools`.
247
+ */
248
+ const STALE_DISCOVERY_MS = 86_400_000;
249
+ /** A fence outlives every entry it could be keeping from reuse. */
250
+ const CATALOG_FENCE_TTL_SECONDS = (MAX_CACHE_TTL_MS + STALE_DISCOVERY_MS) / 1000;
251
+ /** What a refusal's fence is keyed by, beside the catalog: one credential's slot within one generation. */
252
+ const catalogFenceFingerprint = (sharing, partition, authPartition, credentialIdentity) => authCall(fingerprintSerializedCatalog, [
253
+ // A shared credential gets one answer whoever asks, so one fence covers all its callers.
254
+ JSON.stringify(["fence", sharing === "shared" ? authPartition : partition, credentialIdentity ?? null]),
255
+ ]);
239
256
  /** Catalog chunks one storage read fetches together. */
240
257
  const CHUNKS_PER_READ = 8;
241
258
  export function attachCatalogCache(ctx, value) {
@@ -446,6 +463,51 @@ function rememberValidated(key, value, bytes) {
446
463
  validatedCatalogs.set(key, { value, bytes });
447
464
  validatedBytes += bytes;
448
465
  }
466
+ const catalogLocation = authMethod((ctx, config, authPartition) => authFlow(function* () {
467
+ const policy = serviceFor(ctx, CatalogPolicy);
468
+ const partition = JSON.stringify([
469
+ policy?.partition ?? JSON.stringify(callerOf(ctx) ?? null),
470
+ ctx.baseUrl,
471
+ ctx.publicUrl,
472
+ authPartition,
473
+ ]);
474
+ const sharedPartition = JSON.stringify([
475
+ "host-shared",
476
+ policy?.sharedPartition ?? JSON.stringify([ctx.baseUrl, ctx.publicUrl, callerOf(ctx)?.pool]),
477
+ authPartition,
478
+ ]);
479
+ const configHash = (yield authCall(fingerprintSerializedCatalog, [
480
+ JSON.stringify([
481
+ config,
482
+ policy?.defaultTtlMs ?? 300000,
483
+ policy?.minTtlMs ?? 0,
484
+ policy?.maxTtlMs ?? MAX_CACHE_TTL_MS,
485
+ ]),
486
+ ])).fingerprint;
487
+ return { partition, sharedPartition, configHash };
488
+ }));
489
+ /** Prefetch candidates only; authority, identity and acceptance stay fresh. */
490
+ export const prefetchCatalog = authMethod((ctx, connectorId, config, authPartition, sharing, credentialIdentity, negotiationKey) => authFlow(function* () {
491
+ const { partition, sharedPartition, configHash } = yield authCall(catalogLocation, [ctx, config, authPartition]);
492
+ const slots = sharing === "shared" ? [partition, sharedPartition] : [partition];
493
+ const suffixes = [
494
+ (yield catalogFenceFingerprint(sharing, partition, authPartition, credentialIdentity)).fingerprint,
495
+ ];
496
+ for (const slot of slots)
497
+ suffixes.push((yield authCall(fingerprintSerializedCatalog, [
498
+ JSON.stringify(["tools/list", slot, credentialIdentity ?? null]),
499
+ ])).fingerprint);
500
+ const storage = serviceFor(ctx, CatalogPolicy)?.storage ?? ctx.storage;
501
+ yield ownerReads(Context.get(viewServices(ctx), RequestScope)).prefetch([
502
+ { storage: ctx.storage, key: negotiationKey },
503
+ { storage, key: responseCacheKeys.generation(connectorId) },
504
+ ], (storage, keys) => authStorageMany(storage, keys), {
505
+ storage,
506
+ generationKey: responseCacheKeys.generation(connectorId),
507
+ prefix: responseCacheKeys.locatorPrefix(connectorId, configHash),
508
+ suffixes,
509
+ });
510
+ }));
449
511
  /** Keys ignore self-reported serverInfo and bind the configured connector instead. */
450
512
  export function catalogClientOptions(ctx, connectorId, config, authPartition, connectionSignal, owner, sharing = "private", resolveCredentialIdentity, connectionless = false) {
451
513
  return runEdge(authProgram(catalogClientOptionsNative, [
@@ -475,29 +537,11 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
475
537
  // canonical request owner, never the initiating call's live services.
476
538
  const cacheMethod = (program) => authMethod((...args) => program(...args).pipe(Effect.provideService(RequestScope, request)));
477
539
  const policy = serviceFor(ctx, CatalogPolicy);
478
- const partition = JSON.stringify([
479
- policy?.partition ?? JSON.stringify(callerOf(ctx) ?? null),
480
- ctx.baseUrl,
481
- ctx.publicUrl,
482
- authPartition,
483
- ]);
484
- const sharedPartition = JSON.stringify([
485
- "host-shared",
486
- policy?.sharedPartition ?? JSON.stringify([ctx.baseUrl, ctx.publicUrl, callerOf(ctx)?.pool]),
487
- authPartition,
488
- ]);
540
+ const { partition, sharedPartition, configHash } = yield authCall(catalogLocation, [ctx, config, authPartition]);
489
541
  const storage = policy?.storage ?? ctx.storage;
490
542
  let storeIdentity = catalogStores.get(storage);
491
543
  if (!storeIdentity)
492
544
  catalogStores.set(storage, (storeIdentity = crypto.randomUUID()));
493
- const configHash = (yield authCall(fingerprintSerializedCatalog, [
494
- JSON.stringify([
495
- config,
496
- policy?.defaultTtlMs ?? 300000,
497
- policy?.minTtlMs ?? 0,
498
- policy?.maxTtlMs ?? MAX_CACHE_TTL_MS,
499
- ]),
500
- ])).fingerprint;
501
545
  const scope = cacheScope(ctx, connectorId, owner);
502
546
  let unavailable = false;
503
547
  const connectionGeneration = {};
@@ -597,6 +641,58 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
597
641
  ])).fingerprint;
598
642
  return stopped() ? undefined : responseCacheKeys.entry(prefix, digest);
599
643
  }));
644
+ const fenceKey = cacheMethod((operation, generation) => authFlow(function* () {
645
+ const { fingerprint } = (yield catalogFenceFingerprint(sharing, partition, authPartition, operation.credentialIdentity));
646
+ return responseCacheKeys.entry(responseCacheKeys.namespace(connectorId, configHash, generation), fingerprint);
647
+ }));
648
+ // Whether an expired catalog may stand in now. Read past this request's
649
+ // memo: a refusal or invalidation in another isolate retires it at once.
650
+ const staleAllowed = cacheMethod((operation) => authFlow(function* () {
651
+ if (operation.stopped() || !(yield authCall(namespace, [operation])))
652
+ return false;
653
+ const key = (yield authCall(fenceKey, [operation, operation.generation.value]));
654
+ const fence = (yield authCall(operation.io, [
655
+ cacheMethod(() => reads.read(storage, key, authStorage(storage, "get", [key]), true)),
656
+ ]));
657
+ return fence === null && !operation.stopped();
658
+ }));
659
+ // Read before the listing dispatches: a refusal after this point writes a
660
+ // value this listing never saw. An older memoized read only lifts less.
661
+ const readListingFence = cacheMethod((operation) => authFlow(function* () {
662
+ const { listing, stopped, io } = operation;
663
+ if (!listing || listing.fence !== undefined || stopped())
664
+ return;
665
+ // A generation claimed later holds no fence this listing could know of.
666
+ if (!listing.generationStored) {
667
+ listing.fence = null;
668
+ return;
669
+ }
670
+ const key = (yield authCall(fenceKey, [operation, listing.generation.value]));
671
+ listing.fence = (yield authCall(io, [
672
+ cacheMethod(() => reads.read(storage, key, authStorage(storage, "get", [key]))),
673
+ ]));
674
+ }));
675
+ // A complete listing's answer lifts the fence captured before it was
676
+ // asked, and only that one: a refusal that landed while it ran wrote
677
+ // another, which stays. With nothing captured it lifts nothing, so the
678
+ // next listing that reads the fence first does it instead.
679
+ const liftFence = cacheMethod((operation) => authFlow(function* () {
680
+ const { listing, stopped, io } = operation;
681
+ if (!listing?.fence || stopped())
682
+ return;
683
+ try {
684
+ const key = (yield authCall(fenceKey, [operation, operation.generation.value]));
685
+ const lifted = (yield authCall(io, [
686
+ cacheMethod(() => authStorage(storage, "compareAndSet", [key, listing.fence, null])),
687
+ ]));
688
+ if (lifted)
689
+ listing.fence = null;
690
+ }
691
+ catch (error) {
692
+ if (!stopped())
693
+ logFailure(ctx.logger, "catalog fence failed", failureRecord({ connector: connectorId }, error));
694
+ }
695
+ }));
600
696
  const manifest = (raw) => {
601
697
  if (!raw)
602
698
  return undefined;
@@ -676,6 +772,8 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
676
772
  get: cacheMethod((key) => authFlow(function* () {
677
773
  const operation = capture();
678
774
  const { ctx, stopped, io } = operation;
775
+ const trace = traceFor(ctx.requestScope);
776
+ trace?.record("catalog.cache.lookup");
679
777
  const root = (yield authCall(address, [key, operation]));
680
778
  if (!root || stopped())
681
779
  return undefined;
@@ -692,10 +790,18 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
692
790
  ]));
693
791
  roots.push(responseCacheKeys.entry(responseCacheKeys.namespace(connectorId, configHash, operation.generation.value), fingerprint));
694
792
  }
695
- const values = (yield authCall(io, [cacheMethod(() => authStorageMany(storage, roots))]));
793
+ const fence = (yield authCall(fenceKey, [operation, operation.generation.value]));
794
+ yield reads.prefetch([...roots, fence].map((key) => ({ storage, key })), (storage, keys) => authStorageMany(storage, keys));
795
+ (yield authCall(readListingFence, [operation]));
796
+ const values = [];
797
+ for (const root of roots)
798
+ values.push(yield reads.read(storage, root, authStorage(storage, "get", [root])));
696
799
  listing.manifests ??= new Map();
697
800
  roots.forEach((root, i) => listing.manifests.set(root, values[i] ?? null));
698
801
  }
802
+ // A refused credential is listed anew, whatever its catalog's age.
803
+ if (listing?.fence && key.method === "tools/list")
804
+ return undefined;
699
805
  const m = manifest(listing?.manifests?.has(root)
700
806
  ? listing.manifests.get(root)
701
807
  : (yield authCall(io, [
@@ -704,7 +810,7 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
704
810
  if (stopped() ||
705
811
  !m ||
706
812
  (m.expiresAt <= Date.now() &&
707
- (key.method !== "tools/list" || m.expiresAt + STALE_FALLBACK_MS <= Date.now())))
813
+ (key.method !== "tools/list" || m.expiresAt + STALE_DISCOVERY_MS <= Date.now())))
708
814
  return undefined;
709
815
  // Up to CHUNKS_PER_READ chunks per round trip, so one answer stays a
710
816
  // few megabytes; the generation fence below still decides whether
@@ -735,8 +841,10 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
735
841
  // Hash the chunks again above, never trust the manifest alone.
736
842
  const reuseKey = JSON.stringify([storeIdentity, root, partition, m.fingerprint]);
737
843
  let accepted = validatedCatalogs.get(reuseKey)?.value;
844
+ trace?.record(accepted ? "catalog.validated.hit" : "catalog.validated.miss");
738
845
  if (!accepted) {
739
846
  const result = JSON.parse(value);
847
+ trace?.record("catalog.decode", 0, fingerprint.byteLength);
740
848
  const items = catalogItems(method, result);
741
849
  if (!Array.isArray(items) ||
742
850
  items.length > MAX_CATALOG_TOOLS ||
@@ -744,6 +852,7 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
744
852
  (result.resultType !== undefined && result.resultType !== "complete"))
745
853
  return undefined;
746
854
  const validated = (yield authInvoke(catalogSchema(method)["~standard"], "validate", [result]));
855
+ trace?.record("catalog.validate");
747
856
  if (stopped() || validated.issues)
748
857
  return undefined;
749
858
  accepted = validated.value;
@@ -751,12 +860,16 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
751
860
  // Redaction depends on this request's sent credentials. It must
752
861
  // still run, and returns detached objects from the frozen facts.
753
862
  const clean = intake(ctx, method, accepted);
863
+ trace?.record("catalog.intake");
864
+ trace?.record("catalog.chunks", 0, fingerprint.byteLength, 0, 0, m.chunkCount);
754
865
  rememberValidated(reuseKey, accepted, fingerprint.byteLength);
755
866
  if (m.expiresAt <= Date.now()) {
867
+ trace?.record("catalog.cache.expired");
756
868
  if (method === "tools/list" && (!staleTools || staleTools.manifest.fetchedAt < m.fetchedAt))
757
869
  staleTools = { result: clean, manifest: m };
758
870
  return undefined;
759
871
  }
872
+ trace?.record("catalog.cache.hit");
760
873
  observeCatalogFetch(ctx, m.fetchedAt, method);
761
874
  observeCatalogExpiry(ctx, method, clean.ttlMs, m.expiresAt);
762
875
  return { value: JSON.stringify(clean), stamp: m.stamp, expiresAt: m.expiresAt, scope: m.scope };
@@ -775,6 +888,8 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
775
888
  (yield authCall(io, [
776
889
  cacheMethod(() => authStorage(storage, "delete", [root])),
777
890
  ]));
891
+ if (method === "tools/list")
892
+ (yield authCall(liftFence, [operation]));
778
893
  return 0;
779
894
  }
780
895
  if (!Array.isArray(catalogItems(method, result)) ||
@@ -794,7 +909,7 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
794
909
  const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
795
910
  let offset = 0;
796
911
  let chunkCount = 0;
797
- const ttlSeconds = () => Math.max(0.001, (expiresAt + (method === "tools/list" ? STALE_FALLBACK_MS : 0) - Date.now()) / 1000);
912
+ const ttlSeconds = () => Math.max(0.001, (expiresAt + (method === "tools/list" ? STALE_DISCOVERY_MS : 0) - Date.now()) / 1000);
798
913
  while (offset < bytes.length) {
799
914
  if (stopped() || expiresAt <= Date.now())
800
915
  return 0;
@@ -846,6 +961,27 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
846
961
  return 0;
847
962
  if (published) {
848
963
  lastStamp = stamp;
964
+ // Tool chunks outlive their manifest by the stale limit, so a
965
+ // superseded revision is removed now rather than retained for
966
+ // a day. A reader holding it misses and lists again; a failed
967
+ // removal leaves the chunks to their TTL.
968
+ const superseded = manifest(previous);
969
+ if (superseded && superseded.revision !== revision) {
970
+ try {
971
+ for (let index = 0; index < superseded.chunkCount && !stopped(); index++)
972
+ (yield authCall(io, [
973
+ cacheMethod(() => authStorage(storage, "delete", [
974
+ responseCacheKeys.chunk(root, superseded.revision, index),
975
+ ])),
976
+ ]));
977
+ }
978
+ catch (error) {
979
+ if (!stopped())
980
+ logFailure(ctx.logger, "catalog chunk cleanup failed", failureRecord({ connector: connectorId }, error));
981
+ }
982
+ }
983
+ if (method === "tools/list")
984
+ (yield authCall(liftFence, [operation]));
849
985
  return stamp;
850
986
  }
851
987
  }
@@ -891,7 +1027,7 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
891
1027
  // The queue retains only a Promise turn. Awaiting it in this caller's
892
1028
  // request preserves workerd I/O ownership; the listing program stays native.
893
1029
  let tail = Promise.resolve();
894
- const withListing = cacheMethod((listingCtx, read) => authFlow(function* () {
1030
+ const withListing = cacheMethod((listingCtx, read, bound) => authFlow(function* () {
895
1031
  const previous = tail;
896
1032
  let release;
897
1033
  tail = new Promise((resolve) => {
@@ -912,9 +1048,17 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
912
1048
  staleTools = undefined;
913
1049
  const operation = capture();
914
1050
  try {
915
- if (resolveCredentialIdentity)
1051
+ if (bound && "credentialIdentity" in bound)
1052
+ listing.credentialIdentity = bound.credentialIdentity;
1053
+ else if (resolveCredentialIdentity)
916
1054
  listing.credentialIdentity = yield authInvoke(operation, "io", [resolveCredentialIdentity]);
917
- if (!operation.stopped()) {
1055
+ if (bound?.fence !== undefined)
1056
+ listing.fence = bound.fence;
1057
+ if (bound?.generationStored !== undefined) {
1058
+ listing.generationStored = bound.generationStored;
1059
+ listing.generation.value = bound.generation;
1060
+ }
1061
+ else if (!operation.stopped()) {
918
1062
  try {
919
1063
  // Memoized: it locates the listing's entries, which the
920
1064
  // fresh fence in get accepts or refuses.
@@ -933,7 +1077,9 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
933
1077
  logFailure(listingCtx.logger, "catalog read failed", failureRecord({ connector: connectorId }, error));
934
1078
  }
935
1079
  }
936
- return yield authCall(read, []);
1080
+ const result = yield authCall(read, []);
1081
+ markCatalogGeneration(listingCtx.requestScope, connectorId, listing.generation.value);
1082
+ return result;
937
1083
  }
938
1084
  finally {
939
1085
  listing.ended = true;
@@ -948,6 +1094,24 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
948
1094
  void previous.then(release);
949
1095
  }
950
1096
  }));
1097
+ // Capture what an attempt is about to run under. `pinned` names a
1098
+ // credential the caller has already read for it.
1099
+ const bindListing = cacheMethod((listingCtx, pinned, readFence = false) => authCall(withListing, [
1100
+ listingCtx,
1101
+ cacheMethod(() => authFlow(function* () {
1102
+ const operation = capture();
1103
+ if (readFence)
1104
+ (yield authCall(readListingFence, [operation]));
1105
+ const listing = operation.listing;
1106
+ return {
1107
+ credentialIdentity: listing.credentialIdentity,
1108
+ generation: listing.generation.value,
1109
+ generationStored: listing.generationStored ?? false,
1110
+ fence: listing.fence,
1111
+ };
1112
+ })),
1113
+ pinned,
1114
+ ]));
951
1115
  // A complete, unexpired tools/list entry, read before any connection
952
1116
  // exists. This is the SDK's own two-probe read: the private slot
953
1117
  // `[serverIdentity, cachePartition]`, then a shared connector's public
@@ -976,12 +1140,101 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
976
1140
  return Array.isArray(result.tools) ? result : undefined;
977
1141
  })),
978
1142
  ]));
1143
+ // The newest complete tools/list entry this caller could be served, fresh
1144
+ // or within STALE_DISCOVERY_MS of expiry, with its original times. Reads
1145
+ // only, through the same address, fingerprint, generation and credential
1146
+ // fences as a listing, so another owner's entry, an invalidated generation
1147
+ // or a rotated credential is a miss. Never a source for call resolution.
1148
+ const peekTools = cacheMethod((listingCtx, serverIdentity = "") => authCall(withListing, [
1149
+ listingCtx,
1150
+ cacheMethod(() => authFlow(function* () {
1151
+ if (!active?.generationStored)
1152
+ return undefined;
1153
+ const read = (slot) => authInvoke(store, "get", [
1154
+ { method: "tools/list", params: "", partition: JSON.stringify([serverIdentity, slot]) },
1155
+ ]);
1156
+ let entry = (yield read(partition));
1157
+ let stale = staleTools;
1158
+ if (entry === undefined && sharing === "shared") {
1159
+ staleTools = undefined;
1160
+ const shared = (yield read(""));
1161
+ entry = shared?.scope === "public" ? shared : undefined;
1162
+ // Set by the read above, which control flow analysis cannot see.
1163
+ const sharedStale = staleTools;
1164
+ if (sharedStale?.manifest.scope === "public" &&
1165
+ (!stale || stale.manifest.fetchedAt < sharedStale.manifest.fetchedAt))
1166
+ stale = sharedStale;
1167
+ }
1168
+ if (entry?.expiresAt !== undefined && entry.expiresAt > Date.now()) {
1169
+ const result = JSON.parse(entry.value);
1170
+ if (!Array.isArray(result.tools))
1171
+ return undefined;
1172
+ return {
1173
+ result,
1174
+ fetchedAt: catalogFetchedAt(listingCtx) ?? Date.now(),
1175
+ expiresAt: entry.expiresAt,
1176
+ };
1177
+ }
1178
+ const operation = capture();
1179
+ if (!stale ||
1180
+ !(yield authCall(staleAllowed, [operation])) ||
1181
+ stale.manifest.expiresAt + STALE_DISCOVERY_MS <= Date.now())
1182
+ return undefined;
1183
+ return { result: stale.result, fetchedAt: stale.manifest.fetchedAt, expiresAt: stale.manifest.expiresAt };
1184
+ })),
1185
+ ]));
1186
+ // The downstream refused this credential, which says more than a TTL can:
1187
+ // nothing cached for it may stand in again until a listing succeeds. One
1188
+ // key beside the catalog, durable across requests and isolates, in the
1189
+ // namespace of the generation the refused attempt ran under, so a change
1190
+ // of credential or a disconnect discards it with the entries it guards. An
1191
+ // attempt that found no generation fences the one claimed since, if any:
1192
+ // that is where a listing beside it published. Nothing is stored while no
1193
+ // generation is, since then nothing is cached to retire. A failed write is
1194
+ // logged and never masks the refusal.
1195
+ const writeFence = cacheMethod((operation) => authFlow(function* () {
1196
+ const { listing, stopped, io } = operation;
1197
+ if (!listing || stopped())
1198
+ return;
1199
+ try {
1200
+ const generation = listing.generationStored
1201
+ ? listing.generation.value
1202
+ : (yield authCall(io, [cacheMethod(() => generationRead(false))]));
1203
+ if (!generation || stopped())
1204
+ return;
1205
+ const key = (yield authCall(fenceKey, [operation, generation]));
1206
+ (yield authCall(io, [
1207
+ cacheMethod(() => authStorage(storage, "set", [
1208
+ key,
1209
+ crypto.randomUUID(),
1210
+ { ttlSeconds: CATALOG_FENCE_TTL_SECONDS },
1211
+ ])),
1212
+ ]));
1213
+ }
1214
+ catch (error) {
1215
+ if (!stopped())
1216
+ logFailure(ctx.logger, "catalog fence failed", failureRecord({ connector: connectorId }, error));
1217
+ }
1218
+ }));
1219
+ // The refusal of the listing now running, as that listing was bound.
1220
+ const fenceListing = cacheMethod(() => authCall(writeFence, [capture()]));
1221
+ // The refusal of an attempt bound earlier, outside any listing.
1222
+ const fenceTools = cacheMethod((listingCtx, bound) => authCall(withListing, [listingCtx, cacheMethod(() => authCall(writeFence, [capture()])), bound]));
1223
+ // For a listing that never reads the cache before it dispatches.
1224
+ const captureFence = cacheMethod(() => authFlow(function* () {
1225
+ const operation = capture();
1226
+ try {
1227
+ (yield authCall(readListingFence, [operation]));
1228
+ }
1229
+ catch (error) {
1230
+ if (!operation.stopped())
1231
+ logFailure(ctx.logger, "catalog fence failed", failureRecord({ connector: connectorId }, error));
1232
+ }
1233
+ }));
979
1234
  const fallbackTools = cacheMethod(() => authFlow(function* () {
980
1235
  const operation = capture();
981
1236
  if (!staleTools ||
982
- operation.stopped() ||
983
- !(yield authCall(namespace, [operation])) ||
984
- operation.stopped() ||
1237
+ !(yield authCall(staleAllowed, [operation])) ||
985
1238
  staleTools.manifest.expiresAt + STALE_FALLBACK_MS <= Date.now())
986
1239
  return;
987
1240
  const { result, manifest } = staleTools;
@@ -994,6 +1247,11 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
994
1247
  completeCatalogRefresh,
995
1248
  fallbackTools,
996
1249
  cachedTools,
1250
+ peekTools,
1251
+ bindListing,
1252
+ captureFence,
1253
+ fenceListing,
1254
+ fenceTools,
997
1255
  responseCacheStore: store,
998
1256
  cachePartition: partition,
999
1257
  defaultCacheTtlMs: policy?.defaultTtlMs ?? CONFIG_DEFAULTS.discovery.catalogTtlSeconds * 1000,
@@ -1005,36 +1263,54 @@ function isCatalogMethod(method) {
1005
1263
  return method === "tools/list" || method === "resources/list" || method === "resources/templates/list";
1006
1264
  }
1007
1265
  function customCatalogCache(ctx, connectorId) {
1008
- return runEdge(authProgram(customCatalogCacheNative, [
1009
- ctx,
1010
- connectorId,
1011
- ]));
1266
+ return runEdge(authProgram(customCatalogCacheNative, [ctx, connectorId]));
1012
1267
  }
1013
- const customCatalogCacheNative = /* @__PURE__ */ registerAuth(customCatalogCache, (ctx, connectorId) => authFlow(function* () {
1268
+ const customCatalogAuth = authMethod((ctx) => authFlow(function* () {
1014
1269
  const credential = (yield authValue(() => ctx.credential?.getAll()));
1015
1270
  // Credential values enter the stored partition only through a keyed digest.
1016
- const auth = credential == null
1271
+ return credential == null
1017
1272
  ? (yield authCall(fingerprintSerializedCatalog, ["null"])).fingerprint
1018
1273
  : (yield authCall(secretDigest, [ctx, JSON.stringify(credential)]));
1274
+ }));
1275
+ const customCatalogCacheNative = /* @__PURE__ */ registerAuth(customCatalogCache, (ctx, connectorId) => authFlow(function* () {
1276
+ const auth = (yield authCall(customCatalogAuth, [ctx]));
1019
1277
  const cache = (yield authCall(catalogClientOptions, [ctx, connectorId, "custom-connector", auth]));
1020
1278
  const key = {
1021
1279
  method: "tools/list",
1022
1280
  partition: JSON.stringify(["custom-connector", cache.cachePartition]),
1023
1281
  };
1024
- return { cache, key };
1282
+ return { cache, key, auth };
1025
1283
  }));
1026
- /** Custom listings always fetch. Only an authenticated owner may retain a
1027
- * complete private fallback; unscoped/operator reads start no cache I/O. */
1028
- export function storeCustomCatalogFallback(ctx, connectorId, tools) {
1029
- return runEdge(authProgram(storeCustomCatalogFallbackNative, [ctx, connectorId, tools]));
1284
+ /**
1285
+ * Before a custom listing dispatches: only an authenticated owner may retain a
1286
+ * complete private fallback, and unscoped/operator reads start no cache I/O.
1287
+ * Its answer is recorded against this ticket, never a later read of the store.
1288
+ */
1289
+ export function bindCustomCatalogListing(ctx, connectorId) {
1290
+ return runEdge(authProgram(bindCustomCatalogListingNative, [ctx, connectorId]));
1291
+ }
1292
+ const bindCustomCatalogListingNative = /* @__PURE__ */ registerAuth(bindCustomCatalogListing, (ctx, connectorId) => authFlow(function* () {
1293
+ if (!catalogTtlMs(ctx, undefined) || !callerOf(ctx)?.authenticated)
1294
+ return;
1295
+ const custom = (yield authCall(customCatalogCache, [ctx, connectorId]));
1296
+ const bound = (yield authInvoke(custom.cache, "bindListing", [ctx, undefined, true]));
1297
+ return { ...custom, bound };
1298
+ }));
1299
+ /** Custom listings always fetch; the ticket says whether and where one is retained. */
1300
+ export function storeCustomCatalogFallback(ctx, tools, listing) {
1301
+ return runEdge(authProgram(storeCustomCatalogFallbackNative, [ctx, tools, listing]));
1030
1302
  }
1031
- const storeCustomCatalogFallbackNative = /* @__PURE__ */ registerAuth(storeCustomCatalogFallback, (ctx, connectorId, tools) => authFlow(function* () {
1303
+ const storeCustomCatalogFallbackNative = /* @__PURE__ */ registerAuth(storeCustomCatalogFallback, (ctx, tools, listing) => authFlow(function* () {
1032
1304
  const ttlMs = catalogTtlMs(ctx, undefined);
1033
1305
  observeCatalogFetch(ctx, Date.now());
1034
1306
  observeCatalogExpiry(ctx, "tools/list", ttlMs);
1035
- if (!ttlMs || !callerOf(ctx)?.authenticated)
1307
+ if (!ttlMs || !listing)
1036
1308
  return;
1037
- const { cache, key } = (yield authCall(customCatalogCache, [ctx, connectorId]));
1309
+ // The connector read its own credential, so a change while it ran leaves
1310
+ // this answer belonging to neither: it is retained for neither.
1311
+ if ((yield authCall(customCatalogAuth, [ctx])) !== listing.auth)
1312
+ return;
1313
+ const { cache, key, bound } = listing;
1038
1314
  (yield authInvoke(cache, "withListing", [
1039
1315
  ctx,
1040
1316
  authMethod(() => authFlow(function* () {
@@ -1055,8 +1331,41 @@ const storeCustomCatalogFallbackNative = /* @__PURE__ */ registerAuth(storeCusto
1055
1331
  },
1056
1332
  ]));
1057
1333
  })),
1334
+ bound,
1058
1335
  ]));
1059
1336
  }));
1337
+ /** A custom connector's retained catalog, for discovery only. Same owner rule as its fallback. */
1338
+ export function customCatalogPeek(ctx, connectorId) {
1339
+ return runEdge(authProgram(customCatalogPeekNative, [ctx, connectorId]));
1340
+ }
1341
+ const customCatalogPeekNative = /* @__PURE__ */ registerAuth(customCatalogPeek, (ctx, connectorId) => authFlow(function* () {
1342
+ if (!callerOf(ctx)?.authenticated)
1343
+ return;
1344
+ const { cache } = (yield authCall(customCatalogCache, [ctx, connectorId]));
1345
+ const entry = (yield authInvoke(cache, "peekTools", [ctx, "custom-connector"]));
1346
+ return entry
1347
+ ? { tools: entry.result.tools, fetchedAt: entry.fetchedAt, expiresAt: entry.expiresAt }
1348
+ : undefined;
1349
+ }));
1350
+ /**
1351
+ * A custom connector's refusal. It reads its own credential, by any route, so
1352
+ * only one unchanged since the ticket identifies what was refused: that
1353
+ * catalog stands in for nothing until a listing succeeds. A change, or a failed
1354
+ * read, leaves it unknown, so every catalog retained for this connector goes.
1355
+ */
1356
+ export function fenceCustomCatalog(ctx, connectorId, listing) {
1357
+ return runEdge(authProgram(fenceCustomCatalogNative, [ctx, connectorId, listing]));
1358
+ }
1359
+ const fenceCustomCatalogNative = /* @__PURE__ */ registerAuth(fenceCustomCatalog, (ctx, connectorId, listing) => authFlow(function* () {
1360
+ const current = (yield authCall(customCatalogAuth, [ctx]).pipe(Effect.catch(() => Effect.succeed(undefined))));
1361
+ if (current === listing.auth)
1362
+ (yield authInvoke(listing.cache, "fenceTools", [ctx, listing.bound]));
1363
+ else
1364
+ (yield authCall(invalidateCatalogCache, [
1365
+ serviceFor(ctx, CatalogPolicy)?.storage ?? ctx.storage,
1366
+ connectorId,
1367
+ ]));
1368
+ }));
1060
1369
  export function customCatalogFallback(ctx, connectorId) {
1061
1370
  return runEdge(authProgram(customCatalogFallbackNative, [ctx, connectorId]));
1062
1371
  }
@@ -6,8 +6,33 @@ export function markCatalogFreshness(tools, freshUntil) {
6
6
  return tools;
7
7
  }
8
8
  export function carryCatalogFreshness(source, copy) {
9
+ const stale = staleCatalogs.get(source);
10
+ if (stale)
11
+ staleCatalogs.set(copy, stale);
9
12
  return markCatalogFreshness(copy, deadlines.get(source) ?? 0);
10
13
  }
14
+ const staleCatalogs = new WeakMap();
15
+ /** Host-only, beside freshness: a stale listing is never fresh and never grants anything. */
16
+ export function markCatalogStale(tools, staleness) {
17
+ staleCatalogs.set(tools, staleness);
18
+ return tools;
19
+ }
20
+ export function catalogStaleness(tools) {
21
+ return staleCatalogs.get(tools);
22
+ }
11
23
  export function catalogIsFresh(tools) {
12
24
  return (deadlines.get(tools) ?? 0) > Date.now();
13
25
  }
26
+ // Primitive accepted-generation evidence lives only beside the current request.
27
+ const generations = new WeakMap();
28
+ export function markCatalogGeneration(scope, connector, generation) {
29
+ if (!scope || generation === undefined)
30
+ return;
31
+ let values = generations.get(scope);
32
+ if (!values)
33
+ generations.set(scope, (values = new Map()));
34
+ values.set(connector, generation);
35
+ }
36
+ export function catalogGenerationFor(scope, connector) {
37
+ return generations.get(scope)?.get(connector);
38
+ }