@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
@@ -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.
@@ -850,6 +886,8 @@ export class KvOAuthProvider {
850
886
  signal;
851
887
  clientBinding;
852
888
  onReset;
889
+ scope;
890
+ clientMetadataUrl;
853
891
  /**
854
892
  * The reset generation this provider's flow started under. Every OAuth value
855
893
  * it writes carries this epoch, so a late write can land after a reset without
@@ -869,7 +907,9 @@ export class KvOAuthProvider {
869
907
  /** Stable connector configuration bound to dynamically registered clients. */
870
908
  clientBinding,
871
909
  /** Request-local observer so the operator route drains every reset it began. */
872
- onReset) {
910
+ onReset,
911
+ /** SDK uses this only when the authorization server advertises support. */
912
+ clientMetadataUrl, scope) {
873
913
  this.connectorId = connectorId;
874
914
  this.storage = storage;
875
915
  this.redirectUri = redirectUri;
@@ -879,6 +919,9 @@ export class KvOAuthProvider {
879
919
  this.signal = signal;
880
920
  this.clientBinding = clientBinding;
881
921
  this.onReset = onReset;
922
+ this.scope = scope;
923
+ if (clientMetadataUrl !== undefined)
924
+ this.clientMetadataUrl = clientMetadataUrl;
882
925
  }
883
926
  /**
884
927
  * Stamp the force-reauth generation the current connect flow started under.
@@ -994,7 +1037,7 @@ export class KvOAuthProvider {
994
1037
  * check-then-write race: a late old write cannot overwrite a replacement
995
1038
  * flow's value because the two writes have different physical keys.
996
1039
  */
997
- async writeValue(key, value, serializeLegacy, issuer, writtenAt, binding) {
1040
+ async writeValue(key, value, serializeLegacy, issuer, writtenAt, binding, commitAcceptedRefresh = false) {
998
1041
  const generation = await this.writeGeneration();
999
1042
  await this.storeInGeneration(key, generation, () => {
1000
1043
  const stored = {
@@ -1008,7 +1051,7 @@ export class KvOAuthProvider {
1008
1051
  return isModernGeneration(generation) || issuer !== undefined
1009
1052
  ? JSON.stringify(stored)
1010
1053
  : serializeLegacy(value);
1011
- });
1054
+ }, undefined, commitAcceptedRefresh);
1012
1055
  }
1013
1056
  /**
1014
1057
  * The generation fence every write goes through: refuse unless `generation`
@@ -1018,8 +1061,8 @@ export class KvOAuthProvider {
1018
1061
  * still hold exactly that raw value, so re-encoding cannot resurrect a value
1019
1062
  * another request replaced in the meantime.
1020
1063
  */
1021
- async storeInGeneration(key, generation, serialize, expected) {
1022
- if (this.signal?.aborted ||
1064
+ async storeInGeneration(key, generation, serialize, expected, commitAcceptedRefresh = false) {
1065
+ if ((!commitAcceptedRefresh && this.signal?.aborted) ||
1023
1066
  generation.startsWith(RESETTING_GENERATION_PREFIX) ||
1024
1067
  generation.startsWith(DISCONNECTED_GENERATION_PREFIX) ||
1025
1068
  (await this.generation()) !== generation) {
@@ -1033,7 +1076,7 @@ export class KvOAuthProvider {
1033
1076
  sealed: await this.sealer.seal(physicalKey, plaintext),
1034
1077
  })
1035
1078
  : plaintext;
1036
- if (this.signal?.aborted)
1079
+ if (!commitAcceptedRefresh && this.signal?.aborted)
1037
1080
  return;
1038
1081
  if (expected === undefined) {
1039
1082
  await this.storage.set(physicalKey, serialized);
@@ -1045,7 +1088,7 @@ export class KvOAuthProvider {
1045
1088
  // before this set, remove the now-unreachable residue ourselves. The epoch
1046
1089
  // key already provides correctness; this second check is physical hygiene.
1047
1090
  const current = await this.generation();
1048
- if (current !== generation || this.signal?.aborted) {
1091
+ if (current !== generation || (!commitAcceptedRefresh && this.signal?.aborted)) {
1049
1092
  try {
1050
1093
  // On cancellation the generation may still be active. A newer flow
1051
1094
  // could have written this key while the old storage set was pending.
@@ -1209,12 +1252,26 @@ export class KvOAuthProvider {
1209
1252
  * returning no credentials, so every isolate drops the old registration and
1210
1253
  * token set and the SDK starts authorization from scratch.
1211
1254
  */
1212
- async readIssuerBoundValue(key, parseLegacy, serializeLegacy, ctx) {
1213
- const stored = await this.readValue(key, parseLegacy);
1214
- if (!stored || !ctx)
1215
- 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
+ }
1216
1267
  if (stored.issuer === undefined) {
1217
- 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);
1218
1275
  return stored.value;
1219
1276
  }
1220
1277
  if (stored.issuer === ctx.issuer)
@@ -1339,10 +1396,11 @@ export class KvOAuthProvider {
1339
1396
  grant_types: ["authorization_code", "refresh_token"],
1340
1397
  response_types: ["code"],
1341
1398
  token_endpoint_auth_method: "none",
1399
+ ...(this.scope !== undefined ? { scope: this.scope } : {}),
1342
1400
  };
1343
1401
  }
1344
1402
  async clientInformation(ctx) {
1345
- 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);
1346
1404
  }
1347
1405
  async saveClientInformation(info, ctx) {
1348
1406
  await this.writeValue("oauth:client", info, (value) => JSON.stringify(value), ctx?.issuer, undefined, this.clientBinding);
@@ -1358,7 +1416,7 @@ export class KvOAuthProvider {
1358
1416
  const successIdentity = refreshGeneration !== undefined
1359
1417
  ? this.refreshCoordinator?.successfulRefreshIdentity(refreshGeneration)
1360
1418
  : undefined;
1361
- 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);
1362
1420
  if (ctx && tokens && refreshGeneration !== undefined) {
1363
1421
  this.refreshBasis = {
1364
1422
  accessToken: tokens.access_token,
@@ -1372,16 +1430,35 @@ export class KvOAuthProvider {
1372
1430
  return tokens;
1373
1431
  }
1374
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();
1375
1453
  // A retired flight (the owner was cancelled or superseded after the
1376
1454
  // token response) still writes: the authorization server has already
1377
1455
  // consumed the old refresh token, so dropping the rotated one would leave a
1378
1456
  // dead credential. writeValue itself refuses when the generation moved.
1379
1457
  // Only the coordinator bookkeeping belongs to the exact live flight.
1380
- const owned = this.refreshFlight;
1381
1458
  const coordinated = owned !== undefined &&
1382
1459
  this.refreshCoordinator?.beginMutation(owned.generation, owned.flight) === true;
1383
1460
  try {
1384
- 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);
1385
1462
  if (coordinated)
1386
1463
  this.refreshCoordinator?.succeedMutation(owned.generation, owned.flight);
1387
1464
  this.refreshFailure = undefined;
@@ -1396,6 +1473,7 @@ export class KvOAuthProvider {
1396
1473
  // already detached the provider's flight while storage was pending.
1397
1474
  if (this.refreshFlight === owned)
1398
1475
  this.refreshFlight = undefined;
1476
+ releasePartition?.();
1399
1477
  }
1400
1478
  }
1401
1479
  /**
@@ -1594,22 +1672,11 @@ export class KvOAuthProvider {
1594
1672
  // old physical namespace is unreadable. There is deliberately no second
1595
1673
  // "finalize" write: concurrent resets therefore cannot overwrite a newer
1596
1674
  // reset's epoch after their cleanup finishes out of order.
1597
- try {
1598
- await this.storage.set("oauth:generation", active);
1599
- this.refreshCoordinator?.retire(previous);
1600
- }
1601
- catch (error) {
1602
- try {
1603
- await this.deleteAll([
1604
- cleanupBacklogKey(active),
1605
- cleanupTimesKey(active),
1606
- ]);
1607
- }
1608
- catch {
1609
- // Best-effort removal of a manifest for an epoch never activated.
1610
- }
1611
- throw error;
1612
- }
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);
1613
1680
  if (reusableClient && !this.signal?.aborted) {
1614
1681
  // readValue opened the old physical key; writeValue seals the plaintext
1615
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,
@@ -38,6 +38,10 @@ export type RemoteMcpAuth = {
38
38
  headers: Record<string, string>;
39
39
  } | RemoteMcpCredentialAuth | {
40
40
  type: "oauth";
41
+ /** Public HTTPS client metadata document, for servers supporting URL-based client IDs. */
42
+ clientMetadataUrl?: string;
43
+ /** Space-separated default scopes when the resource server does not advertise them. */
44
+ scope?: string;
41
45
  };
42
46
  /**
43
47
  * Apply a maintained provider's slot copy and header framing to credential
@@ -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
@@ -396,6 +398,22 @@ function learnedUrlSafeFetch(connectorId, configured, baseFetch) {
396
398
  * server or hide other connectors).
397
399
  */
398
400
  export function remoteMcp(id, opts) {
401
+ const clientMetadataUrl = opts.auth?.type === "oauth" ? opts.auth.clientMetadataUrl : undefined;
402
+ const oauthScope = opts.auth?.type === "oauth" ? opts.auth.scope : undefined;
403
+ if (oauthScope !== undefined && !/^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/.test(oauthScope)) {
404
+ throw new Error(`[connecta] connector "${id}" OAuth scope must contain space-separated scope tokens.`);
405
+ }
406
+ if (clientMetadataUrl !== undefined) {
407
+ let valid = false;
408
+ try {
409
+ const url = new URL(clientMetadataUrl);
410
+ valid = url.protocol === "https:" && url.pathname !== "/" &&
411
+ !url.username && !url.password && !url.hash;
412
+ }
413
+ catch { /* Invalid configuration is rejected below without echoing its value. */ }
414
+ if (!valid)
415
+ throw new Error(`[connecta] connector "${id}" clientMetadataUrl must be an HTTPS URL with a non-root path, without credentials or a fragment.`);
416
+ }
399
417
  if (opts.authScope === "personal" && opts.auth?.type === "headers") {
400
418
  throw new Error(`[connecta] connector "${id}" cannot combine authScope "personal" ` +
401
419
  "with static headers. Use credential or OAuth auth so each principal " +
@@ -410,7 +428,18 @@ export function remoteMcp(id, opts) {
410
428
  const isOauth = opts.auth?.type === "oauth";
411
429
  // Long-lived enough for distinct request scopes in this connector runtime to
412
430
  // join one token redemption. It owns no client, transport, or request state.
413
- 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
+ };
414
443
  const logger = opts.logger ?? console;
415
444
  const credentialAuth = opts.auth?.type === "credential" ? opts.auth : undefined;
416
445
  if (credentialAuth?.credential?.fields?.length) {
@@ -588,7 +617,7 @@ export function remoteMcp(id, opts) {
588
617
  const newProvider = (ctx, state) => {
589
618
  if (state?.provider)
590
619
  return state.provider;
591
- 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({
592
621
  url: new URL(opts.url).href,
593
622
  redirectUri: `${ctx.baseUrl}/oauth/callback/${id}`,
594
623
  clientMetadata: {
@@ -597,11 +626,13 @@ export function remoteMcp(id, opts) {
597
626
  grant_types: ["authorization_code", "refresh_token"],
598
627
  response_types: ["code"],
599
628
  token_endpoint_auth_method: "none",
629
+ ...(oauthScope !== undefined ? { scope: oauthScope } : {}),
600
630
  },
601
631
  authScope: opts.authScope ?? "shared",
602
632
  versionNegotiation: opts.versionNegotiation ?? "auto",
603
633
  redirects: opts.redirects ?? "none",
604
- }), (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset));
634
+ clientMetadataUrl,
635
+ }), (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset), clientMetadataUrl, oauthScope);
605
636
  if (state)
606
637
  state.provider = provider;
607
638
  return provider;
@@ -621,7 +652,7 @@ export function remoteMcp(id, opts) {
621
652
  const oauthProvider = provider ?? newProvider(ctx);
622
653
  return new StreamableHTTPClientTransport(url, {
623
654
  authProvider: oauthProvider,
624
- fetch: refreshCoordinator.coordinatedFetch(oauthProvider, learnedUrlSafeFetch(id, url, guardedFetch), ctx.signal),
655
+ fetch: refreshCoordinatorFor(ctx).coordinatedFetch(oauthProvider, learnedUrlSafeFetch(id, url, guardedFetch), ctx.signal),
625
656
  });
626
657
  }
627
658
  const headers = opts.auth?.type === "headers"
@@ -957,9 +988,10 @@ export function remoteMcp(id, opts) {
957
988
  return { ok: false, message: msg(err) };
958
989
  }
959
990
  finally {
960
- // A test owns the scope it just opened; leaving the session for
961
- // the downstream to age out is not this button's to spend.
962
- 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);
963
995
  }
964
996
  },
965
997
  }
@@ -1180,17 +1212,23 @@ export function remoteMcp(id, opts) {
1180
1212
  const leased = state.transport;
1181
1213
  const t = (leased ??
1182
1214
  buildTransport(ctx, provider));
1183
- if (callbackParams !== undefined) {
1184
- 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);
1185
1225
  }
1186
- else {
1187
- 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));
1188
1231
  }
1189
- await provider.clearPending();
1190
- // Reset so the next use reconnects with the freshly stored tokens.
1191
- closeHalf(state);
1192
- if (!leased)
1193
- detach(closeConnection(null, t, ctx.logger));
1194
1232
  },
1195
1233
  };
1196
1234
  if (opts.auth?.type === "oauth") {
@@ -1202,11 +1240,11 @@ export function remoteMcp(id, opts) {
1202
1240
  await disconnectAuthorization(ctx, stateFor(ctx), true);
1203
1241
  };
1204
1242
  connector.startAuth = async (ctx, startOpts) => {
1205
- ctx = inheritOAuthSealer(ctx, {
1243
+ ctx = attachOAuthPartition(inheritOAuthSealer(ctx, {
1206
1244
  ...ctx,
1207
1245
  requestScope: ctx.requestScope ?? ctx,
1208
1246
  allowAuthorization: true,
1209
- });
1247
+ }), oauthPartitionFor(ctx));
1210
1248
  const state = stateFor(ctx);
1211
1249
  state.provider = null;
1212
1250
  const p = newProvider(ctx, state);
@@ -1256,5 +1294,25 @@ export function remoteMcp(id, opts) {
1256
1294
  }
1257
1295
  };
1258
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
+ }
1259
1317
  return connector;
1260
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
+ }