@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.
- package/CHANGELOG.md +55 -0
- package/README.md +8 -4
- package/bin/version.mjs +1 -1
- package/dist/auth/downstream-oauth.js +71 -79
- package/dist/auth/static-oauth.js +104 -26
- package/dist/connectors/api-connector.d.ts +17 -0
- package/dist/connectors/api-connector.js +3 -1
- package/dist/http/client.js +1 -1
- package/dist/http/paging.js +1 -2
- package/dist/http/services.js +1 -1
- package/dist/http/tools.js +0 -4
- package/dist/operator-ui/contract.d.ts +1 -0
- package/dist/operator-ui/generated.js +4 -4
- package/dist/providers/_shared/google/kit.js +29 -139
- package/dist/providers/_shared/rest/kit.js +4 -404
- package/dist/providers/docs/tools.js +1 -2
- package/dist/providers/drive/api.js +1 -1
- package/dist/providers/forms/tools.js +1 -2
- package/dist/providers/gmail/tools.js +2 -2
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/quickbooks/api.js +299 -0
- package/dist/providers/quickbooks/index.d.ts +13 -0
- package/dist/providers/quickbooks/index.js +93 -0
- package/dist/providers/quickbooks/lib/query.js +118 -0
- package/dist/providers/quickbooks/lib/writes.js +168 -0
- package/dist/providers/quickbooks/safety.js +222 -0
- package/dist/providers/quickbooks/skill.generated.js +5 -0
- package/dist/providers/quickbooks/tools.js +117 -0
- package/dist/providers/sheets/tools.js +1 -2
- package/dist/providers/slides/tools.js +2 -2
- package/dist/providers/vercel/index.js +1 -2
- package/dist/routes/oauth.js +3 -0
- package/dist/runtime/call.js +0 -4
- package/dist/skills.js +0 -1
- package/dist/types.d.ts +2 -0
- package/dist/ui.d.ts +1 -1
- package/dist/ui.js +0 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/README.md +1 -0
- package/documentation/architecture.md +134 -301
- package/documentation/auth.md +47 -17
- package/documentation/integrating.md +1 -1
- package/documentation/meta-tools.md +1 -1
- package/documentation/operator-ui.md +16 -3
- package/documentation/providers.md +382 -0
- package/package.json +7 -8
- package/templates/node/package.json +1 -1
- package/dist/operator-ui/agents.d.ts +0 -75
- package/dist/operator-ui/agents.js +0 -114
- package/dist/operator-ui/config-summary.js +0 -566
- package/dist/operator-ui/connection-map.js +0 -378
- package/dist/operator-ui/endpoints.js +0 -212
- package/dist/operator-ui/fix-prompts.d.ts +0 -18
- package/dist/operator-ui/fix-prompts.js +0 -79
- package/dist/operator-ui/health.js +0 -52
- package/dist/operator-ui/member-page.js +0 -97
- package/dist/operator-ui/overview.js +0 -126
- package/dist/operator-ui/setup-commands.js +0 -47
- package/dist/operator-ui/view.d.ts +0 -378
- package/dist/operator-ui/view.js +0 -631
- package/dist/providers/_shared/rest/dispatch.js +0 -21
- 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
|
-
|
|
30
|
-
Connecta
|
|
31
|
-
|
|
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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
2314
|
-
stored.completed === true
|
|
2315
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
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
|
|
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
|
|
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
|
|
2531
|
-
//
|
|
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
|
-
//
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
119
|
+
invalid(`tokenEndpoint must not carry a query.`);
|
|
99
120
|
}
|
|
100
121
|
if (typeof config.clientId !== "string" || config.clientId.trim() === "") {
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
+
invalid(`tokenRequestHeaders has an invalid header name.`);
|
|
135
171
|
}
|
|
136
172
|
if (RESERVED_TOKEN_HEADERS.has(name.toLowerCase())) {
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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));
|