@zackbart/connecta 0.41.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.
- package/CHANGELOG.md +61 -0
- package/bin/connecta.mjs +26 -17
- package/bin/meta-tool-names.d.mts +3 -1
- package/bin/meta-tool-names.mjs +4 -1
- package/bin/version.mjs +1 -1
- package/dist/activity.d.ts +2 -1
- package/dist/activity.js +5 -5
- package/dist/advertised-schema.js +15 -0
- package/dist/agent-routes.js +31 -5
- package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
- package/dist/auth/downstream-oauth.js +3 -0
- package/dist/auth/static-oauth.js +26 -18
- package/dist/auth-elicitation.js +118 -61
- package/dist/authorization-handoff.js +36 -3
- package/dist/catalog-cache.js +292 -24
- package/dist/catalog-freshness.js +12 -0
- package/dist/catalog-service.js +373 -70
- package/dist/catalog.js +147 -58
- package/dist/config-defaults.d.ts +2 -1
- package/dist/config-defaults.js +4 -1
- package/dist/config.d.ts +43 -17
- package/dist/config.js +26 -10
- package/dist/connectors/remote-mcp/catalog.js +52 -28
- package/dist/connectors/remote-mcp/connection.js +62 -5
- package/dist/connectors/remote-mcp.js +10 -1
- package/dist/describe-config.d.ts +3 -2
- package/dist/discovery-health.js +26 -11
- package/dist/errors.d.ts +52 -3
- package/dist/errors.js +86 -3
- package/dist/execute.js +476 -200
- package/dist/executor-admission.js +2 -0
- package/dist/executor-contract.js +6 -0
- package/dist/executor-result.js +2 -2
- package/dist/executors/quickjs-child.js +13 -4
- package/dist/executors/quickjs-runtime.d.ts +2 -0
- package/dist/executors/quickjs-runtime.js +36 -19
- package/dist/executors/quickjs.js +23 -6
- package/dist/guest-functions.js +5 -4
- package/dist/guest-runtime.js +37 -1
- package/dist/guest-types.d.ts +18 -0
- package/dist/http/client.js +7 -2
- package/dist/http/errors.js +9 -4
- package/dist/http/fields.js +17 -5
- package/dist/http/hatch.js +9 -3
- package/dist/http/paging.js +1 -1
- package/dist/http/provider.js +2 -6
- package/dist/http/vendor.js +6 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -0
- package/dist/invocation.js +98 -34
- package/dist/meta-output.js +24 -18
- package/dist/meta-tool-names.d.ts +1 -1
- package/dist/meta-tool-names.js +1 -1
- package/dist/meta-tools.js +259 -60
- package/dist/native-recovery.js +4 -1
- package/dist/native-tools.js +166 -51
- package/dist/node.js +3 -1
- package/dist/oauth-handoff.js +53 -43
- package/dist/operation-activity.js +2 -1
- package/dist/operation-vocabulary.js +6 -0
- package/dist/operator-record.d.ts +1 -1
- package/dist/operator-record.js +1 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/program-result.js +2 -0
- package/dist/program-source.js +1 -1
- package/dist/program-writes.js +112 -19
- package/dist/provider.d.ts +0 -13
- package/dist/provider.js +0 -3
- package/dist/providers/_shared/google/kit.js +2 -2
- package/dist/providers/breeze/api.js +2 -2
- package/dist/providers/breeze/skill.generated.js +1 -1
- package/dist/providers/ccb/api.js +2 -2
- package/dist/providers/ccb/skill.generated.js +1 -1
- package/dist/providers/cloudflare/api.js +2 -2
- package/dist/providers/cloudflare/index.js +2 -0
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/docs/skill.generated.js +1 -1
- package/dist/providers/drive/skill.generated.js +1 -1
- package/dist/providers/forms/index.js +1 -0
- package/dist/providers/forms/skill.generated.js +1 -1
- package/dist/providers/github/api.js +98 -6
- package/dist/providers/github/index.js +7 -2
- package/dist/providers/github/lib/auth.d.ts +3 -1
- package/dist/providers/github/lib/auth.js +43 -12
- package/dist/providers/github/lib/discovery.js +11 -1
- package/dist/providers/github/lib/operations.js +8 -0
- package/dist/providers/github/safety.js +39 -0
- package/dist/providers/github/skill.generated.js +1 -1
- package/dist/providers/github/tools.js +12 -5
- package/dist/providers/gmail/index.js +1 -0
- package/dist/providers/gmail/skill.generated.js +1 -1
- package/dist/providers/granola/skill.generated.js +1 -1
- package/dist/providers/overflow/index.js +2 -0
- package/dist/providers/overflow/skill.generated.js +1 -1
- package/dist/providers/planning-center/index.js +11 -5
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/skill.generated.js +1 -1
- package/dist/providers/quickbooks/index.js +2 -5
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/index.js +1 -0
- package/dist/providers/sheets/skill.generated.js +1 -1
- package/dist/providers/slides/index.js +2 -2
- package/dist/providers/slides/skill.generated.js +1 -1
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/tithely/index.js +1 -0
- package/dist/providers/tithely/skill.generated.js +1 -1
- package/dist/providers/vercel/api.js +2 -2
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/registry.d.ts +36 -4
- package/dist/registry.js +127 -27
- package/dist/result-preview.js +210 -0
- package/dist/result-seal.js +168 -0
- package/dist/route-names.js +13 -4
- package/dist/routes/connect.js +21 -13
- package/dist/routes/mcp.js +55 -26
- package/dist/routes/shared.d.ts +6 -0
- package/dist/runtime/call.js +15 -10
- package/dist/runtime/request-scope.js +36 -1
- package/dist/runtime/run.js +4 -1
- package/dist/sent-secrets.d.ts +20 -0
- package/dist/sent-secrets.js +49 -1
- package/dist/skills.js +100 -33
- package/dist/storage/keys.js +9 -6
- package/dist/storage/sql-native.js +1 -1
- package/dist/tool-safety.d.ts +33 -0
- package/dist/tool-safety.js +51 -15
- package/dist/types.d.ts +31 -0
- package/dist/usage-guide.js +14 -13
- package/dist/validate.js +29 -10
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +28 -8
- package/documentation/architecture.md +104 -42
- package/documentation/auth.md +46 -19
- package/documentation/code-mode.md +378 -116
- package/documentation/deploying.md +10 -9
- package/documentation/hosted-mcp-migration.md +9 -3
- package/documentation/integrating.md +27 -5
- package/documentation/meta-tools.md +655 -153
- package/documentation/operating.md +5 -3
- package/documentation/providers.md +18 -6
- package/examples/worker/README.md +3 -3
- package/guides/how-it-works.md +2 -1
- package/guides/safety-and-trust.md +1 -3
- package/package.json +1 -1
- package/templates/node/AGENTS.md +3 -3
- package/templates/node/README.md +1 -1
- package/templates/node/package.json +1 -1
package/dist/catalog-cache.js
CHANGED
|
@@ -238,6 +238,21 @@ export function observeCatalogExpiry(ctx, method, ttlMs, expiresAt, staleFallbac
|
|
|
238
238
|
// failure for five minutes. Resources never use stale inventories. A fallback
|
|
239
239
|
// carries its original expired deadline and cannot authorize auth recovery.
|
|
240
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
|
+
]);
|
|
241
256
|
/** Catalog chunks one storage read fetches together. */
|
|
242
257
|
const CHUNKS_PER_READ = 8;
|
|
243
258
|
export function attachCatalogCache(ctx, value) {
|
|
@@ -475,7 +490,9 @@ const catalogLocation = authMethod((ctx, config, authPartition) => authFlow(func
|
|
|
475
490
|
export const prefetchCatalog = authMethod((ctx, connectorId, config, authPartition, sharing, credentialIdentity, negotiationKey) => authFlow(function* () {
|
|
476
491
|
const { partition, sharedPartition, configHash } = yield authCall(catalogLocation, [ctx, config, authPartition]);
|
|
477
492
|
const slots = sharing === "shared" ? [partition, sharedPartition] : [partition];
|
|
478
|
-
const suffixes = [
|
|
493
|
+
const suffixes = [
|
|
494
|
+
(yield catalogFenceFingerprint(sharing, partition, authPartition, credentialIdentity)).fingerprint,
|
|
495
|
+
];
|
|
479
496
|
for (const slot of slots)
|
|
480
497
|
suffixes.push((yield authCall(fingerprintSerializedCatalog, [
|
|
481
498
|
JSON.stringify(["tools/list", slot, credentialIdentity ?? null]),
|
|
@@ -624,6 +641,58 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
|
|
|
624
641
|
])).fingerprint;
|
|
625
642
|
return stopped() ? undefined : responseCacheKeys.entry(prefix, digest);
|
|
626
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
|
+
}));
|
|
627
696
|
const manifest = (raw) => {
|
|
628
697
|
if (!raw)
|
|
629
698
|
return undefined;
|
|
@@ -721,13 +790,18 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
|
|
|
721
790
|
]));
|
|
722
791
|
roots.push(responseCacheKeys.entry(responseCacheKeys.namespace(connectorId, configHash, operation.generation.value), fingerprint));
|
|
723
792
|
}
|
|
724
|
-
|
|
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]));
|
|
725
796
|
const values = [];
|
|
726
797
|
for (const root of roots)
|
|
727
798
|
values.push(yield reads.read(storage, root, authStorage(storage, "get", [root])));
|
|
728
799
|
listing.manifests ??= new Map();
|
|
729
800
|
roots.forEach((root, i) => listing.manifests.set(root, values[i] ?? null));
|
|
730
801
|
}
|
|
802
|
+
// A refused credential is listed anew, whatever its catalog's age.
|
|
803
|
+
if (listing?.fence && key.method === "tools/list")
|
|
804
|
+
return undefined;
|
|
731
805
|
const m = manifest(listing?.manifests?.has(root)
|
|
732
806
|
? listing.manifests.get(root)
|
|
733
807
|
: (yield authCall(io, [
|
|
@@ -736,7 +810,7 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
|
|
|
736
810
|
if (stopped() ||
|
|
737
811
|
!m ||
|
|
738
812
|
(m.expiresAt <= Date.now() &&
|
|
739
|
-
(key.method !== "tools/list" || m.expiresAt +
|
|
813
|
+
(key.method !== "tools/list" || m.expiresAt + STALE_DISCOVERY_MS <= Date.now())))
|
|
740
814
|
return undefined;
|
|
741
815
|
// Up to CHUNKS_PER_READ chunks per round trip, so one answer stays a
|
|
742
816
|
// few megabytes; the generation fence below still decides whether
|
|
@@ -814,6 +888,8 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
|
|
|
814
888
|
(yield authCall(io, [
|
|
815
889
|
cacheMethod(() => authStorage(storage, "delete", [root])),
|
|
816
890
|
]));
|
|
891
|
+
if (method === "tools/list")
|
|
892
|
+
(yield authCall(liftFence, [operation]));
|
|
817
893
|
return 0;
|
|
818
894
|
}
|
|
819
895
|
if (!Array.isArray(catalogItems(method, result)) ||
|
|
@@ -833,7 +909,7 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
|
|
|
833
909
|
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
834
910
|
let offset = 0;
|
|
835
911
|
let chunkCount = 0;
|
|
836
|
-
const ttlSeconds = () => Math.max(0.001, (expiresAt + (method === "tools/list" ?
|
|
912
|
+
const ttlSeconds = () => Math.max(0.001, (expiresAt + (method === "tools/list" ? STALE_DISCOVERY_MS : 0) - Date.now()) / 1000);
|
|
837
913
|
while (offset < bytes.length) {
|
|
838
914
|
if (stopped() || expiresAt <= Date.now())
|
|
839
915
|
return 0;
|
|
@@ -885,6 +961,27 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
|
|
|
885
961
|
return 0;
|
|
886
962
|
if (published) {
|
|
887
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]));
|
|
888
985
|
return stamp;
|
|
889
986
|
}
|
|
890
987
|
}
|
|
@@ -930,7 +1027,7 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
|
|
|
930
1027
|
// The queue retains only a Promise turn. Awaiting it in this caller's
|
|
931
1028
|
// request preserves workerd I/O ownership; the listing program stays native.
|
|
932
1029
|
let tail = Promise.resolve();
|
|
933
|
-
const withListing = cacheMethod((listingCtx, read) => authFlow(function* () {
|
|
1030
|
+
const withListing = cacheMethod((listingCtx, read, bound) => authFlow(function* () {
|
|
934
1031
|
const previous = tail;
|
|
935
1032
|
let release;
|
|
936
1033
|
tail = new Promise((resolve) => {
|
|
@@ -951,9 +1048,17 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
|
|
|
951
1048
|
staleTools = undefined;
|
|
952
1049
|
const operation = capture();
|
|
953
1050
|
try {
|
|
954
|
-
if (
|
|
1051
|
+
if (bound && "credentialIdentity" in bound)
|
|
1052
|
+
listing.credentialIdentity = bound.credentialIdentity;
|
|
1053
|
+
else if (resolveCredentialIdentity)
|
|
955
1054
|
listing.credentialIdentity = yield authInvoke(operation, "io", [resolveCredentialIdentity]);
|
|
956
|
-
if (
|
|
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()) {
|
|
957
1062
|
try {
|
|
958
1063
|
// Memoized: it locates the listing's entries, which the
|
|
959
1064
|
// fresh fence in get accepts or refuses.
|
|
@@ -989,6 +1094,24 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
|
|
|
989
1094
|
void previous.then(release);
|
|
990
1095
|
}
|
|
991
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
|
+
]));
|
|
992
1115
|
// A complete, unexpired tools/list entry, read before any connection
|
|
993
1116
|
// exists. This is the SDK's own two-probe read: the private slot
|
|
994
1117
|
// `[serverIdentity, cachePartition]`, then a shared connector's public
|
|
@@ -1017,12 +1140,101 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
|
|
|
1017
1140
|
return Array.isArray(result.tools) ? result : undefined;
|
|
1018
1141
|
})),
|
|
1019
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
|
+
}));
|
|
1020
1234
|
const fallbackTools = cacheMethod(() => authFlow(function* () {
|
|
1021
1235
|
const operation = capture();
|
|
1022
1236
|
if (!staleTools ||
|
|
1023
|
-
operation
|
|
1024
|
-
!(yield authCall(namespace, [operation])) ||
|
|
1025
|
-
operation.stopped() ||
|
|
1237
|
+
!(yield authCall(staleAllowed, [operation])) ||
|
|
1026
1238
|
staleTools.manifest.expiresAt + STALE_FALLBACK_MS <= Date.now())
|
|
1027
1239
|
return;
|
|
1028
1240
|
const { result, manifest } = staleTools;
|
|
@@ -1035,6 +1247,11 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
|
|
|
1035
1247
|
completeCatalogRefresh,
|
|
1036
1248
|
fallbackTools,
|
|
1037
1249
|
cachedTools,
|
|
1250
|
+
peekTools,
|
|
1251
|
+
bindListing,
|
|
1252
|
+
captureFence,
|
|
1253
|
+
fenceListing,
|
|
1254
|
+
fenceTools,
|
|
1038
1255
|
responseCacheStore: store,
|
|
1039
1256
|
cachePartition: partition,
|
|
1040
1257
|
defaultCacheTtlMs: policy?.defaultTtlMs ?? CONFIG_DEFAULTS.discovery.catalogTtlSeconds * 1000,
|
|
@@ -1046,36 +1263,54 @@ function isCatalogMethod(method) {
|
|
|
1046
1263
|
return method === "tools/list" || method === "resources/list" || method === "resources/templates/list";
|
|
1047
1264
|
}
|
|
1048
1265
|
function customCatalogCache(ctx, connectorId) {
|
|
1049
|
-
return runEdge(authProgram(customCatalogCacheNative, [
|
|
1050
|
-
ctx,
|
|
1051
|
-
connectorId,
|
|
1052
|
-
]));
|
|
1266
|
+
return runEdge(authProgram(customCatalogCacheNative, [ctx, connectorId]));
|
|
1053
1267
|
}
|
|
1054
|
-
const
|
|
1268
|
+
const customCatalogAuth = authMethod((ctx) => authFlow(function* () {
|
|
1055
1269
|
const credential = (yield authValue(() => ctx.credential?.getAll()));
|
|
1056
1270
|
// Credential values enter the stored partition only through a keyed digest.
|
|
1057
|
-
|
|
1271
|
+
return credential == null
|
|
1058
1272
|
? (yield authCall(fingerprintSerializedCatalog, ["null"])).fingerprint
|
|
1059
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]));
|
|
1060
1277
|
const cache = (yield authCall(catalogClientOptions, [ctx, connectorId, "custom-connector", auth]));
|
|
1061
1278
|
const key = {
|
|
1062
1279
|
method: "tools/list",
|
|
1063
1280
|
partition: JSON.stringify(["custom-connector", cache.cachePartition]),
|
|
1064
1281
|
};
|
|
1065
|
-
return { cache, key };
|
|
1282
|
+
return { cache, key, auth };
|
|
1283
|
+
}));
|
|
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 };
|
|
1066
1298
|
}));
|
|
1067
|
-
/** Custom listings always fetch
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
return runEdge(authProgram(storeCustomCatalogFallbackNative, [ctx, connectorId, tools]));
|
|
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]));
|
|
1071
1302
|
}
|
|
1072
|
-
const storeCustomCatalogFallbackNative = /* @__PURE__ */ registerAuth(storeCustomCatalogFallback, (ctx,
|
|
1303
|
+
const storeCustomCatalogFallbackNative = /* @__PURE__ */ registerAuth(storeCustomCatalogFallback, (ctx, tools, listing) => authFlow(function* () {
|
|
1073
1304
|
const ttlMs = catalogTtlMs(ctx, undefined);
|
|
1074
1305
|
observeCatalogFetch(ctx, Date.now());
|
|
1075
1306
|
observeCatalogExpiry(ctx, "tools/list", ttlMs);
|
|
1076
|
-
if (!ttlMs || !
|
|
1307
|
+
if (!ttlMs || !listing)
|
|
1077
1308
|
return;
|
|
1078
|
-
|
|
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;
|
|
1079
1314
|
(yield authInvoke(cache, "withListing", [
|
|
1080
1315
|
ctx,
|
|
1081
1316
|
authMethod(() => authFlow(function* () {
|
|
@@ -1096,8 +1331,41 @@ const storeCustomCatalogFallbackNative = /* @__PURE__ */ registerAuth(storeCusto
|
|
|
1096
1331
|
},
|
|
1097
1332
|
]));
|
|
1098
1333
|
})),
|
|
1334
|
+
bound,
|
|
1099
1335
|
]));
|
|
1100
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
|
+
}));
|
|
1101
1369
|
export function customCatalogFallback(ctx, connectorId) {
|
|
1102
1370
|
return runEdge(authProgram(customCatalogFallbackNative, [ctx, connectorId]));
|
|
1103
1371
|
}
|
|
@@ -6,8 +6,20 @@ 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
|
}
|