@zackbart/connecta 0.28.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 (40) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +8 -5
  3. package/dist/artifacts/connector.js +1 -1
  4. package/dist/auth/downstream-oauth.js +147 -28
  5. package/dist/auth/static-oauth.js +504 -0
  6. package/dist/connectors/api-connector.d.ts +168 -0
  7. package/dist/connectors/api-connector.js +127 -0
  8. package/dist/connectors/api.d.ts +8 -71
  9. package/dist/connectors/api.js +10 -99
  10. package/dist/connectors/guarded-fetch.js +37 -3
  11. package/dist/connectors/remote-mcp.js +7 -32
  12. package/dist/index.d.ts +1 -1
  13. package/dist/oauth-partition.js +17 -0
  14. package/dist/providers/basecamp.d.ts +65 -0
  15. package/dist/providers/basecamp.js +445 -0
  16. package/dist/providers/breeze.d.ts +34 -0
  17. package/dist/providers/breeze.js +1402 -0
  18. package/dist/providers/ccb.d.ts +103 -0
  19. package/dist/providers/ccb.js +1570 -0
  20. package/dist/providers/cloudflare.js +1 -1
  21. package/dist/providers/linear.js +1 -1
  22. package/dist/providers/mixpanel.js +1 -1
  23. package/dist/providers/notion.js +1 -1
  24. package/dist/providers/overflow.d.ts +60 -0
  25. package/dist/providers/overflow.js +1572 -0
  26. package/dist/providers/planning-center.d.ts +59 -0
  27. package/dist/providers/planning-center.js +2039 -0
  28. package/dist/providers/revenuecat.js +1 -1
  29. package/dist/providers/stripe.js +1 -1
  30. package/dist/providers/tithely.d.ts +49 -0
  31. package/dist/providers/tithely.js +1034 -0
  32. package/dist/providers/vercel.js +1 -1
  33. package/dist/routes/oauth.js +29 -3
  34. package/dist/version.d.ts +1 -1
  35. package/dist/version.js +1 -1
  36. package/documentation/architecture.md +5 -3
  37. package/documentation/auth.md +120 -6
  38. package/ethos.md +3 -3
  39. package/package.json +25 -1
  40. package/templates/node/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,179 @@
2
2
 
3
3
  All notable changes to this package are documented here.
4
4
 
5
+ ## 0.28.1 — 2026-10-05
6
+
7
+ This patch adds six maintained connections for church operations and the
8
+ downstream OAuth they needed. Planning Center, Overflow, Tithe.ly, Breeze ChMS,
9
+ and Church Community Builder (Pushpay ChMS) are hand-written `api()` surfaces
10
+ over their whole APIs, with every money-moving write behind approval; Basecamp
11
+ proxies 37signals' hosted MCP server. Church Community Builder accepts only
12
+ OAuth, so `api()` connectors can now declare a static OAuth 2.0
13
+ authorization-code grant that reuses the `remoteMcp()` machinery. Each
14
+ connection is a new opt-in provider subpath and the OAuth grant is opt-in per
15
+ connector: existing imports, connector declarations, credentials, and
16
+ deployment behavior do not change. Two fixes a deployment may notice: a forced
17
+ OAuth restart no longer reuses a client registration the provider has deleted
18
+ ([#611](https://github.com/zackbart/connecta/issues/611)), and a failed OAuth
19
+ code exchange is now logged as fixed text rather than the provider's message.
20
+ Internally, the guarded transport can repeat a query key for an array value,
21
+ and `api()`'s builder moved into its own module so OAuth stays out of bundles
22
+ that never use it.
23
+
24
+ ### Added
25
+
26
+ - **Downstream OAuth on `api()` connectors.** A hand-written connector can
27
+ declare `oauth: { authorizationEndpoint, tokenEndpoint, clientId,
28
+ clientSecret?, tokenEndpointAuthMethod?, scope?, pkce?, authorizationParams?,
29
+ tokenRequestHeaders?, apiOrigins }` for a REST API that accepts only the authorization code grant,
30
+ such as Church Community Builder. Nothing is discovered or registered: the
31
+ endpoints and the provider-issued client are configuration, and the client
32
+ secret comes from the deployment's environment and is never stored. Handlers
33
+ call `ctx.oauth.fetch(url, init)`, which sends the calling owner's token as a
34
+ bearer to a declared origin only, answers a 401 with one coordinated refresh
35
+ and one replay, and otherwise fails `auth_required`. The grant is the
36
+ `remoteMcp()` grant underneath — the same sealed epochs, shared and personal
37
+ ownership, rotating-refresh coordination, dead-versus-outage verdicts,
38
+ `/oauth/callback/<id>`, `authorize_connector`, and operator Connect,
39
+ Restart, and Disconnect. `oauth` and `credential` are exclusive on one
40
+ connector. See [downstream OAuth on `api()`](./documentation/auth.md#downstream-oauth-on-api).
41
+ - **Church Community Builder (Pushpay ChMS) connection.**
42
+ `@zackbart/connecta/providers/ccb` exports `ccb(id, { purpose, environment,
43
+ mode, clientId, clientSecret, access?, scopes?, subdomain? })`, a
44
+ hand-written v2 REST connection over `api()`'s OAuth grant. `environment`
45
+ (`production` or `sandbox`) and `mode` (`system`, one church-wide grant an
46
+ administrator approves; `identity`, each person signing in as themselves
47
+ with a personal grant) are required, because CCB credentials do not cross
48
+ environments and the two modes reach different data. Twenty-four named reads
49
+ cover people and families, groups and participants, events and attendance,
50
+ forms and responses, processes and queues, scheduling, notes, and giving
51
+ metrics and pledges, with projections that drop allergies, giving
52
+ identifiers, and prayer requests unless `raw: true`. `ccb_api_get` and
53
+ `ccb_api_search` reach every GET and advanced search read-only, and
54
+ `ccb_api_mutate` ships only when a write scope is requested. A default
55
+ 60-per-minute admission window per endpoint transcribes CCB's documented
56
+ limit, and `npm run providers:check -- --provider ccb` compares the 41
57
+ touched endpoints with Pushpay's published OpenAPI document. Clients are
58
+ issued by hand through Pushpay's API access request form.
59
+ - **Maintained Planning Center connection.**
60
+ `@zackbart/connecta/providers/planning-center` exports `planningCenter(id,
61
+ options)`, a hand-written `api()` surface over Planning Center Online's REST
62
+ API with no SDK and no new dependency. Named tools cover the jobs church
63
+ staff run — People search, profiles, contact records, lists, workflows and
64
+ card actions, notes, custom fields, and form submissions; Services plans,
65
+ teams, schedules, songs, and scheduling; Groups memberships and events;
66
+ Check-Ins attendance; Calendar occurrences; Registrations signups; and Giving
67
+ donations, funds, batches, pledges, and recurring gifts as reads only — with
68
+ JSON:API flattened into lean records and `raw: true` beside them.
69
+ `pco_api_get` (read-only) and `pco_api_mutate` (always approval-gated) reach
70
+ every other endpoint, including every write that moves money. The credential
71
+ is a personal access token as two labeled fields, and the Test action names
72
+ the person and organization it acts as. Every request sends a reviewed
73
+ `X-PCO-API-Version` per product and a configurable `User-Agent`, and the
74
+ default call budget transcribes Planning Center's 100 requests per 20
75
+ seconds. `npm run providers:check -- --provider planning-center` compares
76
+ the touched endpoints with Planning Center's per-product OpenAPI documents
77
+ and reports a newly published version of any pinned product. Planning
78
+ Center's own hosted MCP server was considered and is not wrapped: its writes
79
+ stop at People.
80
+ - **Maintained Overflow connection.** `@zackbart/connecta/providers/overflow`
81
+ ships 16 projected reads over contributions, deposits and their line items
82
+ and totals, refunds, chargebacks, donors, recurring gifts, payment methods,
83
+ campaigns, locations, Tap events, and webhook delivery logs, plus a
84
+ read-only GET hatch and a destructive JSON-mutation hatch that carries every
85
+ Overflow write — charges, payment authorization, refunds, recurring-gift
86
+ changes, and donor edits — to the host's approval prompt. `environment`
87
+ (`"production"` or `"staging"`) is required with no default, the credential
88
+ is one operator slot with `clientId` and `apiKey` fields, list rows keep
89
+ donor contact details and webhook payloads out unless `raw: true`, and the
90
+ default admission budget transcribes Overflow's 120 requests per minute per
91
+ client. `npm run providers:check -- --provider overflow` compares all 38
92
+ operations with the OpenAPI document Overflow publishes on staging.
93
+ - A maintained Tithe.ly giving connection, `tithely(id, { purpose, environment })`
94
+ from `@zackbart/connecta/providers/tithely`: a hand-written `api()` surface
95
+ over Tithe.ly's v1 REST API. `environment` (`"live"` for tithe.ly, `"test"`
96
+ for tithelydev.com) is required with no default, because Tithe.ly's key pairs
97
+ are environment-specific and carry nothing that says which. The credential
98
+ slot takes the public and private keys as two fields, refuses an obviously
99
+ swapped pair without a request, and its Test action reports the organization
100
+ the keys reach; API access is by request through support@tithe.ly. Named
101
+ read-only tools cover organizations and their funds, donor accounts, charges
102
+ by organization or donor and created-date range, recurring gifts, and
103
+ payment-method metadata, with amounts as integer cents, dates as ISO 8601,
104
+ `hasMore` paging, and organization projections that omit payout bank and
105
+ legal-contact details. Every write moves money or donor payment state and is
106
+ reached only through the always-destructive, form-encoded
107
+ `tithely_api_mutate`; `tithely_api_get` reaches every v1 read. No call budget
108
+ is declared, since Tithe.ly publishes no rate limit.
109
+ `npm run providers:check -- --provider tithely` digests the OpenAPI snippet
110
+ each reference page embeds, for every documented v1 operation.
111
+ - A maintained Breeze ChMS connection (Tithely Church Management since
112
+ 2025-11-11) at `@zackbart/connecta/providers/breeze`:
113
+ `breeze(id, { subdomain, purpose })` binds one church's
114
+ `<subdomain>.breezechms.com` — validated as a single hostname label at
115
+ construction, so no argument can steer the API key elsewhere — with an
116
+ operator `Api-Key` slot tested against the account summary. Named reads cover
117
+ people, profile fields, tags, events, calendars, attendance, forms,
118
+ volunteers, the account log, and giving (`list_contributions`, `list_funds`,
119
+ which call endpoints Breeze stopped documenting in 2023 and still serves);
120
+ named writes add and update people, assign and unassign tags, and record a
121
+ check-in. Because Breeze sends writes as GET too, the escape hatches split by
122
+ endpoint rather than method: `breeze_api_get` admits only a reviewed
123
+ allowlist of read endpoints and their documented parameters, and the
124
+ destructive `breeze_api_mutate` reaches everything else. No admission budget
125
+ is declared, since Breeze documents no rate limit; drift is review-only,
126
+ since it publishes no machine-readable specification.
127
+ - **`basecamp()` — a maintained Basecamp hosted-MCP connection** at
128
+ `@zackbart/connecta/providers/basecamp`, proxying
129
+ `https://mcp.basecamp.com/mcp` — a server 37signals runs but has not
130
+ documented or announced. OAuth only, and `clientMetadataUrl` is required:
131
+ Basecamp restricts dynamic client registration for HTTPS callbacks, so a
132
+ deployment hosts a Client ID Metadata Document listing
133
+ `<publicUrl>/oauth/callback/<id>`, and construction throws without one. A
134
+ grant reaches exactly one Basecamp account, chosen at consent, so the guide
135
+ leads with that, names `get_me` to confirm it, and a second account is a
136
+ second connector. All 227 tools in the live catalog are classified (105
137
+ read-only, 34 additive, 88 destructive); the four verdicts that part from the
138
+ server's own annotations, and `create_stream_ticket`'s agreement with its
139
+ read-only claim, are argued beside their rows. No access mode ships: the MCP
140
+ client requests the scope Basecamp's challenge names, so a `read` option
141
+ would be a label rather than a limit. Personal access tokens are not offered,
142
+ because nothing shows this endpoint accepts one. No default `callAdmission`:
143
+ Basecamp's limits are dynamic, multi-tier, and metered at its own server's
144
+ IP. With no public setup page or inventory, `npm run providers:check` reads
145
+ the server's OAuth discovery instead — the resource names the endpoint and
146
+ the authorization server still accepts metadata-document clients — and
147
+ catalog drift is runtime-only.
148
+
149
+ ### Fixed
150
+
151
+ - **A forced OAuth restart can no longer wedge on a purged client
152
+ registration** ([#611](https://github.com/zackbart/connecta/issues/611)).
153
+ Restart already carried an issuer-bound dynamic registration into the new
154
+ epoch; it carried it unconditionally, and a provider that had forgotten the
155
+ client refuses it at consent without ever redirecting back, so every later
156
+ Restart reused the same dead client and only Disconnect escaped. A carried
157
+ client is now carried again only once a grant in its epoch proves the
158
+ provider still knows it, so two restarts in a row with no consent between
159
+ them register at most once more. A URL-based client (`clientMetadataUrl`)
160
+ and a client whose secret has expired are never carried. Continue no longer
161
+ hands back a pending consent URL whose client a callback's `invalid_client`
162
+ has dropped; it starts a flow that registers instead. Static `api()` OAuth
163
+ clients are configuration and unaffected.
164
+ - **A refresh rotation saved after its request was cancelled keeps its
165
+ issuer.** When the token endpoint answered after the owning request left,
166
+ connecta persisted the rotated tokens itself but unbound, and the next
167
+ issuer-aware read adopted them for whichever authorization server the
168
+ connector named by then — so a connector repointed at another server would
169
+ send the rotated refresh token there. The recovery save now stamps the issuer
170
+ the refresh answered to, as the SDK's own save does, and a repointed connector
171
+ fences the grant instead.
172
+ - **A failed OAuth code exchange no longer writes the provider's message to the
173
+ operator log.** The SDK quotes a token endpoint's `error_description` or raw
174
+ body, and a provider that echoes a `client_secret_post` request put the client
175
+ secret, code, and verifier in the log. The callback now logs fixed text and,
176
+ when the error carries one, a standard OAuth error code from an allowlist.
177
+
5
178
  ## 0.28.0 — 2026-10-04
6
179
 
7
180
  This minor release removes program pauses ([#672](https://github.com/zackbart/connecta/issues/672)).
package/README.md CHANGED
@@ -64,11 +64,14 @@ Fifty issues in, one small object out. Your context window notices.
64
64
  services in a config file; your client never changes.
65
65
  - **Wrap any HTTP API by hand.** A few lines per tool. No OpenAPI conversion —
66
66
  generated tool sprawl is the problem, not the fix.
67
- - **Use maintained connections** for Cloudflare, Linear, Mixpanel, Notion,
68
- RevenueCat, Stripe, and Vercel: known endpoints, auth defaults, and vetted
69
- read/write classifications, imported one at a time. Cloudflare, Notion, and
70
- Vercel each let the deployment choose their hand-written API interface or
71
- official hosted MCP.
67
+ - **Use maintained connections** for Basecamp, Breeze ChMS, Church Community
68
+ Builder, Cloudflare, Linear, Mixpanel, Notion, Overflow, Planning Center,
69
+ RevenueCat, Stripe, Tithe.ly, and Vercel: known endpoints, auth defaults, and
70
+ vetted read/write classifications, imported one at a time. Cloudflare,
71
+ Notion, and Vercel each let the deployment choose their hand-written API
72
+ interface or official hosted MCP. Planning Center, Overflow, Tithe.ly,
73
+ Breeze, and Church Community Builder are hand-written over their whole APIs,
74
+ with every money-moving write behind approval.
72
75
  - **Let the agent work in code.** Search, chain, filter, join, and reduce
73
76
  inside the sandbox instead of round-tripping every call through the model.
74
77
  - **Teach undeclared result shapes by using them.** Successful read-only calls
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // Who made each version comes from the caller core attached to the context,
8
8
  // never from arguments.
9
- import { api } from "../connectors/api.js";
9
+ import { apiConnector as api } from "../connectors/api-connector.js";
10
10
  import { callerOf } from "../connector-caller.js";
11
11
  import { ConnectorCallError } from "../errors.js";
12
12
  import { resolveTheme } from "../branding.js";
@@ -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 {