@zackbart/connecta 0.26.2 → 0.27.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 (43) hide show
  1. package/AGENTS.md +5 -5
  2. package/CHANGELOG.md +86 -1
  3. package/bin/connecta.mjs +9 -1
  4. package/dist/access-tokens.js +13 -2
  5. package/dist/artifacts/connector.d.ts +3 -0
  6. package/dist/artifacts/connector.js +6 -0
  7. package/dist/artifacts/document.js +6 -0
  8. package/dist/artifacts/module.js +7 -1
  9. package/dist/artifacts/operations.d.ts +3 -1
  10. package/dist/artifacts/operations.js +68 -10
  11. package/dist/artifacts/refresh.d.ts +2 -0
  12. package/dist/artifacts/refresh.js +3 -2
  13. package/dist/artifacts/routes.js +4 -15
  14. package/dist/artifacts/validate.js +1 -2
  15. package/dist/auth/downstream-oauth.js +104 -37
  16. package/dist/connector-scope.d.ts +2 -0
  17. package/dist/connector-scope.js +27 -0
  18. package/dist/connectors/api.js +8 -2
  19. package/dist/connectors/remote-mcp.d.ts +4 -0
  20. package/dist/connectors/remote-mcp.js +76 -18
  21. package/dist/credentials.js +2 -1
  22. package/dist/execute.js +15 -0
  23. package/dist/index.js +1 -0
  24. package/dist/oauth-partition.js +32 -0
  25. package/dist/operator-ui/generated.js +1 -1
  26. package/dist/providers/notion.js +4 -1
  27. package/dist/providers/vercel.js +13 -9
  28. package/dist/registry.d.ts +1 -0
  29. package/dist/registry.js +58 -25
  30. package/dist/resumable.js +42 -10
  31. package/dist/routes/credentials.js +16 -8
  32. package/dist/routes/oauth.js +70 -64
  33. package/dist/runtime/run.js +7 -1
  34. package/dist/storage/file.js +57 -20
  35. package/dist/storage/memory.js +8 -1
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/documentation/architecture.md +44 -3
  39. package/documentation/auth.md +30 -4
  40. package/examples/worker/README.md +4 -3
  41. package/examples/worker/src/d1-storage.ts +27 -5
  42. package/package.json +3 -3
  43. package/templates/node/package.json +2 -2
@@ -147,7 +147,10 @@ async function notionRequest(ctx, request) {
147
147
  if (!response.ok) {
148
148
  throw notionFailure(response.status, payload, response.headers);
149
149
  }
150
- return payload ?? {};
150
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
151
+ throw new ConnectorCallError("connector_call_failed", "Notion returned an unreadable or non-object JSON response for a successful status. The request was sent, but its result could not be read; do not repeat a write to recover its result.", { retryable: false });
152
+ }
153
+ return payload;
151
154
  });
152
155
  }
153
156
  // Projections. Notion wraps every property in a discriminated object, every
@@ -83,7 +83,7 @@ function vercelFailure(status, headers, payload) {
83
83
  retryable: false,
84
84
  });
85
85
  }
86
- function parseBody(text, contentType) {
86
+ function parseBody(text, contentType, strict = false) {
87
87
  if (!text)
88
88
  return undefined;
89
89
  try {
@@ -91,6 +91,8 @@ function parseBody(text, contentType) {
91
91
  }
92
92
  catch {
93
93
  if (contentType?.includes("stream+json") || contentType?.includes("ndjson")) {
94
+ if (strict)
95
+ return parseStreamRows(text);
94
96
  const rows = [];
95
97
  for (const line of text.split("\n")) {
96
98
  if (!line.trim())
@@ -104,6 +106,9 @@ function parseBody(text, contentType) {
104
106
  }
105
107
  return rows;
106
108
  }
109
+ if (strict) {
110
+ throw new ConnectorCallError("connector_call_failed", "Vercel returned a malformed successful response.", { retryable: false });
111
+ }
107
112
  return text;
108
113
  }
109
114
  }
@@ -144,14 +149,13 @@ function vercelTransport(baseUrl) {
144
149
  },
145
150
  });
146
151
  }
147
- async function callVercel(send, request, ctx, parseSuccess) {
152
+ async function callVercel(send, request, ctx, success = {}) {
148
153
  return await send(request, ctx, async (response) => {
149
154
  const text = await response.text();
150
- const payload = parseBody(text, response.headers.get("content-type"));
151
155
  if (!response.ok) {
152
- throw vercelFailure(response.status, response.headers, payload);
156
+ throw vercelFailure(response.status, response.headers, parseBody(text, response.headers.get("content-type")));
153
157
  }
154
- return parseSuccess ? parseSuccess(text) : payload;
158
+ return success.parse ? success.parse(text) : parseBody(text, response.headers.get("content-type"), !success.raw);
155
159
  });
156
160
  }
157
161
  function teamQuery(args, defaultTeamId) {
@@ -553,7 +557,7 @@ function tools(send, defaultPageSize, defaultTeamId) {
553
557
  required: ["result"],
554
558
  },
555
559
  handler: async (args, ctx) => ({
556
- result: (await callVercel(send, { method: "GET", ...rawRequest(args, defaultTeamId) }, ctx)) ?? null,
560
+ result: (await callVercel(send, { method: "GET", ...rawRequest(args, defaultTeamId) }, ctx, { raw: true })) ?? null,
557
561
  }),
558
562
  },
559
563
  {
@@ -588,7 +592,7 @@ function tools(send, defaultPageSize, defaultTeamId) {
588
592
  method: args["method"],
589
593
  ...rawRequest(args, defaultTeamId),
590
594
  ...(args["body"] !== undefined ? { body: args["body"] } : {}),
591
- }, ctx)) ?? null,
595
+ }, ctx, { raw: true })) ?? null,
592
596
  }),
593
597
  },
594
598
  {
@@ -641,7 +645,7 @@ function tools(send, defaultPageSize, defaultTeamId) {
641
645
  "Content-Type": args["contentType"],
642
646
  },
643
647
  rawBody: uploadBody(args),
644
- }, ctx)) ?? null,
648
+ }, ctx, { raw: true })) ?? null,
645
649
  }),
646
650
  },
647
651
  {
@@ -839,7 +843,7 @@ function tools(send, defaultPageSize, defaultTeamId) {
839
843
  method: "GET",
840
844
  path: `/v1/projects/${encodeURIComponent(args["projectId"])}/deployments/${encodeURIComponent(args["deploymentId"])}/runtime-logs`,
841
845
  query: team(args), headers: { Accept: "application/stream+json" },
842
- }, { ...ctx, signal }, parseStreamRows), {
846
+ }, { ...ctx, signal }, { parse: parseStreamRows }), {
843
847
  timeoutMs: RUNTIME_LOG_TIMEOUT_MS,
844
848
  ...(ctx.signal ? { signal: ctx.signal } : {}),
845
849
  timeoutError: new ConnectorCallError("unavailable", `Vercel's runtime-log stream stayed open past this connector's ${RUNTIME_LOG_TIMEOUT_MS / 1_000}-second bound. Retry for a fresh snapshot.`),
@@ -171,6 +171,7 @@ export declare class Registry implements RegistryView {
171
171
  private resultStashBytes;
172
172
  private readonly configuredConnectors;
173
173
  private readonly personalRegistries;
174
+ private readonly oauthPartition;
174
175
  private callAdmissionClosed;
175
176
  /** Bounded FIFO of absent grants already warned about. */
176
177
  private readonly warnedAbsentGrants;
package/dist/registry.js CHANGED
@@ -8,6 +8,7 @@ import { MAX_CATALOG_CHUNK_BYTES, MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTE
8
8
  import { ObservedOutputSchemas } from "./result-shapes.js";
9
9
  import { GUIDE_SUMMARY_LENGTH, normalizeGuideSummary, } from "./skills.js";
10
10
  import { attachOAuthSealer, vaultOAuthSealer } from "./oauth-sealing.js";
11
+ import { attachOAuthPartition, oauthPartitionIdle } from "./oauth-partition.js";
11
12
  import { attachCaller } from "./connector-caller.js";
12
13
  import { closeScopeOnExit } from "./runtime/connector-scope.js";
13
14
  import { detach, runEdge, withDeadlineEffect } from "./runtime/run.js";
@@ -164,6 +165,7 @@ export class Registry {
164
165
  resultStashBytes = 0;
165
166
  configuredConnectors;
166
167
  personalRegistries = new Map();
168
+ oauthPartition = {};
167
169
  callAdmissionClosed = false;
168
170
  /** Bounded FIFO of absent grants already warned about. */
169
171
  warnedAbsentGrants = new Set();
@@ -226,9 +228,12 @@ export class Registry {
226
228
  // principal starts with no generations and no mutation queue, so a
227
229
  // refresh the evicted registry finishes later would persist a listing
228
230
  // that a credential change on the replacement had just deleted.
231
+ // OAuth status/start work bypasses both gates, and accepted rotations
232
+ // may still be saving after their caller leaves. Preserve its partition.
229
233
  const idle = [...this.personalRegistries].find(([, candidate]) => [...candidate.callAdmission.values()].every(admission => admission.isIdle()) &&
230
234
  candidate.catalogRefreshes.size === 0 &&
231
- candidate.catalogMutations.size === 0);
235
+ candidate.catalogMutations.size === 0 &&
236
+ oauthPartitionIdle(candidate.oauthPartition));
232
237
  if (!idle) {
233
238
  throw new Error("Personal connector capacity is exhausted; retry after calls, rolling budgets, and catalog refreshes drain.");
234
239
  }
@@ -287,11 +292,24 @@ export class Registry {
287
292
  }
288
293
  async storeOAuthHandoff(connectorId, state, principalKey) {
289
294
  const key = this.oauthHandoffKey(connectorId, await sha256Hex(state));
290
- const existing = await this.opts.storage.get(key);
291
- if (existing && existing !== principalKey) {
292
- throw new Error(`Connector "${connectorId}" reused one OAuth state across principals`);
295
+ for (let attempt = 0; attempt < 32; attempt++) {
296
+ const existing = await this.opts.storage.get(key);
297
+ if (existing && existing !== principalKey) {
298
+ throw new Error(`Connector "${connectorId}" reused one OAuth state across principals`);
299
+ }
300
+ const expiry = { ttlSeconds: OAUTH_HANDOFF_TTL_SECONDS };
301
+ if (this.opts.storage.compareAndSet) {
302
+ // Bind ownership atomically, including renewal of the same owner's
303
+ // handoff. Two owners reading a miss must not overwrite each other.
304
+ if (!await this.opts.storage.compareAndSet(key, existing, principalKey, expiry))
305
+ continue;
306
+ }
307
+ else {
308
+ await this.opts.storage.set(key, principalKey, expiry);
309
+ }
310
+ return;
293
311
  }
294
- await this.opts.storage.set(key, principalKey, { ttlSeconds: OAUTH_HANDOFF_TTL_SECONDS });
312
+ throw new Error(`OAuth handoff for "${connectorId}" is busy; retry authorization`);
295
313
  }
296
314
  async oauthCallbackView(connectorId, state) {
297
315
  const connector = this.connectors.get(connectorId);
@@ -389,7 +407,10 @@ export class Registry {
389
407
  return values;
390
408
  };
391
409
  credentialAccess = {
392
- get: async (field = "value") => (await readValues())?.[field] ?? null,
410
+ get: async (field = "value") => {
411
+ const values = await readValues();
412
+ return values && Object.hasOwn(values, field) ? values[field] : null;
413
+ },
393
414
  getAll: readValues,
394
415
  };
395
416
  }
@@ -401,6 +422,7 @@ export class Registry {
401
422
  requestScope,
402
423
  ...callOptions,
403
424
  };
425
+ attachOAuthPartition(context, this.oauthPartition);
404
426
  // Downstream OAuth state is sealed under the vault key, bound to this
405
427
  // connector and owner. The sealer rides beside the context, not on it.
406
428
  return this.opts.credentialVault
@@ -818,8 +840,12 @@ export class Registry {
818
840
  const turn = Deferred.makeUnsafe();
819
841
  this.catalogMutations.set(id, turn);
820
842
  try {
821
- if (previous)
822
- await runEdge(Deferred.await(previous));
843
+ if (previous) {
844
+ // A cross-request Deferred alone looks hung to workerd. Keep a timer
845
+ // owned by this request until the preceding mutation hands over its
846
+ // turn; the race interrupts the timer as soon as the wait settles.
847
+ await runEdge(Effect.raceFirst(Deferred.await(previous), Effect.forever(Effect.sleep(Duration.seconds(1)))));
848
+ }
823
849
  await runOnPartition(operation, this.opts);
824
850
  }
825
851
  finally {
@@ -841,14 +867,8 @@ export class Registry {
841
867
  // observation, but must not overwrite a newer same-generation refresh.
842
868
  // Blocking callers do not set this flag and retain their prior publication
843
869
  // behavior when an inbound abort races a completed listing.
844
- if (skipPublicationWhenAborted && ctx.signal?.aborted)
845
- return tools;
846
- // The caller that began this refresh may still use its result, but a
847
- // credential/OAuth change that landed while listTools was in flight, or a
848
- // flight abandoned while it listed, means the listing must not enter
849
- // either shared cache layer.
850
- if (!this.mayPublish(id, generation, flight))
851
- return tools;
870
+ // Every returned listing must pass the ceilings, even when invalidation
871
+ // or abandonment means it can no longer enter either shared cache layer.
852
872
  if (tools.length > MAX_CATALOG_TOOLS) {
853
873
  const message = `Connector "${id}" returned ${tools.length} tools, over the ` +
854
874
  `${MAX_CATALOG_TOOLS}-tool catalog ceiling; refusing the complete catalog.`;
@@ -857,10 +877,6 @@ export class Registry {
857
877
  }
858
878
  const previous = this.cache.get(id);
859
879
  const snapshot = await snapshotCatalog(tools);
860
- if (!this.mayPublish(id, generation, flight) ||
861
- (skipPublicationWhenAborted && ctx.signal?.aborted)) {
862
- return tools;
863
- }
864
880
  if (snapshot.serializedBytes.byteLength > MAX_SERIALIZED_CATALOG_BYTES) {
865
881
  const message = `Connector "${id}" returned a ${snapshot.serializedBytes.byteLength}-byte ` +
866
882
  `serialized catalog, over the ${MAX_SERIALIZED_CATALOG_BYTES}-byte ceiling; ` +
@@ -868,6 +884,13 @@ export class Registry {
868
884
  this.opts.logger.warn(`[connecta] ${message}`);
869
885
  throw new Error(message);
870
886
  }
887
+ // The caller that began this refresh may still use its bounded result,
888
+ // but credential changes, abandoned flights, and deferred cancellation
889
+ // prevent publication. Recheck after snapshotting, which is asynchronous.
890
+ if (!this.mayPublish(id, generation, flight) ||
891
+ (skipPublicationWhenAborted && ctx.signal?.aborted)) {
892
+ return tools;
893
+ }
871
894
  const now = Date.now();
872
895
  const catalogChanged = !previous || previous.fingerprint !== snapshot.fingerprint;
873
896
  const shouldPersist = catalogChanged ||
@@ -1296,23 +1319,33 @@ class ScopedRegistryView {
1296
1319
  allowed;
1297
1320
  scope;
1298
1321
  maxResultBytes;
1299
- personal;
1322
+ /** An evicted view's admission controller must remain closed. */
1323
+ admissionPersonal;
1324
+ get personal() {
1325
+ return this.scope.principalKey
1326
+ ? this.root.personalRegistry(this.scope.principalKey)
1327
+ : undefined;
1328
+ }
1300
1329
  constructor(root, allowed, scope) {
1301
1330
  this.root = root;
1302
1331
  this.allowed = allowed;
1303
1332
  this.scope = scope;
1304
1333
  this.maxResultBytes = root.maxResultBytes;
1305
- this.personal = scope.principalKey
1334
+ // Check capacity at construction, then resolve the current registry on
1335
+ // use. A retained view must not revive one evicted while it was idle.
1336
+ this.admissionPersonal = scope.principalKey
1306
1337
  ? root.personalRegistry(scope.principalKey)
1307
1338
  : undefined;
1308
1339
  }
1309
- registryFor(id) {
1340
+ registryFor(id, admission = false) {
1310
1341
  if (!this.allowed.has(id))
1311
1342
  return undefined;
1312
1343
  const connector = this.root.getConnector(id);
1313
1344
  if (!connector)
1314
1345
  return undefined;
1315
- return connector.authScope === "personal" ? this.personal : this.root;
1346
+ if (connector.authScope !== "personal")
1347
+ return this.root;
1348
+ return admission ? this.admissionPersonal : this.personal;
1316
1349
  }
1317
1350
  listConnectors() {
1318
1351
  return this.root.listConnectors().filter((connector) => this.registryFor(connector.id) !== undefined);
@@ -1358,7 +1391,7 @@ class ScopedRegistryView {
1358
1391
  return attachCaller(registry.contextFor(...args), this.scope.caller);
1359
1392
  }
1360
1393
  admitCall(...args) {
1361
- const registry = this.registryFor(args[0]);
1394
+ const registry = this.registryFor(args[0], true);
1362
1395
  if (!registry) {
1363
1396
  return Promise.reject(new Error(`Unknown connector "${args[0]}"`));
1364
1397
  }
package/dist/resumable.js CHANGED
@@ -226,7 +226,9 @@ export class RunState {
226
226
  /** A claimed replay of a paused run. */
227
227
  static replaying(options) {
228
228
  const { header } = options.claim;
229
- return new RunState(options.journal, options.program, { clockMs: header.clock, seed: header.seed }, "resume_execution", options.settings, options.claim, new ReplayState(options.entries, header.pending?.key));
229
+ const run = new RunState(options.journal, options.program, { clockMs: header.clock, seed: header.seed }, "resume_execution", options.settings, options.claim, new ReplayState(options.entries, header.pending?.key));
230
+ run.writesSpent = header.writes.length;
231
+ return run;
230
232
  }
231
233
  // --- numbering and the gate sequencer ---------------------------------
232
234
  /**
@@ -325,12 +327,9 @@ export class RunState {
325
327
  lookup(key) {
326
328
  if (!this.replay)
327
329
  return { kind: "live" };
328
- const found = this.replay.lookup(key);
329
- // Replayed writes spend the write budget again, as replayed calls spend
330
- // the host-call budget: per-play totals equal per-run totals.
331
- if (found.kind === "hit" && found.entry.write)
332
- this.writesSpent++;
333
- return found;
330
+ // Already-sent writes spend the run budget whether this play repeats them
331
+ // or diverges before doing so. Only new dispatches spend it again.
332
+ return this.replay.lookup(key);
334
333
  }
335
334
  /** Journal a completed live call that was not a write. */
336
335
  record(seq, op, key, outcome) {
@@ -546,11 +545,41 @@ export class RunState {
546
545
  : undefined;
547
546
  if (this.claim && slot !== undefined) {
548
547
  const claim = this.claim;
548
+ const entryBytes = utf8Bytes(RunJournal.entryText(entry));
549
+ let tooLarge = false;
550
+ // Reserve capacity through the same serialized CAS as the write-ahead
551
+ // marks. Concurrent answers cannot both spend the last free bytes.
552
+ const capacity = yield* this.mutateHeader((header) => {
553
+ const pendingBytes = this.unpersisted.reduce((sum, pending) => sum + utf8Bytes(RunJournal.entryText(pending)), 0);
554
+ tooLarge = header.bytes + pendingBytes + entryBytes > MAX_JOURNAL_BYTES;
555
+ if (tooLarge) {
556
+ this.stopWith({
557
+ kind: "failed",
558
+ failure: unknownFailure ?? failure("journal_too_large", `This run recorded more than ${MAX_JOURNAL_BYTES} bytes of source and host calls, too much to keep in its journal. Writes it already sent are counted in writes and are not undone; check them before running the task again.`),
559
+ });
560
+ }
561
+ return {
562
+ ...header,
563
+ bytes: tooLarge ? header.bytes : header.bytes + entryBytes,
564
+ writes: tooLarge
565
+ ? header.writes.map((write) => write.entry === slot ? { ...write, state } : write)
566
+ : header.writes,
567
+ };
568
+ });
569
+ if (capacity !== "ok") {
570
+ this.noteLocally(slot, state);
571
+ const lost = capacity === "lost"
572
+ ? failure("execution_claim_lost", `The write to ${target.address} was sent (${describeState(state)}), but another resume_execution took the run over before its result could be journaled.`)
573
+ : interrupted(`The write to ${target.address} was sent (${describeState(state)}), but paused-run storage failed before its result could be journaled.`);
574
+ this.stopWith({ kind: "failed", failure: lost });
575
+ return guestFailure(lost.code, lost.message);
576
+ }
577
+ if (tooLarge)
578
+ return this.halted();
549
579
  const written = yield* Effect.tryPromise(() => this.journal.writeEntry(slot, entry, claim.header.expiresAt)).pipe(Effect.as(true), Effect.catch(() => Effect.succeed(false)));
550
580
  const recorded = written ? state : "unknown";
551
581
  const settled = yield* this.mutateHeader((header) => ({
552
582
  ...header,
553
- bytes: header.bytes + utf8Bytes(RunJournal.entryText(entry)),
554
583
  writes: header.writes.map((write) => write.entry === slot ? { ...write, state: recorded } : write),
555
584
  }));
556
585
  if (settled !== "ok") {
@@ -670,8 +699,11 @@ export class RunState {
670
699
  const stop = this.stop;
671
700
  if (!stop)
672
701
  return Effect.die(new Error("finishStopped without a stop"));
673
- if (stop.kind === "failed")
674
- return this.finishFailed(stop.failure);
702
+ if (stop.kind === "failed") {
703
+ return this.finishFailed(stop.failure.code === "journal_too_large" && this.writeCounts().unknown > 0
704
+ ? unknownOutcome()
705
+ : stop.failure);
706
+ }
675
707
  if (this.writeCounts().unknown > 0)
676
708
  return this.finishFailed(unknownOutcome());
677
709
  return this.persistPause(stop.pending);
@@ -1,4 +1,5 @@
1
1
  import { Effect } from "effect";
2
+ import { claimConnectorScopeCleanup, closeConnectorScope } from "../connector-scope.js";
2
3
  import { credentialTestRule, describeCredentialTestMismatch, storedCredentialShape, } from "../credential-rules.js";
3
4
  import { authorizedPerson, refuse, serveOperator, visibleRegistry, } from "./operator.js";
4
5
  import { mayManageConnector, isSameOrigin, msg, privateJson, } from "./shared.js";
@@ -164,14 +165,21 @@ function credentialRequest(context, connectorId, action) {
164
165
  }
165
166
  const storedValues = values;
166
167
  const ctx = registry.contextFor(connectorId, baseUrl);
167
- const result = mode === "multiple"
168
- ? await connector.testCredentials(storedValues, ctx)
169
- : await connector.testCredential(
170
- // The single-value shape check above guarantees this key.
171
- storedValues.value, ctx);
172
- const ok = result?.ok === true;
173
- logged(ok, result?.message);
174
- return privateJson({ ok });
168
+ const releaseCleanup = claimConnectorScopeCleanup(ctx, connectorId);
169
+ try {
170
+ const result = mode === "multiple"
171
+ ? await connector.testCredentials(storedValues, ctx)
172
+ : await connector.testCredential(
173
+ // The single-value shape check above guarantees this key.
174
+ storedValues.value, ctx);
175
+ const ok = result?.ok === true;
176
+ logged(ok, result?.message);
177
+ return privateJson({ ok });
178
+ }
179
+ finally {
180
+ releaseCleanup();
181
+ await closeConnectorScope(connector, ctx, context.defer);
182
+ }
175
183
  },
176
184
  catch: (error) => error,
177
185
  }).pipe(Effect.catch((error) => {
@@ -1,4 +1,5 @@
1
1
  import { Effect } from "effect";
2
+ import { closeConnectorScope } from "../connector-scope.js";
2
3
  import { oauthValueStorageKey } from "../auth/downstream-oauth.js";
3
4
  import { escapeHtml, renderPage, resolveBranding, STATUS_ICONS } from "../branding.js";
4
5
  import { oauthCallbackOutcome, providerErrorReason, } from "../oauth-callback-outcome.js";
@@ -127,79 +128,84 @@ async function finishOAuthCallback(context) {
127
128
  await equalizeRefusalCost(connectorContext);
128
129
  return refused();
129
130
  }
130
- const expectedPrincipalKey = callbackTarget?.principalKey;
131
- // A browser returning from consent normally has no MCP Authorization
132
- // header. An interactive bearer provider therefore answers 401 here; state
133
- // and the saved state-to-principal handoff still prove ownership below.
134
- // Rejecting 401 would break that callback. A 403 is an explicit denial.
135
- const browserIdentity = await authorizeUiIdentity(context.request, baseUrl, opts.auth, "OAuth callback", context.runtimeContext, opts.identity);
136
- if (browserIdentity.ok) {
137
- try {
138
- validateAuthPermissions(browserIdentity, opts.registry);
131
+ try {
132
+ const expectedPrincipalKey = callbackTarget?.principalKey;
133
+ // A browser returning from consent normally has no MCP Authorization
134
+ // header. An interactive bearer provider therefore answers 401 here; state
135
+ // and the saved state-to-principal handoff still prove ownership below.
136
+ // Rejecting 401 would break that callback. A 403 is an explicit denial.
137
+ const browserIdentity = await authorizeUiIdentity(context.request, baseUrl, opts.auth, "OAuth callback", context.runtimeContext, opts.identity);
138
+ if (browserIdentity.ok) {
139
+ try {
140
+ validateAuthPermissions(browserIdentity, opts.registry);
141
+ }
142
+ catch {
143
+ return refused();
144
+ }
145
+ if (!mayManageConnector(browserIdentity, connector) || (expectedPrincipalKey && browserIdentity.principalKey !== expectedPrincipalKey))
146
+ return refused();
139
147
  }
140
- catch {
148
+ else if (browserIdentity.response.status === 403 && opts.auth.some(provider => provider.interactiveOperator)) {
141
149
  return refused();
142
150
  }
143
- if (!mayManageConnector(browserIdentity, connector) || (expectedPrincipalKey && browserIdentity.principalKey !== expectedPrincipalKey))
151
+ // CSRF / login-fixation guard: this route is intentionally public, so verify
152
+ // the `state` matches the flow connecta started BEFORE exchanging the code.
153
+ if (!connector.verifyState) {
154
+ await equalizeRefusalCost(connectorContext);
155
+ opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
156
+ `${loggableValue(id)} with 400: it implements finishAuth but no ` +
157
+ "verifyState, so connecta cannot establish that it started this flow. " +
158
+ "No authorization code was exchanged. Implement verifyState before " +
159
+ "trying again.");
144
160
  return refused();
145
- }
146
- else if (browserIdentity.response.status === 403 && opts.auth.some(provider => provider.interactiveOperator)) {
147
- return refused();
148
- }
149
- // CSRF / login-fixation guard: this route is intentionally public, so verify
150
- // the `state` matches the flow connecta started BEFORE exchanging the code.
151
- if (!connector.verifyState) {
152
- await equalizeRefusalCost(connectorContext);
153
- opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
154
- `${loggableValue(id)} with 400: it implements finishAuth but no ` +
155
- "verifyState, so connecta cannot establish that it started this flow. " +
156
- "No authorization code was exchanged. Implement verifyState before " +
157
- "trying again.");
158
- return refused();
159
- }
160
- let stateMatches;
161
- try {
162
- stateMatches = await connector.verifyState(state, connectorContext);
163
- }
164
- catch (err) {
165
- opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
166
- `${loggableValue(id)} with 400: verifyState threw ` +
167
- `${loggableValue(msg(err))}. No authorization code was exchanged. ` +
168
- "Re-run authorization from connecta and check the verifier if it " +
169
- "fails again.");
170
- return refused();
171
- }
172
- if (!stateMatches) {
173
- opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
174
- `${loggableValue(id)} with 400: ` +
175
- (state === null
176
- ? "the state parameter was missing"
177
- : "the state did not match the pending authorization flow") +
178
- ". No authorization code was exchanged. Re-run authorization from " +
179
- "connecta and try again.");
180
- return refused();
181
- }
182
- if (connector.authScope === "personal") {
161
+ }
162
+ let stateMatches;
183
163
  try {
184
- await opts.registry.clearOAuthHandoff(id, state);
164
+ stateMatches = await connector.verifyState(state, connectorContext);
185
165
  }
186
166
  catch (err) {
187
167
  opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
188
- `${loggableValue(id)} with 500: its principal handoff could not be ` +
189
- `consumed (${loggableValue(msg(err))}). No authorization code was exchanged.`);
190
- return html("handoff_failed", opts, connector);
168
+ `${loggableValue(id)} with 400: verifyState threw ` +
169
+ `${loggableValue(msg(err))}. No authorization code was exchanged. ` +
170
+ "Re-run authorization from connecta and check the verifier if it " +
171
+ "fails again.");
172
+ return refused();
173
+ }
174
+ if (!stateMatches) {
175
+ opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
176
+ `${loggableValue(id)} with 400: ` +
177
+ (state === null
178
+ ? "the state parameter was missing"
179
+ : "the state did not match the pending authorization flow") +
180
+ ". No authorization code was exchanged. Re-run authorization from " +
181
+ "connecta and try again.");
182
+ return refused();
183
+ }
184
+ if (connector.authScope === "personal") {
185
+ try {
186
+ await opts.registry.clearOAuthHandoff(id, state);
187
+ }
188
+ catch (err) {
189
+ opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
190
+ `${loggableValue(id)} with 500: its principal handoff could not be ` +
191
+ `consumed (${loggableValue(msg(err))}). No authorization code was exchanged.`);
192
+ return html("handoff_failed", opts, connector);
193
+ }
194
+ }
195
+ try {
196
+ await connector.finishAuth(code, connectorContext, url.searchParams);
197
+ await callbackRegistry.invalidateStored(id);
198
+ return html("connected", opts, connector);
199
+ }
200
+ catch (err) {
201
+ // The page names the reason and nothing else; what the exchange threw can
202
+ // quote a token endpoint's body, so it goes only to the operator log.
203
+ opts.logger.warn(`[connecta] OAuth callback for connector ${loggableValue(id)} failed ` +
204
+ `with 500: the authorization code exchange threw ${loggableValue(msg(err))}.`);
205
+ return html("exchange_failed", opts, connector);
191
206
  }
192
207
  }
193
- try {
194
- await connector.finishAuth(code, connectorContext, url.searchParams);
195
- await callbackRegistry.invalidateStored(id);
196
- return html("connected", opts, connector);
197
- }
198
- catch (err) {
199
- // The page names the reason and nothing else; what the exchange threw can
200
- // quote a token endpoint's body, so it goes only to the operator log.
201
- opts.logger.warn(`[connecta] OAuth callback for connector ${loggableValue(id)} failed ` +
202
- `with 500: the authorization code exchange threw ${loggableValue(msg(err))}.`);
203
- return html("exchange_failed", opts, connector);
208
+ finally {
209
+ await closeConnectorScope(connector, connectorContext, context.defer);
204
210
  }
205
211
  }
@@ -34,7 +34,9 @@ function setMicrotask(task) {
34
34
  export const microtaskScheduler = new Scheduler.MixedScheduler("async", setMicrotask);
35
35
  const runExit = Effect.runPromiseExitWith(Context.make(Scheduler.Scheduler, microtaskScheduler));
36
36
  function abortReason(signal) {
37
- return signal?.reason ?? new DOMException("aborted", "AbortError");
37
+ return signal && signal.reason !== undefined
38
+ ? signal.reason
39
+ : new DOMException("aborted", "AbortError");
38
40
  }
39
41
  /**
40
42
  * Turn a failed Exit back into the value a Promise caller would have seen.
@@ -136,6 +138,10 @@ export function fromSignal(signal) {
136
138
  */
137
139
  export function withDeadlineEffect(operation, options) {
138
140
  return Effect.suspend(() => {
141
+ // Do not dispatch work that already lost its caller. The operation is the
142
+ // first race contender and can finish before the abort contender starts.
143
+ if (options.signal?.aborted)
144
+ return Effect.fail(abortReason(options.signal));
139
145
  const controller = new AbortController();
140
146
  const forwardAbort = () => controller.abort(options.signal?.reason);
141
147
  options.signal?.addEventListener("abort", forwardAbort, { once: true });