@zackbart/connecta 0.37.1 → 0.39.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 +94 -0
- package/README.md +5 -4
- package/bin/connecta.mjs +1 -39
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.js +2 -4
- package/dist/agent-identity.js +1 -1
- package/dist/agent-routes.js +231 -0
- package/dist/auth/clerk-sdk/generated.js +2 -1
- 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 -2516
- package/dist/auth/downstream-revocation.js +1 -1
- package/dist/auth/native.js +27 -0
- package/dist/auth/oauth-grant.js +24 -0
- package/dist/auth/oauth-token-response.js +1 -1
- package/dist/auth/static-oauth.js +153 -131
- package/dist/auth/vercel-agent.js +2 -1
- package/dist/auth-elicitation.js +9 -3
- package/dist/authorization-handoff.js +11 -5
- package/dist/branding.d.ts +1 -15
- package/dist/branding.js +2 -2
- package/dist/bytes.js +42 -0
- package/dist/call-admission-context.js +2 -5
- package/dist/call-recovery.js +0 -3
- package/dist/catalog-cache.js +130 -43
- package/dist/catalog-fingerprint.js +2 -18
- package/dist/catalog-policy.js +18 -0
- package/dist/catalog-service.js +285 -48
- package/dist/catalog.js +18 -2
- package/dist/config-defaults.d.ts +1 -0
- package/dist/config-defaults.js +1 -0
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +6 -2
- package/dist/config.d.ts +57 -25
- package/dist/config.js +30 -77
- 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 +11 -7
- package/dist/connectors/guarded-fetch.d.ts +0 -6
- package/dist/connectors/guarded-fetch.js +1 -11
- 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 +671 -0
- package/dist/connectors/remote-mcp/errors.js +488 -0
- package/dist/connectors/remote-mcp/oauth.js +469 -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 +544 -0
- package/dist/connectors/remote-mcp.js +61 -2898
- package/dist/connectors/resource-uri.js +0 -4
- 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/d1.d.ts +0 -89
- package/dist/d1.js +0 -34
- 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 +4 -2
- package/dist/errors.d.ts +55 -17
- package/dist/errors.js +95 -41
- package/dist/execute.js +172 -125
- package/dist/executor-result.js +5 -2
- package/dist/executors/quickjs-runtime.js +2 -25
- package/dist/executors/quickjs.js +1 -1
- package/dist/guest-functions.js +83 -0
- package/dist/guest-runtime.js +63 -20
- package/dist/guest-types.d.ts +24 -10
- package/dist/http/argument-problems.js +35 -0
- package/dist/http/client.js +1 -1
- package/dist/http/errors.js +7 -6
- 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/json-schema.js +1 -1
- package/dist/http/node-transport.js +0 -1
- package/dist/http/oauth.js +4 -3
- package/dist/http/paging.js +6 -9
- package/dist/http/provider.js +33 -5
- package/dist/http/safety.js +9 -1
- package/dist/http/services.js +1 -8
- package/dist/http/tools.js +45 -30
- package/dist/http/vendor.js +7 -7
- package/dist/identity.js +2 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/dist/invocation-auth.js +2 -1
- package/dist/invocation.js +21 -6
- package/dist/membership.d.ts +13 -5
- package/dist/membership.js +34 -2
- package/dist/meta-tools.js +50 -35
- package/dist/native-recovery.js +4 -16
- package/dist/native-tools.js +6 -2
- package/dist/oauth-handoff.js +2 -2
- package/dist/oauth-sealing.js +10 -0
- package/dist/operation-vocabulary.js +136 -14
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +2 -49
- package/dist/operator-ui/contract.d.ts +12 -2
- package/dist/operator-ui/generated.js +3 -3
- package/dist/operator-ui/model.d.ts +22 -1
- package/dist/program-writes.js +3 -8
- package/dist/provider-call.js +0 -7
- package/dist/provider.d.ts +1 -1
- package/dist/providers/_shared/google/kit.js +1 -6
- package/dist/providers/_shared/google/service-account.js +1 -3
- package/dist/providers/_shared/google/workspace.d.ts +1 -1
- package/dist/providers/_shared/guide.js +3 -0
- package/dist/providers/_shared/json.js +12 -0
- package/dist/providers/_shared/rest/compact-index.js +1 -1
- package/dist/providers/_shared/rest/kit.js +1 -3
- package/dist/providers/_shared/rest/operation-index.js +49 -53
- package/dist/providers/_shared/rest/value-safety.js +106 -28
- package/dist/providers/breeze/api.js +9 -8
- package/dist/providers/breeze/index.js +4 -2
- package/dist/providers/breeze/skill.generated.js +1 -1
- package/dist/providers/breeze/tools.js +7 -7
- package/dist/providers/ccb/api.js +3 -7
- package/dist/providers/ccb/index.js +7 -1
- package/dist/providers/ccb/skill.generated.js +1 -1
- package/dist/providers/ccb/tools.js +1 -6
- package/dist/providers/cloudflare/api.js +10 -3
- package/dist/providers/cloudflare/index.js +2 -1
- package/dist/providers/cloudflare/safety.js +3 -3
- 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 +4 -14
- 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 +23 -41
- package/dist/providers/forms/api.js +1 -0
- package/dist/providers/forms/skill.generated.js +1 -1
- package/dist/providers/forms/tools.js +11 -16
- 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 +12 -26
- package/dist/providers/granola/index.js +2 -0
- package/dist/providers/granola/skill.generated.js +1 -1
- package/dist/providers/overflow/api.js +8 -4
- package/dist/providers/overflow/index.js +2 -1
- package/dist/providers/overflow/skill.generated.js +1 -1
- package/dist/providers/overflow/tools.js +3 -7
- package/dist/providers/planning-center/api.js +5 -2
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/planning-center/lib/json-api.js +1 -9
- package/dist/providers/planning-center/skill.generated.js +1 -1
- package/dist/providers/planning-center/tools.js +2 -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 +14 -23
- package/dist/providers/slides/skill.generated.js +1 -1
- package/dist/providers/slides/tools.js +19 -26
- package/dist/providers/stripe/index.js +2 -1
- package/dist/providers/stripe/safety.js +2 -2
- package/dist/providers/stripe/skill.generated.js +1 -1
- package/dist/providers/tithely/api.js +5 -8
- package/dist/providers/tithely/index.js +3 -2
- package/dist/providers/tithely/skill.generated.js +1 -1
- package/dist/providers/tithely/tools.js +7 -5
- package/dist/providers/vercel/api.js +2 -7
- package/dist/providers/vercel/lib/pin.js +1 -1
- package/dist/providers/vercel/safety.js +1 -1
- package/dist/providers/vercel/skill.generated.js +1 -1
- package/dist/providers/vercel/tools.js +6 -4
- package/dist/redaction-marker.js +2 -2
- package/dist/registry.d.ts +11 -5
- package/dist/registry.js +46 -20
- package/dist/request-principal.js +1 -1
- package/dist/request-state.js +2 -2
- package/dist/routes/access-tokens.js +1 -1
- package/dist/routes/activity-disclosure.js +1 -1
- package/dist/routes/connect.js +2 -2
- package/dist/routes/credentials.js +130 -61
- package/dist/routes/dispatch.js +5 -1
- package/dist/routes/mcp.js +34 -17
- package/dist/routes/oauth-management.js +1 -1
- package/dist/routes/operator.js +6 -5
- package/dist/routes/shared.d.ts +13 -8
- package/dist/routes/shared.js +15 -9
- package/dist/routes/ui-config.js +3 -3
- package/dist/routes/ui-data.js +126 -20
- package/dist/routes/ui.js +17 -1
- package/dist/runtime/call-admission.js +1 -1
- package/dist/runtime/call.js +8 -3
- package/dist/runtime/native-storage.js +9 -0
- package/dist/runtime/request-reads.js +182 -0
- package/dist/runtime/request-scope.js +16 -0
- package/dist/runtime/settlement.js +1 -1
- package/dist/runtime/shared-read.js +2 -15
- package/dist/runtime/storage.js +12 -0
- package/dist/sent-secrets.d.ts +1 -2
- package/dist/sent-secrets.js +3 -8
- package/dist/server.js +4 -4
- package/dist/skills.js +44 -54
- package/dist/sqlite.d.ts +0 -19
- package/dist/sqlite.js +2 -83
- package/dist/storage/keys.js +69 -128
- package/dist/storage/memory.js +8 -2
- package/dist/storage/sql-native.js +27 -101
- package/dist/storage/sql.d.ts +1 -24
- package/dist/storage/sql.js +13 -14
- package/dist/storage-digest.js +32 -0
- package/dist/timeout.js +8 -0
- package/dist/tool-error.js +4 -0
- package/dist/types.d.ts +33 -0
- package/dist/url-safety.js +0 -4
- package/dist/usage-guide.js +18 -37
- 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/worker.js +1 -1
- package/dist/write-outcome.js +0 -3
- package/documentation/README.md +19 -55
- package/documentation/architecture.md +436 -515
- package/documentation/auth.md +229 -88
- package/documentation/code-mode.md +201 -94
- package/documentation/deploying.md +25 -43
- package/documentation/integrating.md +18 -19
- package/documentation/meta-tools.md +225 -51
- package/documentation/migration-0.33.md +10 -6
- package/documentation/migration-0.34.md +5 -5
- package/documentation/migration-retired-layouts.md +74 -0
- package/documentation/operator-tests.md +20 -120
- package/documentation/operator-ui.md +108 -16
- package/documentation/pool-membership-upgrade.md +44 -0
- package/documentation/provider-auth-migration.md +4 -10
- package/documentation/provider-migration-0.29.md +4 -15
- package/documentation/providers.md +105 -0
- package/examples/worker/AGENTS.md +14 -16
- package/examples/worker/README.md +24 -118
- package/examples/worker/src/connecta.config.ts +1 -0
- package/guides/connecting-agents.md +9 -31
- package/guides/how-it-works.md +6 -4
- package/guides/safety-and-trust.md +4 -1
- package/package.json +19 -19
- package/templates/node/AGENTS.md +13 -12
- package/templates/node/README.md +9 -26
- package/templates/node/package.json +1 -1
- package/templates/node/src/connecta.config.ts +1 -0
- package/dist/auth/oauth-v2-migration.js +0 -135
- package/dist/provider-call-facts.d.ts +0 -115
- package/dist/provider-call.d.ts +0 -112
- package/dist/storage/d1-copy.js +0 -255
- package/dist/storage/sql-schema.d.ts +0 -6
- package/examples/worker/kv-to-d1.wrangler.jsonc +0 -26
- package/examples/worker/scripts/copy-kv-to-d1.mjs +0 -162
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import { UnauthorizedError } from "@modelcontextprotocol/client";
|
|
2
|
+
import { Effect, Exit } from "effect";
|
|
3
|
+
import { oauthPartitionFor, retainOAuthPartition } from "../../oauth-partition.js";
|
|
4
|
+
import { fromSignal, runEdge } from "../../runtime/run.js";
|
|
5
|
+
import { Settlement } from "../../runtime/settlement.js";
|
|
6
|
+
import { authCall, authCatch, authDetached, authFlow, authInvoke, authMethod, authProgram, authThen, registerAuth, authMethods, } from "../native.js";
|
|
7
|
+
import { isOAuthCredentialRequest, isRefreshTokenRequest, refreshResponseOutcome, sdkSafeTokenResponse, tokenGrantType, } from "../oauth-token-response.js";
|
|
8
|
+
import { oauthStateDigest } from "./consent.js";
|
|
9
|
+
const REFRESH_REQUEST_DEADLINE_MS = 20_000;
|
|
10
|
+
const REFRESH_COMMIT_DEADLINE_MS = 20_000;
|
|
11
|
+
const REFRESH_WAIT_DEADLINE_MS = 35_000;
|
|
12
|
+
/** The access token an early renewal replaces, carried on its token request's init. */
|
|
13
|
+
const EARLY_RENEWAL = Symbol("connecta.oauth.earlyRenewal");
|
|
14
|
+
/**
|
|
15
|
+
* The token endpoint as an early renewal of `due` reaches it: the coordinator
|
|
16
|
+
* hands back a token another request already rotated in instead of redeeming
|
|
17
|
+
* the refresh token again (`OAuthRefreshCoordinator.refresh`).
|
|
18
|
+
*/
|
|
19
|
+
export function earlyRenewalFetch(send, due) {
|
|
20
|
+
return (input, init) => send(input, { ...init, [EARLY_RENEWAL]: due });
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Access tokens one request's early renewals replaced or produced, so a
|
|
24
|
+
* check later in the request does not renew a produced token again while it
|
|
25
|
+
* is within its stored expiry. One already expired when produced (a zero
|
|
26
|
+
* lifetime) is spared the next check only. A replaced token stands for its
|
|
27
|
+
* replacement; any other token, another isolate's rotation included, is
|
|
28
|
+
* checked as usual.
|
|
29
|
+
*/
|
|
30
|
+
export class EarlyRenewals {
|
|
31
|
+
/** A replaced token's replacement, or a produced token's expiry (0: the next check only). */
|
|
32
|
+
scopes = new WeakMap();
|
|
33
|
+
record(scope, due, fresh, expiresAt) {
|
|
34
|
+
let tokens = this.scopes.get(scope);
|
|
35
|
+
if (!tokens)
|
|
36
|
+
this.scopes.set(scope, (tokens = new Map()));
|
|
37
|
+
if (fresh !== due)
|
|
38
|
+
tokens.set(due, fresh);
|
|
39
|
+
tokens.set(fresh, expiresAt !== undefined && expiresAt > Date.now() ? expiresAt : 0);
|
|
40
|
+
}
|
|
41
|
+
/** Whether `due` needs no renewal now; spends a next-check-only exemption. */
|
|
42
|
+
settled(scope, due) {
|
|
43
|
+
const tokens = this.scopes.get(scope);
|
|
44
|
+
const replacement = tokens?.get(due);
|
|
45
|
+
const token = typeof replacement === "string" ? replacement : due;
|
|
46
|
+
const until = tokens?.get(token);
|
|
47
|
+
if (until === 0)
|
|
48
|
+
tokens.delete(token);
|
|
49
|
+
return until === 0 || (typeof until === "number" && Date.now() < until);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function earlyRenewalOf(init) {
|
|
53
|
+
const due = init?.[EARLY_RENEWAL];
|
|
54
|
+
return typeof due === "string" ? due : undefined;
|
|
55
|
+
}
|
|
56
|
+
export function refreshWait(signal, timeoutMs = REFRESH_WAIT_DEADLINE_MS) {
|
|
57
|
+
const deadline = new AbortController();
|
|
58
|
+
let timer = setTimeout(() => deadline.abort(), timeoutMs);
|
|
59
|
+
let closed = false;
|
|
60
|
+
let transferred = false;
|
|
61
|
+
let requestStarted = false;
|
|
62
|
+
const abort = () => deadline.abort(aborted(signal));
|
|
63
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
64
|
+
if (signal?.aborted)
|
|
65
|
+
abort();
|
|
66
|
+
return {
|
|
67
|
+
signal: deadline.signal,
|
|
68
|
+
get requestStarted() {
|
|
69
|
+
return requestStarted;
|
|
70
|
+
},
|
|
71
|
+
run: authMethod((operation) => {
|
|
72
|
+
return authFlow(function* () {
|
|
73
|
+
// A race's losing operation may still start. Check before constructing it,
|
|
74
|
+
// then again before handing a late result back to preparation.
|
|
75
|
+
if (deadline.signal.aborted)
|
|
76
|
+
return (yield Effect.fail(aborted(deadline.signal)));
|
|
77
|
+
return (yield authThen(Effect.raceAllFirst([fromSignal(deadline.signal), authCall(operation, [])]), (value) => {
|
|
78
|
+
if (deadline.signal.aborted)
|
|
79
|
+
throw aborted(deadline.signal);
|
|
80
|
+
return value;
|
|
81
|
+
}));
|
|
82
|
+
});
|
|
83
|
+
}),
|
|
84
|
+
transfer: () => {
|
|
85
|
+
if (deadline.signal.aborted)
|
|
86
|
+
throw aborted(deadline.signal);
|
|
87
|
+
if (transferred)
|
|
88
|
+
return;
|
|
89
|
+
// Synchronous with CAS submission: cancellation can prevent the write
|
|
90
|
+
// only before this point. Keep the independent preparation timer alive.
|
|
91
|
+
signal?.removeEventListener("abort", abort);
|
|
92
|
+
transferred = true;
|
|
93
|
+
},
|
|
94
|
+
beginRequest: () => {
|
|
95
|
+
if (deadline.signal.aborted)
|
|
96
|
+
throw aborted(deadline.signal);
|
|
97
|
+
requestStarted = true;
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
timer = setTimeout(() => {
|
|
100
|
+
deadline.abort(new DOMException("OAuth refresh timed out", "TimeoutError"));
|
|
101
|
+
}, REFRESH_REQUEST_DEADLINE_MS);
|
|
102
|
+
},
|
|
103
|
+
close: () => {
|
|
104
|
+
if (closed)
|
|
105
|
+
return;
|
|
106
|
+
closed = true;
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
signal?.removeEventListener("abort", abort);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export function aborted(signal) {
|
|
113
|
+
return signal?.reason ?? new DOMException("This operation was aborted", "AbortError");
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The outcome of a flight another request owns, failures included. Rejects
|
|
117
|
+
* only with this caller's own abort.
|
|
118
|
+
*
|
|
119
|
+
* The owner settles the flight from its own request. Settlement wakes this
|
|
120
|
+
* waiter through a Promise this request created, so the wait — and the
|
|
121
|
+
* caller's storage reads after it — resume here, never inside the owner's
|
|
122
|
+
* call (src/runtime/settlement.ts).
|
|
123
|
+
*/
|
|
124
|
+
function waitForRefreshFlight(flight, signal, deadline) {
|
|
125
|
+
const settled = flight.outcome.wait();
|
|
126
|
+
// The abort goes first, so a caller that has already left never joins.
|
|
127
|
+
return runEdge(Effect.raceAllFirst([
|
|
128
|
+
...(signal ? [fromSignal(signal)] : []),
|
|
129
|
+
...(deadline ? [fromSignal(deadline)] : []),
|
|
130
|
+
settled,
|
|
131
|
+
]));
|
|
132
|
+
}
|
|
133
|
+
/** Tokens as a token response the SDK parses: only the client stamps an issuer. */
|
|
134
|
+
export function tokenResponse(tokens) {
|
|
135
|
+
const { issuer: _issuer, ...answer } = tokens;
|
|
136
|
+
return Response.json(answer);
|
|
137
|
+
}
|
|
138
|
+
export function refreshTokensDigest(tokens) {
|
|
139
|
+
return runEdge(authProgram(refreshTokensDigestNative, [tokens]));
|
|
140
|
+
}
|
|
141
|
+
const refreshTokensDigestNative = /* @__PURE__ */ registerAuth(refreshTokensDigest, function (tokens) {
|
|
142
|
+
return authFlow(function* () {
|
|
143
|
+
const { issuer: _issuer, ...answer } = tokens;
|
|
144
|
+
return (yield authCall(oauthStateDigest, [
|
|
145
|
+
JSON.stringify(Object.fromEntries(Object.entries(answer).sort(([a], [b]) => a.localeCompare(b)))),
|
|
146
|
+
]));
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
function requestedRefreshToken(init) {
|
|
150
|
+
return init?.body instanceof URLSearchParams
|
|
151
|
+
? init.body.get("refresh_token")
|
|
152
|
+
: new URLSearchParams(String(init?.body ?? "")).get("refresh_token");
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The token endpoint as one connector runtime's OAuth flows reach it.
|
|
156
|
+
*
|
|
157
|
+
* A refresh generation is redeemed once per owner partition: the first request
|
|
158
|
+
* owns the fetch, classifies the answer, and stores an accepted rotation, by
|
|
159
|
+
* compare-and-set, before anyone is released, so a cancelled owner or an SDK
|
|
160
|
+
* that redirects instead of saving cannot strand a spent refresh token.
|
|
161
|
+
* Requests that arrive meanwhile join its outcome; a request whose token read
|
|
162
|
+
* predates a stored refresh is handed that refresh instead of redeeming again.
|
|
163
|
+
* A shared-storage lease prevents concurrent redemption across isolates;
|
|
164
|
+
* the grant CAS also fences stale rotations.
|
|
165
|
+
*
|
|
166
|
+
* A code exchange claims its consent's flow record and re-checks the epoch in
|
|
167
|
+
* the turn that dispatches it (`KvOAuthProvider.claimCodeExchange`,
|
|
168
|
+
* `dispatchCodeExchange`). Every token answer the SDK parses is rebuilt from
|
|
169
|
+
* the OAuth `error` code alone unless it is a token response.
|
|
170
|
+
*/
|
|
171
|
+
export class OAuthRefreshCoordinator {
|
|
172
|
+
retainPartition;
|
|
173
|
+
flights = new Map();
|
|
174
|
+
constructor(retainPartition = () => () => { }) {
|
|
175
|
+
this.retainPartition = retainPartition;
|
|
176
|
+
}
|
|
177
|
+
coordinatedFetch(provider, baseFetch, requestSignal, defer, resourceFetch = baseFetch, prepareCredentialRequest) {
|
|
178
|
+
return authMethod((input, init) => {
|
|
179
|
+
return authFlow(function* () {
|
|
180
|
+
// Freeze the form before any await: classification and the spent digest
|
|
181
|
+
// must describe the exact token sent, even if its caller mutates a form.
|
|
182
|
+
const early = earlyRenewalOf(init);
|
|
183
|
+
init = init
|
|
184
|
+
? { ...init, ...(init.body instanceof URLSearchParams ? { body: new URLSearchParams(init.body) } : {}) }
|
|
185
|
+
: undefined;
|
|
186
|
+
if (early !== undefined)
|
|
187
|
+
delete init[EARLY_RENEWAL];
|
|
188
|
+
// Production adapters resolve the required host policy before a code or
|
|
189
|
+
// refresh claim can consume its single-use credential. Custom SDK leaves
|
|
190
|
+
// may supply their own policy; this hook performs no I/O and owns no data.
|
|
191
|
+
if (isOAuthCredentialRequest(init))
|
|
192
|
+
prepareCredentialRequest?.();
|
|
193
|
+
if (isRefreshTokenRequest(init)) {
|
|
194
|
+
const signal = requestSignal && init?.signal
|
|
195
|
+
? AbortSignal.any([requestSignal, init.signal])
|
|
196
|
+
: (requestSignal ?? init?.signal ?? undefined);
|
|
197
|
+
return (yield authInvoke(this, "refresh", [
|
|
198
|
+
provider,
|
|
199
|
+
input,
|
|
200
|
+
init,
|
|
201
|
+
baseFetch,
|
|
202
|
+
signal,
|
|
203
|
+
defer,
|
|
204
|
+
early,
|
|
205
|
+
]));
|
|
206
|
+
}
|
|
207
|
+
const grantType = tokenGrantType(init);
|
|
208
|
+
const tokenRequest = isOAuthCredentialRequest(init);
|
|
209
|
+
const exchange = grantType === "authorization_code";
|
|
210
|
+
if (exchange) {
|
|
211
|
+
const answered = (yield authInvoke(provider, "claimCodeExchange", []));
|
|
212
|
+
if (answered)
|
|
213
|
+
return answered;
|
|
214
|
+
}
|
|
215
|
+
const dispatch = authMethod(() => {
|
|
216
|
+
return authFlow(function* () {
|
|
217
|
+
if (requestSignal?.aborted)
|
|
218
|
+
throw aborted(requestSignal);
|
|
219
|
+
const signal = requestSignal
|
|
220
|
+
? init?.signal
|
|
221
|
+
? AbortSignal.any([requestSignal, init.signal])
|
|
222
|
+
: requestSignal
|
|
223
|
+
: init?.signal;
|
|
224
|
+
// Credential-bearing OAuth requests use the raw send path. A redirect
|
|
225
|
+
// is a dispatched failure, never authority to send the form again.
|
|
226
|
+
// Its tokens' lifetime starts no later than this send.
|
|
227
|
+
if (grantType !== undefined)
|
|
228
|
+
provider.tokenRequestSent = Date.now();
|
|
229
|
+
return (yield tokenRequest
|
|
230
|
+
? authCall(baseFetch, [input, { ...init, redirect: "manual", ...(signal ? { signal } : {}) }])
|
|
231
|
+
: authCall(resourceFetch, [input, { ...init, ...(signal ? { signal } : {}) }]));
|
|
232
|
+
}.bind(this));
|
|
233
|
+
});
|
|
234
|
+
// Await the composed fetch here so workerd associates a rejection with
|
|
235
|
+
// the fetch the SDK is already awaiting.
|
|
236
|
+
const response = (yield exchange
|
|
237
|
+
? authInvoke(provider, "dispatchCodeExchange", [dispatch, input])
|
|
238
|
+
: authCall(dispatch, []));
|
|
239
|
+
if (!tokenRequest)
|
|
240
|
+
return response;
|
|
241
|
+
// A code exchange's failure is the SDK's to log as well.
|
|
242
|
+
const forSdk = (yield authCall(sdkSafeTokenResponse, [
|
|
243
|
+
response,
|
|
244
|
+
exchange
|
|
245
|
+
? authMethod((tokens) => {
|
|
246
|
+
return authInvoke(provider, "recordCodeExchangeTokens", [tokens]);
|
|
247
|
+
})
|
|
248
|
+
: undefined,
|
|
249
|
+
]));
|
|
250
|
+
if (exchange && forSdk !== response)
|
|
251
|
+
provider.recordCodeExchangeRefusal(forSdk);
|
|
252
|
+
return forSdk;
|
|
253
|
+
}.bind(this));
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
/** A reset fenced `epoch`: wake its joiners now rather than at the answer. */
|
|
257
|
+
retire(epoch) {
|
|
258
|
+
const flight = this.flights.get(epoch);
|
|
259
|
+
if (!flight)
|
|
260
|
+
return;
|
|
261
|
+
this.flights.delete(epoch);
|
|
262
|
+
flight.outcome.settle(Exit.succeed({ status: "retired" }));
|
|
263
|
+
}
|
|
264
|
+
static {
|
|
265
|
+
authMethods(OAuthRefreshCoordinator.prototype, {
|
|
266
|
+
refresh: function (provider, input, init, baseFetch, signal, defer, early) {
|
|
267
|
+
return authFlow(function* () {
|
|
268
|
+
if (signal?.aborted)
|
|
269
|
+
throw aborted(signal);
|
|
270
|
+
const waiting = refreshWait(signal);
|
|
271
|
+
let redeeming = false;
|
|
272
|
+
try {
|
|
273
|
+
// A verdict belongs to one refresh; never let an older one decide this.
|
|
274
|
+
provider.recordRefreshFailure(undefined);
|
|
275
|
+
const epoch = (yield authInvoke(waiting, "run", [
|
|
276
|
+
authMethod(() => {
|
|
277
|
+
return authInvoke(provider, "flowEpoch", []);
|
|
278
|
+
}),
|
|
279
|
+
]));
|
|
280
|
+
const requested = requestedRefreshToken(init);
|
|
281
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
282
|
+
if (signal?.aborted)
|
|
283
|
+
throw aborted(signal);
|
|
284
|
+
const joined = this.flights.get(epoch);
|
|
285
|
+
if (joined) {
|
|
286
|
+
const outcome = (yield authCall(waitForRefreshFlight, [
|
|
287
|
+
joined,
|
|
288
|
+
signal,
|
|
289
|
+
waiting.signal,
|
|
290
|
+
]));
|
|
291
|
+
if (signal?.aborted)
|
|
292
|
+
throw aborted(signal);
|
|
293
|
+
if (outcome.status === "failed") {
|
|
294
|
+
provider.recordRefreshFailure(outcome.verdict);
|
|
295
|
+
throw outcome.error;
|
|
296
|
+
}
|
|
297
|
+
if (outcome.status === "refreshed")
|
|
298
|
+
return provider.adoptRefresh(outcome.tokens);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
// Preparation reads share the same bound as later claim waits.
|
|
302
|
+
const current = (yield authInvoke(waiting, "run", [
|
|
303
|
+
authMethod(() => {
|
|
304
|
+
return authInvoke(provider, "storedTokens", [waiting.signal]);
|
|
305
|
+
}),
|
|
306
|
+
]));
|
|
307
|
+
if (this.flights.has(epoch))
|
|
308
|
+
continue;
|
|
309
|
+
if (current?.refresh_token &&
|
|
310
|
+
(current.refresh_token !== requested ||
|
|
311
|
+
provider.refreshedSinceRead(current, epoch) ||
|
|
312
|
+
// An early renewal is answered by any token that replaced the
|
|
313
|
+
// one it found near expiry, whoever refreshed it.
|
|
314
|
+
(early !== undefined && current.access_token !== early))) {
|
|
315
|
+
return provider.adoptRefresh(current);
|
|
316
|
+
}
|
|
317
|
+
if (current?.refresh_token !== requested) {
|
|
318
|
+
provider.recordRefreshFailure({ kind: "dead" });
|
|
319
|
+
return Response.json({ error: "invalid_grant", error_description: "Refresh token is no longer active." }, { status: 400 });
|
|
320
|
+
}
|
|
321
|
+
redeeming = true;
|
|
322
|
+
// Claim submission transfers renewal, including preparation, token
|
|
323
|
+
// request and commit, to a bounded native coordinator. Only
|
|
324
|
+
// this caller's wait races its signal; the coordinator receives
|
|
325
|
+
// no request/call Context authority or caller-owned deadline.
|
|
326
|
+
const redemption = authDetached(authInvoke(this, "redeem", [
|
|
327
|
+
provider,
|
|
328
|
+
epoch,
|
|
329
|
+
requested,
|
|
330
|
+
input,
|
|
331
|
+
init,
|
|
332
|
+
baseFetch,
|
|
333
|
+
waiting,
|
|
334
|
+
]).pipe(Effect.ensuring(Effect.sync(() => waiting.close()))), defer);
|
|
335
|
+
return (yield signal ? Effect.raceFirst(redemption, fromSignal(signal)) : redemption);
|
|
336
|
+
}
|
|
337
|
+
throw provider.refreshContended();
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
if (signal?.aborted)
|
|
341
|
+
throw aborted(signal);
|
|
342
|
+
if (waiting.signal.aborted && !waiting.requestStarted)
|
|
343
|
+
throw provider.refreshContended();
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
if (!redeeming)
|
|
348
|
+
waiting.close();
|
|
349
|
+
}
|
|
350
|
+
}.bind(this));
|
|
351
|
+
},
|
|
352
|
+
redeem: function (provider, epoch, requested, input, init, baseFetch, waiting) {
|
|
353
|
+
return authFlow(function* () {
|
|
354
|
+
const flight = { outcome: new Settlement() };
|
|
355
|
+
this.flights.set(epoch, flight);
|
|
356
|
+
// Pin the owner partition until this coordinator exits, whoever is waiting.
|
|
357
|
+
const release = this.retainPartition();
|
|
358
|
+
let settled = false;
|
|
359
|
+
const settle = (outcome) => {
|
|
360
|
+
if (settled)
|
|
361
|
+
return;
|
|
362
|
+
settled = true;
|
|
363
|
+
if (this.flights.get(epoch) === flight)
|
|
364
|
+
this.flights.delete(epoch);
|
|
365
|
+
release();
|
|
366
|
+
// Last: joined waiters wake in their own requests, after this.
|
|
367
|
+
flight.outcome.settle(Exit.succeed(outcome));
|
|
368
|
+
};
|
|
369
|
+
const fail = (error, verdict) => {
|
|
370
|
+
if (verdict)
|
|
371
|
+
provider.recordRefreshFailure(verdict);
|
|
372
|
+
settle({ status: "failed", error, ...(verdict ? { verdict } : {}) });
|
|
373
|
+
};
|
|
374
|
+
let dispatched = false;
|
|
375
|
+
/** When the token request left: the earliest its tokens' lifetime can start. */
|
|
376
|
+
let issuedAt = 0;
|
|
377
|
+
const unknownOutcome = () => new UnauthorizedError("OAuth refresh outcome is unknown; authorization required.");
|
|
378
|
+
// One budget for the entire commit/cleanup phase, including retries
|
|
379
|
+
// and lease release. Interrupt native continuations, never detach a
|
|
380
|
+
// Promise whose late read could still start a credential write.
|
|
381
|
+
const complete = authMethod((operation) => {
|
|
382
|
+
const completion = refreshWait(undefined, REFRESH_COMMIT_DEADLINE_MS);
|
|
383
|
+
return authInvoke(completion, "run", [operation]).pipe(Effect.catch((error) => Effect.fail(completion.signal.aborted ? unknownOutcome() : error)), Effect.ensuring(Effect.sync(() => completion.close())));
|
|
384
|
+
});
|
|
385
|
+
try {
|
|
386
|
+
// The authorization server this redemption answers to, fixed now.
|
|
387
|
+
const issuer = provider.refreshIssuer(epoch);
|
|
388
|
+
let lease;
|
|
389
|
+
try {
|
|
390
|
+
const claim = (yield authInvoke(provider, "claimRefresh", [epoch, requested, undefined, waiting]));
|
|
391
|
+
if (claim instanceof Response) {
|
|
392
|
+
const tokens = (yield authInvoke(waiting, "run", [
|
|
393
|
+
authMethod(() => {
|
|
394
|
+
return authInvoke(provider, "storedTokens", [waiting.signal]);
|
|
395
|
+
}),
|
|
396
|
+
]));
|
|
397
|
+
if (tokens)
|
|
398
|
+
settle({ status: "refreshed", tokens });
|
|
399
|
+
else
|
|
400
|
+
settle({ status: "retired" });
|
|
401
|
+
return claim;
|
|
402
|
+
}
|
|
403
|
+
lease = claim;
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
fail(error, provider.refreshVerdict());
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
const releaseLease = authMethod((verdict, resolution = "ambiguous") => {
|
|
410
|
+
return authFlow(function* () {
|
|
411
|
+
(yield authInvoke(provider, "releaseRefresh", [lease, verdict, undefined, resolution]));
|
|
412
|
+
}.bind(this));
|
|
413
|
+
});
|
|
414
|
+
let outcome;
|
|
415
|
+
const deadline = waiting.signal;
|
|
416
|
+
try {
|
|
417
|
+
const read = authFlow(function* () {
|
|
418
|
+
const response = (yield authInvoke(provider, "dispatchRefresh", [
|
|
419
|
+
lease,
|
|
420
|
+
authMethod(() => authFlow(function* () {
|
|
421
|
+
waiting.beginRequest();
|
|
422
|
+
dispatched = true;
|
|
423
|
+
issuedAt = Date.now();
|
|
424
|
+
return (yield authCall(baseFetch, [
|
|
425
|
+
input,
|
|
426
|
+
{ ...init, redirect: "manual", signal: deadline },
|
|
427
|
+
]));
|
|
428
|
+
}.bind(this))),
|
|
429
|
+
waiting,
|
|
430
|
+
]));
|
|
431
|
+
if (deadline.aborted) {
|
|
432
|
+
void response.body?.cancel().catch(() => { });
|
|
433
|
+
throw aborted(deadline);
|
|
434
|
+
}
|
|
435
|
+
return (yield authCall(refreshResponseOutcome, [response, deadline]));
|
|
436
|
+
}.bind(this));
|
|
437
|
+
outcome = (yield Effect.raceFirst(read, fromSignal(deadline)));
|
|
438
|
+
if (deadline.aborted)
|
|
439
|
+
throw aborted(deadline);
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
if (!dispatched) {
|
|
443
|
+
// Only the coordinator's own preparation deadline can interrupt a
|
|
444
|
+
// submitted claim. Caller cancellation no longer reaches this work.
|
|
445
|
+
if (!waiting.signal.aborted) {
|
|
446
|
+
(yield authCatch(authInvoke(waiting, "run", [
|
|
447
|
+
authMethod(() => {
|
|
448
|
+
return authCall(releaseLease, []);
|
|
449
|
+
}),
|
|
450
|
+
]), () => { }));
|
|
451
|
+
if (provider.refreshVerdict()?.kind === "dead") {
|
|
452
|
+
(yield authCatch(authInvoke(waiting, "run", [
|
|
453
|
+
authMethod(() => {
|
|
454
|
+
return authInvoke(provider, "discardRefusedGrant", [requested, epoch]);
|
|
455
|
+
}),
|
|
456
|
+
]), () => { }));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const failure = waiting.signal.aborted ? provider.refreshContended() : error;
|
|
460
|
+
fail(failure, provider.refreshVerdict());
|
|
461
|
+
throw failure;
|
|
462
|
+
}
|
|
463
|
+
// A sent request without a definitive answer may have rotated the token.
|
|
464
|
+
// Persist the refusal before dropping tokens, so interrupted cleanup or a
|
|
465
|
+
// later write of the same fingerprint cannot make it redeemable again.
|
|
466
|
+
const failure = new UnauthorizedError("OAuth refresh outcome is unknown; authorization required.");
|
|
467
|
+
(yield authCall(complete, [
|
|
468
|
+
authMethod(() => authFlow(function* () {
|
|
469
|
+
(yield authCall(releaseLease, [{ kind: "dead" }]));
|
|
470
|
+
(yield authInvoke(provider, "discardRefusedGrant", [requested, epoch]));
|
|
471
|
+
})),
|
|
472
|
+
]));
|
|
473
|
+
fail(failure, { kind: "dead" });
|
|
474
|
+
throw failure;
|
|
475
|
+
}
|
|
476
|
+
finally {
|
|
477
|
+
waiting.close();
|
|
478
|
+
}
|
|
479
|
+
return (yield authCall(complete, [
|
|
480
|
+
authMethod(() => authFlow(function* () {
|
|
481
|
+
// An answer exists from here on, and is committed whatever the owner does.
|
|
482
|
+
if (outcome.failure) {
|
|
483
|
+
// Drop a refused grant while the flight still stands: a caller arriving
|
|
484
|
+
// meanwhile joins it instead of redeeming the dead token again.
|
|
485
|
+
(yield authInvoke(provider, "discardRefusedGrant", [requested, epoch]));
|
|
486
|
+
(yield authCall(releaseLease, [outcome.verdict, "resolved"]));
|
|
487
|
+
fail(outcome.failure, outcome.verdict);
|
|
488
|
+
return outcome.forSdk;
|
|
489
|
+
}
|
|
490
|
+
// The server consumed the old refresh token; the SDK merges it back the
|
|
491
|
+
// same way when a rotation omits one.
|
|
492
|
+
const accepted = {
|
|
493
|
+
...outcome.tokens,
|
|
494
|
+
...(outcome.tokens.refresh_token === undefined && requested !== null
|
|
495
|
+
? { refresh_token: requested }
|
|
496
|
+
: {}),
|
|
497
|
+
};
|
|
498
|
+
let committed;
|
|
499
|
+
try {
|
|
500
|
+
committed = (yield authInvoke(provider, "storeRefresh", [
|
|
501
|
+
epoch,
|
|
502
|
+
requested,
|
|
503
|
+
accepted,
|
|
504
|
+
issuer,
|
|
505
|
+
issuedAt,
|
|
506
|
+
]));
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
const failure = new UnauthorizedError("OAuth refresh could not be committed; authorization required.");
|
|
510
|
+
(yield authCall(releaseLease, [{ kind: "dead" }, "resolved"]));
|
|
511
|
+
(yield authInvoke(provider, "discardRefusedGrant", [requested, epoch]));
|
|
512
|
+
fail(failure, { kind: "dead" });
|
|
513
|
+
throw failure;
|
|
514
|
+
}
|
|
515
|
+
if (!committed) {
|
|
516
|
+
// Restart, Disconnect, or another consent decides the newer grant.
|
|
517
|
+
(yield authCall(releaseLease, [{ kind: "dead" }, "resolved"]));
|
|
518
|
+
const failure = new UnauthorizedError("OAuth refresh grant is no longer active.");
|
|
519
|
+
fail(failure, { kind: "dead" });
|
|
520
|
+
throw failure;
|
|
521
|
+
}
|
|
522
|
+
(yield authInvoke(provider, "releaseRefresh", [lease, undefined, committed, "resolved"]));
|
|
523
|
+
settle({ status: "refreshed", tokens: committed });
|
|
524
|
+
return provider.adoptRefresh(committed);
|
|
525
|
+
})),
|
|
526
|
+
]));
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
fail(error, dispatched ? { kind: "dead" } : provider.refreshVerdict());
|
|
530
|
+
throw error;
|
|
531
|
+
}
|
|
532
|
+
finally {
|
|
533
|
+
waiting.close();
|
|
534
|
+
// No storage in this finalizer: even interruption or a failed
|
|
535
|
+
// cleanup must drop the local flight and its owner retention.
|
|
536
|
+
if (!settled)
|
|
537
|
+
fail(unknownOutcome(), dispatched ? { kind: "dead" } : provider.refreshVerdict());
|
|
538
|
+
}
|
|
539
|
+
}.bind(this));
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* One refresh coordinator per owner partition, for one connector runtime.
|
|
546
|
+
* Long-lived enough for distinct request scopes to join one token redemption;
|
|
547
|
+
* it owns no client, transport, or request state. Production contexts carry a
|
|
548
|
+
* stable registry partition; hand-written contexts coordinate by their shared
|
|
549
|
+
* storage object instead.
|
|
550
|
+
*/
|
|
551
|
+
export function refreshCoordinatorsByPartition() {
|
|
552
|
+
const coordinators = new WeakMap();
|
|
553
|
+
return (ctx) => {
|
|
554
|
+
const partition = oauthPartitionFor(ctx) ?? ctx.storage;
|
|
555
|
+
let coordinator = coordinators.get(partition);
|
|
556
|
+
if (!coordinator) {
|
|
557
|
+
coordinator = new OAuthRefreshCoordinator(() => retainOAuthPartition(partition));
|
|
558
|
+
coordinators.set(partition, coordinator);
|
|
559
|
+
}
|
|
560
|
+
return coordinator;
|
|
561
|
+
};
|
|
562
|
+
}
|