@zackbart/connecta 0.27.0 → 0.28.1

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 (73) hide show
  1. package/AGENTS.md +6 -6
  2. package/CHANGELOG.md +229 -0
  3. package/README.md +23 -24
  4. package/bin/connecta.mjs +1 -5
  5. package/dist/activity.d.ts +5 -4
  6. package/dist/activity.js +1 -10
  7. package/dist/artifacts/connector.js +1 -1
  8. package/dist/artifacts/json.js +10 -0
  9. package/dist/artifacts/operations.js +1 -1
  10. package/dist/artifacts/validate.js +1 -2
  11. package/dist/auth/downstream-oauth.js +147 -28
  12. package/dist/auth/static-oauth.js +504 -0
  13. package/dist/catalog-service.js +1 -1
  14. package/dist/connectors/api-connector.d.ts +168 -0
  15. package/dist/connectors/api-connector.js +127 -0
  16. package/dist/connectors/api.d.ts +8 -71
  17. package/dist/connectors/api.js +10 -99
  18. package/dist/connectors/guarded-fetch.js +37 -3
  19. package/dist/connectors/remote-mcp.js +7 -32
  20. package/dist/execute.js +92 -368
  21. package/dist/executors/quickjs.js +1 -1
  22. package/dist/exempt-writes.js +162 -0
  23. package/dist/index.d.ts +13 -32
  24. package/dist/index.js +14 -47
  25. package/dist/invocation.js +6 -19
  26. package/dist/meta-tools.js +1 -2
  27. package/dist/oauth-partition.js +17 -0
  28. package/dist/operator-ui/generated.js +1 -1
  29. package/dist/operator-ui/model.d.ts +3 -3
  30. package/dist/operator-ui/view.js +6 -4
  31. package/dist/providers/basecamp.d.ts +65 -0
  32. package/dist/providers/basecamp.js +445 -0
  33. package/dist/providers/breeze.d.ts +34 -0
  34. package/dist/providers/breeze.js +1402 -0
  35. package/dist/providers/ccb.d.ts +103 -0
  36. package/dist/providers/ccb.js +1570 -0
  37. package/dist/providers/cloudflare.js +1 -1
  38. package/dist/providers/linear.js +1 -1
  39. package/dist/providers/mixpanel.js +1 -1
  40. package/dist/providers/notion.js +1 -1
  41. package/dist/providers/overflow.d.ts +60 -0
  42. package/dist/providers/overflow.js +1572 -0
  43. package/dist/providers/planning-center.d.ts +59 -0
  44. package/dist/providers/planning-center.js +2039 -0
  45. package/dist/providers/revenuecat.js +1 -1
  46. package/dist/providers/stripe.js +1 -1
  47. package/dist/providers/tithely.d.ts +49 -0
  48. package/dist/providers/tithely.js +1034 -0
  49. package/dist/providers/vercel.js +1 -1
  50. package/dist/routes/mcp.js +5 -21
  51. package/dist/routes/oauth.js +29 -3
  52. package/dist/routes/shared.d.ts +1 -6
  53. package/dist/server.js +0 -4
  54. package/dist/skills.js +8 -23
  55. package/dist/tool-safety.d.ts +1 -1
  56. package/dist/tool-safety.js +1 -1
  57. package/dist/types.d.ts +2 -2
  58. package/dist/version.d.ts +1 -1
  59. package/dist/version.js +1 -1
  60. package/documentation/architecture.md +15 -17
  61. package/documentation/auth.md +120 -6
  62. package/documentation/code-mode.md +91 -194
  63. package/documentation/meta-tools.md +38 -41
  64. package/ethos.md +8 -9
  65. package/examples/worker/README.md +10 -14
  66. package/examples/worker/src/d1-activity-row.ts +4 -1
  67. package/examples/worker/src/index.ts +1 -9
  68. package/package.json +26 -2
  69. package/templates/node/AGENTS.md +4 -3
  70. package/templates/node/README.md +3 -4
  71. package/templates/node/package.json +1 -1
  72. package/dist/resumable.js +0 -1323
  73. package/dist/run-journal.js +0 -347
@@ -2,7 +2,30 @@ import { UnauthorizedError } from "@modelcontextprotocol/client";
2
2
  import { Deferred, Effect } from "effect";
3
3
  import { retryAfterMs } from "../connectors/guarded-fetch.js";
4
4
  import { ConnectorCallError } from "../errors.js";
5
+ import { attachOAuthPartition, oauthPartitionFor, retainOAuthPartition, } from "../oauth-partition.js";
6
+ import { inheritOAuthSealer } from "../oauth-sealing.js";
5
7
  import { detach, fromSignal, runEdge } from "../runtime/run.js";
8
+ /** RFC 6749 section 3.3: one or more scope tokens, separated by single spaces. */
9
+ const OAUTH_SCOPE = /^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/;
10
+ /** Refuse, at construction, a configured scope no authorization server could parse. */
11
+ export function assertOAuthScope(connectorId, scope) {
12
+ if (scope !== undefined && !OAUTH_SCOPE.test(scope)) {
13
+ throw new Error(`[connecta] connector "${connectorId}" OAuth scope must contain space-separated scope tokens.`);
14
+ }
15
+ }
16
+ /**
17
+ * The context an explicit authorization start runs under: allowed to begin
18
+ * consent, with a request scope of its own, carrying the registry's sealer and
19
+ * owner partition across the copy. Only `startAuth` builds one; status reads
20
+ * and calls never do, which is what keeps them from starting authorization.
21
+ */
22
+ export function authorizingContext(ctx) {
23
+ return attachOAuthPartition(inheritOAuthSealer(ctx, {
24
+ ...ctx,
25
+ requestScope: ctx.requestScope ?? ctx,
26
+ allowAuthorization: true,
27
+ }), oauthPartitionFor(ctx));
28
+ }
6
29
  /** A 256-bit random opaque value, hex-encoded — used for the OAuth `state`. */
7
30
  function randomState() {
8
31
  const bytes = new Uint8Array(32);
@@ -18,7 +41,7 @@ function timingSafeEqual(a, b) {
18
41
  diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
19
42
  return diff === 0;
20
43
  }
21
- const LEGACY_GENERATION = "legacy";
44
+ export const LEGACY_GENERATION = "legacy";
22
45
  const ACTIVE_GENERATION_PREFIX = "v2:";
23
46
  const RESETTING_GENERATION_PREFIX = "reset:";
24
47
  const DISCONNECTED_GENERATION_PREFIX = "disconnected:";
@@ -592,6 +615,9 @@ export class OAuthRefreshCoordinator {
592
615
  // reaches the token endpoint.
593
616
  if (requestSignal?.aborted)
594
617
  throw aborted(requestSignal);
618
+ // The authorization server this redemption answers to, fixed now: a
619
+ // recovery save outlives the SDK flow that would have stamped it.
620
+ const issuer = provider.refreshIssuer(generation);
595
621
  const flight = {
596
622
  outcome: Deferred.makeUnsafe(),
597
623
  stopObservingOwnerAbort: () => { },
@@ -601,7 +627,7 @@ export class OAuthRefreshCoordinator {
601
627
  recoveryPending: false,
602
628
  settled: false,
603
629
  releasePartition: this.retainWork(),
604
- persist: (tokens) => provider.saveAcceptedRefreshTokens(tokens, generation, flight),
630
+ persist: (tokens) => provider.saveAcceptedRefreshTokens(tokens, generation, flight, issuer),
605
631
  };
606
632
  this.flights.set(generation, flight);
607
633
  this.advanceStateRevision();
@@ -790,6 +816,25 @@ export class OAuthRefreshCoordinator {
790
816
  this.settle(generation, flight, { status: "retired" });
791
817
  }
792
818
  }
819
+ /**
820
+ * One refresh coordinator per owner partition, for one connector runtime.
821
+ * Long-lived enough for distinct request scopes to join one token redemption;
822
+ * it owns no client, transport, or request state. Production contexts carry a
823
+ * stable registry partition; hand-written contexts coordinate by their shared
824
+ * storage object instead.
825
+ */
826
+ export function refreshCoordinatorsByPartition() {
827
+ const coordinators = new WeakMap();
828
+ return (ctx) => {
829
+ const partition = oauthPartitionFor(ctx) ?? ctx.storage;
830
+ let coordinator = coordinators.get(partition);
831
+ if (!coordinator) {
832
+ coordinator = new OAuthRefreshCoordinator(() => retainOAuthPartition(partition));
833
+ coordinators.set(partition, coordinator);
834
+ }
835
+ return coordinator;
836
+ };
837
+ }
793
838
  function storedOAuthValue(value) {
794
839
  if (!value || typeof value !== "object")
795
840
  return false;
@@ -798,6 +843,7 @@ function storedOAuthValue(value) {
798
843
  typeof candidate.generation === "string" &&
799
844
  (candidate.issuer === undefined || typeof candidate.issuer === "string") &&
800
845
  (candidate.binding === undefined || typeof candidate.binding === "string") &&
846
+ (candidate.carried === undefined || candidate.carried === true) &&
801
847
  "value" in candidate);
802
848
  }
803
849
  function legacyStoredOAuthValue(value) {
@@ -1012,6 +1058,11 @@ export class KvOAuthProvider {
1012
1058
  this.refreshCoordinator?.fail(owned.generation, owned.flight, error);
1013
1059
  }
1014
1060
  }
1061
+ /** @internal The issuer this flow's issuer-aware read bound its refresh to. */
1062
+ refreshIssuer(generation) {
1063
+ const basis = this.refreshBasis;
1064
+ return basis?.generation === generation ? basis.issuer : undefined;
1065
+ }
1015
1066
  /** True when another request saved a refresh result after this flow's read. */
1016
1067
  refreshBasisChanged(current, generation) {
1017
1068
  const basis = this.refreshBasis;
@@ -1226,6 +1277,7 @@ export class KvOAuthProvider {
1226
1277
  generation,
1227
1278
  ...(parsed.issuer !== undefined ? { issuer: parsed.issuer } : {}),
1228
1279
  ...(parsed.binding !== undefined ? { binding: parsed.binding } : {}),
1280
+ ...(parsed.carried === true ? { carried: true } : {}),
1229
1281
  // Only a finite number is a time; anything else reads as untimed.
1230
1282
  ...(typeof parsed.writtenAt === "number" &&
1231
1283
  Number.isFinite(parsed.writtenAt)
@@ -1421,6 +1473,7 @@ export class KvOAuthProvider {
1421
1473
  this.refreshBasis = {
1422
1474
  accessToken: tokens.access_token,
1423
1475
  generation: refreshGeneration,
1476
+ issuer: ctx.issuer,
1424
1477
  ...(tokens.refresh_token !== undefined
1425
1478
  ? { refreshToken: tokens.refresh_token }
1426
1479
  : {}),
@@ -1438,9 +1491,16 @@ export class KvOAuthProvider {
1438
1491
  await this.persistTokens(tokens, ctx, false, owned);
1439
1492
  }
1440
1493
  }
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 });
1494
+ /**
1495
+ * @internal Commit an already redeemed rotation even after its request
1496
+ * leaves. A token response names no issuer, so the one the refreshed grant
1497
+ * was bound to is stamped here as the SDK's own save would: an unbound
1498
+ * rotation would read as legacy state and bind to whichever authorization
1499
+ * server the connector names next.
1500
+ */
1501
+ async saveAcceptedRefreshTokens(tokens, generation, flight, issuer) {
1502
+ const stamped = issuer !== undefined ? { ...tokens, issuer } : tokens;
1503
+ await this.persistAcceptedTokens(stamped, issuer !== undefined ? { issuer } : undefined, { generation, flight });
1444
1504
  }
1445
1505
  persistAcceptedTokens(tokens, ctx, owned) {
1446
1506
  // The SDK can save the same response while cancellation recovery is
@@ -1542,13 +1602,27 @@ export class KvOAuthProvider {
1542
1602
  * direction to tolerate clock skew between isolates. A URL with no write
1543
1603
  * time — stored by an earlier release, or under a pre-envelope generation —
1544
1604
  * is treated as stale, so the caller starts a fresh flow instead.
1605
+ *
1606
+ * The URL must also still name the epoch's client. A callback whose code
1607
+ * exchange the server refused with `invalid_client` leaves the URL behind
1608
+ * but drops the client; handing that URL back would send the operator to a
1609
+ * consent the server has already refused, where a fresh flow registers.
1545
1610
  */
1546
1611
  async reusablePendingAuthorizationUrl() {
1547
1612
  const pending = await this.readValue("oauth:pending", (raw) => raw);
1548
- if (pending?.writtenAt === undefined)
1613
+ if (pending?.writtenAt === undefined ||
1614
+ Math.abs(Date.now() - pending.writtenAt) >= PENDING_AUTHORIZATION_MAX_AGE_MS) {
1615
+ return undefined;
1616
+ }
1617
+ let clientId;
1618
+ try {
1619
+ clientId = new URL(pending.value).searchParams.get("client_id");
1620
+ }
1621
+ catch {
1549
1622
  return undefined;
1550
- return Math.abs(Date.now() - pending.writtenAt) <
1551
- PENDING_AUTHORIZATION_MAX_AGE_MS
1623
+ }
1624
+ const client = await this.clientInformation();
1625
+ return clientId !== null && client?.client_id === clientId
1552
1626
  ? pending.value
1553
1627
  : undefined;
1554
1628
  }
@@ -1616,24 +1690,13 @@ export class KvOAuthProvider {
1616
1690
  async performResetAuthorization(operatorDisconnected, preserveClient) {
1617
1691
  const nonce = crypto.randomUUID();
1618
1692
  const previous = await this.generation();
1619
- // Only an explicitly forced restart may carry a registration forward.
1620
- // Discovery is never copied: the SDK must select the authorization server
1621
- // again, then its issuer-aware clientInformation hook checks the issuer.
1622
- const priorClient = preserveClient && !operatorDisconnected && this.clientBinding
1623
- ? await this.readValue("oauth:client", (raw) => {
1624
- try {
1625
- return JSON.parse(raw);
1626
- }
1627
- catch {
1628
- return {};
1629
- }
1630
- })
1631
- : undefined;
1632
- const reusableClient = priorClient?.generation === previous &&
1633
- priorClient.issuer !== undefined &&
1634
- priorClient.binding === this.clientBinding &&
1635
- typeof priorClient.value?.client_id === "string"
1636
- ? priorClient
1693
+ // Only an explicitly forced restart may carry a registration forward, and
1694
+ // only one this connector registered: an operator disconnect, an issuer
1695
+ // mismatch, and every unforced reset discard it. Discovery is never
1696
+ // copied: the SDK must select the authorization server again, then its
1697
+ // issuer-aware clientInformation hook checks the issuer.
1698
+ const reusableClient = preserveClient && !operatorDisconnected && this.clientBinding
1699
+ ? await this.carriableClient(previous)
1637
1700
  : undefined;
1638
1701
  const now = Date.now();
1639
1702
  const inherited = await this.cleanupBacklog(previous, now);
@@ -1678,10 +1741,17 @@ export class KvOAuthProvider {
1678
1741
  await this.storage.set("oauth:generation", active);
1679
1742
  this.refreshCoordinator?.retire(previous);
1680
1743
  if (reusableClient && !this.signal?.aborted) {
1681
- // readValue opened the old physical key; writeValue seals the plaintext
1744
+ // readValue opened the old physical key; the write seals the plaintext
1682
1745
  // under the new one. Copying ciphertext would fail its AAD check.
1683
1746
  this.captureGeneration(active);
1684
- await this.writeValue("oauth:client", reusableClient.value, (value) => JSON.stringify(value), reusableClient.issuer, undefined, this.clientBinding);
1747
+ await this.storeInGeneration("oauth:client", active, () => JSON.stringify({
1748
+ connectaOAuthVersion: STORED_VALUE_VERSION,
1749
+ generation: active,
1750
+ issuer: reusableClient.issuer,
1751
+ binding: reusableClient.binding,
1752
+ carried: true,
1753
+ value: reusableClient.value,
1754
+ }));
1685
1755
  }
1686
1756
  // Delete the generation just retired: its values, then the lineage it
1687
1757
  // published, which the new epoch has copied. Then retry the newest few
@@ -1713,6 +1783,55 @@ export class KvOAuthProvider {
1713
1783
  if (failure)
1714
1784
  throw failure.reason;
1715
1785
  }
1786
+ /**
1787
+ * The registration a forced restart may copy out of `previous`, or nothing.
1788
+ *
1789
+ * It must have been registered in that epoch for this authorization server
1790
+ * (an issuer stamp) under this connector's exact configuration, redirect
1791
+ * URI, and client metadata (the binding), and it is read through this
1792
+ * owner's own storage, so another principal's partition is never a source.
1793
+ * Three registrations are never carried:
1794
+ *
1795
+ * - A URL-based client (`clientMetadataUrl`). Nothing was registered, so
1796
+ * nothing is saved by copying it, and leaving it behind makes the SDK ask
1797
+ * the freshly discovered metadata whether the server still accepts one.
1798
+ * - One whose `client_secret_expires_at` has passed (RFC 7591: zero means
1799
+ * never). The token endpoint would refuse it.
1800
+ * - One a previous restart carried that has not since earned a grant. The
1801
+ * SDK builds a consent URL locally and sends nothing that could answer
1802
+ * `invalid_client`, and RFC 6749 section 4.1.2.1 forbids an authorization
1803
+ * server from redirecting a client it does not recognize, so a forgotten
1804
+ * registration never reaches the callback. Tokens in the epoch are the
1805
+ * only proof the server still knows the client. Without them the second
1806
+ * restart registers again, which bounds a forgotten registration to one
1807
+ * refused consent rather than a restart loop only a disconnect escapes.
1808
+ */
1809
+ async carriableClient(previous) {
1810
+ const prior = await this.readValue("oauth:client", (raw) => {
1811
+ try {
1812
+ return JSON.parse(raw);
1813
+ }
1814
+ catch {
1815
+ return {};
1816
+ }
1817
+ });
1818
+ const clientId = prior?.value?.client_id;
1819
+ const expiresAt = prior?.value?.client_secret_expires_at;
1820
+ if (prior?.generation !== previous ||
1821
+ prior.issuer === undefined ||
1822
+ prior.binding === undefined ||
1823
+ prior.binding !== this.clientBinding ||
1824
+ typeof clientId !== "string" ||
1825
+ clientId === this.clientMetadataUrl ||
1826
+ (typeof expiresAt === "number" && expiresAt > 0 && expiresAt * 1000 <= Date.now())) {
1827
+ return undefined;
1828
+ }
1829
+ const carriable = { ...prior, issuer: prior.issuer, binding: prior.binding };
1830
+ if (!prior.carried)
1831
+ return carriable;
1832
+ const earnedGrant = (await this.storage.get(oauthValueStorageKey("oauth:tokens", previous))) !== null;
1833
+ return earnedGrant ? carriable : undefined;
1834
+ }
1716
1835
  async invalidateCredentials(scope) {
1717
1836
  const endsRefresh = scope === "all" || scope === "tokens";
1718
1837
  try {