@zackbart/connecta 0.35.0 → 0.36.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 (63) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +8 -4
  3. package/bin/version.mjs +1 -1
  4. package/dist/auth/downstream-oauth.js +71 -79
  5. package/dist/auth/static-oauth.js +104 -26
  6. package/dist/connectors/api-connector.d.ts +17 -0
  7. package/dist/connectors/api-connector.js +3 -1
  8. package/dist/http/client.js +1 -1
  9. package/dist/http/paging.js +1 -2
  10. package/dist/http/services.js +1 -1
  11. package/dist/http/tools.js +0 -4
  12. package/dist/operator-ui/contract.d.ts +1 -0
  13. package/dist/operator-ui/generated.js +4 -4
  14. package/dist/providers/_shared/google/kit.js +29 -139
  15. package/dist/providers/_shared/rest/kit.js +4 -404
  16. package/dist/providers/docs/tools.js +1 -2
  17. package/dist/providers/drive/api.js +1 -1
  18. package/dist/providers/forms/tools.js +1 -2
  19. package/dist/providers/gmail/tools.js +2 -2
  20. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  21. package/dist/providers/quickbooks/api.js +299 -0
  22. package/dist/providers/quickbooks/index.d.ts +13 -0
  23. package/dist/providers/quickbooks/index.js +93 -0
  24. package/dist/providers/quickbooks/lib/query.js +118 -0
  25. package/dist/providers/quickbooks/lib/writes.js +168 -0
  26. package/dist/providers/quickbooks/safety.js +222 -0
  27. package/dist/providers/quickbooks/skill.generated.js +5 -0
  28. package/dist/providers/quickbooks/tools.js +117 -0
  29. package/dist/providers/sheets/tools.js +1 -2
  30. package/dist/providers/slides/tools.js +2 -2
  31. package/dist/providers/vercel/index.js +1 -2
  32. package/dist/routes/oauth.js +3 -0
  33. package/dist/runtime/call.js +0 -4
  34. package/dist/skills.js +0 -1
  35. package/dist/types.d.ts +2 -0
  36. package/dist/ui.d.ts +1 -1
  37. package/dist/ui.js +0 -1
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/documentation/README.md +1 -0
  41. package/documentation/architecture.md +134 -301
  42. package/documentation/auth.md +47 -17
  43. package/documentation/integrating.md +1 -1
  44. package/documentation/meta-tools.md +1 -1
  45. package/documentation/operator-ui.md +16 -3
  46. package/documentation/providers.md +382 -0
  47. package/package.json +7 -8
  48. package/templates/node/package.json +1 -1
  49. package/dist/operator-ui/agents.d.ts +0 -75
  50. package/dist/operator-ui/agents.js +0 -114
  51. package/dist/operator-ui/config-summary.js +0 -566
  52. package/dist/operator-ui/connection-map.js +0 -378
  53. package/dist/operator-ui/endpoints.js +0 -212
  54. package/dist/operator-ui/fix-prompts.d.ts +0 -18
  55. package/dist/operator-ui/fix-prompts.js +0 -79
  56. package/dist/operator-ui/health.js +0 -52
  57. package/dist/operator-ui/member-page.js +0 -97
  58. package/dist/operator-ui/overview.js +0 -126
  59. package/dist/operator-ui/setup-commands.js +0 -47
  60. package/dist/operator-ui/view.d.ts +0 -378
  61. package/dist/operator-ui/view.js +0 -631
  62. package/dist/providers/_shared/rest/dispatch.js +0 -21
  63. package/dist/providers/_shared/rest/tools.js +0 -592
package/CHANGELOG.md CHANGED
@@ -2,6 +2,61 @@
2
2
 
3
3
  All notable changes to this package are documented here.
4
4
 
5
+ ## 0.36.0 — 2026-10-27
6
+
7
+ Connecta 0.36.0 adds a maintained QuickBooks Online Accounting provider for one fixed sandbox or production company per connector. It offers company identity, bounded record searches, individual records and hierarchical financial reports, with contact details and payment credentials withheld. Deployments can opt into customer creation, sparse customer naming updates and unsent US service-invoice creation by setting `readOnly: false` on the connector. Writes need a stable request ID and are never replayed automatically; the API hatch stays read-only, and invoice updates remain unavailable. See [QuickBooks Online](https://github.com/zackbart/connecta/blob/v0.36.0/documentation/providers.md#quickbooks-online).
8
+
9
+ Static API OAuth gains two options used by QuickBooks and available to any `api()` connector: fixed callback parameters that must match before code exchange, and a trusted revocation endpoint with form or JSON framing. Connectors that don't configure them keep their behavior. Separately, the shared OAuth refresh coordinator now handles vendors that return unchanged refresh-token bytes after a successful refresh. This applies automatically to every OAuth connection, remote MCP included, with no setting.
10
+
11
+ `@zackbart/connecta-eve` now targets Eve 0.84 (`>=0.84.0 <0.85.0`), which is a breaking change for helper users. Upgrade the Eve app to 0.84.0 together with the helper, and retarget its undici override from `eve@0.83.0>undici` to `eve@0.84.0>undici` (Eve 0.84.0 still pins undici 8.10.2). The helper's two agent files need no edits. Endpoint instructions are now fetched once per turn through Eve's memo, and a cancelled turn no longer delays other sessions' fetches. See the [package guide](https://github.com/zackbart/connecta/blob/v0.36.0/packages/connecta-eve/README.md).
12
+
13
+ The operator UI build is smaller, with no change to pages or styles. The remaining entries are contributor-facing: retired characterization goldens and provider test mirrors in favor of outcome checks, archived historical evals, and a required outcome-eval CI job. Deployments without QuickBooks or Eve agents can upgrade without changes. No database conversion or token rotation is required.
14
+
15
+ ### Added
16
+
17
+ - Static API OAuth can require fixed callback parameters, refusing missing, duplicate, or mismatched values after state verification and before issuer validation or code exchange.
18
+ - Document maintained API sources and the generated read/write tool inventory for every provider, linked from the README and provider setup guide.
19
+ - Add a QuickBooks Online Accounting API provider for one fixed sandbox or
20
+ production company: company identity, bounded record searches, individual
21
+ records and hierarchical financial reports, with shared OAuth and a restricted
22
+ read hatch. Contact details and payment credentials remain withheld; public
23
+ SyncToken record versions remain available.
24
+
25
+ Support successive coordinated OAuth refreshes when a vendor returns unchanged
26
+ refresh-token bytes after a successful commit. Static API OAuth can configure
27
+ a trusted revocation endpoint and form or JSON request framing; QuickBooks
28
+ disconnect revokes the latest grant after local removal. Malformed query and
29
+ report payloads fail instead of appearing as empty accounting results.
30
+ - Add explicitly enabled QuickBooks Online customer creation, sparse customer naming updates and real unsent service-invoice creation for US companies and USD customers. The connector remains read-only unless deployment code sets `readOnly: false`; the API hatch remains read-only. Writes require a stable request ID and are never automatically replayed. Invoice creation fixes email status and disables online payment options; invoice updates remain deferred pending safe preservation of existing delivery/payment settings.
31
+
32
+ ### Changed
33
+
34
+ - Keep Google Discovery source evidence once per provider and derive normalized OpenAPI in memory. Remove duplicate raw test fixtures and retained conversions while preserving compiled indexes, reviewed safety and frozen measurement digests.
35
+ - **Breaking:** Move `@zackbart/connecta-eve` to Eve 0.84 (`>=0.84.0 <0.85.0`). Upgrade the Eve app to 0.84.0 with the helper and retarget its undici override to `eve@0.84.0>undici`. Endpoint instructions use Eve's function-form `defineDynamic` and keep one result per turn in Eve's memo, app auth declares `principalType: "app"`, and `approval` accepts Eve's approval configurations as well as policies.
36
+ - Validate QuickBooks accounting reads, OAuth persistence and privacy through shared Node and native Workers outcome journeys. New provider behavior uses executable outcomes instead of characterization goldens (#1072, #1079).
37
+ - Exercise opt-in QuickBooks customer writes and unsent service invoices in required outcome checks and both deployment smokes, including concurrent version conflicts, ambiguous-response reconciliation, privacy and one physical send per intent.
38
+ - Remove unused private REST and Google provider migration adapters and their exclusive regression coverage. Maintained provider tools, public exports, and hosted-selector migration errors keep their current behavior.
39
+ - Replace redundant provider catalog, schema, adapter and kit/core comparison tests
40
+ with shared stateful provider outcome tasks and a real HTTP hostile-acknowledgement
41
+ journey. Retain provider-specific algorithms, privacy, physical-send and native
42
+ fault coverage; provider runtime behavior and public fixtures remain unchanged.
43
+ Read outcomes require requested facts to reach the agent, with data-loss
44
+ controls that keep vendor calls and the expected answer intact.
45
+ - Slim the operator UI build by removing redundant Query and Tailwind layers, unused Button Slot support, and duplicate browser-only server emissions. Preserve the public page type, authored styles, local fonts, and identity-scoped read cancellation.
46
+
47
+ ### Fixed
48
+
49
+ - Contributor evals: `eval:agent` now exits nonzero unless every declared trial ran once, completed normally and passed, so failed, errored, timed-out, interrupted, skipped, missing, duplicated and stopped trials fail the batch. `eval:smoke` provisions the Node template with `rotate-token local-machine`, reaches `/agent/local-machine`, runs `connecta doctor`, claims no write where no write-capable tool exists, and runs the Worker targets with the example's own compatibility date and flags. A new required `eval:outcomes` command and CI `behavior` job grade seven scripted outcome scenarios and their no-false-pass controls. No package behavior changes.
50
+
51
+ ### Removed
52
+
53
+ - Archive redundant REST eval regrades, invalid initial diagnostic batches and obsolete 0.24/0.25 measurements in Git history, preserving failure notes, exact-file hashes and immutable recovery links. Retain raw live and review-round evidence and the selftest's 0.29 baselines (#1072).
54
+ - Retire the migration-only provider characterization snapshots, capture engine,
55
+ source controls and self-tests. Keep behavioral failure, privacy, physical-send
56
+ and request-lifetime coverage, with compact HTTP/QuickJS privacy journeys for
57
+ credential echoes in newly returned vendor fields.
58
+ - Retire completed schema-preload, index-compaction, Effect evaluation, and Worker-disconnect experiment artifacts from the source checkout. Architecture conclusions retain immutable links to historical evidence. Remove release-note inputs already preserved in the changelog; future release narratives use temporary files before assembly into `CHANGELOG.md`.
59
+
5
60
  ## 0.35.0 — 2026-10-26
6
61
 
7
62
  Connecta 0.35.0 brings the provider kit and standard agent surfaces to all 15 maintained providers. Operation search and details expose reviewed API contracts without reading account data; indexed read and write tools complement the retained named tools. Gmail keeps its reviewed exception: draft writes use named tools and sending remains unavailable. Results use consistent list envelopes, narrowing `fields`, signed continuations and truncation paths. The [provider migration guide](https://github.com/zackbart/connecta/blob/v0.35.0/documentation/provider-migration-0.35.md) maps every renamed or removed tool and argument, including retired `raw`/`select` arguments, list keys, paging inputs and wrappers replaced by indexed operations. Update saved programs, pool grants, classification entries and deployment instructions before upgrading, and restart old continuations where the guide requires it.
package/README.md CHANGED
@@ -24,11 +24,15 @@ Connecta supplies the service credentials, so the agent doesn't need them.
24
24
 
25
25
  ## What you can connect
26
26
 
27
- Maintained connections: <!-- providers:start -->Breeze ChMS, Church Community Builder, Cloudflare, GitHub, Gmail, Google Docs, Google Drive, Google Forms, Google Sheets, Google Slides, Overflow, Planning Center, Stripe, Tithe.ly, and Vercel<!-- providers:end -->.
27
+ Maintained connections: <!-- providers:start -->Breeze ChMS, Church Community Builder, Cloudflare, GitHub, Gmail, Google Docs, Google Drive, Google Forms, Google Sheets, Google Slides, Overflow, Planning Center, QuickBooks Online, Stripe, Tithe.ly, and Vercel<!-- providers:end -->.
28
28
 
29
- You can also connect another hosted MCP tool server, or describe an API
30
- Connecta should call. Hosted connections use the server's current tools;
31
- maintained integrations come with tools built by Connecta.
29
+ Maintained API providers pair reviewed vendor contracts with tools built by
30
+ Connecta. See the [API sources and tool inventory](./documentation/providers.md#api-sources-and-tool-inventory)
31
+ for each provider's named workflows and reviewed API tools.
32
+
33
+ You can also connect another hosted MCP tool server through `remoteMcp()`,
34
+ which discovers the server's live tool catalog, or describe an API Connecta
35
+ should call.
32
36
 
33
37
  ## What it protects
34
38
 
package/bin/version.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated from package.json by scripts/generate-version.mjs.
2
2
  // Workers-safe: no runtime filesystem read or deployment override.
3
- export const CONNECTA_VERSION = "0.35.0";
3
+ export const CONNECTA_VERSION = "0.36.0";
@@ -229,7 +229,7 @@ function requestedRefreshToken(init) {
229
229
  /**
230
230
  * The token endpoint as one connector runtime's OAuth flows reach it.
231
231
  *
232
- * A refresh is redeemed once per owner partition and epoch: the first request
232
+ * A refresh generation is redeemed once per owner partition: the first request
233
233
  * owns the fetch, classifies the answer, and stores an accepted rotation, by
234
234
  * compare-and-set, before anyone is released, so a cancelled owner or an SDK
235
235
  * that redirects instead of saving cannot strand a spent refresh token.
@@ -1280,6 +1280,10 @@ export class KvOAuthProvider {
1280
1280
  disconnectAuthorization(send) {
1281
1281
  return runEdge(authOwnMethod(KvOAuthProvider.prototype, this, "disconnectAuthorization", [send]));
1282
1282
  }
1283
+ /** Static adapters supply trusted configuration without persisting client secrets. */
1284
+ revocationSnapshot(body) {
1285
+ return { body, ...(this.clientOptions?.client ? { client: this.clientOptions.client } : {}) };
1286
+ }
1283
1287
  performReset(operatorDisconnected, preserveClient, revoke) {
1284
1288
  return runEdge(authOwnMethod(KvOAuthProvider.prototype, this, "performReset", [
1285
1289
  operatorDisconnected,
@@ -2282,6 +2286,13 @@ export class KvOAuthProvider {
2282
2286
  return authFlow(function* () {
2283
2287
  const waiting = sharedWait ?? refreshWait(signal);
2284
2288
  const wait = waiting.run;
2289
+ const read = (key) => authCall(wait, [authMethod(() => authStorage(this.storage, "get", [key]))]);
2290
+ const discard = () => authCall(wait, [
2291
+ authMethod(() => authInvoke(this, "discardRefusedGrant", [requested, epoch, waiting.transfer, waiting.signal])),
2292
+ ]);
2293
+ const tokens = () => authCall(wait, [
2294
+ authMethod(() => authInvoke(this, "storedTokens", [waiting.signal])),
2295
+ ]);
2285
2296
  let delay = 10;
2286
2297
  try {
2287
2298
  const digest = (yield authCall(wait, [
@@ -2292,11 +2303,7 @@ export class KvOAuthProvider {
2292
2303
  const key = oauthRefreshKeys.lease(epoch, digest);
2293
2304
  const spentKey = oauthRefreshSpentKeys.spent(digest);
2294
2305
  while (!waiting.signal.aborted) {
2295
- const current = (yield authCall(wait, [
2296
- authMethod(() => {
2297
- return authInvoke(this, "storedTokens", [waiting.signal]);
2298
- }),
2299
- ]));
2306
+ const current = (yield tokens());
2300
2307
  if (current?.refresh_token !== requested || (current && this.refreshedSinceRead(current, epoch))) {
2301
2308
  if (!current)
2302
2309
  this.recordRefreshFailure({ kind: "dead" });
@@ -2304,46 +2311,29 @@ export class KvOAuthProvider {
2304
2311
  ? this.adoptRefresh(current)
2305
2312
  : Response.json({ error: "invalid_grant" }, { status: 400 });
2306
2313
  }
2307
- const raw = (yield authCall(wait, [
2308
- authMethod(() => {
2309
- return authStorage(this.storage, "get", [key]);
2310
- }),
2311
- ]));
2314
+ const raw = (yield read(key));
2312
2315
  const stored = raw === null ? undefined : parsed(raw);
2313
- if (plainObject(stored) &&
2314
- stored.completed === true &&
2315
- current &&
2316
+ const committed = plainObject(stored) &&
2317
+ (stored.completed === true ||
2318
+ (stored.state === "claimed" && typeof stored.predecessor === "string")) &&
2319
+ current !== undefined &&
2316
2320
  stored.tokenDigest ===
2317
2321
  (yield authCall(wait, [
2318
2322
  authMethod(() => {
2319
2323
  return authCall(refreshTokensDigest, [current]);
2320
2324
  }),
2321
- ]))) {
2322
- return this.adoptRefresh(current);
2323
- }
2324
- if (plainObject(stored) && (plainObject(stored.verdict) || stored.completed === true)) {
2325
+ ]));
2326
+ const completed = committed && stored.completed === true;
2327
+ if (plainObject(stored) && (plainObject(stored.verdict) || (stored.completed === true && !committed))) {
2325
2328
  this.recordRefreshFailure({ kind: "dead" });
2326
- (yield authCall(wait, [
2327
- authMethod(() => {
2328
- return authInvoke(this, "discardRefusedGrant", [
2329
- requested,
2330
- epoch,
2331
- waiting.transfer,
2332
- waiting.signal,
2333
- ]);
2334
- }),
2335
- ]));
2329
+ (yield discard());
2336
2330
  throw new UnauthorizedError("OAuth refresh grant is no longer active.");
2337
2331
  }
2338
- if (plainObject(stored) && stored.state !== "claimed") {
2332
+ if (plainObject(stored) && stored.state !== "claimed" && !completed) {
2339
2333
  // Never compare another isolate's clock for a sent request. The
2340
2334
  // durable record survives expiry of this storage-owned liveness key.
2341
2335
  const active = typeof stored.activeKey === "string"
2342
- ? (yield authCall(wait, [
2343
- authMethod(() => {
2344
- return authStorage(this.storage, "get", [stored.activeKey]);
2345
- }),
2346
- ]))
2336
+ ? (yield read(stored.activeKey))
2347
2337
  : null;
2348
2338
  if (active === null) {
2349
2339
  const refused = JSON.stringify({ ...stored, verdict: { kind: "dead" } });
@@ -2361,27 +2351,14 @@ export class KvOAuthProvider {
2361
2351
  }),
2362
2352
  ]));
2363
2353
  this.recordRefreshFailure({ kind: "dead" });
2364
- (yield authCall(wait, [
2365
- authMethod(() => {
2366
- return authInvoke(this, "discardRefusedGrant", [
2367
- requested,
2368
- epoch,
2369
- waiting.transfer,
2370
- waiting.signal,
2371
- ]);
2372
- }),
2373
- ]));
2354
+ (yield discard());
2374
2355
  throw new UnauthorizedError("OAuth refresh outcome is unknown; authorization required.");
2375
2356
  }
2376
2357
  continue;
2377
2358
  }
2378
2359
  }
2379
2360
  else {
2380
- const spentExpected = (yield authCall(wait, [
2381
- authMethod(() => {
2382
- return authStorage(this.storage, "get", [spentKey]);
2383
- }),
2384
- ]));
2361
+ const spentExpected = (yield read(spentKey));
2385
2362
  if (spentExpected !== null) {
2386
2363
  const spent = storedRefreshSpent(spentExpected);
2387
2364
  const consent = (yield authCall(wait, [
@@ -2390,17 +2367,23 @@ export class KvOAuthProvider {
2390
2367
  }),
2391
2368
  ])).body.refreshConsent;
2392
2369
  if (spent?.state !== "resolved" ||
2393
- spent.epoch === epoch ||
2394
- consent?.digest !== digest ||
2395
- consent.resolution !== spentExpected) {
2370
+ !(
2371
+ // A completed lease proves these exact tokens committed.
2372
+ // Reusable refresh tokens may start another generation,
2373
+ // but only after its own dispatch is durably resolved.
2374
+ (committed &&
2375
+ spent.epoch === epoch &&
2376
+ spent.holder === (completed ? stored.holder : stored.predecessor)) ||
2377
+ (spent.epoch !== epoch && consent?.digest === digest && consent.resolution === spentExpected))) {
2378
+ // Lease and fingerprint are separate records. A competing
2379
+ // generation can advance both between these reads; retry
2380
+ // its new lease instead of discarding a live grant.
2381
+ const latestLease = (yield read(key));
2382
+ if (latestLease !== raw)
2383
+ continue;
2396
2384
  // Reset can sweep the old lease while its request remains live.
2397
2385
  // Only storage-owned expiry can make an outstanding send ambiguous.
2398
- if (spent?.state === "outstanding" &&
2399
- (yield authCall(wait, [
2400
- authMethod(() => {
2401
- return authStorage(this.storage, "get", [spent.activeKey]);
2402
- }),
2403
- ])) === null) {
2386
+ if (spent?.state === "outstanding" && (yield read(spent.activeKey)) === null) {
2404
2387
  (yield authCall(wait, [
2405
2388
  authMethod(() => {
2406
2389
  return authStorage(this.storage, "compareAndSet", [spentKey, spentExpected, JSON.stringify({ ...spent, state: "ambiguous" })], () => waiting.transfer());
@@ -2408,29 +2391,32 @@ export class KvOAuthProvider {
2408
2391
  ]));
2409
2392
  }
2410
2393
  this.recordRefreshFailure({ kind: "dead" });
2411
- (yield authCall(wait, [
2412
- authMethod(() => {
2413
- return authInvoke(this, "discardRefusedGrant", [
2414
- requested,
2415
- epoch,
2416
- waiting.transfer,
2417
- waiting.signal,
2418
- ]);
2419
- }),
2420
- ]));
2394
+ (yield discard());
2421
2395
  throw new UnauthorizedError("OAuth refresh token was already spent; authorization required.");
2422
2396
  }
2423
2397
  }
2424
2398
  if (raw === null ||
2399
+ completed ||
2425
2400
  (plainObject(stored) &&
2426
2401
  typeof stored.expiresAt === "number" &&
2427
2402
  stored.expiresAt <= this.refreshNow())) {
2428
2403
  // Clock skew can replace an unsent claim; its dispatch CAS then loses.
2404
+ const predecessor = completed
2405
+ ? stored.holder
2406
+ : plainObject(stored)
2407
+ ? stored.predecessor
2408
+ : undefined;
2429
2409
  const claimed = JSON.stringify({
2430
2410
  connectaOAuthRefresh: 1,
2431
2411
  holder: crypto.randomUUID(),
2432
2412
  state: "claimed",
2433
2413
  expiresAt: this.refreshNow() + OAUTH_REFRESH_LEASE_SECONDS * 1000,
2414
+ ...(committed && typeof predecessor === "string" && typeof stored.tokenDigest === "string"
2415
+ ? {
2416
+ predecessor,
2417
+ tokenDigest: stored.tokenDigest,
2418
+ }
2419
+ : {}),
2434
2420
  });
2435
2421
  let won;
2436
2422
  try {
@@ -2445,11 +2431,7 @@ export class KvOAuthProvider {
2445
2431
  }
2446
2432
  if (won) {
2447
2433
  try {
2448
- const latest = (yield authCall(wait, [
2449
- authMethod(() => {
2450
- return authInvoke(this, "storedTokens", [waiting.signal]);
2451
- }),
2452
- ]));
2434
+ const latest = (yield tokens());
2453
2435
  if (latest?.refresh_token !== requested || (latest && this.refreshedSinceRead(latest, epoch))) {
2454
2436
  (yield authCall(wait, [
2455
2437
  authMethod(() => {
@@ -2527,8 +2509,8 @@ export class KvOAuthProvider {
2527
2509
  lease.raw = dispatched;
2528
2510
  if (waiting.signal.aborted)
2529
2511
  throw aborted(waiting.signal);
2530
- // One CAS owns the fingerprint across every epoch. Only a later consent
2531
- // that observed a resolved write may replace it. No TTL or deletion path.
2512
+ // One CAS owns the fingerprint across epochs and generations. Only a
2513
+ // proven successful commit or later consent may replace a resolved send.
2532
2514
  lease.spentRaw = JSON.stringify({
2533
2515
  connectaOAuthRefreshSpent: 1,
2534
2516
  epoch,
@@ -2565,9 +2547,17 @@ export class KvOAuthProvider {
2565
2547
  registerAuthMethod(KvOAuthProvider.prototype, "releaseRefresh", function (lease, verdict, tokens, resolution = "ambiguous") {
2566
2548
  return authFlow(function* () {
2567
2549
  const stored = parsed(lease.raw);
2568
- // Only an unsent claim may be removed. Dispatched outcomes never reopen it.
2550
+ // Unsent successors restore the prior completed proof; other unsent
2551
+ // claims may be removed. Dispatched outcomes never reopen a send.
2569
2552
  const next = stored.state === "claimed"
2570
- ? null
2553
+ ? stored.predecessor && stored.tokenDigest
2554
+ ? JSON.stringify({
2555
+ ...stored,
2556
+ holder: stored.predecessor,
2557
+ state: "dispatched",
2558
+ completed: true,
2559
+ })
2560
+ : null
2571
2561
  : JSON.stringify({
2572
2562
  ...stored,
2573
2563
  ...(tokens
@@ -2711,8 +2701,10 @@ export class KvOAuthProvider {
2711
2701
  (yield authInvoke(this, "sweepRefreshLeases", []));
2712
2702
  }
2713
2703
  finally {
2714
- if (revoke)
2715
- (yield authCall(revokeDownstreamGrant, [grant.body, revoke, this.clientOptions?.client]));
2704
+ if (revoke) {
2705
+ const snapshot = this.revocationSnapshot(grant.body);
2706
+ (yield authCall(revokeDownstreamGrant, [snapshot.body, revoke, snapshot.client]));
2707
+ }
2716
2708
  }
2717
2709
  return;
2718
2710
  }
@@ -54,6 +54,15 @@ const RESERVED_AUTHORIZATION_PARAMS = new Set([
54
54
  "code_challenge_method",
55
55
  "resource",
56
56
  ]);
57
+ /** Protocol response fields have their own validation, never static assertions. */
58
+ const RESERVED_CALLBACK_PARAMS = new Set([
59
+ "code",
60
+ "state",
61
+ "iss",
62
+ "error",
63
+ "error_description",
64
+ "error_uri",
65
+ ]);
57
66
  /** Token-request headers the grant owns: client authentication and framing. */
58
67
  const RESERVED_TOKEN_HEADERS = new Set([
59
68
  "authorization",
@@ -92,65 +101,91 @@ function settingsFor(id, config) {
92
101
  if (!config || typeof config !== "object") {
93
102
  throw new Error(`[connecta] connector "${id}" oauth must be an object.`);
94
103
  }
104
+ const invalid = (detail) => {
105
+ throw new Error(`[connecta] connector "${id}" oauth.${detail}`);
106
+ };
95
107
  const authorizationEndpoint = configuredUrl(id, "authorizationEndpoint", config.authorizationEndpoint);
96
108
  const tokenEndpoint = configuredUrl(id, "tokenEndpoint", config.tokenEndpoint);
109
+ const revocationEndpoint = config.revocationEndpoint === undefined
110
+ ? undefined
111
+ : configuredUrl(id, "revocationEndpoint", config.revocationEndpoint);
112
+ if (revocationEndpoint?.search)
113
+ invalid(`revocationEndpoint must not carry a query.`);
114
+ const revocationRequestFormat = config.revocationRequestFormat ?? "form";
115
+ if ((revocationRequestFormat !== "form" && revocationRequestFormat !== "json") ||
116
+ (config.revocationRequestFormat !== undefined && !revocationEndpoint))
117
+ invalid(`revocationRequestFormat requires an endpoint and form or json.`);
97
118
  if (tokenEndpoint.search) {
98
- throw new Error(`[connecta] connector "${id}" oauth.tokenEndpoint must not carry a query.`);
119
+ invalid(`tokenEndpoint must not carry a query.`);
99
120
  }
100
121
  if (typeof config.clientId !== "string" || config.clientId.trim() === "") {
101
- throw new Error(`[connecta] connector "${id}" oauth.clientId must be a non-empty string.`);
122
+ invalid(`clientId must be a non-empty string.`);
102
123
  }
103
124
  if (config.clientSecret !== undefined && (typeof config.clientSecret !== "string" || config.clientSecret === "")) {
104
125
  // Usually an unset environment variable read as "". Never echo it.
105
- throw new Error(`[connecta] connector "${id}" oauth.clientSecret must be a non-empty string when set.`);
126
+ invalid(`clientSecret must be a non-empty string when set.`);
106
127
  }
107
128
  const clientAuthentication = config.tokenEndpointAuthMethod ?? (config.clientSecret !== undefined ? "client_secret_basic" : "none");
108
129
  if (!CLIENT_AUTHENTICATION.has(clientAuthentication)) {
109
- throw new Error(`[connecta] connector "${id}" oauth.tokenEndpointAuthMethod must be ` +
110
- '"client_secret_basic", "client_secret_post", or "none".');
130
+ invalid(`tokenEndpointAuthMethod must be ` + '"client_secret_basic", "client_secret_post", or "none".');
111
131
  }
112
132
  if (clientAuthentication === "none" && config.clientSecret !== undefined) {
113
133
  throw new Error(`[connecta] connector "${id}" oauth declares a clientSecret with ` +
114
134
  'tokenEndpointAuthMethod "none"; a public client sends no secret.');
115
135
  }
116
136
  if (clientAuthentication !== "none" && config.clientSecret === undefined) {
117
- throw new Error(`[connecta] connector "${id}" oauth.tokenEndpointAuthMethod ` + `"${clientAuthentication}" needs a clientSecret.`);
137
+ invalid(`tokenEndpointAuthMethod ` + `"${clientAuthentication}" needs a clientSecret.`);
118
138
  }
119
139
  assertOAuthScope(id, config.scope);
120
140
  const authorizationParams = [];
121
141
  for (const [name, value] of Object.entries(config.authorizationParams ?? {})) {
122
142
  if (RESERVED_AUTHORIZATION_PARAMS.has(name)) {
123
- throw new Error(`[connecta] connector "${id}" oauth.authorizationParams may not set ` +
124
- `"${name}"; the authorization request owns it.`);
143
+ invalid(`authorizationParams may not set ` + `"${name}"; the authorization request owns it.`);
125
144
  }
126
145
  if (typeof value !== "string") {
127
- throw new Error(`[connecta] connector "${id}" oauth.authorizationParams.${name} must be a string.`);
146
+ invalid(`authorizationParams.${name} must be a string.`);
128
147
  }
129
148
  authorizationParams.push([name, value]);
130
149
  }
150
+ const expectedCallbackParameters = [];
151
+ if (config.expectedCallbackParameters !== undefined &&
152
+ (!config.expectedCallbackParameters ||
153
+ typeof config.expectedCallbackParameters !== "object" ||
154
+ Array.isArray(config.expectedCallbackParameters))) {
155
+ invalid(`expectedCallbackParameters must be a string record.`);
156
+ }
157
+ for (const [name, value] of Object.entries(config.expectedCallbackParameters ?? {})) {
158
+ if (!/^[A-Za-z0-9_.-]+$/.test(name) ||
159
+ RESERVED_CALLBACK_PARAMS.has(name) ||
160
+ typeof value !== "string" ||
161
+ value === "") {
162
+ invalid(`expectedCallbackParameters must use nonempty parameter names ` +
163
+ "(ASCII letters, digits, dots, underscores or hyphens), nonempty string values, and no OAuth response fields.");
164
+ }
165
+ expectedCallbackParameters.push([name, value]);
166
+ }
131
167
  const tokenRequestHeaders = [];
132
168
  for (const [name, value] of Object.entries(config.tokenRequestHeaders ?? {})) {
133
169
  if (!HEADER_NAME.test(name)) {
134
- throw new Error(`[connecta] connector "${id}" oauth.tokenRequestHeaders has an invalid header name.`);
170
+ invalid(`tokenRequestHeaders has an invalid header name.`);
135
171
  }
136
172
  if (RESERVED_TOKEN_HEADERS.has(name.toLowerCase())) {
137
- throw new Error(`[connecta] connector "${id}" oauth.tokenRequestHeaders may not set ` + `"${name}"; the token request owns it.`);
173
+ invalid(`tokenRequestHeaders may not set ` + `"${name}"; the token request owns it.`);
138
174
  }
139
175
  if (typeof value !== "string" || value.includes("\r") || value.includes("\n") || value.includes("\0")) {
140
- throw new Error(`[connecta] connector "${id}" oauth.tokenRequestHeaders.${name} must be a single-line string.`);
176
+ invalid(`tokenRequestHeaders.${name} must be a single-line string.`);
141
177
  }
142
178
  tokenRequestHeaders.push([name, value]);
143
179
  }
144
180
  const origins = Array.isArray(config.apiOrigins) ? config.apiOrigins : [];
145
181
  if (origins.length === 0) {
146
- throw new Error(`[connecta] connector "${id}" oauth.apiOrigins must name at least one ` +
147
- "origin; ctx.oauth.fetch sends the access token nowhere else.");
182
+ invalid(`apiOrigins must name at least one ` + "origin; ctx.oauth.fetch sends the access token nowhere else.");
148
183
  }
149
184
  const apiOrigins = new Set();
150
185
  for (const origin of origins) {
151
186
  const url = configuredUrl(id, "apiOrigins", origin);
152
187
  if (url.origin !== origin) {
153
- throw new Error(`[connecta] connector "${id}" oauth.apiOrigins entries must be exact ` +
188
+ invalid(`apiOrigins entries must be exact ` +
154
189
  'origins, like "https://api.example.com", with no path or trailing slash.');
155
190
  }
156
191
  apiOrigins.add(url.origin);
@@ -158,12 +193,15 @@ function settingsFor(id, config) {
158
193
  return {
159
194
  authorizationEndpoint,
160
195
  tokenEndpoint,
196
+ ...(revocationEndpoint ? { revocationEndpoint } : {}),
197
+ revocationRequestFormat,
161
198
  clientId: config.clientId,
162
199
  ...(config.clientSecret !== undefined ? { clientSecret: config.clientSecret } : {}),
163
200
  clientAuthentication,
164
201
  ...(config.scope !== undefined ? { scope: config.scope } : {}),
165
202
  pkce: config.pkce ?? true,
166
203
  authorizationParams,
204
+ expectedCallbackParameters,
167
205
  tokenRequestHeaders,
168
206
  apiOrigins,
169
207
  serverUrl: new URL(origins[0]),
@@ -202,6 +240,29 @@ class StaticOAuthProvider extends KvOAuthProvider {
202
240
  tokenEndpoint: this.settings.tokenEndpoint.href,
203
241
  };
204
242
  }
243
+ configuredClientInformation() {
244
+ const { settings } = this;
245
+ return {
246
+ client_id: settings.clientId,
247
+ ...(settings.clientSecret !== undefined ? { client_secret: settings.clientSecret } : {}),
248
+ redirect_uris: [this.redirectUrl],
249
+ token_endpoint_auth_method: settings.clientAuthentication,
250
+ issuer: settings.identity,
251
+ };
252
+ }
253
+ revocationSnapshot(body) {
254
+ const { settings } = this;
255
+ return {
256
+ body: {
257
+ ...body,
258
+ client: { value: this.configuredClientInformation() },
259
+ discovery: {
260
+ authorizationServerUrl: settings.identity,
261
+ authorizationServerMetadata: { revocation_endpoint: settings.revocationEndpoint.href },
262
+ },
263
+ },
264
+ };
265
+ }
205
266
  saveClientInformation() {
206
267
  return runEdge(authOwnMethod(StaticOAuthProvider.prototype, this, "saveClientInformation", []));
207
268
  }
@@ -245,16 +306,7 @@ class StaticOAuthProvider extends KvOAuthProvider {
245
306
  }
246
307
  static {
247
308
  registerAuthMethod(StaticOAuthProvider.prototype, "clientInformation", function () {
248
- return authValue(() => {
249
- const { settings } = this;
250
- return {
251
- client_id: settings.clientId,
252
- ...(settings.clientSecret !== undefined ? { client_secret: settings.clientSecret } : {}),
253
- redirect_uris: [this.redirectUrl],
254
- token_endpoint_auth_method: settings.clientAuthentication,
255
- issuer: settings.identity,
256
- };
257
- });
309
+ return authValue(() => this.configuredClientInformation());
258
310
  }, StaticOAuthProvider.prototype.clientInformation);
259
311
  registerAuthMethod(StaticOAuthProvider.prototype, "saveClientInformation", function () {
260
312
  // Nothing to save: the SDK only saves a client it registered or stamped.
@@ -359,6 +411,10 @@ const readChallengeNative = /* @__PURE__ */ registerAuth(readChallenge, function
359
411
  /** The grant behind one `api()` connector's `oauth`, as the hooks it exposes. */
360
412
  export function staticOAuth(id, config) {
361
413
  const settings = settingsFor(id, config);
414
+ const callbackParametersMatch = (params) => settings.expectedCallbackParameters.every(([name, expected]) => {
415
+ const values = params?.getAll(name);
416
+ return values?.length === 1 && values[0] === expected;
417
+ });
362
418
  const issuerContext = { issuer: settings.identity };
363
419
  const coordinatorFor = refreshCoordinatorsByPartition();
364
420
  // Keyed by request scope, so nothing here outlives the request that set it.
@@ -830,7 +886,25 @@ export function staticOAuth(id, config) {
830
886
  const disconnectAuth = authMethod((ctx) => {
831
887
  return authFlow(function* () {
832
888
  rejectedScopes.delete(scopeOf(ctx));
833
- (yield authInvoke(providerFor(ctx), "resetAuthorization", [true]));
889
+ if (!settings.revocationEndpoint) {
890
+ (yield authInvoke(providerFor(ctx), "resetAuthorization", [true]));
891
+ return;
892
+ }
893
+ (yield authInvoke(providerFor(ctx), "disconnectAuthorization", [
894
+ authMethod((input, init = {}) => {
895
+ // Register the standard form's credentials before any JSON framing.
896
+ trackRemoteClientRequest(sentSecretsFor(ctx), input, init);
897
+ if (settings.revocationRequestFormat === "json" && init.body instanceof URLSearchParams) {
898
+ const params = new URLSearchParams(init.body);
899
+ params.delete("token_type_hint");
900
+ const headers = new Headers(init.headers);
901
+ headers.set("Content-Type", "application/json");
902
+ headers.set("Accept", "application/json");
903
+ init = { ...init, headers, body: JSON.stringify(Object.fromEntries(params)) };
904
+ }
905
+ return authCall(tokenEndpointFetch, [sentSecretsFor(ctx), input, init]);
906
+ }),
907
+ ]));
834
908
  });
835
909
  });
836
910
  const verifyState = authMethod((state, ctx) => {
@@ -854,6 +928,9 @@ export function staticOAuth(id, config) {
854
928
  if (!consent || !(yield authInvoke(provider, "verifyCallbackState", [consent]))) {
855
929
  throw new ConnectorCallError("connector_call_failed", `Connector "${id}" authorization callback matches no pending consent; nothing was exchanged.`);
856
930
  }
931
+ if (!callbackParametersMatch(callbackParams)) {
932
+ throw new ConnectorCallError("connector_call_failed", `Connector "${id}" authorization callback did not match its configured parameters; nothing was exchanged.`);
933
+ }
857
934
  provider.validateCallbackIssuer(callbackParams?.get("iss") ?? null);
858
935
  provider.exchanging(authorizationCode);
859
936
  (yield authInvoke(provider, "bindFlow", []));
@@ -877,6 +954,7 @@ export function staticOAuth(id, config) {
877
954
  startAuth: retainAuthPartition(startAuth, 0),
878
955
  disconnectAuth: retainAuthPartition(disconnectAuth, 0),
879
956
  verifyState: retainAuthPartition(verifyState, 1),
957
+ verifyCallbackParameters: retainAuthPartition(authMethod((params, _ctx) => authValue(() => callbackParametersMatch(params))), 1),
880
958
  verifyCallbackIssuer: retainAuthPartition(authMethod((issuer, ctx) => {
881
959
  return authValue(() => {
882
960
  const provider = callbackProviders.get(scopeOf(ctx));