@zackbart/connecta 0.21.1 → 0.21.2
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 +30 -0
- package/dist/auth/downstream-oauth.d.ts +65 -2
- package/dist/auth/downstream-oauth.js +408 -20
- package/dist/connectors/remote-mcp.js +8 -4
- package/dist/executors/quickjs.js +4 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +17 -0
- package/documentation/auth.md +1 -1
- package/documentation/code-mode.md +2 -2
- package/documentation/operations.md +2 -2
- package/documentation/storage-and-credentials.md +35 -0
- package/documentation/upgrading.md +7 -7
- package/package.json +1 -1
- package/templates/node/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this package are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.21.2 — 2026-08-31
|
|
6
|
+
|
|
7
|
+
This patch closes two runtime isolation gaps: concurrent request scopes now
|
|
8
|
+
share one downstream OAuth refresh inside a connector runtime, and QuickJS
|
|
9
|
+
children no longer inherit the deployment process environment. Existing
|
|
10
|
+
deployments need no configuration or storage migration. OAuth deployments get
|
|
11
|
+
the refresh fix automatically; Node deployments using QuickJS get the tighter
|
|
12
|
+
child boundary automatically. Deployments using neither path can ignore this
|
|
13
|
+
release.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **Empty QuickJS child environments.** The Node process hosting the QuickJS
|
|
18
|
+
guest runtime starts with an explicit empty environment, so deployment
|
|
19
|
+
credentials and Node startup configuration such as `NODE_OPTIONS` never
|
|
20
|
+
cross the child-process boundary (#515).
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **One rotating-token redemption per runtime.** Concurrent request scopes for
|
|
25
|
+
one OAuth connector generation share the owner's refresh result or bounded
|
|
26
|
+
failure instead of independently redeeming the same refresh token. The gate
|
|
27
|
+
remains request-safe across follower and owner cancellation, storage
|
|
28
|
+
failures, force reauthorization, and issuer-generation changes (#514).
|
|
29
|
+
- **Late and byte-identical refresh races.** Generation-scoped mutation and
|
|
30
|
+
success identities close token-save TOCTOU and complete-flight ABA windows,
|
|
31
|
+
including authorization servers that preserve the refresh token or return
|
|
32
|
+
byte-identical credentials. The guarantee is deliberately runtime-local;
|
|
33
|
+
`KVStorage` still has no cross-isolate compare-and-set primitive (#514).
|
|
34
|
+
|
|
5
35
|
## 0.21.1 — 2026-08-30
|
|
6
36
|
|
|
7
37
|
This patch aligns the handwritten Cloudflare and Notion contracts and the four
|
|
@@ -1,5 +1,55 @@
|
|
|
1
|
-
import type { OAuthClientInformationContext, OAuthClientInformationMixed, OAuthClientMetadata, OAuthClientProvider, OAuthTokens } from "@modelcontextprotocol/client";
|
|
1
|
+
import type { FetchLike, OAuthClientInformationContext, OAuthClientInformationMixed, OAuthClientMetadata, OAuthClientProvider, OAuthTokens } from "@modelcontextprotocol/client";
|
|
2
2
|
import type { KVStorage } from "../types.js";
|
|
3
|
+
type OAuthRefreshFlightOutcome = {
|
|
4
|
+
status: "refreshed";
|
|
5
|
+
} | {
|
|
6
|
+
status: "retired";
|
|
7
|
+
} | {
|
|
8
|
+
status: "failed";
|
|
9
|
+
error: unknown;
|
|
10
|
+
};
|
|
11
|
+
interface OAuthRefreshFlight {
|
|
12
|
+
done: Promise<OAuthRefreshFlightOutcome>;
|
|
13
|
+
release: (outcome: OAuthRefreshFlightOutcome) => void;
|
|
14
|
+
stopObservingOwnerAbort: () => void;
|
|
15
|
+
mutationId: object;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Share one rotating-token redemption within one connector runtime and OAuth
|
|
19
|
+
* generation. The first request still owns the real fetch and response. Its
|
|
20
|
+
* abort signal has one bounded listener until the exact flight settles, so a
|
|
21
|
+
* cancellation after the response cannot strand waiters during token storage.
|
|
22
|
+
* Followers wait for that provider to save tokens, then re-read storage. The
|
|
23
|
+
* map never retains a token response or transport.
|
|
24
|
+
*
|
|
25
|
+
* This is intentionally runtime-local. KVStorage has no atomic coordination
|
|
26
|
+
* operation, so a second isolate can still race the same refresh token.
|
|
27
|
+
*/
|
|
28
|
+
export declare class OAuthRefreshCoordinator {
|
|
29
|
+
private readonly flights;
|
|
30
|
+
/** Opaque identities only: no request promise, signal, callback, or response. */
|
|
31
|
+
private readonly pendingMutations;
|
|
32
|
+
/** One bounded latest-success slot, containing only generation + identity. */
|
|
33
|
+
private successfulRefresh;
|
|
34
|
+
/** Replaced on every map mutation, closing flight/pending ABA across awaits. */
|
|
35
|
+
private stateRevision;
|
|
36
|
+
private advanceStateRevision;
|
|
37
|
+
private observeAuthoritativeGeneration;
|
|
38
|
+
private settle;
|
|
39
|
+
private markMutationPending;
|
|
40
|
+
private finishMutation;
|
|
41
|
+
/** @internal Opaque basis for issuer-aware provider token reads. */
|
|
42
|
+
successfulRefreshIdentity(generation: string): object | undefined;
|
|
43
|
+
coordinatedFetch(provider: KvOAuthProvider, baseFetch: FetchLike, requestSignal?: AbortSignal): FetchLike;
|
|
44
|
+
/** Publish one exact owner's successful save without disturbing a newer try. */
|
|
45
|
+
succeedMutation(generation: string, flight: OAuthRefreshFlight): void;
|
|
46
|
+
/** Give joined callers a fetch/flow failure, without rejecting the gate. */
|
|
47
|
+
fail(generation: string, flight: OAuthRefreshFlight, error: unknown): void;
|
|
48
|
+
/** Finish an exact failed credential write, then publish its failure. */
|
|
49
|
+
failMutation(generation: string, flight: OAuthRefreshFlight, error: unknown): void;
|
|
50
|
+
/** Force reauthorization fences and wakes every waiter on the retired epoch. */
|
|
51
|
+
retire(generation: string): void;
|
|
52
|
+
}
|
|
3
53
|
/**
|
|
4
54
|
* Physical key for an OAuth value in one authorization epoch. Legacy values
|
|
5
55
|
* keep their historical names so upgrades can read an existing grant. Modern
|
|
@@ -20,19 +70,31 @@ export declare class KvOAuthProvider implements OAuthClientProvider {
|
|
|
20
70
|
private readonly connectorId;
|
|
21
71
|
private readonly storage;
|
|
22
72
|
private readonly redirectUri;
|
|
73
|
+
private readonly refreshCoordinator?;
|
|
23
74
|
/**
|
|
24
75
|
* The reset generation this provider's flow started under. Every OAuth value
|
|
25
76
|
* it writes carries this epoch, so a late write can land after a reset without
|
|
26
77
|
* becoming readable under the new generation.
|
|
27
78
|
*/
|
|
28
79
|
private capturedGeneration;
|
|
29
|
-
|
|
80
|
+
private refreshFlight;
|
|
81
|
+
/** Tokens this request's issuer-aware auth flow decided to refresh. */
|
|
82
|
+
private refreshBasis;
|
|
83
|
+
constructor(connectorId: string, storage: KVStorage, redirectUri: string, refreshCoordinator?: OAuthRefreshCoordinator | undefined);
|
|
30
84
|
/**
|
|
31
85
|
* Stamp the force-reauth generation the current connect flow started under.
|
|
32
86
|
* Called by the connector once per connect, before c.connect(). The callback
|
|
33
87
|
* path captures the generation stored beside its verified state instead.
|
|
34
88
|
*/
|
|
35
89
|
captureGeneration(gen: string): void;
|
|
90
|
+
/** The generation captured for this flow, before a concurrent reset. */
|
|
91
|
+
flowGeneration(): Promise<string>;
|
|
92
|
+
/** @internal Record the refresh attempt this provider owns. */
|
|
93
|
+
captureRefreshFlight(generation: string, flight: OAuthRefreshFlight): void;
|
|
94
|
+
private succeedRefreshFlight;
|
|
95
|
+
private failRefreshFlight;
|
|
96
|
+
/** True when another request saved a refresh result after this flow's read. */
|
|
97
|
+
refreshBasisChanged(current: OAuthTokens, generation: string): boolean;
|
|
36
98
|
/**
|
|
37
99
|
* The epoch this provider writes under. Direct unit/custom use lazily captures
|
|
38
100
|
* the current generation; connector-driven connect and callback paths stamp it
|
|
@@ -121,3 +183,4 @@ export declare class KvOAuthProvider implements OAuthClientProvider {
|
|
|
121
183
|
resetAuthorization(operatorDisconnected?: boolean): Promise<void>;
|
|
122
184
|
invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise<void>;
|
|
123
185
|
}
|
|
186
|
+
export {};
|
|
@@ -26,6 +26,312 @@ const OAUTH_VALUE_KEYS = [
|
|
|
26
26
|
"oauth:state",
|
|
27
27
|
];
|
|
28
28
|
const MAX_CLEANUP_BACKLOG = 1_000;
|
|
29
|
+
function isRefreshTokenRequest(init) {
|
|
30
|
+
if ((init?.method ?? "GET").toUpperCase() !== "POST")
|
|
31
|
+
return false;
|
|
32
|
+
const body = init?.body;
|
|
33
|
+
if (body instanceof URLSearchParams) {
|
|
34
|
+
return body.get("grant_type") === "refresh_token";
|
|
35
|
+
}
|
|
36
|
+
if (typeof body !== "string")
|
|
37
|
+
return false;
|
|
38
|
+
return new URLSearchParams(body).get("grant_type") === "refresh_token";
|
|
39
|
+
}
|
|
40
|
+
function sdkAcceptsOAuthTokens(value) {
|
|
41
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
42
|
+
return false;
|
|
43
|
+
const candidate = value;
|
|
44
|
+
if (typeof candidate.access_token !== "string" ||
|
|
45
|
+
typeof candidate.token_type !== "string") {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
for (const key of ["id_token", "scope", "refresh_token"]) {
|
|
49
|
+
if (candidate[key] !== undefined && typeof candidate[key] !== "string") {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (candidate.expires_in !== undefined) {
|
|
54
|
+
try {
|
|
55
|
+
if (!Number.isFinite(Number(candidate.expires_in)))
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
async function refreshResponseFailure(response) {
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
return new Error(`OAuth refresh failed with HTTP ${response.status}.`);
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
return sdkAcceptsOAuthTokens(await response.clone().json())
|
|
70
|
+
? undefined
|
|
71
|
+
: new Error("OAuth refresh response did not match the token schema.");
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return new Error("OAuth refresh response did not contain JSON tokens.");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function refreshMutationPendingResponse() {
|
|
78
|
+
return Response.json({
|
|
79
|
+
error: "temporarily_unavailable",
|
|
80
|
+
error_description: "OAuth refresh is temporarily unavailable while previous credentials commit.",
|
|
81
|
+
}, { status: 503 });
|
|
82
|
+
}
|
|
83
|
+
function aborted(signal) {
|
|
84
|
+
return (signal.reason ?? new DOMException("This operation was aborted", "AbortError"));
|
|
85
|
+
}
|
|
86
|
+
async function waitForRefreshFlight(flight, signal) {
|
|
87
|
+
let outcome;
|
|
88
|
+
if (!signal) {
|
|
89
|
+
outcome = await flight.done;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
outcome = await new Promise((resolve, reject) => {
|
|
93
|
+
let settled = false;
|
|
94
|
+
const finish = (settle) => {
|
|
95
|
+
if (settled)
|
|
96
|
+
return;
|
|
97
|
+
settled = true;
|
|
98
|
+
signal.removeEventListener("abort", onAbort);
|
|
99
|
+
settle();
|
|
100
|
+
};
|
|
101
|
+
const onAbort = () => finish(() => reject(aborted(signal)));
|
|
102
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
103
|
+
void flight.done.then((result) => finish(() => resolve(result)));
|
|
104
|
+
// Abort may land between the caller's check and listener registration.
|
|
105
|
+
if (signal.aborted)
|
|
106
|
+
onAbort();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (outcome.status === "failed")
|
|
110
|
+
throw outcome.error;
|
|
111
|
+
return outcome;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Share one rotating-token redemption within one connector runtime and OAuth
|
|
115
|
+
* generation. The first request still owns the real fetch and response. Its
|
|
116
|
+
* abort signal has one bounded listener until the exact flight settles, so a
|
|
117
|
+
* cancellation after the response cannot strand waiters during token storage.
|
|
118
|
+
* Followers wait for that provider to save tokens, then re-read storage. The
|
|
119
|
+
* map never retains a token response or transport.
|
|
120
|
+
*
|
|
121
|
+
* This is intentionally runtime-local. KVStorage has no atomic coordination
|
|
122
|
+
* operation, so a second isolate can still race the same refresh token.
|
|
123
|
+
*/
|
|
124
|
+
export class OAuthRefreshCoordinator {
|
|
125
|
+
flights = new Map();
|
|
126
|
+
/** Opaque identities only: no request promise, signal, callback, or response. */
|
|
127
|
+
pendingMutations = new Map();
|
|
128
|
+
/** One bounded latest-success slot, containing only generation + identity. */
|
|
129
|
+
successfulRefresh;
|
|
130
|
+
/** Replaced on every map mutation, closing flight/pending ABA across awaits. */
|
|
131
|
+
stateRevision = {};
|
|
132
|
+
advanceStateRevision() {
|
|
133
|
+
this.stateRevision = {};
|
|
134
|
+
}
|
|
135
|
+
observeAuthoritativeGeneration(generation) {
|
|
136
|
+
const staleGenerations = new Set([
|
|
137
|
+
...this.flights.keys(),
|
|
138
|
+
...this.pendingMutations.keys(),
|
|
139
|
+
]);
|
|
140
|
+
for (const stale of staleGenerations) {
|
|
141
|
+
if (stale !== generation)
|
|
142
|
+
this.retire(stale);
|
|
143
|
+
}
|
|
144
|
+
if (this.successfulRefresh &&
|
|
145
|
+
this.successfulRefresh.generation !== generation) {
|
|
146
|
+
this.successfulRefresh = undefined;
|
|
147
|
+
this.advanceStateRevision();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
settle(generation, flight, outcome) {
|
|
151
|
+
if (this.flights.get(generation) !== flight)
|
|
152
|
+
return;
|
|
153
|
+
this.flights.delete(generation);
|
|
154
|
+
this.advanceStateRevision();
|
|
155
|
+
flight.stopObservingOwnerAbort();
|
|
156
|
+
flight.release(outcome);
|
|
157
|
+
}
|
|
158
|
+
markMutationPending(generation, flight) {
|
|
159
|
+
if (this.flights.get(generation) !== flight)
|
|
160
|
+
return false;
|
|
161
|
+
this.pendingMutations.set(generation, flight.mutationId);
|
|
162
|
+
this.advanceStateRevision();
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
finishMutation(generation, flight) {
|
|
166
|
+
if (this.pendingMutations.get(generation) === flight.mutationId) {
|
|
167
|
+
this.pendingMutations.delete(generation);
|
|
168
|
+
this.advanceStateRevision();
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
/** @internal Opaque basis for issuer-aware provider token reads. */
|
|
174
|
+
successfulRefreshIdentity(generation) {
|
|
175
|
+
return this.successfulRefresh?.generation === generation
|
|
176
|
+
? this.successfulRefresh.identity
|
|
177
|
+
: undefined;
|
|
178
|
+
}
|
|
179
|
+
coordinatedFetch(provider, baseFetch, requestSignal) {
|
|
180
|
+
return async (input, init) => {
|
|
181
|
+
// Await passthrough failures here so workerd associates the rejection
|
|
182
|
+
// with the fetch the SDK is already awaiting, rather than reporting the
|
|
183
|
+
// adopted inner promise as an unhandled rejection.
|
|
184
|
+
if (!isRefreshTokenRequest(init))
|
|
185
|
+
return await baseFetch(input, init);
|
|
186
|
+
const generation = await provider.flowGeneration();
|
|
187
|
+
const requestedRefreshToken = init?.body instanceof URLSearchParams
|
|
188
|
+
? init.body.get("refresh_token")
|
|
189
|
+
: new URLSearchParams(String(init?.body ?? "")).get("refresh_token");
|
|
190
|
+
while (true) {
|
|
191
|
+
const revisionBeforeReads = this.stateRevision;
|
|
192
|
+
const activeGeneration = await provider.generation();
|
|
193
|
+
this.observeAuthoritativeGeneration(activeGeneration);
|
|
194
|
+
if (activeGeneration !== generation)
|
|
195
|
+
this.retire(generation);
|
|
196
|
+
const activeFlight = this.flights.get(generation);
|
|
197
|
+
const pendingMutation = this.pendingMutations.get(generation);
|
|
198
|
+
if (activeGeneration === generation &&
|
|
199
|
+
pendingMutation &&
|
|
200
|
+
activeFlight?.mutationId !== pendingMutation) {
|
|
201
|
+
return refreshMutationPendingResponse();
|
|
202
|
+
}
|
|
203
|
+
let currentTokens = activeGeneration === generation ? await provider.tokens() : undefined;
|
|
204
|
+
const latestGeneration = await provider.generation();
|
|
205
|
+
this.observeAuthoritativeGeneration(latestGeneration);
|
|
206
|
+
if (latestGeneration !== generation) {
|
|
207
|
+
this.retire(generation);
|
|
208
|
+
currentTokens = undefined;
|
|
209
|
+
}
|
|
210
|
+
if (this.stateRevision !== revisionBeforeReads)
|
|
211
|
+
continue;
|
|
212
|
+
// Re-check both identities after every storage await. An owner can
|
|
213
|
+
// abort while this caller reads tokens, leaving only the mutation
|
|
214
|
+
// marker; a reset can likewise retire this caller's captured epoch.
|
|
215
|
+
const existing = this.flights.get(generation);
|
|
216
|
+
const latestPendingMutation = this.pendingMutations.get(generation);
|
|
217
|
+
if (latestGeneration === generation &&
|
|
218
|
+
latestPendingMutation &&
|
|
219
|
+
existing?.mutationId !== latestPendingMutation) {
|
|
220
|
+
return refreshMutationPendingResponse();
|
|
221
|
+
}
|
|
222
|
+
// This flow may have read a token just before another flow saved its
|
|
223
|
+
// rotation. Replaying the retired token would recreate the race after
|
|
224
|
+
// the first network response. Give the SDK the already-saved rotating
|
|
225
|
+
// credential instead. A tokenless current value cannot do that: the
|
|
226
|
+
// SDK would merge the requested old refresh token back into it.
|
|
227
|
+
if (currentTokens?.refresh_token &&
|
|
228
|
+
(currentTokens.refresh_token !== requestedRefreshToken ||
|
|
229
|
+
provider.refreshBasisChanged(currentTokens, generation))) {
|
|
230
|
+
return Response.json(currentTokens);
|
|
231
|
+
}
|
|
232
|
+
if (currentTokens?.refresh_token !== requestedRefreshToken) {
|
|
233
|
+
return Response.json({
|
|
234
|
+
error: "invalid_grant",
|
|
235
|
+
error_description: "Refresh token is no longer active.",
|
|
236
|
+
}, { status: 400 });
|
|
237
|
+
}
|
|
238
|
+
if (existing) {
|
|
239
|
+
const outcome = await waitForRefreshFlight(existing, requestSignal);
|
|
240
|
+
if (outcome.status === "refreshed") {
|
|
241
|
+
const activeGeneration = await provider.generation();
|
|
242
|
+
const refreshedTokens = activeGeneration === generation
|
|
243
|
+
? await provider.tokens()
|
|
244
|
+
: undefined;
|
|
245
|
+
if (refreshedTokens?.refresh_token) {
|
|
246
|
+
return Response.json(refreshedTokens);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
let release;
|
|
252
|
+
const flight = {
|
|
253
|
+
done: new Promise((resolve) => {
|
|
254
|
+
release = resolve;
|
|
255
|
+
}),
|
|
256
|
+
release: (outcome) => release(outcome),
|
|
257
|
+
stopObservingOwnerAbort: () => { },
|
|
258
|
+
mutationId: {},
|
|
259
|
+
};
|
|
260
|
+
this.flights.set(generation, flight);
|
|
261
|
+
this.advanceStateRevision();
|
|
262
|
+
provider.captureRefreshFlight(generation, flight);
|
|
263
|
+
if (requestSignal) {
|
|
264
|
+
let observing = true;
|
|
265
|
+
const onOwnerAbort = () => {
|
|
266
|
+
this.fail(generation, flight, aborted(requestSignal));
|
|
267
|
+
};
|
|
268
|
+
flight.stopObservingOwnerAbort = () => {
|
|
269
|
+
if (!observing)
|
|
270
|
+
return;
|
|
271
|
+
observing = false;
|
|
272
|
+
requestSignal.removeEventListener("abort", onOwnerAbort);
|
|
273
|
+
};
|
|
274
|
+
requestSignal.addEventListener("abort", onOwnerAbort, { once: true });
|
|
275
|
+
// Abort may land between flight publication and listener registration.
|
|
276
|
+
if (requestSignal.aborted)
|
|
277
|
+
onOwnerAbort();
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
const response = await baseFetch(input, requestSignal ? { ...init, signal: requestSignal } : init);
|
|
281
|
+
// These responses never reach a successful saveTokens callback. Give
|
|
282
|
+
// current waiters a bounded failure now while leaving the owner's
|
|
283
|
+
// response untouched for the SDK to parse and classify itself.
|
|
284
|
+
const failure = await refreshResponseFailure(response);
|
|
285
|
+
if (failure) {
|
|
286
|
+
this.fail(generation, flight, failure);
|
|
287
|
+
}
|
|
288
|
+
else if (requestSignal?.aborted ||
|
|
289
|
+
!this.markMutationPending(generation, flight)) {
|
|
290
|
+
throw requestSignal?.aborted
|
|
291
|
+
? aborted(requestSignal)
|
|
292
|
+
: new Error("OAuth refresh ended before tokens could be saved.");
|
|
293
|
+
}
|
|
294
|
+
return response;
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
this.fail(generation, flight, error);
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
/** Publish one exact owner's successful save without disturbing a newer try. */
|
|
304
|
+
succeedMutation(generation, flight) {
|
|
305
|
+
if (this.finishMutation(generation, flight)) {
|
|
306
|
+
this.successfulRefresh = { generation, identity: {} };
|
|
307
|
+
this.advanceStateRevision();
|
|
308
|
+
}
|
|
309
|
+
this.settle(generation, flight, { status: "refreshed" });
|
|
310
|
+
}
|
|
311
|
+
/** Give joined callers a fetch/flow failure, without rejecting the gate. */
|
|
312
|
+
fail(generation, flight, error) {
|
|
313
|
+
this.settle(generation, flight, { status: "failed", error });
|
|
314
|
+
}
|
|
315
|
+
/** Finish an exact failed credential write, then publish its failure. */
|
|
316
|
+
failMutation(generation, flight, error) {
|
|
317
|
+
this.finishMutation(generation, flight);
|
|
318
|
+
this.settle(generation, flight, { status: "failed", error });
|
|
319
|
+
}
|
|
320
|
+
/** Force reauthorization fences and wakes every waiter on the retired epoch. */
|
|
321
|
+
retire(generation) {
|
|
322
|
+
if (this.pendingMutations.delete(generation)) {
|
|
323
|
+
this.advanceStateRevision();
|
|
324
|
+
}
|
|
325
|
+
if (this.successfulRefresh?.generation === generation) {
|
|
326
|
+
this.successfulRefresh = undefined;
|
|
327
|
+
this.advanceStateRevision();
|
|
328
|
+
}
|
|
329
|
+
const flight = this.flights.get(generation);
|
|
330
|
+
if (!flight)
|
|
331
|
+
return;
|
|
332
|
+
this.settle(generation, flight, { status: "retired" });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
29
335
|
function storedOAuthValue(value) {
|
|
30
336
|
if (!value || typeof value !== "object")
|
|
31
337
|
return false;
|
|
@@ -75,16 +381,21 @@ export class KvOAuthProvider {
|
|
|
75
381
|
connectorId;
|
|
76
382
|
storage;
|
|
77
383
|
redirectUri;
|
|
384
|
+
refreshCoordinator;
|
|
78
385
|
/**
|
|
79
386
|
* The reset generation this provider's flow started under. Every OAuth value
|
|
80
387
|
* it writes carries this epoch, so a late write can land after a reset without
|
|
81
388
|
* becoming readable under the new generation.
|
|
82
389
|
*/
|
|
83
390
|
capturedGeneration = null;
|
|
84
|
-
|
|
391
|
+
refreshFlight;
|
|
392
|
+
/** Tokens this request's issuer-aware auth flow decided to refresh. */
|
|
393
|
+
refreshBasis;
|
|
394
|
+
constructor(connectorId, storage, redirectUri, refreshCoordinator) {
|
|
85
395
|
this.connectorId = connectorId;
|
|
86
396
|
this.storage = storage;
|
|
87
397
|
this.redirectUri = redirectUri;
|
|
398
|
+
this.refreshCoordinator = refreshCoordinator;
|
|
88
399
|
}
|
|
89
400
|
/**
|
|
90
401
|
* Stamp the force-reauth generation the current connect flow started under.
|
|
@@ -94,6 +405,43 @@ export class KvOAuthProvider {
|
|
|
94
405
|
captureGeneration(gen) {
|
|
95
406
|
this.capturedGeneration = gen;
|
|
96
407
|
}
|
|
408
|
+
/** The generation captured for this flow, before a concurrent reset. */
|
|
409
|
+
async flowGeneration() {
|
|
410
|
+
return this.capturedGeneration ?? this.generation();
|
|
411
|
+
}
|
|
412
|
+
/** @internal Record the refresh attempt this provider owns. */
|
|
413
|
+
captureRefreshFlight(generation, flight) {
|
|
414
|
+
this.refreshFlight = { generation, flight };
|
|
415
|
+
}
|
|
416
|
+
succeedRefreshFlight() {
|
|
417
|
+
const owned = this.refreshFlight;
|
|
418
|
+
this.refreshFlight = undefined;
|
|
419
|
+
if (owned) {
|
|
420
|
+
this.refreshCoordinator?.succeedMutation(owned.generation, owned.flight);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
failRefreshFlight(error, mutationFinished = false) {
|
|
424
|
+
const owned = this.refreshFlight;
|
|
425
|
+
this.refreshFlight = undefined;
|
|
426
|
+
if (owned) {
|
|
427
|
+
if (mutationFinished) {
|
|
428
|
+
this.refreshCoordinator?.failMutation(owned.generation, owned.flight, error);
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
this.refreshCoordinator?.fail(owned.generation, owned.flight, error);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/** True when another request saved a refresh result after this flow's read. */
|
|
436
|
+
refreshBasisChanged(current, generation) {
|
|
437
|
+
const basis = this.refreshBasis;
|
|
438
|
+
return Boolean(basis &&
|
|
439
|
+
basis.generation === generation &&
|
|
440
|
+
(basis.accessToken !== current.access_token ||
|
|
441
|
+
basis.refreshToken !== current.refresh_token ||
|
|
442
|
+
basis.successIdentity !==
|
|
443
|
+
this.refreshCoordinator?.successfulRefreshIdentity(generation)));
|
|
444
|
+
}
|
|
97
445
|
/**
|
|
98
446
|
* The epoch this provider writes under. Direct unit/custom use lazily captures
|
|
99
447
|
* the current generation; connector-driven connect and callback paths stamp it
|
|
@@ -274,10 +622,32 @@ export class KvOAuthProvider {
|
|
|
274
622
|
await this.writeValue("oauth:client", info, (value) => JSON.stringify(value), ctx?.issuer);
|
|
275
623
|
}
|
|
276
624
|
async tokens(ctx) {
|
|
277
|
-
|
|
625
|
+
const refreshGeneration = ctx ? await this.flowGeneration() : undefined;
|
|
626
|
+
const successIdentity = refreshGeneration !== undefined
|
|
627
|
+
? this.refreshCoordinator?.successfulRefreshIdentity(refreshGeneration)
|
|
628
|
+
: undefined;
|
|
629
|
+
const tokens = await this.readIssuerBoundValue("oauth:tokens", (raw) => JSON.parse(raw), (value) => JSON.stringify(value), ctx);
|
|
630
|
+
if (ctx && tokens && refreshGeneration !== undefined) {
|
|
631
|
+
this.refreshBasis = {
|
|
632
|
+
accessToken: tokens.access_token,
|
|
633
|
+
generation: refreshGeneration,
|
|
634
|
+
...(tokens.refresh_token !== undefined
|
|
635
|
+
? { refreshToken: tokens.refresh_token }
|
|
636
|
+
: {}),
|
|
637
|
+
...(successIdentity !== undefined ? { successIdentity } : {}),
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
return tokens;
|
|
278
641
|
}
|
|
279
642
|
async saveTokens(tokens, ctx) {
|
|
280
|
-
|
|
643
|
+
try {
|
|
644
|
+
await this.writeValue("oauth:tokens", tokens, (value) => JSON.stringify(value), ctx?.issuer);
|
|
645
|
+
this.succeedRefreshFlight();
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
this.failRefreshFlight(error, true);
|
|
649
|
+
throw error;
|
|
650
|
+
}
|
|
281
651
|
}
|
|
282
652
|
/**
|
|
283
653
|
* OAuth `state`. The SDK calls this (when present) and appends the value to
|
|
@@ -317,7 +687,14 @@ export class KvOAuthProvider {
|
|
|
317
687
|
return stored.value;
|
|
318
688
|
}
|
|
319
689
|
async redirectToAuthorization(authorizationUrl) {
|
|
320
|
-
|
|
690
|
+
try {
|
|
691
|
+
await this.writeValue("oauth:pending", authorizationUrl.toString(), (raw) => raw);
|
|
692
|
+
this.failRefreshFlight(new Error("OAuth refresh required reauthorization before tokens were saved."));
|
|
693
|
+
}
|
|
694
|
+
catch (error) {
|
|
695
|
+
this.failRefreshFlight(error);
|
|
696
|
+
throw error;
|
|
697
|
+
}
|
|
321
698
|
}
|
|
322
699
|
/** The stored authorization URL, if a flow is pending. */
|
|
323
700
|
async pendingAuthorizationUrl() {
|
|
@@ -385,6 +762,7 @@ export class KvOAuthProvider {
|
|
|
385
762
|
// reset's epoch after their cleanup finishes out of order.
|
|
386
763
|
try {
|
|
387
764
|
await this.storage.set("oauth:generation", active);
|
|
765
|
+
this.refreshCoordinator?.retire(previous);
|
|
388
766
|
}
|
|
389
767
|
catch (error) {
|
|
390
768
|
try {
|
|
@@ -414,23 +792,33 @@ export class KvOAuthProvider {
|
|
|
414
792
|
throw firstError;
|
|
415
793
|
}
|
|
416
794
|
async invalidateCredentials(scope) {
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
await this.
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
795
|
+
const endsRefresh = scope === "all" || scope === "tokens";
|
|
796
|
+
try {
|
|
797
|
+
const generation = await this.writeGeneration();
|
|
798
|
+
if (scope === "all") {
|
|
799
|
+
await this.deleteAll([
|
|
800
|
+
oauthValueStorageKey("oauth:client", generation),
|
|
801
|
+
oauthValueStorageKey("oauth:tokens", generation),
|
|
802
|
+
oauthValueStorageKey("oauth:verifier", generation),
|
|
803
|
+
]);
|
|
804
|
+
}
|
|
805
|
+
else if (scope === "client") {
|
|
806
|
+
await this.storage.delete(oauthValueStorageKey("oauth:client", generation));
|
|
807
|
+
}
|
|
808
|
+
else if (scope === "tokens") {
|
|
809
|
+
await this.storage.delete(oauthValueStorageKey("oauth:tokens", generation));
|
|
810
|
+
}
|
|
811
|
+
else if (scope === "verifier") {
|
|
812
|
+
await this.storage.delete(oauthValueStorageKey("oauth:verifier", generation));
|
|
813
|
+
}
|
|
814
|
+
if (endsRefresh) {
|
|
815
|
+
this.failRefreshFlight(new Error("OAuth refresh invalidated credentials before tokens were saved."));
|
|
816
|
+
}
|
|
431
817
|
}
|
|
432
|
-
|
|
433
|
-
|
|
818
|
+
catch (error) {
|
|
819
|
+
if (endsRefresh)
|
|
820
|
+
this.failRefreshFlight(error);
|
|
821
|
+
throw error;
|
|
434
822
|
}
|
|
435
823
|
}
|
|
436
824
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Client, isInputRequiredResult, specTypeSchemas, StreamableHTTPClientTransport, UnauthorizedError, } from "@modelcontextprotocol/client";
|
|
2
|
-
import { KvOAuthProvider } from "../auth/downstream-oauth.js";
|
|
2
|
+
import { KvOAuthProvider, OAuthRefreshCoordinator, } from "../auth/downstream-oauth.js";
|
|
3
3
|
import { MAX_CATALOG_TOOLS } from "../catalog-limits.js";
|
|
4
4
|
import { ConnectorCallError, msg } from "../errors.js";
|
|
5
5
|
import { CONNECTA_VERSION } from "../version.js";
|
|
@@ -328,6 +328,9 @@ export function remoteMcp(id, opts) {
|
|
|
328
328
|
// must not recreate an ownerless connection under the ended scope.
|
|
329
329
|
const closedScopes = new WeakSet();
|
|
330
330
|
const isOauth = opts.auth?.type === "oauth";
|
|
331
|
+
// Long-lived enough for distinct request scopes in this connector runtime to
|
|
332
|
+
// join one token redemption. It owns no client, transport, or request state.
|
|
333
|
+
const refreshCoordinator = new OAuthRefreshCoordinator();
|
|
331
334
|
const logger = opts.logger ?? console;
|
|
332
335
|
const credentialAuth = opts.auth?.type === "credential" ? opts.auth : undefined;
|
|
333
336
|
if (credentialAuth?.credential?.fields?.length) {
|
|
@@ -497,7 +500,7 @@ export function remoteMcp(id, opts) {
|
|
|
497
500
|
const newProvider = (ctx, state) => {
|
|
498
501
|
if (state?.provider)
|
|
499
502
|
return state.provider;
|
|
500
|
-
const provider = new KvOAuthProvider(id, ctx.storage, `${ctx.baseUrl}/oauth/callback/${id}
|
|
503
|
+
const provider = new KvOAuthProvider(id, ctx.storage, `${ctx.baseUrl}/oauth/callback/${id}`, refreshCoordinator);
|
|
501
504
|
if (state)
|
|
502
505
|
state.provider = provider;
|
|
503
506
|
return provider;
|
|
@@ -514,9 +517,10 @@ export function remoteMcp(id, opts) {
|
|
|
514
517
|
const url = new URL(opts.url);
|
|
515
518
|
const guardedFetch = redirectSafeFetch(id, opts.redirects);
|
|
516
519
|
if (opts.auth?.type === "oauth") {
|
|
520
|
+
const oauthProvider = provider ?? newProvider(ctx);
|
|
517
521
|
return new StreamableHTTPClientTransport(url, {
|
|
518
|
-
authProvider:
|
|
519
|
-
fetch: guardedFetch,
|
|
522
|
+
authProvider: oauthProvider,
|
|
523
|
+
fetch: refreshCoordinator.coordinatedFetch(oauthProvider, guardedFetch, ctx.signal),
|
|
520
524
|
});
|
|
521
525
|
}
|
|
522
526
|
const headers = opts.auth?.type === "headers"
|
|
@@ -278,6 +278,10 @@ class QuickJsChildPool {
|
|
|
278
278
|
"@zackbart/connecta/quickjs) when bundling the server.");
|
|
279
279
|
}
|
|
280
280
|
const child = fork(childPath, [], {
|
|
281
|
+
// The child needs only its entry path, exec arguments, and IPC channel.
|
|
282
|
+
// Do not copy deployment credentials or Node startup configuration into
|
|
283
|
+
// the process that contains the guest runtime.
|
|
284
|
+
env: {},
|
|
281
285
|
execArgv: sourceMode ? ["--import", "tsx"] : [],
|
|
282
286
|
stdio: ["ignore", "ignore", "pipe", "ipc"],
|
|
283
287
|
});
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
|
@@ -20,6 +20,23 @@ isolate or process —
|
|
|
20
20
|
on Workers that means a lazy module-scope singleton, which is why both
|
|
21
21
|
deployment shapes build it outside the request handler.
|
|
22
22
|
|
|
23
|
+
An OAuth `remoteMcp()` connector also owns a runtime-local refresh completion
|
|
24
|
+
gate. It coordinates credential mutation across concurrent request scopes but
|
|
25
|
+
never shares their clients, transports, or responses, and never lets a follower
|
|
26
|
+
cancel the owner. Every participant still awaits the refresh inside its own
|
|
27
|
+
request lifetime; a cancelled follower leaves the shared owner untouched and
|
|
28
|
+
removes only its own wait. The owner's request signal belongs to its token
|
|
29
|
+
fetch. Cancelling that owner fails current joiners too because promoting one
|
|
30
|
+
could replay a refresh token the authorization server already consumed.
|
|
31
|
+
|
|
32
|
+
The coordinator retains the owner's abort signal only through one temporary
|
|
33
|
+
listener on the exact active refresh. Save, failure, cancellation, or
|
|
34
|
+
generation retirement removes it along with the map entry. It never retains a
|
|
35
|
+
token response, client, or transport. If cancellation lands after a valid
|
|
36
|
+
response while its credential write is still running, a generation-keyed
|
|
37
|
+
identity marker rejects new owners until that exact write finishes. The marker
|
|
38
|
+
contains no promise and generation retirement removes it.
|
|
39
|
+
|
|
23
40
|
**Per request, and no longer.** The MCP server, its transport, downstream MCP
|
|
24
41
|
clients, abort signals, and the connector scope a probe opens all belong to the
|
|
25
42
|
request that created them. `Nothing request-bound survives a request` is an
|
package/documentation/auth.md
CHANGED
|
@@ -65,7 +65,7 @@ Worker-level Access runs before every connecta route. Consequently:
|
|
|
65
65
|
OAuth discovery paths when Managed OAuth is enabled.
|
|
66
66
|
|
|
67
67
|
The [Worker example](../examples/worker/) carries the complete deployment shape
|
|
68
|
-
and the [upgrade guide](./upgrading.md#0200--
|
|
68
|
+
and the [upgrade guide](./upgrading.md#0200--0212) gives the reversible Clerk
|
|
69
69
|
migration.
|
|
70
70
|
|
|
71
71
|
## Clerk configuration is checked at construction
|
|
@@ -746,7 +746,7 @@ Worker renders arguments with `String()` (so an object logs as
|
|
|
746
746
|
latter two. Only the three captured everywhere are contract (`R5`); rendering is
|
|
747
747
|
not.
|
|
748
748
|
|
|
749
|
-
**X5. Leftover authority.** QuickJS blocks imports and has no `fetch`, `process`, timers, `crypto`, or `WebSocket`. A Dynamic Worker has those globals plus a non-contract set of runtime builtins through `import()` and `process.getBuiltinModule()`, including `node:path`, `node:crypto`, `node:net`, `node:tls`, `node:dns`, `node:module`, and `cloudflare:workers`. The upstream set can drift; this list is not an allowlist.
|
|
749
|
+
**X5. Leftover authority.** QuickJS blocks imports and has no `fetch`, `process`, timers, `crypto`, or `WebSocket`. Its Node child starts with an explicitly empty process environment rather than inheriting deployment variables or `NODE_OPTIONS`. A Dynamic Worker has those globals plus a non-contract set of runtime builtins through `import()` and `process.getBuiltinModule()`, including `node:path`, `node:crypto`, `node:net`, `node:tls`, `node:dns`, `node:module`, and `cloudflare:workers`. The upstream set can drift; this list is not an allowlist.
|
|
750
750
|
The supported Worker construction is exactly `new DynamicWorkerExecutor({ loader })`. Do not pass `bindings`, `modules`, or `globalOutbound`: each can grant ambient configuration, code, or egress. Under it, `process.env`, lexical `this.env`, and `cloudflare:workers.env` are empty; `node:fs`, `node:http`, and `node:https` are unavailable through either access route; external `fetch`, `WebSocket`, `node:net`, and `node:tls` fail with workerd's outbound-denial error; DNS lookup ends unresolved; and `fetch("data:...")` resolves locally.
|
|
751
751
|
`P2` is the portable contract. Programs use none of this runtime-only authority, including timers and `crypto`, because the same code fails on QuickJS. The `execute_code` description and served `usage` skill say so before an agent writes code.
|
|
752
752
|
|
|
@@ -832,7 +832,7 @@ the upstream `Executor` shape assignable.
|
|
|
832
832
|
| Clauses | Test |
|
|
833
833
|
| --- | --- |
|
|
834
834
|
| `P1`, `P5` | `test/guest-api-contract.test.ts` (TypeScript syntax), `test/quickjs-executor.test.ts` (`normalizeCode`) |
|
|
835
|
-
| `P2`, `X5` | `test/guest-api-contract.test.ts` (Dynamic globals plus loader-only filesystem, HTTP, environment, egress, DNS, and local `data:` boundaries), `test/guest-api-contract-quickjs.test.ts` (exact absent globals and blocked imports), `test/deployment-shapes.test.ts` (loader-only Worker construction) |
|
|
835
|
+
| `P2`, `X5` | `test/guest-api-contract.test.ts` (Dynamic globals plus loader-only filesystem, HTTP, environment, egress, DNS, and local `data:` boundaries), `test/guest-api-contract-quickjs.test.ts` (exact absent globals and blocked imports), `test/quickjs-child-stderr.test.ts` (empty child-process environment), `test/deployment-shapes.test.ts` (loader-only Worker construction) |
|
|
836
836
|
| `P3`, `X9` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` |
|
|
837
837
|
| `P4` | `test/guest-api-contract.test.ts` (no cross-run leakage), `test/execute.test.ts` (one catalog load per connector per execution) |
|
|
838
838
|
| `A1`, `A2` | `test/guest-api-contract.test.ts`, `test/execute.test.ts` (sanitizing), `test/server.test.ts` (bounded live connector inventory) |
|
|
@@ -239,7 +239,7 @@ in.
|
|
|
239
239
|
| `config.test.ts` | the grouped `ConnectaConfig` boundary — each group forwarding to its internals, malformed admission bounds failing construction, and unknown own-properties rejected by their complete path before construction does work |
|
|
240
240
|
| `credentials.test.ts` | the pure stored-shape classifier (containment, not equality) and the AES-GCM vault: round-trip, ciphertext bound to its connector id, named field sets, masked metadata, wrong-key rejection, deletion, coexistence with OAuth keys |
|
|
241
241
|
| `d1-activity-example.test.ts` | the Worker example's deployment-owned D1 activity store: actor namespace round-trip, payload-free friction reconstructed from the persisted code, and agreement with the package's friction table |
|
|
242
|
-
| `downstream-oauth.test.ts` | `KvOAuthProvider` round-trips and races, `auth_required` versus `error`, `startAuth`/`finishAuth`, callback refusal equality, bounded diagnostics, and HTML escaping |
|
|
242
|
+
| `downstream-oauth.test.ts` | `KvOAuthProvider` round-trips and generation races, runtime-local rotating-token refresh coordination across request scopes, refresh failure/retry, `auth_required` versus `error`, `startAuth`/`finishAuth`, callback refusal equality, bounded diagnostics, and HTML escaping |
|
|
243
243
|
| `errors.test.ts` | `ConnectorCallError` codes, retryable defaults and overrides, `retryAfterMs` round-trip, typed-over-heuristic classification, `AbortError` as a retryable timeout, and framing errors |
|
|
244
244
|
| `execute.test.ts` | the code-mode host bridge: identifier sanitization, MCP-result unwrapping, sandbox provider construction, authenticated thrown-failure framing, fail-closed filtering of destructive and unannotated tools, MCP/code-mode invocation parity, and payload-free describe diagnostics |
|
|
245
245
|
| `execute-emit.test.ts` | `connecta.emit` (M1–M10) — block validation, budgets, the provider, delivery after the result envelope on success only, and the defaults |
|
|
@@ -291,7 +291,7 @@ justification for *not* re-running it in workerd, so "it was easier" is not one.
|
|
|
291
291
|
| `package-surface.test.ts` | the published boundary — built output shipped, the `exports` map carrying exactly the documented subpaths plus `./package.json`, only generic factories, platform storage kept in examples, Clerk and QuickJS behind optional subpaths, dependency-free Cloudflare Access behind its Worker subpath, every provider independently importable, and the Cloudflare API provider free of bare specifiers | walks the package tree with Node filesystem APIs |
|
|
292
292
|
| `purity.test.ts` | the import-graph guardrail ([architecture](./architecture.md#import-graph-purity)) — the core stays Workers-clean | walks the source import graph with Node filesystem APIs |
|
|
293
293
|
| `quickjs-child-entry.test.ts` | a missing QuickJS child entry failing before `fork()`, with the expected path and the bundler-externalization constraint | mocks Node child-process and filesystem APIs |
|
|
294
|
-
| `quickjs-child-stderr.test.ts` |
|
|
294
|
+
| `quickjs-child-stderr.test.ts` | the QuickJS child-process boundary: an explicitly empty environment despite parent secrets and `NODE_OPTIONS`, plus abnormal exits retaining only an 8 KiB stderr tail in the parent-side diagnostic | mocks Node child-process streams |
|
|
295
295
|
| `quickjs-executor.test.ts` | the child-process sandbox — code normalization, lazy namespace proxies, bounded IPC, separate guest-CPU and wall budgets, saturation, cancellation and shutdown, crash and OOM recovery, host-call hangs, stalled-promise detection | runs the Node QuickJS child-process executor |
|
|
296
296
|
| `quickjs-log-limits.test.ts` | bounded `console.*` capture — per-entry cut, cumulative character and transport budgets, escape-heavy floods preserving the guest result | runs the Node QuickJS child-process executor |
|
|
297
297
|
| `suite-partition.test.ts` | this partition, including itself: every `*.test.ts` in exactly one list, stale entries and empty reasons refused | walks the test directory to guard the partition |
|
|
@@ -107,3 +107,38 @@ the retired grant.
|
|
|
107
107
|
The OAuth callback verifies the one-shot `state` first, then hands the complete
|
|
108
108
|
query string—including RFC 9207 `iss`—to the SDK transport. One-shot state,
|
|
109
109
|
verifier, and pending URL are cleared only after a successful exchange.
|
|
110
|
+
|
|
111
|
+
Within one `remoteMcp()` runtime, one request scope owns refresh-token
|
|
112
|
+
redemption for an OAuth generation. Concurrent scopes wait for the owner's
|
|
113
|
+
token save or bounded failure, then either read storage again or receive that
|
|
114
|
+
failure. A scope that had already read the retired refresh token reuses the
|
|
115
|
+
newly stored rotating token locally instead of sending the retired value
|
|
116
|
+
upstream. Force reauthorization retires the old generation's gate, and a
|
|
117
|
+
failed flow releases ownership for a later attempt. The coordinator retains
|
|
118
|
+
only a completion signal and one temporary owner-abort listener until that
|
|
119
|
+
exact flight settles, never the token response or downstream transport. A
|
|
120
|
+
follower may stop waiting when its own request is cancelled without cancelling
|
|
121
|
+
the owner or poisoning the generation for later callers. If the owner's
|
|
122
|
+
credential mutation fails, joined callers receive that same bounded failure
|
|
123
|
+
instead of waking to redeem the unchanged token; a later independent call may
|
|
124
|
+
retry. Non-success and malformed token responses settle current waiters at the
|
|
125
|
+
fetch boundary, before any later authorization callback can itself fail.
|
|
126
|
+
Cancelling the owner aborts its fetch and fails current joiners rather than
|
|
127
|
+
promoting one: once a request reaches the authorization server, repeating its
|
|
128
|
+
old refresh token is not known to be safe. If that cancellation lands while
|
|
129
|
+
the valid response's credential write is already running, a same-generation
|
|
130
|
+
attempt receives `temporarily_unavailable` until the exact write succeeds or
|
|
131
|
+
fails. This mutation marker contains no retained promise; force
|
|
132
|
+
reauthorization removes it when the old generation becomes unreadable.
|
|
133
|
+
An additional opaque success identity lets a request recognize a refresh that
|
|
134
|
+
completed after its issuer-aware token read even when the authorization server
|
|
135
|
+
returned byte-identical credentials. The identity is generation-scoped and is
|
|
136
|
+
discarded with the retired generation. Every authoritative storage-generation
|
|
137
|
+
read also retires coordinator state from other epochs, so an externally
|
|
138
|
+
advanced generation cannot be overwritten in runtime state by late old work.
|
|
139
|
+
|
|
140
|
+
This guarantee is runtime-local. `KVStorage` has no atomic lock or
|
|
141
|
+
compare-and-set operation, so separate processes or Worker isolates can still
|
|
142
|
+
redeem the same refresh token concurrently. Generation envelopes continue to
|
|
143
|
+
fence their writes, but Connecta does not claim cross-isolate exactly-once
|
|
144
|
+
refresh.
|
|
@@ -57,7 +57,7 @@ exist so far:
|
|
|
57
57
|
| --- | --- | --- |
|
|
58
58
|
| **pre-template** | before 0.10.2 | no `connecta init` existed; hand-written, or copied from the retired `examples/node` |
|
|
59
59
|
| **A** | 0.10.2 – 0.15.1 | `.env.example`, `.gitignore`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `package.json`, `src/index.ts`, `tsconfig.json` |
|
|
60
|
-
| **B** | 0.16.0 – 0.21.
|
|
60
|
+
| **B** | 0.16.0 – 0.21.2 | adds `.dockerignore`, `Dockerfile`, `docker-compose.yml`, and `src/file-activity.ts`; `src/index.ts` grows the four commented operator blocks; `.env.example` ships `CONNECTA_TOKEN=` empty |
|
|
61
61
|
|
|
62
62
|
Generation A is a decade in template years and identifying it precisely does
|
|
63
63
|
not matter, because you are about to reconstruct it exactly rather than guess
|
|
@@ -106,7 +106,7 @@ know what to preserve, once to know what to re-verify at the end.
|
|
|
106
106
|
### Bump the pin and install
|
|
107
107
|
|
|
108
108
|
```sh
|
|
109
|
-
npm pkg set dependencies.@zackbart/connecta=0.21.
|
|
109
|
+
npm pkg set dependencies.@zackbart/connecta=0.21.2
|
|
110
110
|
npm install
|
|
111
111
|
```
|
|
112
112
|
|
|
@@ -130,7 +130,7 @@ Generate the *current* template beside the base you already made, into the same
|
|
|
130
130
|
`$SCRATCH`:
|
|
131
131
|
|
|
132
132
|
```sh
|
|
133
|
-
(cd "$SCRATCH" && npx @zackbart/connecta@0.21.
|
|
133
|
+
(cd "$SCRATCH" && npx @zackbart/connecta@0.21.2 init current)
|
|
134
134
|
```
|
|
135
135
|
|
|
136
136
|
You now have a three-way merge with a real base: `$SCRATCH/base` is what this
|
|
@@ -186,7 +186,7 @@ A deployment older than 0.10.2 has no base to diff against. Do not try to
|
|
|
186
186
|
manufacture one. Instead:
|
|
187
187
|
|
|
188
188
|
1. `SCRATCH=$(mktemp -d)`, then
|
|
189
|
-
`(cd "$SCRATCH" && npx @zackbart/connecta@0.21.
|
|
189
|
+
`(cd "$SCRATCH" && npx @zackbart/connecta@0.21.2 init current)` — there is no
|
|
190
190
|
`base` leg here, only the current template to read from.
|
|
191
191
|
2. Copy `$SCRATCH/current` into the deployment file by file, **skipping
|
|
192
192
|
`src/index.ts`**.
|
|
@@ -207,9 +207,9 @@ first, so cross them bottom-up: start at the oldest one still above this
|
|
|
207
207
|
deployment's pin and work back up the page, because each boundary assumes the
|
|
208
208
|
older ones are already done.
|
|
209
209
|
|
|
210
|
-
### 0.20.0 → 0.21.
|
|
210
|
+
### 0.20.0 → 0.21.2
|
|
211
211
|
|
|
212
|
-
0.21.
|
|
212
|
+
0.21.2 adds no deployment migration beyond 0.21.0. The boundary is additive
|
|
213
213
|
for Node and existing Clerk deployments. The new Worker path
|
|
214
214
|
uses Cloudflare Access identity directly and removes Clerk only after the edge
|
|
215
215
|
cutover has been verified. An agent can perform every repository edit; a human
|
|
@@ -218,7 +218,7 @@ Managed OAuth in the Cloudflare dashboard.
|
|
|
218
218
|
|
|
219
219
|
For a Worker currently using Clerk, keep rollback live through the cutover:
|
|
220
220
|
|
|
221
|
-
1. Bump and install 0.21.
|
|
221
|
+
1. Bump and install 0.21.2. Add the new provider **before** the existing Clerk
|
|
222
222
|
provider, but remove nothing:
|
|
223
223
|
|
|
224
224
|
```ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zackbart/connecta",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "One MCP to rule them all — a single MCP endpoint aggregating many downstream connectors behind a code-first surface of seven meta-tools.",
|