@zackbart/connecta 0.24.4 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/AGENTS.md +43 -12
  2. package/CHANGELOG.md +524 -0
  3. package/README.md +32 -15
  4. package/SECURITY.md +6 -5
  5. package/bin/connecta.mjs +5 -1
  6. package/dist/activity.d.ts +10 -3
  7. package/dist/activity.js +10 -1
  8. package/dist/advertised-schema.js +53 -0
  9. package/dist/artifacts/connector.d.ts +55 -0
  10. package/dist/artifacts/connector.js +724 -0
  11. package/dist/artifacts/document.js +76 -0
  12. package/dist/artifacts/guide.js +140 -0
  13. package/dist/artifacts/html-scan.js +329 -0
  14. package/dist/artifacts/json.js +68 -0
  15. package/dist/artifacts/kv-store.d.ts +20 -0
  16. package/dist/artifacts/kv-store.js +140 -0
  17. package/dist/artifacts/markdown.js +449 -0
  18. package/dist/artifacts/module.d.ts +37 -0
  19. package/dist/artifacts/module.js +60 -0
  20. package/dist/artifacts/operations.d.ts +195 -0
  21. package/dist/artifacts/operations.js +944 -0
  22. package/dist/artifacts/refresh.d.ts +53 -0
  23. package/dist/artifacts/refresh.js +235 -0
  24. package/dist/artifacts/routes.js +194 -0
  25. package/dist/artifacts/types.d.ts +214 -0
  26. package/dist/artifacts/types.js +22 -0
  27. package/dist/artifacts/validate.d.ts +62 -0
  28. package/dist/artifacts/validate.js +652 -0
  29. package/dist/artifacts.d.ts +6 -0
  30. package/dist/artifacts.js +9 -0
  31. package/dist/auth/downstream-oauth.js +490 -106
  32. package/dist/call-admission.js +139 -113
  33. package/dist/catalog-service.js +152 -106
  34. package/dist/catalog.js +297 -38
  35. package/dist/concurrency.js +24 -23
  36. package/dist/connector-access.d.ts +18 -4
  37. package/dist/connector-access.js +65 -20
  38. package/dist/connector-caller.d.ts +8 -0
  39. package/dist/connector-caller.js +19 -0
  40. package/dist/connector-scope.js +6 -37
  41. package/dist/connectors/api.d.ts +2 -2
  42. package/dist/connectors/remote-mcp.d.ts +6 -5
  43. package/dist/connectors/remote-mcp.js +330 -240
  44. package/dist/credential-contract.d.ts +9 -0
  45. package/dist/credential-rules.js +2 -2
  46. package/dist/credentials.d.ts +6 -1
  47. package/dist/credentials.js +45 -3
  48. package/dist/errors.d.ts +16 -1
  49. package/dist/errors.js +28 -0
  50. package/dist/execute.js +745 -282
  51. package/dist/executor-admission.d.ts +11 -1
  52. package/dist/executor-admission.js +145 -63
  53. package/dist/executors/quickjs-runtime.js +4 -2
  54. package/dist/executors/quickjs.js +255 -212
  55. package/dist/fix-prompt.js +37 -0
  56. package/dist/index.d.ts +72 -7
  57. package/dist/index.js +281 -26
  58. package/dist/invocation.js +371 -286
  59. package/dist/meta-tools.js +213 -94
  60. package/dist/module-contracts.d.ts +21 -1
  61. package/dist/oauth-callback-outcome.js +94 -0
  62. package/dist/oauth-sealing.js +29 -0
  63. package/dist/operator-ui/fix-prompts.js +87 -0
  64. package/dist/operator-ui/generated.js +2 -2
  65. package/dist/operator-ui/model.d.ts +103 -1
  66. package/dist/operator-ui/setup-commands.js +53 -0
  67. package/dist/operator-ui/view.js +128 -3
  68. package/dist/program-source.js +321 -0
  69. package/dist/providers/notion.js +2 -2
  70. package/dist/providers/revenuecat.js +32 -5
  71. package/dist/providers/stripe.d.ts +3 -2
  72. package/dist/providers/vercel.js +1 -1
  73. package/dist/registry.d.ts +43 -7
  74. package/dist/registry.js +434 -288
  75. package/dist/resumable.js +1291 -0
  76. package/dist/routes/activity.js +74 -154
  77. package/dist/routes/actor-labels.js +114 -0
  78. package/dist/routes/credentials.js +196 -163
  79. package/dist/routes/mcp.js +228 -112
  80. package/dist/routes/oauth-management.js +73 -71
  81. package/dist/routes/oauth.js +44 -16
  82. package/dist/routes/operator.js +83 -0
  83. package/dist/routes/shared.d.ts +25 -1
  84. package/dist/routes/shared.js +20 -2
  85. package/dist/routes/ui-data.js +211 -0
  86. package/dist/routes/ui.js +76 -35
  87. package/dist/run-journal.js +347 -0
  88. package/dist/runtime/admission.js +43 -0
  89. package/dist/runtime/call-admission.js +67 -0
  90. package/dist/runtime/connector-scope.js +72 -0
  91. package/dist/runtime/run.js +174 -0
  92. package/dist/runtime/services.js +130 -0
  93. package/dist/runtime/shared-read.js +106 -0
  94. package/dist/runtime/storage.js +66 -0
  95. package/dist/server.js +137 -89
  96. package/dist/skills.js +28 -9
  97. package/dist/storage/file.js +39 -2
  98. package/dist/storage/memory.js +28 -16
  99. package/dist/timeout.js +39 -43
  100. package/dist/tool-safety.d.ts +27 -1
  101. package/dist/tool-safety.js +31 -0
  102. package/dist/types.d.ts +45 -7
  103. package/dist/ui.d.ts +25 -4
  104. package/dist/ui.js +77 -167
  105. package/dist/url-safety.js +157 -0
  106. package/dist/version.d.ts +1 -1
  107. package/dist/version.js +1 -1
  108. package/documentation/architecture.md +367 -16
  109. package/documentation/auth.md +163 -2
  110. package/documentation/code-mode.md +274 -56
  111. package/documentation/meta-tools.md +200 -44
  112. package/ethos.md +46 -47
  113. package/examples/worker/AGENTS.md +6 -0
  114. package/examples/worker/README.md +224 -14
  115. package/examples/worker/src/d1-activity-row.ts +14 -2
  116. package/examples/worker/src/d1-activity.ts +4 -2
  117. package/examples/worker/src/d1-storage.ts +116 -0
  118. package/examples/worker/src/index.ts +42 -7
  119. package/examples/worker/src/r2-artifact-blobs.ts +29 -0
  120. package/examples/worker/wrangler.jsonc +21 -5
  121. package/package.json +16 -4
  122. package/templates/node/AGENTS.md +9 -2
  123. package/templates/node/README.md +31 -3
  124. package/templates/node/package.json +1 -1
  125. package/templates/node/src/index.ts +27 -0
  126. package/dist/auth/downstream-oauth.d.ts +0 -200
  127. package/dist/catalog-fingerprint.d.ts +0 -19
  128. package/dist/catalog-limits.d.ts +0 -9
  129. package/dist/catalog-service.d.ts +0 -263
  130. package/dist/catalog.d.ts +0 -87
  131. package/dist/concurrency.d.ts +0 -3
  132. package/dist/connectors/guarded-fetch.d.ts +0 -96
  133. package/dist/credential-rules.d.ts +0 -85
  134. package/dist/execute.d.ts +0 -154
  135. package/dist/executor-result.d.ts +0 -10
  136. package/dist/executors/quickjs-child.d.ts +0 -1
  137. package/dist/executors/quickjs-protocol.d.ts +0 -67
  138. package/dist/favicon.d.ts +0 -1
  139. package/dist/identity.d.ts +0 -4
  140. package/dist/invocation.d.ts +0 -74
  141. package/dist/mcp-result.d.ts +0 -8
  142. package/dist/meta-tools.d.ts +0 -129
  143. package/dist/operator-ui/generated.d.ts +0 -2
  144. package/dist/operator-ui/view.d.ts +0 -188
  145. package/dist/result-shapes.d.ts +0 -13
  146. package/dist/retired-toolkits.d.ts +0 -2
  147. package/dist/routes/activity.d.ts +0 -2
  148. package/dist/routes/credentials.d.ts +0 -2
  149. package/dist/routes/mcp.d.ts +0 -10
  150. package/dist/routes/oauth-management.d.ts +0 -2
  151. package/dist/routes/oauth.d.ts +0 -2
  152. package/dist/routes/ui.d.ts +0 -2
  153. package/dist/server.d.ts +0 -9
  154. package/dist/skills.d.ts +0 -44
  155. package/dist/timeout.d.ts +0 -21
@@ -1,4 +1,8 @@
1
1
  import { UnauthorizedError } from "@modelcontextprotocol/client";
2
+ import { Deferred, Effect } from "effect";
3
+ import { retryAfterMs } from "../connectors/guarded-fetch.js";
4
+ import { ConnectorCallError } from "../errors.js";
5
+ import { detach, fromSignal, runEdge } from "../runtime/run.js";
2
6
  /** A 256-bit random opaque value, hex-encoded — used for the OAuth `state`. */
3
7
  function randomState() {
4
8
  const bytes = new Uint8Array(32);
@@ -27,6 +31,19 @@ const OAUTH_VALUE_KEYS = [
27
31
  "oauth:state",
28
32
  "oauth:discovery",
29
33
  ];
34
+ /**
35
+ * The values that are credentials: tokens, a registered client (which may
36
+ * carry a secret), and the PKCE verifier. With a sealing vault they are
37
+ * ciphertext at rest. Flow bookkeeping — state, pending URL, discovery, and
38
+ * the generation — stays plaintext: the callback route reads state directly,
39
+ * and none of it authenticates anything on its own.
40
+ */
41
+ const SEALED_OAUTH_KEYS = new Set([
42
+ "oauth:client",
43
+ "oauth:tokens",
44
+ "oauth:verifier",
45
+ ]);
46
+ const SEALED_VALUE_VERSION = 1;
30
47
  const MAX_CLEANUP_BACKLOG = 1_000;
31
48
  function isRefreshTokenRequest(init) {
32
49
  if ((init?.method ?? "GET").toUpperCase() !== "POST")
@@ -101,15 +118,103 @@ async function readRefreshResponse(response) {
101
118
  reader.releaseLock();
102
119
  }
103
120
  }
121
+ /** Client-error statuses that describe the moment, not the grant. */
122
+ const TRANSIENT_CLIENT_STATUSES = new Set([408, 425, 429]);
123
+ /**
124
+ * OAuth `error` codes on which the SDK's own `auth()` drops state and starts
125
+ * over. Handing the SDK one of these is how a dead grant reaches consent on an
126
+ * explicit authorization, and `auth_required` on a passive call.
127
+ */
128
+ const SDK_RESTARTING_CODES = new Set([
129
+ "invalid_grant",
130
+ "invalid_client",
131
+ "unauthorized_client",
132
+ ]);
133
+ function oauthErrorCode(body) {
134
+ if (!body || typeof body !== "object" || Array.isArray(body))
135
+ return undefined;
136
+ const code = body.error;
137
+ return typeof code === "string" ? code : undefined;
138
+ }
139
+ function transientFailure(reason, response, cause) {
140
+ const wait = response ? retryAfterMs(response.headers) : undefined;
141
+ return {
142
+ kind: "transient",
143
+ reason,
144
+ ...(response ? { status: response.status } : {}),
145
+ ...(wait !== undefined ? { retryAfterMs: wait } : {}),
146
+ ...(cause !== undefined ? { cause } : {}),
147
+ };
148
+ }
149
+ /** What the SDK sees for a refused grant it would otherwise misread. */
150
+ function refusedGrantResponse(original) {
151
+ void original.body?.cancel().catch(() => { });
152
+ return Response.json({
153
+ error: "invalid_grant",
154
+ error_description: "The authorization server refused the refresh token.",
155
+ }, { status: 400 });
156
+ }
157
+ /** What the SDK sees for an outage whose body names a restarting code. */
158
+ function outageResponse(original) {
159
+ void original.body?.cancel().catch(() => { });
160
+ const retryAfter = original.headers.get("retry-after");
161
+ return Response.json({
162
+ error: "server_error",
163
+ error_description: "The authorization server is temporarily unavailable.",
164
+ }, {
165
+ status: original.status,
166
+ ...(retryAfter !== null ? { headers: { "retry-after": retryAfter } } : {}),
167
+ });
168
+ }
104
169
  /**
105
170
  * Classify the token endpoint's answer once, on a clone, and keep the parsed
106
171
  * tokens: a valid response has already consumed the rotating refresh token, so
107
172
  * the host persists it (`coordinatedFetch`) rather than trusting the SDK's
108
173
  * later `saveTokens` to arrive on a request that may already be cancelled.
174
+ *
175
+ * A failure also decides what the SDK gets to parse. Pinned against
176
+ * `@modelcontextprotocol/client` 2.0.0, `dist/index.mjs`: `authInternal()`
177
+ * swallows a refresh failure that is not an `OAuthError`, or is `server_error`,
178
+ * and falls through to `startAuthorization` (`state()`, `saveCodeVerifier()`,
179
+ * `redirectToAuthorization()`); it rethrows every other `OAuthError`, and
180
+ * `auth()` retries once after `invalidateCredentials()` only for
181
+ * `invalid_grant` (tokens) and `invalid_client`/`unauthorized_client` (client
182
+ * and tokens). `executeTokenRequest()` turns a 2xx body carrying `error` into
183
+ * an `OAuthError` with that code. So a dead grant the SDK would rethrow
184
+ * (`bad_refresh_token`, `invalid_scope`, …) reaches it as `invalid_grant`, and
185
+ * an outage whose body names any code but `server_error` reaches it as
186
+ * `server_error` — a 5xx `invalid_grant` must not make the SDK drop a grant
187
+ * nobody refused. The provider hooks below finish the job. If the SDK moves,
188
+ * the tests under "remoteMcp() dead and transient refresh grants" fail first.
109
189
  */
110
190
  async function refreshResponseOutcome(response) {
111
191
  if (!response.ok) {
112
- return { failure: new Error(`OAuth refresh failed with HTTP ${response.status}.`) };
192
+ let code;
193
+ try {
194
+ code = oauthErrorCode(await readRefreshResponse(response));
195
+ }
196
+ catch {
197
+ // Not JSON, or too large to be an OAuth error: the status decides.
198
+ }
199
+ const failure = new Error(`OAuth refresh failed with HTTP ${response.status}.`);
200
+ if (response.status >= 400 &&
201
+ response.status < 500 &&
202
+ !TRANSIENT_CLIENT_STATUSES.has(response.status)) {
203
+ return {
204
+ failure,
205
+ verdict: { kind: "dead" },
206
+ forSdk: code !== undefined && SDK_RESTARTING_CODES.has(code)
207
+ ? response
208
+ : refusedGrantResponse(response),
209
+ };
210
+ }
211
+ return {
212
+ failure,
213
+ verdict: transientFailure(`answered HTTP ${response.status}`, response),
214
+ forSdk: code !== undefined && code !== "server_error"
215
+ ? outageResponse(response)
216
+ : response,
217
+ };
113
218
  }
114
219
  let parsed;
115
220
  try {
@@ -118,11 +223,26 @@ async function refreshResponseOutcome(response) {
118
223
  catch (error) {
119
224
  if (error instanceof OversizedRefreshResponse)
120
225
  throw error;
121
- return { failure: new Error("OAuth refresh response did not contain JSON tokens.") };
226
+ return {
227
+ failure: new Error("OAuth refresh response did not contain JSON tokens."),
228
+ verdict: transientFailure("answered without a token response", response),
229
+ forSdk: response,
230
+ };
231
+ }
232
+ if (sdkAcceptsOAuthTokens(parsed))
233
+ return { tokens: parsed };
234
+ if (oauthErrorCode(parsed) !== undefined) {
235
+ return {
236
+ failure: new Error("OAuth refresh was refused by the authorization server."),
237
+ verdict: { kind: "dead" },
238
+ forSdk: refusedGrantResponse(response),
239
+ };
122
240
  }
123
- return sdkAcceptsOAuthTokens(parsed)
124
- ? { tokens: parsed }
125
- : { failure: new Error("OAuth refresh response did not match the token schema.") };
241
+ return {
242
+ failure: new Error("OAuth refresh response did not match the token schema."),
243
+ verdict: transientFailure("answered without a token response", response),
244
+ forSdk: response,
245
+ };
126
246
  }
127
247
  function refreshMutationPendingResponse() {
128
248
  return Response.json({
@@ -131,42 +251,62 @@ function refreshMutationPendingResponse() {
131
251
  }, { status: 503 });
132
252
  }
133
253
  function aborted(signal) {
134
- return (signal.reason ?? new DOMException("This operation was aborted", "AbortError"));
254
+ return (signal?.reason ?? new DOMException("This operation was aborted", "AbortError"));
135
255
  }
136
- async function waitForRefreshFlight(flight, signal) {
137
- let outcome;
138
- if (!signal) {
139
- outcome = await flight.done;
140
- }
141
- else {
142
- outcome = await new Promise((resolve, reject) => {
143
- let settled = false;
144
- const finish = (settle) => {
145
- if (settled)
146
- return;
147
- settled = true;
148
- signal.removeEventListener("abort", onAbort);
149
- settle();
150
- };
151
- const onAbort = () => finish(() => reject(aborted(signal)));
152
- signal.addEventListener("abort", onAbort, { once: true });
153
- void flight.done.then((result) => finish(() => resolve(result)));
154
- // Abort may land between the caller's check and listener registration.
155
- if (signal.aborted)
156
- onAbort();
157
- });
158
- }
159
- if (outcome.status === "failed")
160
- throw outcome.error;
161
- return outcome;
256
+ /**
257
+ * Call `onAbort` once when `signal` aborts, at once if it already has, until
258
+ * the returned function stops watching. The owner's abort needs a listener
259
+ * rather than an interrupt once the SDK holds the answer: the SDK saves the
260
+ * tokens after `coordinatedFetch` has returned, when no fiber of the owner's
261
+ * is left to interrupt.
262
+ */
263
+ function whenAborted(signal, onAbort) {
264
+ let watching = true;
265
+ const stop = () => {
266
+ if (!watching)
267
+ return;
268
+ watching = false;
269
+ signal.removeEventListener("abort", listener);
270
+ };
271
+ const listener = () => {
272
+ stop();
273
+ onAbort();
274
+ };
275
+ signal.addEventListener("abort", listener, { once: true });
276
+ if (signal.aborted)
277
+ listener();
278
+ return stop;
279
+ }
280
+ /**
281
+ * The outcome of a flight another request owns, failures included. Rejects
282
+ * only with this caller's own abort.
283
+ *
284
+ * This wait is a Promise edge of its own, and the caller's storage reads stay
285
+ * outside it, on purpose. The owner settles the Deferred from its own request,
286
+ * and Deferred resumes a waiting fiber synchronously inside that call — on
287
+ * Workers, inside the owner's I/O context. Resolving this caller's promise
288
+ * instead hands the rest of its work back to its own request, as workerd does
289
+ * for any promise resolved from another one.
290
+ */
291
+ function waitForRefreshFlight(flight, signal) {
292
+ const settled = Deferred.await(flight.outcome);
293
+ // The abort goes first, so a caller that has already left never joins.
294
+ return runEdge(signal
295
+ ? Effect.raceAllFirst([
296
+ fromSignal(signal),
297
+ settled,
298
+ ])
299
+ : settled);
162
300
  }
163
301
  /**
164
302
  * Share one rotating-token redemption within one connector runtime and OAuth
165
- * generation. The first request still owns the real fetch and response. Its
166
- * abort signal has one bounded listener until the exact flight settles, so a
167
- * cancellation after the response cannot strand waiters during token storage.
168
- * Followers wait for that provider to save tokens, then re-read storage. The
169
- * map never retains a token response or transport.
303
+ * generation. The first request still owns the real fetch and response, and
304
+ * its abort ends the flight at any point until the flight settles: first by
305
+ * interrupting the redemption, then, once the SDK holds the answer, through
306
+ * one bounded listener, so a cancellation cannot strand waiters during token
307
+ * storage. Followers wait on the flight's Deferred for that provider to save
308
+ * tokens, then re-read storage. The map never retains a token response or
309
+ * transport.
170
310
  *
171
311
  * This is intentionally runtime-local. KVStorage has no atomic coordination
172
312
  * operation, so a second isolate can still race the same refresh token.
@@ -203,7 +343,8 @@ export class OAuthRefreshCoordinator {
203
343
  this.flights.delete(generation);
204
344
  this.advanceStateRevision();
205
345
  flight.stopObservingOwnerAbort();
206
- flight.release(outcome);
346
+ // Last, because joined fibers resume inside this call.
347
+ Deferred.doneUnsafe(flight.outcome, Effect.succeed(outcome));
207
348
  }
208
349
  markMutationPending(generation, flight) {
209
350
  if (this.flights.get(generation) !== flight)
@@ -233,6 +374,8 @@ export class OAuthRefreshCoordinator {
233
374
  // adopted inner promise as an unhandled rejection.
234
375
  if (!isRefreshTokenRequest(init))
235
376
  return await baseFetch(input, init);
377
+ // A verdict belongs to one refresh; never let an older one decide this.
378
+ provider.recordRefreshFailure(undefined);
236
379
  const generation = await provider.flowGeneration();
237
380
  const requestedRefreshToken = init?.body instanceof URLSearchParams
238
381
  ? init.body.get("refresh_token")
@@ -289,6 +432,12 @@ export class OAuthRefreshCoordinator {
289
432
  }
290
433
  if (existing) {
291
434
  const outcome = await waitForRefreshFlight(existing, requestSignal);
435
+ if (outcome.status === "failed") {
436
+ // A joined caller inherits the owner's verdict, so every scope on
437
+ // one flight ends the same way: auth_required or a retryable outage.
438
+ provider.recordRefreshFailure(outcome.verdict);
439
+ throw outcome.error;
440
+ }
292
441
  if (outcome.status === "refreshed") {
293
442
  const activeGeneration = await provider.generation();
294
443
  const refreshedTokens = activeGeneration === generation
@@ -300,12 +449,12 @@ export class OAuthRefreshCoordinator {
300
449
  }
301
450
  continue;
302
451
  }
303
- let release;
452
+ // An owner that has already left never publishes a flight, and never
453
+ // reaches the token endpoint.
454
+ if (requestSignal?.aborted)
455
+ throw aborted(requestSignal);
304
456
  const flight = {
305
- done: new Promise((resolve) => {
306
- release = resolve;
307
- }),
308
- release: (outcome) => release(outcome),
457
+ outcome: Deferred.makeUnsafe(),
309
458
  stopObservingOwnerAbort: () => { },
310
459
  mutationId: {},
311
460
  writing: false,
@@ -314,60 +463,89 @@ export class OAuthRefreshCoordinator {
314
463
  this.flights.set(generation, flight);
315
464
  this.advanceStateRevision();
316
465
  provider.captureRefreshFlight(generation, flight);
317
- if (requestSignal) {
318
- let observing = true;
319
- const onOwnerAbort = () => {
320
- this.fail(generation, flight, aborted(requestSignal));
321
- };
322
- flight.stopObservingOwnerAbort = () => {
323
- if (!observing)
324
- return;
325
- observing = false;
326
- requestSignal.removeEventListener("abort", onOwnerAbort);
327
- };
328
- requestSignal.addEventListener("abort", onOwnerAbort, { once: true });
329
- // Abort may land between flight publication and listener registration.
330
- if (requestSignal.aborted)
331
- onOwnerAbort();
332
- }
333
- try {
466
+ const currentRefreshToken = currentTokens?.refresh_token;
467
+ // The token request starts now, as a plain promise: an owner that
468
+ // stops waiting for its answer can still hand the answer on.
469
+ const answer = (async () => {
334
470
  const response = await baseFetch(input, requestSignal ? { ...init, signal: requestSignal } : init);
335
- // Failed responses never reach a successful saveTokens callback. Give
336
- // current waiters a bounded failure now while leaving the owner's
337
- // response untouched for the SDK to parse and classify itself.
338
- const outcome = await refreshResponseOutcome(response);
471
+ return { response, outcome: await refreshResponseOutcome(response) };
472
+ })();
473
+ // Record what the token endpoint said, then settle the flight or
474
+ // hand it to the SDK's saveTokens.
475
+ const commit = ({ response, outcome, }) => {
339
476
  if (outcome.failure) {
340
- this.fail(generation, flight, outcome.failure);
341
- return response;
477
+ // Failed responses never reach a successful saveTokens callback.
478
+ // Give current waiters a bounded failure now, carrying the
479
+ // verdict, and hand the SDK an answer it classifies the same way.
480
+ provider.recordRefreshFailure(outcome.verdict);
481
+ // Drop a refused grant while the flight still stands: a caller
482
+ // arriving meanwhile joins it instead of redeeming the dead token
483
+ // again, and no later request or isolate can replay it.
484
+ const discard = outcome.verdict.kind === "dead"
485
+ ? Effect.promise(() => provider.discardRefusedGrant(requestedRefreshToken, generation))
486
+ : Effect.void;
487
+ return discard.pipe(Effect.map(() => {
488
+ this.fail(generation, flight, outcome.failure, outcome.verdict);
489
+ return outcome.forSdk;
490
+ }));
342
491
  }
343
492
  // A valid response means the authorization server has consumed the
344
493
  // rotating refresh token. The SDK's saveTokens normally persists it;
345
494
  // keep this copy so `fail` can persist it instead if that callback
346
495
  // never comes (the SDK merges the old refresh token the same way).
347
- const rotatedRefreshToken = outcome.tokens.refresh_token ?? currentTokens?.refresh_token;
348
- flight.acceptedTokens = {
496
+ const rotatedRefreshToken = outcome.tokens.refresh_token ?? currentRefreshToken;
497
+ const accepted = {
349
498
  ...outcome.tokens,
350
499
  ...(rotatedRefreshToken !== undefined
351
500
  ? { refresh_token: rotatedRefreshToken }
352
501
  : {}),
353
502
  };
503
+ flight.acceptedTokens = accepted;
354
504
  if (!this.markMutationPending(generation, flight)) {
355
505
  // Already settled (the owner was cancelled first). Still write the
356
506
  // rotation: losing it would leave a dead credential.
357
- void flight.persist?.(flight.acceptedTokens).catch(() => { });
358
- throw new Error("OAuth refresh ended before tokens could be saved.");
507
+ detach(Effect.promise(() => flight.persist(accepted)));
508
+ return Effect.fail(new Error("OAuth refresh ended before tokens could be saved."));
359
509
  }
360
- if (requestSignal?.aborted) {
361
- // Same recovery as an abort landing later: `fail` persists.
362
- this.fail(generation, flight, aborted(requestSignal));
363
- throw aborted(requestSignal);
510
+ // The SDK holds the answer from here, after this fiber is gone, so
511
+ // a listener watches for the owner leaving before it saves. Then
512
+ // `fail` persists the rotation instead.
513
+ if (requestSignal) {
514
+ flight.stopObservingOwnerAbort = whenAborted(requestSignal, () => this.fail(generation, flight, aborted(requestSignal)));
364
515
  }
365
- return response;
366
- }
367
- catch (error) {
368
- this.fail(generation, flight, error);
369
- throw error;
370
- }
516
+ return Effect.succeed(response);
517
+ };
518
+ // The owner's redemption. Its abort interrupts the wait for the token
519
+ // endpoint and nothing after it: an answer, once it exists, is
520
+ // committed before anyone is released, so no caller is let go to
521
+ // redeem a token the server has already refused or spent. An answer
522
+ // that arrives after the owner left is committed in the background,
523
+ // as the foreground would have.
524
+ const redemption = Effect.uninterruptibleMask((restore) => restore(Effect.tryPromise({ try: () => answer, catch: (error) => error })).pipe(Effect.onInterrupt(() => Effect.sync(() => {
525
+ this.fail(generation, flight, aborted(requestSignal));
526
+ detach(Effect.promise(() => answer).pipe(Effect.flatMap(commit)));
527
+ })), Effect.catch((error) => {
528
+ // No answer at all — the network, or a body too large to be
529
+ // one — is an outage. A refusal connecta itself raised (a
530
+ // redirect the policy forbids) and this owner's own
531
+ // cancellation are not.
532
+ const verdict = !requestSignal?.aborted &&
533
+ !(error instanceof ConnectorCallError && !error.retryable)
534
+ ? transientFailure(error instanceof OversizedRefreshResponse
535
+ ? "answered with an oversized response"
536
+ : "could not be reached", undefined, error)
537
+ : undefined;
538
+ if (verdict)
539
+ provider.recordRefreshFailure(verdict);
540
+ this.fail(generation, flight, error, verdict);
541
+ return Effect.fail(error);
542
+ }), Effect.flatMap(commit)));
543
+ return await runEdge(requestSignal
544
+ ? Effect.raceAllFirst([
545
+ redemption,
546
+ fromSignal(requestSignal),
547
+ ])
548
+ : redemption);
371
549
  }
372
550
  return refreshMutationPendingResponse();
373
551
  };
@@ -387,8 +565,12 @@ export class OAuthRefreshCoordinator {
387
565
  }
388
566
  this.settle(generation, flight, { status: "refreshed" });
389
567
  }
390
- /** Give joined callers a fetch/flow failure, without rejecting the gate. */
391
- fail(generation, flight, error) {
568
+ /**
569
+ * Give joined callers a fetch/flow failure, without rejecting the gate. A
570
+ * `verdict` travels with it only when the token endpoint decided the
571
+ * failure; an abort or a write failure carries none.
572
+ */
573
+ fail(generation, flight, error, verdict) {
392
574
  // Only saveTokens owns a live credential write. If it has started, its
393
575
  // success/failure callback clears the marker even after owner cancellation.
394
576
  if (flight.writing) {
@@ -404,23 +586,29 @@ export class OAuthRefreshCoordinator {
404
586
  // provider still holds it; otherwise settle here once the write ends.
405
587
  const tokens = flight.acceptedTokens;
406
588
  if (tokens !== undefined &&
407
- flight.persist !== undefined &&
408
589
  this.pendingMutations.get(generation) === flight.mutationId) {
409
590
  flight.writing = true;
410
- void flight.persist(tokens).then(() => {
411
- if (this.finishMutation(generation, flight)) {
412
- this.settle(generation, flight, { status: "refreshed" });
413
- }
414
- }, (writeError) => {
591
+ const written = (outcome) => {
415
592
  if (this.finishMutation(generation, flight)) {
416
- this.settle(generation, flight, { status: "failed", error: writeError });
593
+ this.settle(generation, flight, outcome);
417
594
  }
418
- });
595
+ };
596
+ detach(Effect.tryPromise({
597
+ try: () => flight.persist(tokens),
598
+ catch: (writeError) => writeError,
599
+ }).pipe(Effect.match({
600
+ onSuccess: () => written({ status: "refreshed" }),
601
+ onFailure: (error) => written({ status: "failed", error }),
602
+ })));
419
603
  return;
420
604
  }
421
605
  // A response alone is not a write and must never strand the generation.
422
606
  this.finishMutation(generation, flight);
423
- this.settle(generation, flight, { status: "failed", error });
607
+ this.settle(generation, flight, {
608
+ status: "failed",
609
+ error,
610
+ ...(verdict !== undefined ? { verdict } : {}),
611
+ });
424
612
  }
425
613
  /** Finish an exact failed credential write, then publish its failure. */
426
614
  failMutation(generation, flight, error) {
@@ -459,6 +647,13 @@ function legacyStoredOAuthValue(value) {
459
647
  typeof candidate.generation === "string" &&
460
648
  "value" in candidate);
461
649
  }
650
+ function sealedOAuthValue(value) {
651
+ if (!value || typeof value !== "object")
652
+ return false;
653
+ const candidate = value;
654
+ return (candidate.connectaOAuthSealed === SEALED_VALUE_VERSION &&
655
+ typeof candidate.sealed === "string");
656
+ }
462
657
  function isModernGeneration(generation) {
463
658
  return (generation.startsWith(ACTIVE_GENERATION_PREFIX) ||
464
659
  generation.startsWith(RESETTING_GENERATION_PREFIX) ||
@@ -493,6 +688,7 @@ export class KvOAuthProvider {
493
688
  redirectUri;
494
689
  refreshCoordinator;
495
690
  allowAuthorization;
691
+ sealer;
496
692
  /**
497
693
  * The reset generation this provider's flow started under. Every OAuth value
498
694
  * it writes carries this epoch, so a late write can land after a reset without
@@ -500,14 +696,19 @@ export class KvOAuthProvider {
500
696
  */
501
697
  capturedGeneration = null;
502
698
  refreshFlight;
699
+ /** How the last refresh this provider took part in failed, if it did. */
700
+ refreshFailure;
503
701
  /** Tokens this request's issuer-aware auth flow decided to refresh. */
504
702
  refreshBasis;
505
- constructor(connectorId, storage, redirectUri, refreshCoordinator, allowAuthorization = true) {
703
+ constructor(connectorId, storage, redirectUri, refreshCoordinator, allowAuthorization = true,
704
+ /** Present when the deployment's vault can seal; credentials are then ciphertext. */
705
+ sealer) {
506
706
  this.connectorId = connectorId;
507
707
  this.storage = storage;
508
708
  this.redirectUri = redirectUri;
509
709
  this.refreshCoordinator = refreshCoordinator;
510
710
  this.allowAuthorization = allowAuthorization;
711
+ this.sealer = sealer;
511
712
  }
512
713
  /**
513
714
  * Stamp the force-reauth generation the current connect flow started under.
@@ -525,6 +726,72 @@ export class KvOAuthProvider {
525
726
  captureRefreshFlight(generation, flight) {
526
727
  this.refreshFlight = { generation, flight };
527
728
  }
729
+ /**
730
+ * @internal How the refresh this provider owned or joined failed, recorded by
731
+ * the coordinator; `undefined` clears it. The SDK learns nothing from this
732
+ * directly — the hooks it calls next (`state`, `saveCodeVerifier`,
733
+ * `redirectToAuthorization`, `invalidateCredentials`) read it.
734
+ */
735
+ recordRefreshFailure(failure) {
736
+ this.refreshFailure = failure;
737
+ }
738
+ /**
739
+ * @internal Delete the grant the authorization server just refused, but only
740
+ * while storage still holds exactly that refresh token in this generation: a
741
+ * consent that completed meanwhile must survive. Where the store has
742
+ * `compareAndSet`, the delete is conditional on the exact raw string read
743
+ * (ciphertext, when sealed), so a write landing in between wins. A store
744
+ * without it (Workers KV) gets the read followed by a plain delete, and a
745
+ * write landing between the two can still be lost — the same window the
746
+ * SDK's own invalidation has. Best effort: if storage fails, the next
747
+ * refresh is refused again and ends the same way.
748
+ */
749
+ async discardRefusedGrant(refreshToken, generation) {
750
+ try {
751
+ const read = await this.readStoredValue("oauth:tokens", (raw) => JSON.parse(raw));
752
+ if (!read ||
753
+ read.stored.generation !== generation ||
754
+ read.stored.value.refresh_token !== refreshToken) {
755
+ return;
756
+ }
757
+ const physicalKey = oauthValueStorageKey("oauth:tokens", generation);
758
+ if (this.storage.compareAndSet) {
759
+ await this.storage.compareAndSet(physicalKey, read.raw, null);
760
+ }
761
+ else {
762
+ await this.storage.delete(physicalKey);
763
+ }
764
+ }
765
+ catch {
766
+ // See above: a refusal that could not be recorded recurs, it does not hide.
767
+ }
768
+ }
769
+ /**
770
+ * Why a passive request cannot start consent. After a transient refresh
771
+ * failure that is a retryable outage, not an authorization problem: the SDK
772
+ * falls through to authorization on any refresh it could not parse, and
773
+ * answering that with `UnauthorizedError` would tell the agent a working
774
+ * grant needs consent.
775
+ */
776
+ authorizationRefused() {
777
+ const failure = this.refreshFailure;
778
+ return failure?.kind === "transient"
779
+ ? this.refreshOutage(failure)
780
+ : new UnauthorizedError("Authorization required. Use authorize_connector or Connect to start consent.");
781
+ }
782
+ refreshOutage(failure) {
783
+ const { cause } = failure;
784
+ return new ConnectorCallError(failure.status === 429 ? "rate_limited" : "unavailable", `Connector "${this.connectorId}" could not refresh its OAuth grant: the ` +
785
+ `authorization server ${failure.reason}. The grant is kept; retry later.`, {
786
+ ...(cause !== undefined ? { cause } : {}),
787
+ ...(failure.retryAfterMs !== undefined
788
+ ? { retryAfterMs: failure.retryAfterMs }
789
+ : {}),
790
+ ...(cause instanceof ConnectorCallError && cause.details
791
+ ? { details: cause.details }
792
+ : {}),
793
+ });
794
+ }
528
795
  failRefreshFlight(error) {
529
796
  const owned = this.refreshFlight;
530
797
  this.refreshFlight = undefined;
@@ -559,21 +826,46 @@ export class KvOAuthProvider {
559
826
  */
560
827
  async writeValue(key, value, serializeLegacy, issuer) {
561
828
  const generation = await this.writeGeneration();
829
+ await this.storeInGeneration(key, generation, () => {
830
+ const stored = {
831
+ connectaOAuthVersion: STORED_VALUE_VERSION,
832
+ generation,
833
+ ...(issuer !== undefined ? { issuer } : {}),
834
+ value,
835
+ };
836
+ return isModernGeneration(generation) || issuer !== undefined
837
+ ? JSON.stringify(stored)
838
+ : serializeLegacy(value);
839
+ });
840
+ }
841
+ /**
842
+ * The generation fence every write goes through: refuse unless `generation`
843
+ * is still the live, writable epoch, write (sealed when the key holds a
844
+ * credential and a sealer exists), then clean up if the epoch moved under
845
+ * the write. With `expected`, the write also requires the physical key to
846
+ * still hold exactly that raw value, so re-encoding cannot resurrect a value
847
+ * another request replaced in the meantime.
848
+ */
849
+ async storeInGeneration(key, generation, serialize, expected) {
562
850
  if (generation.startsWith(RESETTING_GENERATION_PREFIX) ||
563
851
  generation.startsWith(DISCONNECTED_GENERATION_PREFIX) ||
564
852
  (await this.generation()) !== generation) {
565
853
  return;
566
854
  }
567
- const stored = {
568
- connectaOAuthVersion: STORED_VALUE_VERSION,
569
- generation,
570
- ...(issuer !== undefined ? { issuer } : {}),
571
- value,
572
- };
573
855
  const physicalKey = oauthValueStorageKey(key, generation);
574
- await this.storage.set(physicalKey, isModernGeneration(generation) || issuer !== undefined
575
- ? JSON.stringify(stored)
576
- : serializeLegacy(value));
856
+ const plaintext = serialize();
857
+ const serialized = this.sealer && SEALED_OAUTH_KEYS.has(key)
858
+ ? JSON.stringify({
859
+ connectaOAuthSealed: SEALED_VALUE_VERSION,
860
+ sealed: await this.sealer.seal(physicalKey, plaintext),
861
+ })
862
+ : plaintext;
863
+ if (expected === undefined) {
864
+ await this.storage.set(physicalKey, serialized);
865
+ }
866
+ else if (!(await this.replaceExactly(physicalKey, expected, serialized))) {
867
+ return;
868
+ }
577
869
  // If reset landed after the pre-write check and completed its cleanup
578
870
  // before this set, remove the now-unreachable residue ourselves. The epoch
579
871
  // key already provides correctness; this second check is physical hygiene.
@@ -595,21 +887,85 @@ export class KvOAuthProvider {
595
887
  }
596
888
  }
597
889
  }
890
+ /**
891
+ * Compare-and-set where the store has it. A store without it gets a re-read
892
+ * immediately before the write: it narrows the race to one round trip, the
893
+ * same exposure that store already has for every other OAuth write.
894
+ */
895
+ async replaceExactly(physicalKey, expected, next) {
896
+ if (this.storage.compareAndSet) {
897
+ return this.storage.compareAndSet(physicalKey, expected, next);
898
+ }
899
+ if ((await this.storage.get(physicalKey)) !== expected)
900
+ return false;
901
+ await this.storage.set(physicalKey, next);
902
+ return true;
903
+ }
904
+ /**
905
+ * Open a sealed credential. Anything that does not open — a tampered value,
906
+ * a rotated vault key, ciphertext copied from another connector or owner, or
907
+ * sealed state with no sealer configured — reads as absent, which fails
908
+ * closed to reauthorization.
909
+ */
910
+ async openValue(key, physicalKey, sealed) {
911
+ if (!this.sealer || !SEALED_OAUTH_KEYS.has(key))
912
+ return undefined;
913
+ try {
914
+ return await this.sealer.open(physicalKey, sealed);
915
+ }
916
+ catch {
917
+ this.sealer.warn(`[connecta] connector "${this.connectorId}" has sealed OAuth state ` +
918
+ `(${key}) the configured vault cannot open; treating it as absent, ` +
919
+ "so the connector needs authorization again. A changed vault key " +
920
+ "or tampered storage causes this.");
921
+ return undefined;
922
+ }
923
+ }
924
+ /**
925
+ * Re-encode plaintext an older release (or a vault-less deployment) wrote,
926
+ * in place and byte-for-byte, through the same generation fence as every
927
+ * write. A failure leaves the plaintext readable; the next write seals it.
928
+ */
929
+ async sealInPlace(key, generation, raw) {
930
+ try {
931
+ await this.storeInGeneration(key, generation, () => raw, raw);
932
+ }
933
+ catch {
934
+ this.sealer?.warn(`[connecta] connector "${this.connectorId}" could not seal plaintext ` +
935
+ `OAuth state (${key}); it stays readable and is sealed on its next write.`);
936
+ }
937
+ }
598
938
  /**
599
939
  * Read a value only when it belongs to the active generation. Plain legacy
600
940
  * values remain readable until the first v2 reset, so upgrades do not discard
601
941
  * an existing grant; once a modern epoch exists, untagged residue fails
602
- * closed.
942
+ * closed. With a sealer, a credential found in plaintext is returned and
943
+ * then sealed where it lies.
603
944
  */
604
945
  async readValue(key, parseLegacy) {
946
+ const read = await this.readStoredValue(key, parseLegacy);
947
+ if (!read)
948
+ return undefined;
949
+ if (read.plaintextCredential) {
950
+ await this.sealInPlace(key, read.stored.generation, read.raw);
951
+ }
952
+ return read.stored;
953
+ }
954
+ /**
955
+ * `readValue` without the in-place sealing, keeping the exact raw string
956
+ * (ciphertext, when sealed) the value came from, for a compare-and-set.
957
+ */
958
+ async readStoredValue(key, parseLegacy) {
605
959
  const generation = await this.generation();
606
- const raw = await this.storage.get(oauthValueStorageKey(key, generation));
960
+ const physicalKey = oauthValueStorageKey(key, generation);
961
+ const raw = await this.storage.get(physicalKey);
607
962
  if (raw === null)
608
963
  return undefined;
609
964
  if (generation.startsWith(RESETTING_GENERATION_PREFIX) ||
610
965
  generation.startsWith(DISCONNECTED_GENERATION_PREFIX)) {
611
966
  return undefined;
612
967
  }
968
+ let text = raw;
613
969
  let parsed;
614
970
  try {
615
971
  parsed = JSON.parse(raw);
@@ -617,6 +973,27 @@ export class KvOAuthProvider {
617
973
  catch {
618
974
  // Raw string state from a pre-envelope deployment is handled below.
619
975
  }
976
+ let plaintextCredential = false;
977
+ if (sealedOAuthValue(parsed)) {
978
+ const opened = await this.openValue(key, physicalKey, parsed.sealed);
979
+ if (opened === undefined)
980
+ return undefined;
981
+ text = opened;
982
+ parsed = undefined;
983
+ try {
984
+ parsed = JSON.parse(text);
985
+ }
986
+ catch {
987
+ // A sealed raw legacy string, handled below like its plaintext form.
988
+ }
989
+ }
990
+ else {
991
+ plaintextCredential = this.sealer !== undefined && SEALED_OAUTH_KEYS.has(key);
992
+ }
993
+ const stored = this.interpretValue(text, parsed, generation, parseLegacy);
994
+ return stored ? { stored, raw, plaintextCredential } : undefined;
995
+ }
996
+ interpretValue(raw, parsed, generation, parseLegacy) {
620
997
  if (storedOAuthValue(parsed)) {
621
998
  return parsed.generation === generation
622
999
  ? {
@@ -758,6 +1135,7 @@ export class KvOAuthProvider {
758
1135
  await this.writeValue("oauth:tokens", tokens, (value) => JSON.stringify(value), ctx?.issuer);
759
1136
  if (coordinated)
760
1137
  this.refreshCoordinator?.succeedMutation(owned.generation, owned.flight);
1138
+ this.refreshFailure = undefined;
761
1139
  }
762
1140
  catch (error) {
763
1141
  if (coordinated)
@@ -782,7 +1160,7 @@ export class KvOAuthProvider {
782
1160
  */
783
1161
  async state() {
784
1162
  if (!this.allowAuthorization)
785
- throw new UnauthorizedError("Authorization required. Use authorize_connector or Connect to start consent.");
1163
+ throw this.authorizationRefused();
786
1164
  const value = randomState();
787
1165
  await this.writeValue("oauth:state", value, (raw) => raw);
788
1166
  return value;
@@ -802,7 +1180,7 @@ export class KvOAuthProvider {
802
1180
  }
803
1181
  async saveCodeVerifier(verifier) {
804
1182
  if (!this.allowAuthorization)
805
- throw new UnauthorizedError("Authorization required. Use authorize_connector or Connect to start consent.");
1183
+ throw this.authorizationRefused();
806
1184
  await this.writeValue("oauth:verifier", verifier, (raw) => raw);
807
1185
  }
808
1186
  async codeVerifier() {
@@ -815,7 +1193,7 @@ export class KvOAuthProvider {
815
1193
  async redirectToAuthorization(authorizationUrl) {
816
1194
  try {
817
1195
  if (!this.allowAuthorization)
818
- throw new UnauthorizedError("Authorization required. Use authorize_connector or Connect to start consent.");
1196
+ throw this.authorizationRefused();
819
1197
  await this.writeValue("oauth:pending", authorizationUrl.toString(), (raw) => raw);
820
1198
  this.failRefreshFlight(new Error("OAuth refresh required reauthorization before tokens were saved."));
821
1199
  }
@@ -952,5 +1330,11 @@ export class KvOAuthProvider {
952
1330
  this.failRefreshFlight(error);
953
1331
  throw error;
954
1332
  }
1333
+ // The SDK invalidates after a refused refresh and then starts over, which
1334
+ // on `invalid_client` means registering a new client. A passive request
1335
+ // must not begin authorization, so it ends here, as auth_required.
1336
+ if (!this.allowAuthorization && this.refreshFailure?.kind === "dead") {
1337
+ throw this.authorizationRefused();
1338
+ }
955
1339
  }
956
1340
  }