@zackbart/connecta 0.38.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -0
- package/README.md +3 -2
- package/bin/connecta.mjs +4 -8
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.js +6 -4
- package/dist/agent-routes.js +231 -0
- package/dist/auth/downstream-oauth/consent.js +519 -0
- package/dist/auth/downstream-oauth/grant-store.js +439 -0
- package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
- package/dist/auth/downstream-oauth/refresh-records.js +447 -0
- package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
- package/dist/auth/downstream-oauth.js +66 -2382
- package/dist/auth/native.js +37 -0
- package/dist/auth/oauth-grant.js +13 -0
- package/dist/auth/static-oauth.js +153 -131
- package/dist/auth-elicitation.js +7 -1
- package/dist/authorization-handoff.js +11 -5
- package/dist/catalog-cache.js +219 -59
- package/dist/catalog-policy.js +18 -0
- package/dist/catalog-service.js +284 -47
- package/dist/catalog.js +18 -2
- package/dist/config.d.ts +16 -11
- package/dist/config.js +9 -6
- package/dist/connector-caller.d.ts +1 -1
- package/dist/connector-health.js +59 -0
- package/dist/connector-status.js +117 -0
- package/dist/connectors/api-connector.d.ts +7 -2
- package/dist/connectors/api-connector.js +7 -4
- package/dist/connectors/negotiation-cache.js +22 -4
- package/dist/connectors/remote-mcp/call.js +164 -0
- package/dist/connectors/remote-mcp/catalog.js +278 -0
- package/dist/connectors/remote-mcp/config.js +263 -0
- package/dist/connectors/remote-mcp/connection.js +682 -0
- package/dist/connectors/remote-mcp/errors.js +488 -0
- package/dist/connectors/remote-mcp/oauth.js +473 -0
- package/dist/connectors/remote-mcp/resources.js +473 -0
- package/dist/connectors/remote-mcp/runtime.js +1 -0
- package/dist/connectors/remote-mcp/transport.js +555 -0
- package/dist/connectors/remote-mcp.js +62 -2863
- package/dist/credential-contract.d.ts +23 -0
- package/dist/credential-reads.js +17 -0
- package/dist/credential-verification.js +53 -0
- package/dist/credentials.d.ts +7 -0
- package/dist/credentials.js +60 -25
- package/dist/describe-config.d.ts +12 -2
- package/dist/describe-config.js +2 -0
- package/dist/discovery-health.js +199 -0
- package/dist/downstream-input.js +2 -0
- package/dist/errors.d.ts +55 -17
- package/dist/errors.js +95 -41
- package/dist/execute.js +152 -114
- package/dist/executor-result.js +3 -1
- package/dist/guest-functions.js +83 -0
- package/dist/guest-runtime.js +60 -19
- package/dist/guest-types.d.ts +19 -5
- package/dist/http/argument-problems.js +35 -0
- package/dist/http/errors.js +5 -4
- package/dist/http/fields.js +192 -13
- package/dist/http/hatch-response.js +73 -0
- package/dist/http/hatch.js +88 -23
- package/dist/http/oauth.js +3 -2
- package/dist/http/provider.js +32 -4
- package/dist/http/safety.js +8 -0
- package/dist/http/tools.js +44 -23
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/dist/invocation.js +26 -8
- package/dist/membership.d.ts +13 -5
- package/dist/membership.js +34 -2
- package/dist/meta-tools.js +54 -31
- package/dist/native-recovery.js +4 -16
- package/dist/native-tools.js +5 -2
- package/dist/oauth-sealing.js +10 -0
- package/dist/operation-vocabulary.js +135 -13
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +1 -0
- package/dist/operator-ui/contract.d.ts +10 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +22 -1
- package/dist/provider-call-facts.js +6 -2
- package/dist/providers/_shared/rest/operation-index.js +48 -50
- package/dist/providers/_shared/rest/value-safety.js +80 -0
- package/dist/providers/breeze/api.js +7 -1
- package/dist/providers/breeze/skill.generated.js +1 -1
- package/dist/providers/breeze/tools.js +4 -3
- package/dist/providers/ccb/api.js +2 -1
- package/dist/providers/ccb/index.js +5 -0
- package/dist/providers/ccb/skill.generated.js +1 -1
- package/dist/providers/cloudflare/api.js +8 -1
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/docs/lib/schemas.js +24 -22
- package/dist/providers/docs/safety.js +2 -0
- package/dist/providers/docs/skill.generated.js +1 -1
- package/dist/providers/docs/tools.js +2 -4
- package/dist/providers/drive/api.js +1 -0
- package/dist/providers/drive/lib/contracts.js +8 -5
- package/dist/providers/drive/skill.generated.js +1 -1
- package/dist/providers/drive/tools.js +11 -16
- package/dist/providers/forms/api.js +1 -0
- package/dist/providers/forms/skill.generated.js +1 -1
- package/dist/providers/forms/tools.js +10 -7
- package/dist/providers/github/api.js +1 -0
- package/dist/providers/github/lib/discovery.js +1 -1
- package/dist/providers/github/skill.generated.js +1 -1
- package/dist/providers/github/tools.js +61 -17
- package/dist/providers/gmail/lib/schemas.js +60 -50
- package/dist/providers/gmail/skill.generated.js +1 -1
- package/dist/providers/gmail/tools.js +7 -5
- package/dist/providers/granola/index.js +2 -0
- package/dist/providers/granola/skill.generated.js +1 -1
- package/dist/providers/overflow/api.js +7 -1
- package/dist/providers/overflow/skill.generated.js +1 -1
- package/dist/providers/planning-center/api.js +3 -1
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/skill.generated.js +1 -1
- package/dist/providers/quickbooks/safety.js +80 -39
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/api.js +1 -0
- package/dist/providers/sheets/skill.generated.js +1 -1
- package/dist/providers/sheets/tools.js +13 -14
- package/dist/providers/slides/skill.generated.js +1 -1
- package/dist/providers/slides/tools.js +15 -13
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/tithely/api.js +3 -1
- package/dist/providers/tithely/skill.generated.js +1 -1
- package/dist/providers/vercel/api.js +1 -1
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/providers/vercel/tools.js +4 -3
- package/dist/registry.d.ts +10 -4
- package/dist/registry.js +41 -13
- package/dist/routes/credentials.js +129 -60
- package/dist/routes/dispatch.js +5 -1
- package/dist/routes/mcp.js +30 -14
- package/dist/routes/operator.js +2 -2
- package/dist/routes/shared.d.ts +6 -1
- package/dist/routes/shared.js +7 -1
- package/dist/routes/ui-config.js +2 -2
- package/dist/routes/ui-data.js +126 -20
- package/dist/routes/ui.js +16 -0
- package/dist/runtime/call.js +7 -2
- package/dist/runtime/native-storage.js +12 -0
- package/dist/runtime/request-reads.js +207 -0
- package/dist/runtime/request-scope.js +16 -0
- package/dist/runtime/storage.js +12 -0
- package/dist/skills.js +37 -20
- package/dist/storage/keys.js +53 -3
- package/dist/storage/memory.js +8 -2
- package/dist/storage/sql-native.js +53 -1
- package/dist/storage/sql.js +12 -2
- package/dist/storage-digest.js +32 -0
- package/dist/timeout.js +8 -0
- package/dist/types.d.ts +33 -0
- package/dist/usage-guide.js +16 -35
- package/dist/validate.d.ts +3 -0
- package/dist/validate.js +220 -158
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/write-outcome.js +0 -3
- package/documentation/README.md +13 -13
- package/documentation/architecture.md +385 -20
- package/documentation/auth.md +233 -64
- package/documentation/code-mode.md +122 -39
- package/documentation/deploying.md +6 -3
- package/documentation/integrating.md +16 -2
- package/documentation/meta-tools.md +240 -49
- package/documentation/migration-0.33.md +5 -1
- package/documentation/migration-0.34.md +3 -1
- package/documentation/operator-tests.md +1 -1
- package/documentation/operator-ui.md +105 -12
- package/documentation/pool-membership-upgrade.md +44 -0
- package/documentation/providers.md +105 -0
- package/examples/worker/AGENTS.md +10 -10
- package/examples/worker/README.md +4 -3
- package/examples/worker/src/connecta.config.ts +1 -0
- package/guides/connecting-agents.md +2 -1
- package/guides/how-it-works.md +4 -2
- package/package.json +1 -1
- package/templates/node/AGENTS.md +13 -12
- package/templates/node/package.json +1 -1
- package/templates/node/src/connecta.config.ts +1 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
// The per-request-scope connection state machine: one connect attempt per
|
|
2
|
+
// scope, credential and OAuth epoch checks before reuse, and a cached catalog
|
|
3
|
+
// served without connecting (INV-10).
|
|
4
|
+
import { routePhrases } from "../../agent-routes.js";
|
|
5
|
+
import { authView } from "../../auth/native-call.js";
|
|
6
|
+
import { authFlow, authCall, authInvoke, authMethod, authStorageMany } from "../../auth/native.js";
|
|
7
|
+
import { openRequest, viewServices, viewSignal, viewReads, serviceFor } from "../../runtime/call.js";
|
|
8
|
+
import { secretDigest } from "../../storage-digest.js";
|
|
9
|
+
import { downstreamClientMetadata, downstreamRedirectUri, remoteClientAuthMethod, selfHostedClientUrl, } from "../../auth/downstream-client-metadata.js";
|
|
10
|
+
import { Client, SdkErrorCode, SdkHttpError, UnauthorizedError } from "@modelcontextprotocol/client";
|
|
11
|
+
import { Deferred, Effect, Exit, Scope } from "effect";
|
|
12
|
+
import { KvOAuthProvider, refreshCoordinatorsByPartition } from "../../auth/downstream-oauth.js";
|
|
13
|
+
import { trackOAuthStartReset } from "../../auth/oauth-start-reset.js";
|
|
14
|
+
import { sentSecretsFor } from "../../sent-secrets.js";
|
|
15
|
+
import { catalogClientOptions, observeCatalogChange } from "../../catalog-cache.js";
|
|
16
|
+
import { ConnectorCallError, resendAdvice } from "../../errors.js";
|
|
17
|
+
import { carryFailureFacts, failureRecord, logFailure } from "../../operator-record.js";
|
|
18
|
+
import { CONNECTA_VERSION } from "../../version.js";
|
|
19
|
+
import { oauthSealerFor } from "../../oauth-sealing.js";
|
|
20
|
+
import { RequestScope } from "../../runtime/request-scope.js";
|
|
21
|
+
import { Context } from "effect";
|
|
22
|
+
import { downstreamInputCapabilities } from "../../downstream-input-context.js";
|
|
23
|
+
import { detach, runEdge } from "../../runtime/run.js";
|
|
24
|
+
import { requestPrincipalFor } from "../../request-principal.js";
|
|
25
|
+
import { callerOf } from "../../connector-caller.js";
|
|
26
|
+
import { CatalogPolicy } from "../../catalog-policy.js";
|
|
27
|
+
import { negotiationKeys, responseCacheKeys } from "../../storage/keys.js";
|
|
28
|
+
import { forgetNegotiation, readNegotiation, storeNegotiation } from "../negotiation-cache.js";
|
|
29
|
+
import { carriesIllegalHeaderChar, credentialHeaderValue } from "./config.js";
|
|
30
|
+
import { AUTH_OR_THROTTLE, promised, recordWireErrors, requiresAuthorization } from "./errors.js";
|
|
31
|
+
import { MAX_TOOL_PAGES } from "./catalog.js";
|
|
32
|
+
import { SKILLS_EXTENSION } from "./resources.js";
|
|
33
|
+
/** One connector's connection states, credential reads and connect attempts. */
|
|
34
|
+
export function remoteMcpConnection(rt) {
|
|
35
|
+
const { requestAuth, forwarding, id, staticClient, clientMetadataUrl, opts, oauthScope, authorizationParams, destination, isOauth, extraHeaders, credentialHeader, credentialScheme, skillsEnabled, principalPolicy, credentialAuth, } = rt;
|
|
36
|
+
// Weak keys ensure a completed request does not leave its SDK client,
|
|
37
|
+
// transport, response bodies, AbortSignals, or connect attempt reachable
|
|
38
|
+
// from the isolate singleton. Those are request-bound in Cloudflare Workers.
|
|
39
|
+
// A closed scope keeps its (emptied) entry, so a late or future lookup finds
|
|
40
|
+
// it closed rather than recreating an ownerless connection under it.
|
|
41
|
+
const states = new WeakMap();
|
|
42
|
+
// Post-connect auth checks keep the exact provider that bound this client,
|
|
43
|
+
// even if another attempt has since replaced the request scope's state.
|
|
44
|
+
const connectedProviders = new WeakMap();
|
|
45
|
+
const cacheOwner = {};
|
|
46
|
+
const connectingWaiters = new WeakMap();
|
|
47
|
+
// Long-lived enough for distinct request scopes in this connector runtime to
|
|
48
|
+
// join one token redemption. It owns no client, transport, or request state.
|
|
49
|
+
const refreshCoordinatorFor = refreshCoordinatorsByPartition();
|
|
50
|
+
/**
|
|
51
|
+
* Read this request's credential. Called before every connect and before
|
|
52
|
+
* trusting any cached client, so an operator's replacement is picked up
|
|
53
|
+
* without a redeploy. The value stays in the caller's local scope.
|
|
54
|
+
*/
|
|
55
|
+
const readCredential = authMethod((ctx, principal) => authFlow(function* () {
|
|
56
|
+
if (requestAuth) {
|
|
57
|
+
let value;
|
|
58
|
+
try {
|
|
59
|
+
// A fresh frozen input per resolution: one resolver cannot alter another's.
|
|
60
|
+
value = (yield authInvoke(forwarding ?? requestAuth, "token", principal ? [ctx, Object.freeze({ principal })] : [ctx]));
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error instanceof ConnectorCallError)
|
|
64
|
+
throw error;
|
|
65
|
+
throw new rt.UnresolvedTokenError();
|
|
66
|
+
}
|
|
67
|
+
ctx.signal?.throwIfAborted();
|
|
68
|
+
if (typeof value !== "string" ||
|
|
69
|
+
!value.trim() ||
|
|
70
|
+
Array.from(value).some((char) => {
|
|
71
|
+
const code = char.charCodeAt(0);
|
|
72
|
+
return code <= 32 || (code >= 127 && code <= 159);
|
|
73
|
+
})) {
|
|
74
|
+
throw new ConnectorCallError("auth_required", "The request token is empty or cannot be sent as a Bearer header.");
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
if (!ctx.credential) {
|
|
79
|
+
throw new rt.CredentialRequiredError(`Connector "${id}" needs an operator-managed credential, but ` +
|
|
80
|
+
`credential storage is not configured. Configure vault in deployment code and redeploy. ${routePhrases(ctx).authorizeFor("recovery options")}.`);
|
|
81
|
+
}
|
|
82
|
+
const value = (yield authInvoke(ctx.credential, "get", []))?.trim();
|
|
83
|
+
if (!value) {
|
|
84
|
+
throw new rt.CredentialRequiredError(`Connector "${id}" has no stored credential — ${routePhrases(ctx).authorizeAndFollow(id)}.`);
|
|
85
|
+
}
|
|
86
|
+
if (carriesIllegalHeaderChar(value)) {
|
|
87
|
+
throw new rt.CredentialRequiredError(`Connector "${id}"'s stored credential contains a character a header ` +
|
|
88
|
+
"cannot carry (a line break or other control character). " +
|
|
89
|
+
`${routePhrases(ctx).authorizeFor("recovery options")}. When available, re-enter ` +
|
|
90
|
+
"it in this connection in the operator UI. The value is not shown or logged.");
|
|
91
|
+
}
|
|
92
|
+
return value;
|
|
93
|
+
}));
|
|
94
|
+
const requestOptions = (ctx) => ctx.timeoutMs || ctx.signal
|
|
95
|
+
? {
|
|
96
|
+
...(ctx.timeoutMs ? { timeout: ctx.timeoutMs } : {}),
|
|
97
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
98
|
+
}
|
|
99
|
+
: undefined;
|
|
100
|
+
/** This request scope's entry, open or closed, created on first sight. */
|
|
101
|
+
const entryFor = (ctx) => {
|
|
102
|
+
const owner = Context.get(viewServices(ctx), RequestScope);
|
|
103
|
+
const key = owner;
|
|
104
|
+
let state = states.get(key);
|
|
105
|
+
if (!state) {
|
|
106
|
+
state = {
|
|
107
|
+
scope: Scope.makeUnsafe(),
|
|
108
|
+
lease: null,
|
|
109
|
+
client: null,
|
|
110
|
+
transport: null,
|
|
111
|
+
toolDefinitions: new Map(),
|
|
112
|
+
dispatches: new Map(),
|
|
113
|
+
connecting: null,
|
|
114
|
+
authRequired: false,
|
|
115
|
+
provider: null,
|
|
116
|
+
connectedGeneration: null,
|
|
117
|
+
attemptProvider: null,
|
|
118
|
+
credentialDigest: null,
|
|
119
|
+
};
|
|
120
|
+
states.set(key, state);
|
|
121
|
+
const owned = state;
|
|
122
|
+
owner.own(() => {
|
|
123
|
+
const closing = Scope.closeUnsafe(owned.scope, Exit.void);
|
|
124
|
+
reset(owned);
|
|
125
|
+
if (closing)
|
|
126
|
+
detach(closing);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return state;
|
|
130
|
+
};
|
|
131
|
+
const isClosed = (state) => state.scope.state._tag === "Closed";
|
|
132
|
+
const stateFor = (ctx) => {
|
|
133
|
+
const state = entryFor(ctx);
|
|
134
|
+
if (isClosed(state))
|
|
135
|
+
throw rt.scopeEndedError();
|
|
136
|
+
return state;
|
|
137
|
+
};
|
|
138
|
+
const newProvider = (ctx, state, signal = viewSignal(ctx, true)) => {
|
|
139
|
+
if (state?.provider)
|
|
140
|
+
return state.provider;
|
|
141
|
+
const metadataUrl = staticClient ? undefined : (clientMetadataUrl ?? selfHostedClientUrl(ctx.publicUrl, id));
|
|
142
|
+
const redirectUri = downstreamRedirectUri(ctx.baseUrl, ctx.publicUrl, id);
|
|
143
|
+
const provider = new KvOAuthProvider(id, ctx.storage, redirectUri, refreshCoordinatorFor(ctx), ctx.allowAuthorization === true, oauthSealerFor(ctx), signal, JSON.stringify({
|
|
144
|
+
url: new URL(opts.url).href,
|
|
145
|
+
redirectUri,
|
|
146
|
+
clientMetadata: downstreamClientMetadata(redirectUri, oauthScope, ctx.oauthClientName, staticClient ? remoteClientAuthMethod(staticClient) : "none"),
|
|
147
|
+
...(authorizationParams.length ? { authorizationParams } : {}),
|
|
148
|
+
authScope: opts.authScope ?? "shared",
|
|
149
|
+
versionNegotiation: opts.versionNegotiation ?? "auto",
|
|
150
|
+
redirects: opts.redirects ?? "none",
|
|
151
|
+
clientMetadataUrl: metadataUrl,
|
|
152
|
+
...(staticClient
|
|
153
|
+
? {
|
|
154
|
+
client: {
|
|
155
|
+
issuer: staticClient.issuer,
|
|
156
|
+
clientId: staticClient.clientId,
|
|
157
|
+
tokenEndpointAuthMethod: staticClient.tokenEndpointAuthMethod,
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
: {}),
|
|
161
|
+
}), (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset), metadataUrl, oauthScope, undefined, {
|
|
162
|
+
name: ctx.oauthClientName,
|
|
163
|
+
client: staticClient,
|
|
164
|
+
secrets: sentSecretsFor(ctx),
|
|
165
|
+
authorizationParams,
|
|
166
|
+
resourceOrigin: destination.origin,
|
|
167
|
+
});
|
|
168
|
+
if (state)
|
|
169
|
+
state.provider = provider;
|
|
170
|
+
return provider;
|
|
171
|
+
};
|
|
172
|
+
const reset = (state) => {
|
|
173
|
+
state.lease = null;
|
|
174
|
+
state.client = null;
|
|
175
|
+
state.transport = null;
|
|
176
|
+
state.toolDefinitions.clear();
|
|
177
|
+
state.connecting = null;
|
|
178
|
+
state.authRequired = false;
|
|
179
|
+
state.provider = null;
|
|
180
|
+
state.connectedGeneration = null;
|
|
181
|
+
state.credentialDigest = null;
|
|
182
|
+
// `scope` is deliberately left as it is — see ConnectionState.
|
|
183
|
+
};
|
|
184
|
+
/**
|
|
185
|
+
* Forget the live connection and close its lease without waiting. A connect
|
|
186
|
+
* still in flight is unpublished with it, and sees at its next check that it
|
|
187
|
+
* has been abandoned.
|
|
188
|
+
*/
|
|
189
|
+
const closeHalf = (state) => {
|
|
190
|
+
const lease = state.lease;
|
|
191
|
+
reset(state);
|
|
192
|
+
const release = lease && Scope.closeUnsafe(lease, Exit.void);
|
|
193
|
+
if (release)
|
|
194
|
+
detach(release);
|
|
195
|
+
};
|
|
196
|
+
const waitForConnection = (attempt, signal) => runEdge(Deferred.await(attempt), signal ? { signal } : {});
|
|
197
|
+
const oauthConfigFor = (ctx) => isOauth
|
|
198
|
+
? {
|
|
199
|
+
client: staticClient,
|
|
200
|
+
clientMetadataUrl: staticClient ? undefined : (clientMetadataUrl ?? selfHostedClientUrl(ctx.publicUrl, id)),
|
|
201
|
+
redirectUri: downstreamRedirectUri(ctx.baseUrl, ctx.publicUrl, id),
|
|
202
|
+
clientName: ctx.oauthClientName,
|
|
203
|
+
}
|
|
204
|
+
: undefined;
|
|
205
|
+
// Stored keys see a connector's configuration only through the keyed digest,
|
|
206
|
+
// whole: every configured URL (endpoint, client metadata document, static
|
|
207
|
+
// client issuer, redirect URI), header values and the static client's secret.
|
|
208
|
+
// The catalog cache's configuration, partition and sharing are derived the
|
|
209
|
+
// same way for a connect and for a pre-connect cache read, so both address
|
|
210
|
+
// exactly the same entries.
|
|
211
|
+
const catalogConfigFor = (ctx) => secretDigest(ctx, JSON.stringify({
|
|
212
|
+
url: new URL(opts.url).href,
|
|
213
|
+
auth: opts.auth?.type ?? null,
|
|
214
|
+
headers: opts.auth?.type === "headers" ? opts.auth.headers : requestAuth?.headers,
|
|
215
|
+
...(extraHeaders ? { extraHeaders } : {}),
|
|
216
|
+
credentialHeader,
|
|
217
|
+
credentialScheme,
|
|
218
|
+
authScope: opts.authScope ?? "shared",
|
|
219
|
+
redirects: opts.redirects ?? "none",
|
|
220
|
+
versionNegotiation: opts.versionNegotiation ?? "auto",
|
|
221
|
+
oauthConfig: oauthConfigFor(ctx),
|
|
222
|
+
oauthScope,
|
|
223
|
+
}));
|
|
224
|
+
const catalogSharing = requestAuth || opts.authScope === "personal" ? "private" : "shared";
|
|
225
|
+
const catalogIdentityFor = (ctx, provider) => authMethod(() => authFlow(function* () {
|
|
226
|
+
const tokens = yield authInvoke(provider, "tokens", []);
|
|
227
|
+
return yield authCall(secretDigest, [ctx, JSON.stringify(tokens)]);
|
|
228
|
+
}));
|
|
229
|
+
// The verdict's key: everything that decides how this connector's handshake
|
|
230
|
+
// negotiates, through the keyed digest.
|
|
231
|
+
const negotiationKey = (ctx, credentialDigest, epoch) => secretDigest(ctx, JSON.stringify([
|
|
232
|
+
opts.url,
|
|
233
|
+
opts.versionNegotiation ?? "auto",
|
|
234
|
+
opts.auth?.type,
|
|
235
|
+
opts.auth?.type === "headers" ? opts.auth.headers : requestAuth?.headers,
|
|
236
|
+
...(extraHeaders ? [extraHeaders] : []),
|
|
237
|
+
credentialHeader,
|
|
238
|
+
credentialScheme,
|
|
239
|
+
opts.authScope ?? "shared",
|
|
240
|
+
opts.redirects ?? "none",
|
|
241
|
+
oauthConfigFor(ctx),
|
|
242
|
+
oauthScope,
|
|
243
|
+
credentialDigest,
|
|
244
|
+
epoch,
|
|
245
|
+
callerOf(ctx),
|
|
246
|
+
skillsEnabled === true,
|
|
247
|
+
]));
|
|
248
|
+
/**
|
|
249
|
+
* Serve a complete, unexpired cached catalog without connecting. The read
|
|
250
|
+
* derives the partition a connect would bind — the owner's live OAuth epoch
|
|
251
|
+
* and tokens, or the digest of this caller's credential — from storage reads
|
|
252
|
+
* alone: no transport, no handshake, no authorization started and no auth or
|
|
253
|
+
* capability state changed (INV-10). Only the catalog cache's own complete
|
|
254
|
+
* entries are served (INV-8); a miss, an expired or `ttlMs: 0` catalog, an
|
|
255
|
+
* operator-disconnected or token-less grant, or a failed cache read all
|
|
256
|
+
* return no tools, and the caller connects. A downstream whose stored
|
|
257
|
+
* negotiation verdict is modern connects without a handshake and reads the
|
|
258
|
+
* same cache through the SDK, so it skips this read. What was already read
|
|
259
|
+
* is handed to that connect, so a resolver runs once per listing.
|
|
260
|
+
*/
|
|
261
|
+
const cachedCatalog = authMethod((ctx) => authFlow(function* () {
|
|
262
|
+
const principal = principalPolicy
|
|
263
|
+
? requestPrincipalFor(principalPolicy, ctx, (reason) => new rt.CredentialRequiredError(`Connector "${id}" ${reason}.`))
|
|
264
|
+
: undefined;
|
|
265
|
+
const preread = {};
|
|
266
|
+
let credentialDigest = null;
|
|
267
|
+
if (credentialAuth || requestAuth) {
|
|
268
|
+
preread.credential = (yield authCall(readCredential, [ctx, principal]));
|
|
269
|
+
credentialDigest = (yield authCall(secretDigest, [
|
|
270
|
+
ctx,
|
|
271
|
+
principal ? JSON.stringify([principal, preread.credential]) : preread.credential,
|
|
272
|
+
]));
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
let epoch = "";
|
|
276
|
+
let identity;
|
|
277
|
+
if (isOauth) {
|
|
278
|
+
// Read-only grant snapshots: no in-place sealing, epoch binding or
|
|
279
|
+
// refresh. Every identity check during cache acceptance re-reads the
|
|
280
|
+
// grant and refuses unless both the epoch this partition was derived
|
|
281
|
+
// from and the token digest still hold, as a bound connect would.
|
|
282
|
+
const provider = newProvider(ctx);
|
|
283
|
+
const snapshot = (yield authInvoke(provider, "peekGrant", []));
|
|
284
|
+
epoch = snapshot.epoch;
|
|
285
|
+
if (provider.isOperatorDisconnectedEpoch(epoch) || !snapshot.tokens)
|
|
286
|
+
return preread;
|
|
287
|
+
const bound = epoch;
|
|
288
|
+
identity = authMethod(() => authFlow(function* () {
|
|
289
|
+
const current = (yield authInvoke(provider, "peekGrant", []));
|
|
290
|
+
// A distinct value per refusal never matches the listing's identity.
|
|
291
|
+
if (current.epoch !== bound || !current.tokens)
|
|
292
|
+
return `superseded:${crypto.randomUUID()}`;
|
|
293
|
+
return yield authCall(secretDigest, [ctx, JSON.stringify(current.tokens)]);
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
296
|
+
if (opts.versionNegotiation !== "legacy") {
|
|
297
|
+
const digest = (yield authCall(negotiationKey, [ctx, credentialDigest, epoch]));
|
|
298
|
+
// Both reads only locate cached data. Acceptance and dispatch keep
|
|
299
|
+
// their fresh generation and credential checks.
|
|
300
|
+
yield viewReads(ctx).prefetch([
|
|
301
|
+
{ storage: ctx.storage, key: negotiationKeys.verdict(digest) },
|
|
302
|
+
{
|
|
303
|
+
storage: serviceFor(ctx, CatalogPolicy)?.storage ?? ctx.storage,
|
|
304
|
+
key: responseCacheKeys.generation(id),
|
|
305
|
+
},
|
|
306
|
+
], (storage, keys) => authStorageMany(storage, keys));
|
|
307
|
+
const prior = (yield authCall(readNegotiation, [ctx, digest]));
|
|
308
|
+
preread.negotiation = { digest, prior };
|
|
309
|
+
if (prior?.kind === "modern")
|
|
310
|
+
return preread;
|
|
311
|
+
}
|
|
312
|
+
const cache = (yield authCall(catalogClientOptions, [
|
|
313
|
+
ctx,
|
|
314
|
+
id,
|
|
315
|
+
(yield authCall(catalogConfigFor, [ctx])),
|
|
316
|
+
JSON.stringify([epoch, credentialDigest]),
|
|
317
|
+
undefined,
|
|
318
|
+
cacheOwner,
|
|
319
|
+
catalogSharing,
|
|
320
|
+
identity,
|
|
321
|
+
true,
|
|
322
|
+
]));
|
|
323
|
+
const result = (yield authInvoke(cache, "cachedTools", [ctx]));
|
|
324
|
+
if (result)
|
|
325
|
+
return { tools: result.tools };
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
if (viewSignal(ctx).aborted)
|
|
329
|
+
throw error;
|
|
330
|
+
logFailure(ctx.logger, "catalog read failed", failureRecord({ connector: id }, error));
|
|
331
|
+
}
|
|
332
|
+
return preread;
|
|
333
|
+
}));
|
|
334
|
+
const ensureConnected = authMethod((ctx, state, preread) => authFlow(function* () {
|
|
335
|
+
let joined = state.connecting;
|
|
336
|
+
if (joined)
|
|
337
|
+
connectingWaiters.set(joined, (connectingWaiters.get(joined) ?? 0) + 1);
|
|
338
|
+
const leave = () => {
|
|
339
|
+
if (!joined)
|
|
340
|
+
return;
|
|
341
|
+
const remaining = (connectingWaiters.get(joined) ?? 1) - 1;
|
|
342
|
+
if (remaining > 0)
|
|
343
|
+
connectingWaiters.set(joined, remaining);
|
|
344
|
+
else {
|
|
345
|
+
connectingWaiters.delete(joined);
|
|
346
|
+
if (ctx.signal?.aborted && state.connecting === joined)
|
|
347
|
+
closeHalf(state);
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
try {
|
|
351
|
+
if (state.authRequired) {
|
|
352
|
+
throw rt.authRequiredError(ctx);
|
|
353
|
+
}
|
|
354
|
+
let oauthGeneration;
|
|
355
|
+
if (isOauth && (state.client || state.connecting)) {
|
|
356
|
+
const provider = newProvider(ctx, state, openRequest(ctx.requestScope ?? ctx).signal);
|
|
357
|
+
oauthGeneration = (yield authView(ctx, authMethod(() => authInvoke(provider, "liveEpoch", []))));
|
|
358
|
+
if (provider.isOperatorDisconnectedEpoch(oauthGeneration)) {
|
|
359
|
+
closeHalf(state);
|
|
360
|
+
throw rt.operatorDisconnectedError();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (state.client && oauthGeneration !== undefined && state.connectedGeneration !== null) {
|
|
364
|
+
if (isClosed(state))
|
|
365
|
+
throw rt.scopeEndedError();
|
|
366
|
+
if (oauthGeneration !== state.connectedGeneration) {
|
|
367
|
+
closeHalf(state);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
let credentialValue = null;
|
|
371
|
+
let credentialFramed = null;
|
|
372
|
+
let credentialDigest = null;
|
|
373
|
+
// Only the admitted caller on this operation's context, never an argument.
|
|
374
|
+
// Refused before the resolver runs, so no token is minted for anyone else.
|
|
375
|
+
const principal = principalPolicy
|
|
376
|
+
? requestPrincipalFor(principalPolicy, ctx, (reason) => new rt.CredentialRequiredError(`Connector "${id}" ${reason}.`))
|
|
377
|
+
: undefined;
|
|
378
|
+
if (credentialAuth || requestAuth) {
|
|
379
|
+
credentialValue = preread?.credential ?? (yield authCall(readCredential, [ctx, principal]));
|
|
380
|
+
credentialFramed = credentialHeaderValue(credentialScheme, credentialValue);
|
|
381
|
+
// The principal joins the client's identity: another principal never
|
|
382
|
+
// reuses this client, even if its resolver returns the same token.
|
|
383
|
+
credentialDigest = (yield authCall(secretDigest, [
|
|
384
|
+
ctx,
|
|
385
|
+
principal ? JSON.stringify([principal, credentialValue]) : credentialValue,
|
|
386
|
+
]));
|
|
387
|
+
if ((state.client || state.connecting) &&
|
|
388
|
+
state.credentialDigest !== null &&
|
|
389
|
+
state.credentialDigest !== credentialDigest) {
|
|
390
|
+
const observedConnection = state.client ?? state.connecting;
|
|
391
|
+
const currentCredential = (yield authCall(readCredential, [ctx, principal]));
|
|
392
|
+
if (isClosed(state))
|
|
393
|
+
throw rt.scopeEndedError();
|
|
394
|
+
if (currentCredential !== credentialValue || (state.client ?? state.connecting) !== observedConnection) {
|
|
395
|
+
throw resendAdvice(new ConnectorCallError("connector_call_failed", "The downstream credential changed while connecting.", {
|
|
396
|
+
retryable: true,
|
|
397
|
+
}), "; retry the operation");
|
|
398
|
+
}
|
|
399
|
+
closeHalf(state);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
viewSignal(ctx).throwIfAborted();
|
|
403
|
+
if (isClosed(state))
|
|
404
|
+
throw rt.scopeEndedError();
|
|
405
|
+
if (state.client && rt.eraRefused.has(state.client))
|
|
406
|
+
closeHalf(state);
|
|
407
|
+
if (state.client)
|
|
408
|
+
return state.client;
|
|
409
|
+
const attempt = state.connecting ??
|
|
410
|
+
startConnect(ctx, state, credentialValue, credentialFramed, credentialDigest, principal, preread?.negotiation);
|
|
411
|
+
if (joined !== attempt) {
|
|
412
|
+
leave();
|
|
413
|
+
joined = attempt;
|
|
414
|
+
connectingWaiters.set(attempt, (connectingWaiters.get(attempt) ?? 0) + 1);
|
|
415
|
+
}
|
|
416
|
+
const client = (yield authCall(waitForConnection, [attempt, ctx.signal]));
|
|
417
|
+
if (isClosed(state))
|
|
418
|
+
throw rt.scopeEndedError();
|
|
419
|
+
return client;
|
|
420
|
+
}
|
|
421
|
+
finally {
|
|
422
|
+
leave();
|
|
423
|
+
}
|
|
424
|
+
}));
|
|
425
|
+
/**
|
|
426
|
+
* Start this scope's connect attempt, publishing it before any of it runs.
|
|
427
|
+
* The attempt completes its Deferred itself, and every caller — the one that
|
|
428
|
+
* started it included — waits on that; nothing awaits the run directly,
|
|
429
|
+
* which is why handing it to `detach` orphans nothing.
|
|
430
|
+
*/
|
|
431
|
+
const startConnect = (ctx, state, credentialValue, credentialFramed, credentialDigest, principal, negotiation) => {
|
|
432
|
+
const attempt = Deferred.makeUnsafe();
|
|
433
|
+
state.connecting = attempt;
|
|
434
|
+
// Published with the attempt, not with its result: a rotation that lands
|
|
435
|
+
// while this connect is in flight has to be visible to the next caller,
|
|
436
|
+
// which would otherwise wait on a client bound to the older key.
|
|
437
|
+
state.credentialDigest = credentialDigest;
|
|
438
|
+
detach(connect(ctx, state, attempt, credentialValue, credentialFramed, principal, negotiation).pipe(
|
|
439
|
+
// The connection belongs to this request, without the first waiter's
|
|
440
|
+
// call/credential/budget authority. Refresh coordinators stay separate.
|
|
441
|
+
Effect.provideService(RequestScope, Context.get(viewServices(ctx), RequestScope)), Effect.onExit((exit) => Effect.sync(() => {
|
|
442
|
+
// Force reset may have abandoned this attempt and installed a new
|
|
443
|
+
// one in the same request scope. An old completion must not erase
|
|
444
|
+
// the new attempt and allow a third concurrent connect.
|
|
445
|
+
if (state.connecting === attempt)
|
|
446
|
+
state.connecting = null;
|
|
447
|
+
// Last: a Deferred resumes its waiters inside this call.
|
|
448
|
+
Deferred.doneUnsafe(attempt, exit);
|
|
449
|
+
}))));
|
|
450
|
+
return attempt;
|
|
451
|
+
};
|
|
452
|
+
const connect = (ctx, state, attempt, credentialValue, credentialFramed, principal, negotiation) => {
|
|
453
|
+
// Force reset, rotation, and teardown all abandon an attempt the same way:
|
|
454
|
+
// they unpublish it and close the lease it holds, if it holds one yet.
|
|
455
|
+
const owned = () => state.connecting === attempt && !isClosed(state);
|
|
456
|
+
return Effect.gen(function* () {
|
|
457
|
+
// The transport and its OAuth provider are shared by every call in this
|
|
458
|
+
// scope. A call's deadline signal is aborted even on success, so retaining
|
|
459
|
+
// the first caller's signal here would abort its siblings and later calls.
|
|
460
|
+
// The provider uses the connection's lifetime. Each challenged response
|
|
461
|
+
// separately binds renewal preparation to the call that initiated it.
|
|
462
|
+
const connectionAbort = new AbortController();
|
|
463
|
+
// Own cancellation before beginFlow's storage reads.
|
|
464
|
+
// The lease first owns just the signal, then the transport it creates.
|
|
465
|
+
const lease = Scope.forkUnsafe(state.scope);
|
|
466
|
+
const handshakeAbort = new AbortController();
|
|
467
|
+
const held = {
|
|
468
|
+
client: null,
|
|
469
|
+
transport: null,
|
|
470
|
+
};
|
|
471
|
+
yield* Scope.addFinalizer(lease, Effect.sync(() => handshakeAbort.abort()).pipe(Effect.andThen(Effect.suspend(() => {
|
|
472
|
+
if (!held.transport)
|
|
473
|
+
connectionAbort.abort();
|
|
474
|
+
return rt.closeConnection(held.client, held.transport, ctx.logger);
|
|
475
|
+
})),
|
|
476
|
+
// Let session termination use the OAuth fetch wrapper before ending
|
|
477
|
+
// its lifetime. Local transport close aborts all active MCP fetches.
|
|
478
|
+
Effect.ensuring(Effect.sync(() => connectionAbort.abort()))));
|
|
479
|
+
state.lease = lease;
|
|
480
|
+
return yield* Effect.gen(function* () {
|
|
481
|
+
// A provider belongs to exactly one connect attempt. A force reset can
|
|
482
|
+
// abandon that attempt while its transport still holds the provider;
|
|
483
|
+
// the replacement must never mutate the abandoned provider's epoch.
|
|
484
|
+
const provider = isOauth ? newProvider(ctx, undefined, connectionAbort.signal) : null;
|
|
485
|
+
// Every OAuth run of this attempt — a 401's refresh or consent, a
|
|
486
|
+
// step-up — happens inside the SDK, bound to the epoch live now: a
|
|
487
|
+
// reset meanwhile fails the attempt rather than handing it the next
|
|
488
|
+
// epoch's grant.
|
|
489
|
+
const genAtStart = provider
|
|
490
|
+
? yield* promised(authMethod(() => authInvoke(provider, "beginFlow", [])))
|
|
491
|
+
: "";
|
|
492
|
+
if (!owned())
|
|
493
|
+
return yield* Effect.fail(rt.scopeEndedError());
|
|
494
|
+
if (provider)
|
|
495
|
+
state.attemptProvider = provider;
|
|
496
|
+
if (provider?.isOperatorDisconnectedEpoch(genAtStart)) {
|
|
497
|
+
return yield* Effect.fail(rt.operatorDisconnectedError());
|
|
498
|
+
}
|
|
499
|
+
// SDK v2 selects its validator by runtime export condition: AJV on
|
|
500
|
+
// Node and @cfworker/json-schema under workerd. The Workers-safe path
|
|
501
|
+
// no longer needs Connecta-specific wiring.
|
|
502
|
+
const negotiationDigest = yield* promised(authMethod(() => authCall(negotiationKey, [ctx, state.credentialDigest, genAtStart])));
|
|
503
|
+
let prior = opts.versionNegotiation === "legacy"
|
|
504
|
+
? undefined
|
|
505
|
+
: negotiation?.digest === negotiationDigest
|
|
506
|
+
? negotiation.prior
|
|
507
|
+
: yield* promised(authMethod(() => authCall(readNegotiation, [ctx, negotiationDigest])));
|
|
508
|
+
if (!owned())
|
|
509
|
+
return yield* Effect.fail(rt.scopeEndedError());
|
|
510
|
+
const catalogConfig = yield* promised(authMethod(() => authCall(catalogConfigFor, [ctx])));
|
|
511
|
+
const cacheOptions = yield* promised(authMethod(() => authCall(catalogClientOptions, [
|
|
512
|
+
ctx,
|
|
513
|
+
id,
|
|
514
|
+
catalogConfig,
|
|
515
|
+
JSON.stringify([genAtStart, state.credentialDigest]),
|
|
516
|
+
connectionAbort.signal,
|
|
517
|
+
cacheOwner,
|
|
518
|
+
catalogSharing,
|
|
519
|
+
provider ? catalogIdentityFor(ctx, provider) : undefined,
|
|
520
|
+
])));
|
|
521
|
+
if (!owned())
|
|
522
|
+
return yield* Effect.fail(rt.scopeEndedError());
|
|
523
|
+
const makeClient = () => {
|
|
524
|
+
const { intake: _intake, completeCatalogRefresh: _refresh, withListing: _listing, currentContext: _context, cachedTools: _cached, ...clientOptions } = cacheOptions;
|
|
525
|
+
const client = new Client({ name: "connecta", version: CONNECTA_VERSION }, {
|
|
526
|
+
...clientOptions,
|
|
527
|
+
listMaxPages: MAX_TOOL_PAGES,
|
|
528
|
+
versionNegotiation: {
|
|
529
|
+
mode: opts.versionNegotiation ?? "auto",
|
|
530
|
+
},
|
|
531
|
+
capabilities: {
|
|
532
|
+
...downstreamInputCapabilities(ctx.requestScope ?? ctx),
|
|
533
|
+
...(skillsEnabled ? { extensions: { [SKILLS_EXTENSION]: {} } } : {}),
|
|
534
|
+
},
|
|
535
|
+
// The host owns each sealed continuation; never auto-retry a write.
|
|
536
|
+
inputRequired: { autoFulfill: false },
|
|
537
|
+
});
|
|
538
|
+
rt.installCatalogIntake(client, ctx, cacheOptions);
|
|
539
|
+
return client;
|
|
540
|
+
};
|
|
541
|
+
let c = makeClient();
|
|
542
|
+
let t = rt.buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue, false, principal);
|
|
543
|
+
if (!owned()) {
|
|
544
|
+
held.transport = t;
|
|
545
|
+
return yield* Effect.fail(rt.scopeEndedError());
|
|
546
|
+
}
|
|
547
|
+
state.transport = t;
|
|
548
|
+
held.transport = t;
|
|
549
|
+
recordWireErrors(t, () => observeCatalogChange(ctx, id, connectionAbort.signal, cacheOwner));
|
|
550
|
+
yield* promised(authMethod(() => authFlow(function* () {
|
|
551
|
+
try {
|
|
552
|
+
(yield authInvoke(c, "connect", [
|
|
553
|
+
t,
|
|
554
|
+
{ signal: handshakeAbort.signal, ...(prior ? { prior } : {}) },
|
|
555
|
+
]));
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
// A server that stopped answering the legacy handshake its
|
|
559
|
+
// verdict recorded refuses it with a 4xx (a modern-only server
|
|
560
|
+
// names its versions). Negotiate afresh rather than wait out
|
|
561
|
+
// the verdict; the handshake is not a write.
|
|
562
|
+
const refused = prior?.kind === "legacy" &&
|
|
563
|
+
error instanceof SdkHttpError &&
|
|
564
|
+
error.status >= 400 &&
|
|
565
|
+
error.status < 500 &&
|
|
566
|
+
!AUTH_OR_THROTTLE.includes(error.status);
|
|
567
|
+
if (!owned() ||
|
|
568
|
+
handshakeAbort.signal.aborted ||
|
|
569
|
+
(!refused &&
|
|
570
|
+
(prior ||
|
|
571
|
+
opts.versionNegotiation === "legacy" ||
|
|
572
|
+
!(error instanceof SdkHttpError) ||
|
|
573
|
+
error.code !== SdkErrorCode.EraNegotiationFailed ||
|
|
574
|
+
error.status < 500 ||
|
|
575
|
+
error.status >= 600)))
|
|
576
|
+
throw error;
|
|
577
|
+
c = makeClient();
|
|
578
|
+
t = rt.buildTransport(ctx, provider, credentialFramed, connectionAbort.signal, credentialValue, false, principal);
|
|
579
|
+
state.transport = held.transport = t;
|
|
580
|
+
recordWireErrors(t, () => observeCatalogChange(ctx, id, connectionAbort.signal, cacheOwner));
|
|
581
|
+
if (refused)
|
|
582
|
+
prior = undefined;
|
|
583
|
+
(yield authInvoke(c, "connect", [
|
|
584
|
+
t,
|
|
585
|
+
{ signal: handshakeAbort.signal, ...(refused ? {} : { prior: { kind: "legacy" } }) },
|
|
586
|
+
]));
|
|
587
|
+
}
|
|
588
|
+
}))).pipe(Effect.mapError((err) => rt.atMcpBoundary(ctx,
|
|
589
|
+
// First, while the runtime's text can still be read for it.
|
|
590
|
+
rt.withoutCredential(ctx, err, credentialValue, credentialFramed), "MCP handshake", t, [ctx.signal, connectionAbort.signal, handshakeAbort.signal])));
|
|
591
|
+
held.client = c;
|
|
592
|
+
// A probe deadline can end its scope while connect is still in flight.
|
|
593
|
+
// The transport is closed immediately by closeScope; if connect wins
|
|
594
|
+
// that race anyway, close the resulting client rather than resurrecting
|
|
595
|
+
// a session in the detached state object.
|
|
596
|
+
if (!owned())
|
|
597
|
+
return yield* Effect.fail(rt.scopeEndedError());
|
|
598
|
+
// A force re-auth that landed WHILE we were connecting wiped the
|
|
599
|
+
// credentials this client just bound to. Discard it rather than cache
|
|
600
|
+
// a stale-isolate connection.
|
|
601
|
+
if (provider) {
|
|
602
|
+
const generation = yield* promised(authMethod(() => authInvoke(provider, "liveEpoch", []))).pipe(Effect.mapError((error) => (owned() ? error : rt.scopeEndedError())));
|
|
603
|
+
// closeScope can land while the epoch read is pending, after
|
|
604
|
+
// connect succeeded but before this client is cached. Discard the
|
|
605
|
+
// client on that side of the await too.
|
|
606
|
+
if (!owned())
|
|
607
|
+
return yield* Effect.fail(rt.scopeEndedError());
|
|
608
|
+
if (generation !== genAtStart) {
|
|
609
|
+
return yield* Effect.fail(new UnauthorizedError("Connector was re-authorized during connect; reconnect required."));
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
state.client = c;
|
|
613
|
+
if (provider)
|
|
614
|
+
connectedProviders.set(c, provider);
|
|
615
|
+
state.connectedGeneration = genAtStart;
|
|
616
|
+
state.authRequired = false;
|
|
617
|
+
const discover = c.getDiscoverResult();
|
|
618
|
+
// A modern connection, adopted or just negotiated, rests on the stored verdict.
|
|
619
|
+
const era = discover ? rt.eraRefusals.get(t) : undefined;
|
|
620
|
+
if (era)
|
|
621
|
+
era.refused = () => {
|
|
622
|
+
era.refused = undefined;
|
|
623
|
+
rt.eraRefused.add(c);
|
|
624
|
+
return forgetNegotiation(ctx, negotiationDigest);
|
|
625
|
+
};
|
|
626
|
+
if (!prior)
|
|
627
|
+
yield* promised(authMethod(() => authCall(storeNegotiation, [
|
|
628
|
+
ctx,
|
|
629
|
+
negotiationDigest,
|
|
630
|
+
discover ? { kind: "modern", discover } : { kind: "legacy" },
|
|
631
|
+
])));
|
|
632
|
+
if (!owned())
|
|
633
|
+
return yield* Effect.fail(rt.scopeEndedError());
|
|
634
|
+
return c;
|
|
635
|
+
}).pipe(Effect.catchDefect(Effect.fail), Effect.catch((err) => {
|
|
636
|
+
// Close the failed connection through its client, without waiting.
|
|
637
|
+
if (owned()) {
|
|
638
|
+
state.lease = null;
|
|
639
|
+
state.transport = null;
|
|
640
|
+
// Only a real 401/UnauthorizedError means auth is the problem —
|
|
641
|
+
// a network error on an oauth connector must surface as "error",
|
|
642
|
+
// not "auth_required".
|
|
643
|
+
if (requiresAuthorization(err))
|
|
644
|
+
state.authRequired = true;
|
|
645
|
+
const release = Scope.closeUnsafe(lease, Exit.void);
|
|
646
|
+
if (release)
|
|
647
|
+
detach(release);
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
// Whoever abandoned this attempt closed the lease already, while
|
|
651
|
+
// the connect was in flight. A session it acquired after that
|
|
652
|
+
// close still owes its DELETE.
|
|
653
|
+
detach(rt.closeConnection(held.client, held.transport, ctx.logger));
|
|
654
|
+
}
|
|
655
|
+
if (err instanceof UnauthorizedError) {
|
|
656
|
+
return Effect.fail(carryFailureFacts(err, rt.authRequiredError(ctx)));
|
|
657
|
+
}
|
|
658
|
+
// Defense in depth for the one error class that can quote the
|
|
659
|
+
// credential: a runtime refusing the assembled header.
|
|
660
|
+
// `readCredential` already rejects a value that cannot ride one, so
|
|
661
|
+
// reaching this is a gap in that check rather than a routine
|
|
662
|
+
// outcome.
|
|
663
|
+
return Effect.fail(rt.withoutCredential(ctx, err, credentialValue, credentialFramed));
|
|
664
|
+
}));
|
|
665
|
+
});
|
|
666
|
+
};
|
|
667
|
+
return {
|
|
668
|
+
entryFor,
|
|
669
|
+
newProvider,
|
|
670
|
+
isClosed,
|
|
671
|
+
refreshCoordinatorFor,
|
|
672
|
+
closeHalf,
|
|
673
|
+
connectedProviders,
|
|
674
|
+
stateFor,
|
|
675
|
+
ensureConnected,
|
|
676
|
+
requestOptions,
|
|
677
|
+
cachedCatalog,
|
|
678
|
+
cacheOwner,
|
|
679
|
+
reset,
|
|
680
|
+
states,
|
|
681
|
+
};
|
|
682
|
+
}
|