@zackbart/connecta 0.26.3 → 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 (42) hide show
  1. package/AGENTS.md +5 -5
  2. package/CHANGELOG.md +72 -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 +95 -36
  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.js +57 -17
  20. package/dist/credentials.js +2 -1
  21. package/dist/execute.js +15 -0
  22. package/dist/index.js +1 -0
  23. package/dist/oauth-partition.js +32 -0
  24. package/dist/operator-ui/generated.js +1 -1
  25. package/dist/providers/notion.js +4 -1
  26. package/dist/providers/vercel.js +13 -9
  27. package/dist/registry.d.ts +1 -0
  28. package/dist/registry.js +58 -25
  29. package/dist/resumable.js +42 -10
  30. package/dist/routes/credentials.js +16 -8
  31. package/dist/routes/oauth.js +70 -64
  32. package/dist/runtime/run.js +7 -1
  33. package/dist/storage/file.js +57 -20
  34. package/dist/storage/memory.js +8 -1
  35. package/dist/version.d.ts +1 -1
  36. package/dist/version.js +1 -1
  37. package/documentation/architecture.md +44 -3
  38. package/documentation/auth.md +7 -4
  39. package/examples/worker/README.md +4 -3
  40. package/examples/worker/src/d1-storage.ts +27 -5
  41. package/package.json +3 -3
  42. package/templates/node/package.json +2 -2
@@ -428,6 +428,19 @@ function waitForRefreshFlight(flight, signal) {
428
428
  * operation, so a second isolate can still race the same refresh token.
429
429
  */
430
430
  export class OAuthRefreshCoordinator {
431
+ retainPartition;
432
+ constructor(retainPartition = () => () => { }) {
433
+ this.retainPartition = retainPartition;
434
+ }
435
+ /** Keep an accepted credential write alive independently of its caller. */
436
+ retainWork() {
437
+ return this.retainPartition();
438
+ }
439
+ releaseFlightPartition(flight) {
440
+ if (flight.settled && !flight.answerPending && !flight.recoveryPending) {
441
+ flight.releasePartition();
442
+ }
443
+ }
431
444
  flights = new Map();
432
445
  /** Opaque identities only: no request promise, signal, callback, or response. */
433
446
  pendingMutations = new Map();
@@ -457,8 +470,10 @@ export class OAuthRefreshCoordinator {
457
470
  if (this.flights.get(generation) !== flight)
458
471
  return;
459
472
  this.flights.delete(generation);
473
+ flight.settled = true;
460
474
  this.advanceStateRevision();
461
475
  flight.stopObservingOwnerAbort();
476
+ this.releaseFlightPartition(flight);
462
477
  // Last, because joined fibers resume inside this call.
463
478
  Deferred.doneUnsafe(flight.outcome, Effect.succeed(outcome));
464
479
  }
@@ -582,7 +597,11 @@ export class OAuthRefreshCoordinator {
582
597
  stopObservingOwnerAbort: () => { },
583
598
  mutationId: {},
584
599
  writing: false,
585
- persist: (tokens) => provider.saveTokens(tokens),
600
+ answerPending: true,
601
+ recoveryPending: false,
602
+ settled: false,
603
+ releasePartition: this.retainWork(),
604
+ persist: (tokens) => provider.saveAcceptedRefreshTokens(tokens, generation, flight),
586
605
  };
587
606
  this.flights.set(generation, flight);
588
607
  this.advanceStateRevision();
@@ -628,7 +647,11 @@ export class OAuthRefreshCoordinator {
628
647
  if (!this.markMutationPending(generation, flight)) {
629
648
  // Already settled (the owner was cancelled first). Still write the
630
649
  // rotation: losing it would leave a dead credential.
631
- detach(Effect.promise(() => flight.persist(accepted)));
650
+ flight.recoveryPending = true;
651
+ detach(Effect.promise(() => flight.persist(accepted)).pipe(Effect.ensuring(Effect.sync(() => {
652
+ flight.recoveryPending = false;
653
+ this.releaseFlightPartition(flight);
654
+ }))));
632
655
  return Effect.fail(new Error("OAuth refresh ended before tokens could be saved."));
633
656
  }
634
657
  // The SDK holds the answer from here, after this fiber is gone, so
@@ -639,6 +662,11 @@ export class OAuthRefreshCoordinator {
639
662
  }
640
663
  return Effect.succeed(response);
641
664
  };
665
+ const finishAnswer = Effect.sync(() => {
666
+ flight.answerPending = false;
667
+ this.releaseFlightPartition(flight);
668
+ });
669
+ const commitAnswer = (value) => commit(value).pipe(Effect.ensuring(finishAnswer));
642
670
  // The owner's redemption. Its abort interrupts the wait for the token
643
671
  // endpoint and nothing after it: an answer, once it exists, is
644
672
  // committed before anyone is released, so no caller is let go to
@@ -647,7 +675,7 @@ export class OAuthRefreshCoordinator {
647
675
  // as the foreground would have.
648
676
  const redemption = Effect.uninterruptibleMask((restore) => restore(Effect.tryPromise({ try: () => answer, catch: (error) => error })).pipe(Effect.onInterrupt(() => Effect.sync(() => {
649
677
  this.fail(generation, flight, aborted(requestSignal));
650
- detach(Effect.promise(() => answer).pipe(Effect.flatMap(commit)));
678
+ detach(Effect.promise(() => answer).pipe(Effect.flatMap(commitAnswer), Effect.ensuring(finishAnswer)));
651
679
  })), Effect.catch((error) => {
652
680
  // No answer at all — the network, or a body too large to be
653
681
  // one — is an outage. A refusal connecta itself raised (a
@@ -661,9 +689,13 @@ export class OAuthRefreshCoordinator {
661
689
  : undefined;
662
690
  if (verdict)
663
691
  provider.recordRefreshFailure(verdict);
692
+ flight.answerPending = false;
664
693
  this.fail(generation, flight, error, verdict);
694
+ // Retirement may already have removed the flight from the map.
695
+ // Its rejected answer still finishes the retained partition work.
696
+ this.releaseFlightPartition(flight);
665
697
  return Effect.fail(error);
666
- }), Effect.flatMap(commit)));
698
+ }), Effect.flatMap(commitAnswer)));
667
699
  return await runEdge(requestSignal
668
700
  ? Effect.raceAllFirst([
669
701
  redemption,
@@ -712,6 +744,7 @@ export class OAuthRefreshCoordinator {
712
744
  if (tokens !== undefined &&
713
745
  this.pendingMutations.get(generation) === flight.mutationId) {
714
746
  flight.writing = true;
747
+ flight.recoveryPending = true;
715
748
  const written = (outcome) => {
716
749
  if (this.finishMutation(generation, flight)) {
717
750
  this.settle(generation, flight, outcome);
@@ -723,7 +756,10 @@ export class OAuthRefreshCoordinator {
723
756
  }).pipe(Effect.match({
724
757
  onSuccess: () => written({ status: "refreshed" }),
725
758
  onFailure: (error) => written({ status: "failed", error }),
726
- })));
759
+ }), Effect.ensuring(Effect.sync(() => {
760
+ flight.recoveryPending = false;
761
+ this.releaseFlightPartition(flight);
762
+ }))));
727
763
  return;
728
764
  }
729
765
  // A response alone is not a write and must never strand the generation.
@@ -1001,7 +1037,7 @@ export class KvOAuthProvider {
1001
1037
  * check-then-write race: a late old write cannot overwrite a replacement
1002
1038
  * flow's value because the two writes have different physical keys.
1003
1039
  */
1004
- async writeValue(key, value, serializeLegacy, issuer, writtenAt, binding) {
1040
+ async writeValue(key, value, serializeLegacy, issuer, writtenAt, binding, commitAcceptedRefresh = false) {
1005
1041
  const generation = await this.writeGeneration();
1006
1042
  await this.storeInGeneration(key, generation, () => {
1007
1043
  const stored = {
@@ -1015,7 +1051,7 @@ export class KvOAuthProvider {
1015
1051
  return isModernGeneration(generation) || issuer !== undefined
1016
1052
  ? JSON.stringify(stored)
1017
1053
  : serializeLegacy(value);
1018
- });
1054
+ }, undefined, commitAcceptedRefresh);
1019
1055
  }
1020
1056
  /**
1021
1057
  * The generation fence every write goes through: refuse unless `generation`
@@ -1025,8 +1061,8 @@ export class KvOAuthProvider {
1025
1061
  * still hold exactly that raw value, so re-encoding cannot resurrect a value
1026
1062
  * another request replaced in the meantime.
1027
1063
  */
1028
- async storeInGeneration(key, generation, serialize, expected) {
1029
- if (this.signal?.aborted ||
1064
+ async storeInGeneration(key, generation, serialize, expected, commitAcceptedRefresh = false) {
1065
+ if ((!commitAcceptedRefresh && this.signal?.aborted) ||
1030
1066
  generation.startsWith(RESETTING_GENERATION_PREFIX) ||
1031
1067
  generation.startsWith(DISCONNECTED_GENERATION_PREFIX) ||
1032
1068
  (await this.generation()) !== generation) {
@@ -1040,7 +1076,7 @@ export class KvOAuthProvider {
1040
1076
  sealed: await this.sealer.seal(physicalKey, plaintext),
1041
1077
  })
1042
1078
  : plaintext;
1043
- if (this.signal?.aborted)
1079
+ if (!commitAcceptedRefresh && this.signal?.aborted)
1044
1080
  return;
1045
1081
  if (expected === undefined) {
1046
1082
  await this.storage.set(physicalKey, serialized);
@@ -1052,7 +1088,7 @@ export class KvOAuthProvider {
1052
1088
  // before this set, remove the now-unreachable residue ourselves. The epoch
1053
1089
  // key already provides correctness; this second check is physical hygiene.
1054
1090
  const current = await this.generation();
1055
- if (current !== generation || this.signal?.aborted) {
1091
+ if (current !== generation || (!commitAcceptedRefresh && this.signal?.aborted)) {
1056
1092
  try {
1057
1093
  // On cancellation the generation may still be active. A newer flow
1058
1094
  // could have written this key while the old storage set was pending.
@@ -1216,12 +1252,26 @@ export class KvOAuthProvider {
1216
1252
  * returning no credentials, so every isolate drops the old registration and
1217
1253
  * token set and the SDK starts authorization from scratch.
1218
1254
  */
1219
- async readIssuerBoundValue(key, parseLegacy, serializeLegacy, ctx) {
1220
- const stored = await this.readValue(key, parseLegacy);
1221
- if (!stored || !ctx)
1222
- return stored?.value;
1255
+ async readIssuerBoundValue(key, parseLegacy, ctx) {
1256
+ const read = await this.readStoredValue(key, parseLegacy);
1257
+ if (!read)
1258
+ return undefined;
1259
+ const stored = read.stored;
1260
+ if (!ctx || stored.issuer !== undefined) {
1261
+ if (read.plaintextCredential) {
1262
+ await this.sealInPlace(key, stored.generation, read.raw);
1263
+ }
1264
+ if (!ctx)
1265
+ return stored.value;
1266
+ }
1223
1267
  if (stored.issuer === undefined) {
1224
- await this.writeValue(key, stored.value, serializeLegacy, ctx.issuer);
1268
+ // Binding is a migration of the value just read, never a new grant.
1269
+ // A rotation or revocation that replaced it must win this race.
1270
+ await this.storeInGeneration(key, stored.generation, () => JSON.stringify({
1271
+ connectaOAuthVersion: STORED_VALUE_VERSION,
1272
+ ...stored,
1273
+ issuer: ctx.issuer,
1274
+ }), read.raw);
1225
1275
  return stored.value;
1226
1276
  }
1227
1277
  if (stored.issuer === ctx.issuer)
@@ -1350,7 +1400,7 @@ export class KvOAuthProvider {
1350
1400
  };
1351
1401
  }
1352
1402
  async clientInformation(ctx) {
1353
- return this.readIssuerBoundValue("oauth:client", (raw) => JSON.parse(raw), (value) => JSON.stringify(value), ctx);
1403
+ return this.readIssuerBoundValue("oauth:client", (raw) => JSON.parse(raw), ctx);
1354
1404
  }
1355
1405
  async saveClientInformation(info, ctx) {
1356
1406
  await this.writeValue("oauth:client", info, (value) => JSON.stringify(value), ctx?.issuer, undefined, this.clientBinding);
@@ -1366,7 +1416,7 @@ export class KvOAuthProvider {
1366
1416
  const successIdentity = refreshGeneration !== undefined
1367
1417
  ? this.refreshCoordinator?.successfulRefreshIdentity(refreshGeneration)
1368
1418
  : undefined;
1369
- const tokens = await this.readIssuerBoundValue("oauth:tokens", (raw) => JSON.parse(raw), (value) => JSON.stringify(value), ctx);
1419
+ const tokens = await this.readIssuerBoundValue("oauth:tokens", (raw) => JSON.parse(raw), ctx);
1370
1420
  if (ctx && tokens && refreshGeneration !== undefined) {
1371
1421
  this.refreshBasis = {
1372
1422
  accessToken: tokens.access_token,
@@ -1380,16 +1430,35 @@ export class KvOAuthProvider {
1380
1430
  return tokens;
1381
1431
  }
1382
1432
  async saveTokens(tokens, ctx) {
1433
+ const owned = this.refreshFlight;
1434
+ if (owned?.flight.acceptedTokens !== undefined) {
1435
+ await this.persistAcceptedTokens(tokens, ctx, owned);
1436
+ }
1437
+ else {
1438
+ await this.persistTokens(tokens, ctx, false, owned);
1439
+ }
1440
+ }
1441
+ /** @internal Commit an already redeemed rotation even after its request leaves. */
1442
+ async saveAcceptedRefreshTokens(tokens, generation, flight) {
1443
+ await this.persistAcceptedTokens(tokens, undefined, { generation, flight });
1444
+ }
1445
+ persistAcceptedTokens(tokens, ctx, owned) {
1446
+ // The SDK can save the same response while cancellation recovery is
1447
+ // writing it. A second write could land after the next rotation, so both
1448
+ // paths await this exact flight's one commit, including its failure.
1449
+ return owned.flight.persistence ??= this.persistTokens(tokens, ctx, true, owned);
1450
+ }
1451
+ async persistTokens(tokens, ctx, commitAcceptedRefresh, owned) {
1452
+ const releasePartition = this.refreshCoordinator?.retainWork();
1383
1453
  // A retired flight (the owner was cancelled or superseded after the
1384
1454
  // token response) still writes: the authorization server has already
1385
1455
  // consumed the old refresh token, so dropping the rotated one would leave a
1386
1456
  // dead credential. writeValue itself refuses when the generation moved.
1387
1457
  // Only the coordinator bookkeeping belongs to the exact live flight.
1388
- const owned = this.refreshFlight;
1389
1458
  const coordinated = owned !== undefined &&
1390
1459
  this.refreshCoordinator?.beginMutation(owned.generation, owned.flight) === true;
1391
1460
  try {
1392
- await this.writeValue("oauth:tokens", tokens, (value) => JSON.stringify(value), ctx?.issuer);
1461
+ await this.writeValue("oauth:tokens", tokens, (value) => JSON.stringify(value), ctx?.issuer, undefined, undefined, commitAcceptedRefresh);
1393
1462
  if (coordinated)
1394
1463
  this.refreshCoordinator?.succeedMutation(owned.generation, owned.flight);
1395
1464
  this.refreshFailure = undefined;
@@ -1404,6 +1473,7 @@ export class KvOAuthProvider {
1404
1473
  // already detached the provider's flight while storage was pending.
1405
1474
  if (this.refreshFlight === owned)
1406
1475
  this.refreshFlight = undefined;
1476
+ releasePartition?.();
1407
1477
  }
1408
1478
  }
1409
1479
  /**
@@ -1602,22 +1672,11 @@ export class KvOAuthProvider {
1602
1672
  // old physical namespace is unreadable. There is deliberately no second
1603
1673
  // "finalize" write: concurrent resets therefore cannot overwrite a newer
1604
1674
  // reset's epoch after their cleanup finishes out of order.
1605
- try {
1606
- await this.storage.set("oauth:generation", active);
1607
- this.refreshCoordinator?.retire(previous);
1608
- }
1609
- catch (error) {
1610
- try {
1611
- await this.deleteAll([
1612
- cleanupBacklogKey(active),
1613
- cleanupTimesKey(active),
1614
- ]);
1615
- }
1616
- catch {
1617
- // Best-effort removal of a manifest for an epoch never activated.
1618
- }
1619
- throw error;
1620
- }
1675
+ // A rejection may follow a committed fence. Keep its lineage even when
1676
+ // the answer is lost: the next reset must still find the retired grants.
1677
+ // A manifest for an epoch that never activated is harmless residue.
1678
+ await this.storage.set("oauth:generation", active);
1679
+ this.refreshCoordinator?.retire(previous);
1621
1680
  if (reusableClient && !this.signal?.aborted) {
1622
1681
  // readValue opened the old physical key; writeValue seals the plaintext
1623
1682
  // under the new one. Copying ciphertext would fail its AAD check.
@@ -1,4 +1,6 @@
1
1
  import type { Connector, ConnectorContext } from "./types.js";
2
+ export declare function claimConnectorScopeCleanup(ctx: ConnectorContext, connectorId: string): () => void;
3
+ export declare function connectorScopeCleanupClaimed(ctx: ConnectorContext, connectorId: string): boolean;
2
4
  /** Runtime hook for work that may safely continue after a response is ready. */
3
5
  export type DeferredWork = (promise: Promise<unknown>) => void;
4
6
  /**
@@ -1,5 +1,32 @@
1
1
  import { closeScope } from "./runtime/connector-scope.js";
2
2
  import { runEdge } from "./runtime/run.js";
3
+ // Internal ownership for credential probes invoked by a route. Context copies
4
+ // keep the requestScope, and each connector retains its own cleanup ownership.
5
+ // Standalone connector tests still close themselves.
6
+ const cleanupOwners = new WeakMap();
7
+ export function claimConnectorScopeCleanup(ctx, connectorId) {
8
+ const scope = ctx.requestScope ?? ctx;
9
+ let owners = cleanupOwners.get(scope);
10
+ if (!owners)
11
+ cleanupOwners.set(scope, owners = new Map());
12
+ owners.set(connectorId, (owners.get(connectorId) ?? 0) + 1);
13
+ let released = false;
14
+ return () => {
15
+ if (released)
16
+ return;
17
+ released = true;
18
+ const remaining = owners.get(connectorId) - 1;
19
+ if (remaining)
20
+ owners.set(connectorId, remaining);
21
+ else
22
+ owners.delete(connectorId);
23
+ if (!owners.size)
24
+ cleanupOwners.delete(scope);
25
+ };
26
+ }
27
+ export function connectorScopeCleanupClaimed(ctx, connectorId) {
28
+ return cleanupOwners.get(ctx.requestScope ?? ctx)?.has(connectorId) === true;
29
+ }
3
30
  /**
4
31
  * Tell a connector that a scope owned by the core has ended.
5
32
  *
@@ -30,8 +30,14 @@ function checkToolContract(id, tool) {
30
30
  }
31
31
  /** A static connector; every tool passes {@link checkToolContract} first. */
32
32
  export function api(id, opts) {
33
- for (const t of opts.tools)
34
- checkToolContract(id, t);
33
+ const names = new Set();
34
+ for (const tool of opts.tools) {
35
+ if (names.has(tool.name)) {
36
+ throw new Error(`api() tool "${id}.${tool.name}" is declared more than once; discovery and dispatch must use one definition.`);
37
+ }
38
+ names.add(tool.name);
39
+ checkToolContract(id, tool);
40
+ }
35
41
  const defs = opts.tools.map((t) => ({
36
42
  name: t.name,
37
43
  description: t.description,
@@ -3,10 +3,12 @@ import { Deferred, Duration, Effect, Exit, Scope } from "effect";
3
3
  import { KvOAuthProvider, OAuthRefreshCoordinator, } from "../auth/downstream-oauth.js";
4
4
  import { trackOAuthStartReset } from "../auth/oauth-start-reset.js";
5
5
  import { MAX_CATALOG_TOOLS } from "../catalog-limits.js";
6
+ import { connectorScopeCleanupClaimed } from "../connector-scope.js";
6
7
  import { boundedEchoText, ConnectorCallError, msg, unavailableCallError, } from "../errors.js";
7
8
  import { CONNECTA_VERSION } from "../version.js";
8
9
  import { learnedUrlRefusal } from "../url-safety.js";
9
10
  import { inheritOAuthSealer, oauthSealerFor } from "../oauth-sealing.js";
11
+ import { attachOAuthPartition, oauthPartitionFor, retainOAuthPartition } from "../oauth-partition.js";
10
12
  import { detach, runEdge } from "../runtime/run.js";
11
13
  /**
12
14
  * Apply a maintained provider's slot copy and header framing to credential
@@ -426,7 +428,18 @@ export function remoteMcp(id, opts) {
426
428
  const isOauth = opts.auth?.type === "oauth";
427
429
  // Long-lived enough for distinct request scopes in this connector runtime to
428
430
  // join one token redemption. It owns no client, transport, or request state.
429
- const refreshCoordinator = new OAuthRefreshCoordinator();
431
+ const refreshCoordinators = new WeakMap();
432
+ const refreshCoordinatorFor = (ctx) => {
433
+ // Production contexts carry a stable registry partition. Hand-written
434
+ // contexts can coordinate by their shared storage object instead.
435
+ const partition = oauthPartitionFor(ctx) ?? ctx.storage;
436
+ let coordinator = refreshCoordinators.get(partition);
437
+ if (!coordinator) {
438
+ coordinator = new OAuthRefreshCoordinator(() => retainOAuthPartition(partition));
439
+ refreshCoordinators.set(partition, coordinator);
440
+ }
441
+ return coordinator;
442
+ };
430
443
  const logger = opts.logger ?? console;
431
444
  const credentialAuth = opts.auth?.type === "credential" ? opts.auth : undefined;
432
445
  if (credentialAuth?.credential?.fields?.length) {
@@ -604,7 +617,7 @@ export function remoteMcp(id, opts) {
604
617
  const newProvider = (ctx, state) => {
605
618
  if (state?.provider)
606
619
  return state.provider;
607
- const provider = new KvOAuthProvider(id, ctx.storage, `${ctx.baseUrl}/oauth/callback/${id}`, refreshCoordinator, ctx.allowAuthorization === true, oauthSealerFor(ctx), ctx.signal, JSON.stringify({
620
+ const provider = new KvOAuthProvider(id, ctx.storage, `${ctx.baseUrl}/oauth/callback/${id}`, refreshCoordinatorFor(ctx), ctx.allowAuthorization === true, oauthSealerFor(ctx), ctx.signal, JSON.stringify({
608
621
  url: new URL(opts.url).href,
609
622
  redirectUri: `${ctx.baseUrl}/oauth/callback/${id}`,
610
623
  clientMetadata: {
@@ -639,7 +652,7 @@ export function remoteMcp(id, opts) {
639
652
  const oauthProvider = provider ?? newProvider(ctx);
640
653
  return new StreamableHTTPClientTransport(url, {
641
654
  authProvider: oauthProvider,
642
- fetch: refreshCoordinator.coordinatedFetch(oauthProvider, learnedUrlSafeFetch(id, url, guardedFetch), ctx.signal),
655
+ fetch: refreshCoordinatorFor(ctx).coordinatedFetch(oauthProvider, learnedUrlSafeFetch(id, url, guardedFetch), ctx.signal),
643
656
  });
644
657
  }
645
658
  const headers = opts.auth?.type === "headers"
@@ -975,9 +988,10 @@ export function remoteMcp(id, opts) {
975
988
  return { ok: false, message: msg(err) };
976
989
  }
977
990
  finally {
978
- // A test owns the scope it just opened; leaving the session for
979
- // the downstream to age out is not this button's to spend.
980
- await connector.closeScope?.(ctx);
991
+ // Standalone tests own their scope; an operator route owns
992
+ // bounded teardown for the context it supplied to this hook.
993
+ if (!connectorScopeCleanupClaimed(ctx, id))
994
+ await connector.closeScope?.(ctx);
981
995
  }
982
996
  },
983
997
  }
@@ -1198,17 +1212,23 @@ export function remoteMcp(id, opts) {
1198
1212
  const leased = state.transport;
1199
1213
  const t = (leased ??
1200
1214
  buildTransport(ctx, provider));
1201
- if (callbackParams !== undefined) {
1202
- await t.finishAuth(callbackParams);
1215
+ try {
1216
+ if (callbackParams !== undefined) {
1217
+ await t.finishAuth(callbackParams);
1218
+ }
1219
+ else {
1220
+ await t.finishAuth(code);
1221
+ }
1222
+ await provider.clearPending();
1223
+ // Reset so the next use reconnects with the freshly stored tokens.
1224
+ closeHalf(state);
1203
1225
  }
1204
- else {
1205
- await t.finishAuth(code);
1226
+ finally {
1227
+ // This exchange-only transport has no lease in the request scope.
1228
+ // It must close even when redemption or pending-state cleanup fails.
1229
+ if (!leased)
1230
+ detach(closeConnection(null, t, ctx.logger));
1206
1231
  }
1207
- await provider.clearPending();
1208
- // Reset so the next use reconnects with the freshly stored tokens.
1209
- closeHalf(state);
1210
- if (!leased)
1211
- detach(closeConnection(null, t, ctx.logger));
1212
1232
  },
1213
1233
  };
1214
1234
  if (opts.auth?.type === "oauth") {
@@ -1220,11 +1240,11 @@ export function remoteMcp(id, opts) {
1220
1240
  await disconnectAuthorization(ctx, stateFor(ctx), true);
1221
1241
  };
1222
1242
  connector.startAuth = async (ctx, startOpts) => {
1223
- ctx = inheritOAuthSealer(ctx, {
1243
+ ctx = attachOAuthPartition(inheritOAuthSealer(ctx, {
1224
1244
  ...ctx,
1225
1245
  requestScope: ctx.requestScope ?? ctx,
1226
1246
  allowAuthorization: true,
1227
- });
1247
+ }), oauthPartitionFor(ctx));
1228
1248
  const state = stateFor(ctx);
1229
1249
  state.provider = null;
1230
1250
  const p = newProvider(ctx, state);
@@ -1274,5 +1294,25 @@ export function remoteMcp(id, opts) {
1274
1294
  }
1275
1295
  };
1276
1296
  }
1297
+ if (isOauth) {
1298
+ // Pin before the first asynchronous storage/discovery read, not only once
1299
+ // a refresh flight exists. These operations do not take call admission.
1300
+ const retain = (operation, contextIndex) => async (...args) => {
1301
+ const release = retainOAuthPartition(oauthPartitionFor(args[contextIndex]));
1302
+ try {
1303
+ return await operation(...args);
1304
+ }
1305
+ finally {
1306
+ release();
1307
+ }
1308
+ };
1309
+ connector.listTools = retain(connector.listTools, 0);
1310
+ connector.callTool = retain(connector.callTool, 2);
1311
+ connector.status = retain(connector.status, 0);
1312
+ connector.startAuth = retain(connector.startAuth, 0);
1313
+ connector.disconnectAuth = retain(connector.disconnectAuth, 0);
1314
+ connector.verifyState = retain(connector.verifyState, 1);
1315
+ connector.finishAuth = retain(connector.finishAuth, 1);
1316
+ }
1277
1317
  return connector;
1278
1318
  }
@@ -134,7 +134,8 @@ export class CredentialVault {
134
134
  }
135
135
  }
136
136
  async get(connectorId, field = "value", owner) {
137
- return (await this.read(connectorId, owner))?.values[field] ?? null;
137
+ const values = (await this.read(connectorId, owner))?.values;
138
+ return values && Object.hasOwn(values, field) ? values[field] : null;
138
139
  }
139
140
  async getAll(connectorId, owner) {
140
141
  const credential = await this.read(connectorId, owner);
package/dist/execute.js CHANGED
@@ -966,7 +966,22 @@ function failedRun(err, logger, { emitted, diagnostics }) {
966
966
  }
967
967
  /** The response for an ExecuteResult: the program's error or its value. */
968
968
  function finishedRun(outcome, { emitted, diagnostics, invocationFailures }) {
969
+ if (outcome === null || typeof outcome !== "object" || Array.isArray(outcome)) {
970
+ return failureResponse("Executor failed: expected an ExecuteResult object.", {
971
+ emitted,
972
+ diagnostics,
973
+ code: "executor_failed",
974
+ });
975
+ }
969
976
  const logs = executeLogs(outcome.logs);
977
+ if (outcome.error !== undefined && typeof outcome.error !== "string") {
978
+ return failureResponse("Executor failed: ExecuteResult.error must be a string.", {
979
+ logs,
980
+ emitted,
981
+ diagnostics,
982
+ code: "executor_failed",
983
+ });
984
+ }
970
985
  if (outcome.error !== undefined) {
971
986
  // Executor bridges necessarily reduce thrown host errors to strings.
972
987
  // Match that terminal string back to the request-local typed failure so
package/dist/index.js CHANGED
@@ -582,6 +582,7 @@ export function createConnecta(config) {
582
582
  };
583
583
  const refreshRunner = createProgramRunner(registry.scoped({ connectorIds: [] }), config.publicUrl, executor, logger, undefined, refreshConfig);
584
584
  config.artifacts.bindRefresh({
585
+ ...(config.ui?.branding ? { branding: config.ui.branding } : {}),
585
586
  claimMs: refreshRunner.claimMs,
586
587
  execute: async (program, owner, signal) => {
587
588
  if (!owner)
@@ -0,0 +1,32 @@
1
+ // Registry-owned identity for downstream credential coordination. Storage
2
+ // wrappers are rebuilt per context, and epochs can be identical across owners.
3
+ // Keep this beside the context rather than widening the public connector API.
4
+ const partitions = new WeakMap();
5
+ const activeWork = new WeakMap();
6
+ export function retainOAuthPartition(partition) {
7
+ if (!partition)
8
+ return () => { };
9
+ activeWork.set(partition, (activeWork.get(partition) ?? 0) + 1);
10
+ let released = false;
11
+ return () => {
12
+ if (released)
13
+ return;
14
+ released = true;
15
+ const remaining = (activeWork.get(partition) ?? 1) - 1;
16
+ if (remaining === 0)
17
+ activeWork.delete(partition);
18
+ else
19
+ activeWork.set(partition, remaining);
20
+ };
21
+ }
22
+ export function oauthPartitionIdle(partition) {
23
+ return !activeWork.has(partition);
24
+ }
25
+ export function attachOAuthPartition(ctx, partition) {
26
+ if (partition)
27
+ partitions.set(ctx, partition);
28
+ return ctx;
29
+ }
30
+ export function oauthPartitionFor(ctx) {
31
+ return partitions.get(ctx);
32
+ }