@zackbart/connecta 0.36.0 → 0.36.2

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 (71) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +1 -1
  3. package/bin/version.mjs +1 -1
  4. package/dist/auth/downstream-oauth.js +3 -0
  5. package/dist/auth/oauth-authorization-params.js +52 -0
  6. package/dist/auth/static-oauth.js +1 -4
  7. package/dist/connectors/option-shapes.js +1 -0
  8. package/dist/connectors/remote-mcp.d.ts +4 -1
  9. package/dist/connectors/remote-mcp.js +12 -1
  10. package/dist/google.d.ts +13 -0
  11. package/dist/google.js +42 -0
  12. package/dist/http/hatch-paging.js +2 -2
  13. package/dist/http/paging.js +3 -88
  14. package/dist/http/provider.js +1 -2
  15. package/dist/http/tools.js +1 -23
  16. package/dist/program-writes.js +0 -1
  17. package/dist/providers/_shared/google/service-account.d.ts +1 -1
  18. package/dist/providers/_shared/google/service-account.js +20 -8
  19. package/dist/providers/_shared/sensitive-fields.js +0 -16
  20. package/dist/providers/breeze/api.js +2 -2
  21. package/dist/providers/ccb/api.js +1 -1
  22. package/dist/providers/cloudflare/api.js +8 -8
  23. package/dist/providers/cloudflare/lib/config.d.ts +2 -1
  24. package/dist/providers/docs/api.js +1 -1
  25. package/dist/providers/drive/api.js +1 -1
  26. package/dist/providers/drive/lib/hatch.js +1 -1
  27. package/dist/providers/drive/tools.js +1 -1
  28. package/dist/providers/forms/api.js +1 -1
  29. package/dist/providers/forms/lib/hatch.js +1 -1
  30. package/dist/providers/forms/tools.js +1 -1
  31. package/dist/providers/github/api.js +1 -1
  32. package/dist/providers/github/lib/discovery.js +2 -2
  33. package/dist/providers/gmail/api.js +1 -1
  34. package/dist/providers/granola/api.js +155 -0
  35. package/dist/providers/granola/index.d.ts +10 -0
  36. package/dist/providers/granola/index.js +68 -0
  37. package/dist/providers/granola/openapi.generated.js +30 -0
  38. package/dist/providers/granola/safety.js +37 -0
  39. package/dist/providers/granola/skill.generated.js +5 -0
  40. package/dist/providers/granola/tools.js +2 -0
  41. package/dist/providers/overflow/api.js +26 -26
  42. package/dist/providers/planning-center/api.js +4 -4
  43. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  44. package/dist/providers/planning-center/lib/json-api.js +2 -2
  45. package/dist/providers/quickbooks/lib/query.js +1 -1
  46. package/dist/providers/quickbooks/safety.js +1 -1
  47. package/dist/providers/sheets/api.js +1 -1
  48. package/dist/providers/sheets/lib/hatch.js +2 -2
  49. package/dist/providers/sheets/lib/requests.js +1 -1
  50. package/dist/providers/slides/api.js +1 -1
  51. package/dist/providers/stripe/api.js +7 -7
  52. package/dist/providers/tithely/api.js +2 -2
  53. package/dist/providers/vercel/api.js +12 -12
  54. package/dist/providers/vercel/lib/pin.js +1 -1
  55. package/dist/request-token.d.ts +7 -0
  56. package/dist/request-token.js +1 -0
  57. package/dist/routes/mcp.js +1 -1
  58. package/dist/runtime/admission.js +0 -12
  59. package/dist/storage/keys.js +2 -3
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.js +1 -1
  62. package/documentation/README.md +13 -12
  63. package/documentation/architecture.md +148 -76
  64. package/documentation/auth.md +198 -4
  65. package/documentation/code-mode.md +6 -6
  66. package/documentation/meta-tools.md +5 -2
  67. package/documentation/operator-tests.md +11 -2
  68. package/documentation/operator-ui.md +1 -2
  69. package/documentation/providers.md +110 -0
  70. package/package.json +10 -2
  71. package/templates/node/package.json +1 -1
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.2 — 2026-10-27
6
+
7
+ Connecta 0.36.2 makes Google's hosted MCP servers, such as BigQuery, usable through generic `remoteMcp()` connections. The new `@zackbart/connecta/google` entry mints Google service-account tokens for request-local auth, so agent pools can share a service-account connection without a browser consent. The service account acts as itself, or as a Workspace user when a delegation subject is given. A rejected token is dropped and re-minted for the next request; dispatched writes are never replayed.
8
+
9
+ Remote MCP OAuth connections can now add browser-only authorization parameters with `auth.authorizationParams`. Personal Google connections set `access_type: "offline"` and `prompt: "consent"` to receive refresh tokens. Protocol and security fields are reserved, and changing the parameters requires reconnecting. API OAuth keeps its existing `authorizationParams` validation.
10
+
11
+ Both additions are opt-in. Deployments that do not use them need no configuration change. The root bundle cap rises to 403,500 B gzip to fit the new validation. Live Google sign-in and BigQuery calls have not been exercised with deployment credentials.
12
+
13
+ ### Added
14
+
15
+ - Mint Google service-account tokens for generic remote MCP request auth through `@zackbart/connecta/google`, with optional Workspace delegation and rejected-token cache invalidation.
16
+ - Let remote MCP OAuth connections configure browser-only authorization parameters, including Google's offline-access and consent settings. Protocol and security fields, including `response_mode`, are reserved in every case variant; changing remote MCP authorization parameters requires reconnecting. API OAuth keeps its existing `authorizationParams` validation.
17
+
18
+ ## 0.36.1 — 2026-10-27
19
+
20
+ Connecta 0.36.1 adds read-only Granola workspace API access and lets Eve agents select a pool before each model call. Pool changes keep guidance caches separate and prevent replay of parked calls against a different pool. Existing static-pool configuration and public provider tools keep their behavior.
21
+
22
+ The contributor checks now center on real Node/Workers, browser, persistence and installed-package journeys. Every PR runs browser, audit, Eve and Docker package checks. This patch requires no deployment configuration migration. Granola operators supply a workspace key; Eve pool selectors are optional.
23
+
24
+ QuickBooks invoice updates remain unavailable while their delivery contract is unresolved. The workload validation recipe is reproducible locally; live validation requires separate authorization and is not claimed by this release.
25
+
26
+ ### Added
27
+
28
+ - Let the Eve helper choose a pool before each model call with an app-owned selector, fence parked calls after pool changes, and cache instructions and failure backoff per endpoint. Instruction requests also accept a custom client name.
29
+ - Add a read-only Granola workspace API provider for meeting notes, details, paginated transcripts, folders and spaces, with encrypted shared credentials and the standard operation search/details/read surface.
30
+
31
+ ### Changed
32
+
33
+ - Add a locally reproducible Eve 0.84 workload fixture with independently verified audience-exchange controls and retained server cancellation receipts, and clarify that custom-audience workload tokens are refused. Live validation still requires separate authorization for disposable resources. Verify the standard 2 GiB Fluid resource preset and require confirmed cleanup even when evidence persistence fails.
34
+ - Replace invariant/spec test-title bookkeeping with executable evidence file
35
+ references and the existing stateful behavior journeys. Require installed-package,
36
+ generated-deployment and Docker smoke in the shared CI build job, with browser,
37
+ audit and real Eve compatibility on every PR. Remove the path classifier,
38
+ duplicate package workflow and completed migration's cross-commit exemption guard.
39
+ Keep focused runtime probes, provider contracts, build/declaration checks and the
40
+ single fail-closed `check` status.
41
+ - Remove completed provider-migration exemptions, legacy generator fixtures and
42
+ unused internal list/guide adapters. Consolidate contributor provider and release
43
+ workflows around the current standard without changing public tools or guides.
44
+ - Replace lower-layer operator, configuration and storage test mirrors with real browser, SQLite restart and deployed Node/Workers persistence journeys. Preserve focused SQL rollback, expiry, isolation, secrecy and cancellation probes.
45
+ - Replace redundant auth, write, privacy and runtime test matrices with real HTTP/MCP identity, one-send recovery, secrecy and socket-cancellation journeys. Require prompt downstream closure and admission release after client cancellation, before the ordinary request deadline. Retain focused security and lifetime fault probes without changing runtime behavior or public APIs.
46
+ - Clarify why QuickBooks invoice updates remain unavailable: sparse field preservation and request IDs do not establish email suppression for existing send-ready invoices. Record the authoritative contract gaps and a proposed unsent-only update for further review; no accounting write surface is added.
47
+ - Replace the remaining catalog-wide provider failure sweep and redundant
48
+ provider/HTTP agreement checks with shared stateful outcomes and real HTTP
49
+ failure/recovery journeys. Retain vendor-specific privacy, MIME, ownership,
50
+ revision, physical-send and lifetime fault probes; remove five internal paging
51
+ helpers used only by the retired tests and private exports hidden by the old
52
+ source scan. Require successful, complete delivered reconciliation data for the
53
+ Tithe.ly cancellation journey; failed, empty, malformed and incomplete replies
54
+ cannot establish recovery. Public provider behavior is unchanged.
55
+
56
+ ### Fixed
57
+
58
+ - Use permanently unreachable loopback port zero for native transport-failure probes so later test servers cannot answer them. Verify each platform's native connection error while preserving Node/Workers write-outcome, retry, reset and abort assertions.
59
+
5
60
  ## 0.36.0 — 2026-10-27
6
61
 
7
62
  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).
package/README.md CHANGED
@@ -24,7 +24,7 @@ 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, QuickBooks Online, 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, Granola, Overflow, Planning Center, QuickBooks Online, Stripe, Tithe.ly, and Vercel<!-- providers:end -->.
28
28
 
29
29
  Maintained API providers pair reviewed vendor contracts with tools built by
30
30
  Connecta. See the [API sources and tool inventory](./documentation/providers.md#api-sources-and-tool-inventory)
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.36.0";
3
+ export const CONNECTA_VERSION = "0.36.2";
@@ -1972,6 +1972,9 @@ export class KvOAuthProvider {
1972
1972
  return;
1973
1973
  if (!this.allowAuthorization)
1974
1974
  throw this.authorizationRefused();
1975
+ for (const [name, value] of this.clientOptions?.authorizationParams ?? []) {
1976
+ authorizationUrl.searchParams.set(name, value);
1977
+ }
1975
1978
  this.assertCredentialFree(authorizationUrl.toString(), (yield authInvoke(this, "boundGrant", [])).body, true);
1976
1979
  const state = this.consent.state ?? authorizationUrl.searchParams.get("state");
1977
1980
  if (!state)
@@ -0,0 +1,52 @@
1
+ import { ConfigError } from "../config-schema.js";
2
+ /**
3
+ * Protocol and security fields cannot be supplied through remote MCP extensions.
4
+ * `response_mode` stays reserved: callbacks arrive only as GET query parameters.
5
+ */
6
+ const RESERVED = new Set([
7
+ "response_type",
8
+ "response_mode",
9
+ "client_id",
10
+ "client_secret",
11
+ "redirect_uri",
12
+ "state",
13
+ "code_challenge",
14
+ "code_challenge_method",
15
+ "scope",
16
+ "resource",
17
+ "request",
18
+ "request_uri",
19
+ ]);
20
+ /** Validate and snapshot non-secret browser-only parameters at construction. */
21
+ export function authorizationParamsFor(value, path) {
22
+ if (value === undefined)
23
+ return [];
24
+ const invalid = (detail) => {
25
+ throw new ConfigError(`${path} ${detail}`);
26
+ };
27
+ if (value === null || typeof value !== "object" || Array.isArray(value))
28
+ invalid("must be a plain string map.");
29
+ const entries = Object.entries(value);
30
+ if (entries.length > 32)
31
+ invalid("must contain at most 32 parameters.");
32
+ const names = new Set();
33
+ return entries
34
+ .map(([name, entry]) => {
35
+ if (!/^[A-Za-z0-9_.-]{1,128}$/.test(name))
36
+ invalid("names must be 1–128 ASCII letters, digits, dots, underscores or hyphens.");
37
+ const lower = name.toLowerCase();
38
+ if (RESERVED.has(lower))
39
+ invalid(`may not set "${name}"; the authorization request owns it.`);
40
+ if (names.has(lower))
41
+ invalid("must not contain case-variant duplicate names.");
42
+ names.add(lower);
43
+ if (typeof entry !== "string" ||
44
+ !entry.trim() ||
45
+ entry.length > 2048 ||
46
+ Array.from(entry).some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) {
47
+ invalid("values must be non-empty strings of at most 2048 code units without control characters.");
48
+ }
49
+ return [name, entry];
50
+ })
51
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
52
+ }
@@ -222,7 +222,7 @@ class StaticOAuthProvider extends KvOAuthProvider {
222
222
  super(connectorId, ctx.storage, `${ctx.baseUrl}/oauth/callback/${connectorId}`, coordinator, ctx.allowAuthorization === true, oauthSealerFor(ctx), viewSignal(ctx, requestOwned),
223
223
  // No client binding: the client is configuration, so there is no
224
224
  // registration for a restart to carry forward or discard.
225
- undefined, (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset), undefined, settings.scope, undefined, { secrets: sentSecretsFor(ctx) });
225
+ undefined, (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset), undefined, settings.scope, undefined, { secrets: sentSecretsFor(ctx), authorizationParams: settings.authorizationParams });
226
226
  this.settings = settings;
227
227
  }
228
228
  /**
@@ -341,9 +341,6 @@ class StaticOAuthProvider extends KvOAuthProvider {
341
341
  }, StaticOAuthProvider.prototype.validateResourceURL);
342
342
  registerAuthMethod(StaticOAuthProvider.prototype, "redirectToAuthorization", function (authorizationUrl) {
343
343
  return authFlow(function* () {
344
- for (const [name, value] of this.settings.authorizationParams) {
345
- authorizationUrl.searchParams.set(name, value);
346
- }
347
344
  if (!this.settings.pkce) {
348
345
  authorizationUrl.searchParams.delete("code_challenge");
349
346
  authorizationUrl.searchParams.delete("code_challenge_method");
@@ -22,6 +22,7 @@ export const REMOTE_MCP_AUTH = variants("type", {
22
22
  }).shape,
23
23
  oauth: optionsOf()({
24
24
  ...keys("type", "clientMetadataUrl", "scope"),
25
+ authorizationParams: strings(),
25
26
  client: optionsOf()(keys("issuer", "clientId", "clientSecret", "tokenEndpointAuthMethod")),
26
27
  }).shape,
27
28
  });
@@ -1,6 +1,7 @@
1
1
  import { type RemoteOAuthClient } from "../auth/downstream-client-metadata.js";
2
2
  import type { FetchLike, Transport } from "@modelcontextprotocol/client";
3
3
  import { ConnectorCallError } from "../errors.js";
4
+ import type { RequestToken } from "../request-token.js";
4
5
  import type { Connector, ConnectorCallAdmissionPolicy, ConnectorContext, ConnectorCredentialConfig, ConnectorUsageGuide, Logger } from "../types.js";
5
6
  /**
6
7
  * A static downstream credential supplied through the connection in the
@@ -37,7 +38,7 @@ interface RemoteMcpCredentialAuth {
37
38
  export type RemoteMcpAuth = {
38
39
  type: "request";
39
40
  /** Resolve a Bearer token inside this request. Never persisted or described. */
40
- token: (ctx: ConnectorContext) => Promise<string>;
41
+ token: RequestToken;
41
42
  /** Static protocol/catalog headers, never Authorization. */
42
43
  headers?: Record<string, string>;
43
44
  } | {
@@ -51,6 +52,8 @@ export type RemoteMcpAuth = {
51
52
  client?: RemoteOAuthClient;
52
53
  /** Space-separated default scopes when the resource server does not advertise them. */
53
54
  scope?: string;
55
+ /** Non-secret browser authorization parameters; protocol fields are reserved. Changes require reconnect. */
56
+ authorizationParams?: Record<string, string>;
54
57
  };
55
58
  export type RemoteMcpRedirectPolicy = "none" | "same-origin";
56
59
  export interface RemoteMcpOptions {
@@ -1,3 +1,4 @@
1
+ import { authorizationParamsFor } from "../auth/oauth-authorization-params.js";
1
2
  import { authView } from "../auth/native-call.js";
2
3
  import { authFlow, authCall, authInvoke, authValue, authMethod, authCatch } from "../auth/native.js";
3
4
  import { isOAuthCredentialRequest } from "../auth/oauth-token-response.js";
@@ -1101,6 +1102,7 @@ export function remoteMcp(id, opts) {
1101
1102
  const clientMetadataUrl = opts.auth?.type === "oauth" ? opts.auth.clientMetadataUrl : undefined;
1102
1103
  const oauthScope = opts.auth?.type === "oauth" ? opts.auth.scope : undefined;
1103
1104
  assertOAuthScope(id, oauthScope);
1105
+ const authorizationParams = authorizationParamsFor(opts.auth?.type === "oauth" ? opts.auth.authorizationParams : undefined, `remoteMcp(${JSON.stringify(id)}).auth.authorizationParams`);
1104
1106
  const staticClient = opts.auth?.type === "oauth" && opts.auth.client ? { ...opts.auth.client } : undefined;
1105
1107
  assertRemoteOAuthClient(id, staticClient);
1106
1108
  if (staticClient && clientMetadataUrl !== undefined)
@@ -1722,6 +1724,7 @@ export function remoteMcp(id, opts) {
1722
1724
  url: new URL(opts.url).href,
1723
1725
  redirectUri,
1724
1726
  clientMetadata: downstreamClientMetadata(redirectUri, oauthScope, ctx.oauthClientName, staticClient ? remoteClientAuthMethod(staticClient) : "none"),
1727
+ ...(authorizationParams.length ? { authorizationParams } : {}),
1725
1728
  authScope: opts.authScope ?? "shared",
1726
1729
  versionNegotiation: opts.versionNegotiation ?? "auto",
1727
1730
  redirects: opts.redirects ?? "none",
@@ -1735,7 +1738,7 @@ export function remoteMcp(id, opts) {
1735
1738
  },
1736
1739
  }
1737
1740
  : {}),
1738
- }), (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset), metadataUrl, oauthScope, undefined, { name: ctx.oauthClientName, client: staticClient, secrets: sentSecretsFor(ctx) });
1741
+ }), (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset), metadataUrl, oauthScope, undefined, { name: ctx.oauthClientName, client: staticClient, secrets: sentSecretsFor(ctx), authorizationParams });
1739
1742
  if (state)
1740
1743
  state.provider = provider;
1741
1744
  return provider;
@@ -1856,6 +1859,11 @@ export function remoteMcp(id, opts) {
1856
1859
  return response;
1857
1860
  });
1858
1861
  }));
1862
+ if (response.status === 401 && requestAuth?.token.invalidate && credentialValue !== null) {
1863
+ // Invalidate the exact bytes this transport sent, never a newer token.
1864
+ // Preserve the original 401 even if an operator hook fails. No replay.
1865
+ (yield authCatch(authInvoke(requestAuth.token, "invalidate", [credentialValue, ctx]), () => { }));
1866
+ }
1859
1867
  if (credentialPost && !isOAuthCredentialRequest(init) && REDIRECT_STATUSES.has(response.status)) {
1860
1868
  (yield authCatch(authValue(() => response.body?.cancel()), () => { }));
1861
1869
  throw new ConnectorCallError("connector_call_failed", "OAuth registration endpoint redirected; the request was not repeated.", { retryable: false });
@@ -2408,6 +2416,9 @@ export function remoteMcp(id, opts) {
2408
2416
  : isOauth
2409
2417
  ? {
2410
2418
  mode: "oauth",
2419
+ ...(authorizationParams.length
2420
+ ? { authorizationParamNames: authorizationParams.map(([name]) => name) }
2421
+ : {}),
2411
2422
  ...(oauthScope !== undefined ? { scope: oauthScope } : {}),
2412
2423
  ...(describedClientMetadataUrl !== undefined ? { clientMetadataUrl: describedClientMetadataUrl } : {}),
2413
2424
  }
@@ -0,0 +1,13 @@
1
+ import type { RequestToken } from "./request-token.js";
2
+ import { type GoogleServiceAccount } from "./providers/_shared/google/service-account.js";
3
+ export type { GoogleServiceAccount } from "./providers/_shared/google/service-account.js";
4
+ export interface GoogleServiceAccountTokenOptions {
5
+ /** The downloaded JSON key text, or its camelCase fields. Kept in deployment memory only. */
6
+ serviceAccount: GoogleServiceAccount | string;
7
+ /** Non-empty HTTPS OAuth scope URLs for the target API. */
8
+ scopes: readonly string[];
9
+ /** Optional Workspace user for domain-wide delegation; omitted means the account itself. */
10
+ subject?: string;
11
+ }
12
+ /** Mint Google OAuth access tokens for remoteMcp's shared request auth on Node or Workers. */
13
+ export declare function googleServiceAccountToken(options: GoogleServiceAccountTokenOptions): RequestToken;
package/dist/google.js ADDED
@@ -0,0 +1,42 @@
1
+ import { assertKnownOptions, ConfigError, keys, optionsOf } from "./config-schema.js";
2
+ import { delegatedToken, forgetDelegatedToken, parseServiceAccount, } from "./providers/_shared/google/service-account.js";
3
+ const OPTIONS = optionsOf()(keys("serviceAccount", "scopes", "subject"));
4
+ const ACCOUNT = optionsOf()(keys("clientEmail", "privateKey", "clientId"));
5
+ /** Mint Google OAuth access tokens for remoteMcp's shared request auth on Node or Workers. */
6
+ export function googleServiceAccountToken(options) {
7
+ const opts = assertKnownOptions(options, "googleServiceAccountToken", OPTIONS);
8
+ let input = opts.serviceAccount;
9
+ if (typeof input === "object" && input !== null) {
10
+ input = assertKnownOptions(input, "googleServiceAccountToken.serviceAccount", ACCOUNT);
11
+ }
12
+ let account;
13
+ try {
14
+ account = parseServiceAccount("googleServiceAccountToken", input);
15
+ }
16
+ catch {
17
+ // The legacy Workspace parser may quote a JSON credential's type. Never
18
+ // reflect arbitrary credential-file fields through this public helper.
19
+ throw new ConfigError("googleServiceAccountToken.serviceAccount requires a valid Google service-account JSON key or { clientEmail, privateKey, clientId? } with a PKCS#8 RSA key of at least 2048 bits.");
20
+ }
21
+ if (!Array.isArray(opts.scopes) || opts.scopes.length === 0 || !Array.from(opts.scopes).every(validScope)) {
22
+ throw new ConfigError("googleServiceAccountToken.scopes must be a non-empty array of HTTPS OAuth scope URLs.");
23
+ }
24
+ if (opts.subject !== undefined && (typeof opts.subject !== "string" || !/^[^\s@]+@[^\s@]+$/.test(opts.subject))) {
25
+ throw new ConfigError("googleServiceAccountToken.subject must be a Workspace email address when provided.");
26
+ }
27
+ const request = { account, subject: opts.subject, scopes: [...new Set(opts.scopes)] };
28
+ const token = async (ctx) => await delegatedToken(request, ctx);
29
+ token.invalidate = async (rejected) => await forgetDelegatedToken(request, rejected);
30
+ return token;
31
+ }
32
+ function validScope(value) {
33
+ if (typeof value !== "string" || /\s/.test(value))
34
+ return false;
35
+ try {
36
+ const url = new URL(value);
37
+ return url.protocol === "https:" && !url.username && !url.password && !url.hash;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
@@ -1,5 +1,5 @@
1
- // Internal opt-in adapters for the standard hatch. No maintained provider opts
2
- // in until its separate step-2 migration. Inputs have crossed connection safety.
1
+ // Internal opt-in adapters for the standard hatch. Inputs have crossed
2
+ // connection safety.
3
3
  import { ConnectorCallError } from "../errors.js";
4
4
  function record(value) {
5
5
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
@@ -1,29 +1,17 @@
1
1
  // Provider kit (decision 0010, B.4). Unreachable from src/index.ts.
2
2
  //
3
- // Paging is a strategy plus one cursor codec. A strategy turns "the next page
4
- // after this cursor" into a vendor request and reads back rows and the next
5
- // vendor cursor; tools take one page, and `allRows` walks every page as a
6
- // Stream for programs and the hatch. The codec is the only place a cursor is
7
- // minted: `plain` hands the vendor's cursor through; `bound` ties it to the tool and its
8
- // arguments so a cursor from another call is refused instead of silently
9
- // reading the wrong list.
10
- import { Effect, Option, Schema, Stream } from "effect";
3
+ // List tools take one page at a time. Lookahead detects an extra row; bound
4
+ // cursors authenticate the tool, arguments, connection and credential owner.
5
+ import { Effect, Schema } from "effect";
11
6
  import { ConnectorCallError } from "../errors.js";
12
7
  import { Refusal } from "./errors.js";
13
- import { confinementBase } from "../http-policy.js";
14
8
  import { canonicalJson } from "../canonical-json.js";
15
9
  import { ContinuationBinding, ContinuationStorage } from "../runtime/call.js";
16
10
  import { continuationKeys } from "../storage/keys.js";
17
- /** The `page` object every list tool returns. */
18
- export const PageInfo = Schema.Struct({
19
- hasMore: Schema.Boolean,
20
- nextCursor: Schema.NullOr(Schema.String),
21
- });
22
11
  /**
23
12
  * The standard `page` (decision 0011 S2): `nextCursor` is present exactly
24
13
  * when `hasMore` is true and continuation is safe. A hatch adapter can report
25
14
  * more data without a cursor when its continuation cannot be confined.
26
- * Step-1 lists keep `PageInfo` until their step 2.
27
15
  */
28
16
  export const StandardPage = Schema.Struct({
29
17
  hasMore: Schema.Boolean,
@@ -55,79 +43,6 @@ export function lookahead(options) {
55
43
  return { rows, page: { hasMore, nextCursor: id } };
56
44
  });
57
45
  }
58
- /** Vendor-supplied continuation, read only after the connection made its data safe. */
59
- export function token(options) {
60
- return Effect.flatMap(Effect.suspend(options.fetch), ({ rows, hasMore, nextCursor }) => {
61
- if (hasMore && !nextCursor)
62
- return badPage("The vendor reported another page without a next cursor.");
63
- return Effect.succeed({ rows, page: { hasMore, nextCursor: hasMore ? nextCursor : null } });
64
- });
65
- }
66
- /** Offset paging advances by the rows actually returned, with a total when supplied. */
67
- export function offset(options) {
68
- return Effect.gen(function* () {
69
- yield* position(options.offset, 0);
70
- yield* position(options.limit, 1);
71
- return yield* Effect.map(options.fetch(options.offset, options.limit), ({ rows, total }) => {
72
- const next = options.offset + rows.length;
73
- const hasMore = rows.length > 0 && (total === undefined ? rows.length >= options.limit : next < total);
74
- return { rows, page: { hasMore, nextCursor: hasMore ? String(next) : null } };
75
- });
76
- });
77
- }
78
- /** Page numbers from a vendor's safe body or headers, with full-page fallback. */
79
- export function page(options) {
80
- return Effect.gen(function* () {
81
- yield* position(options.page, 1);
82
- yield* position(options.limit, 1);
83
- return yield* Effect.map(options.fetch(options.page, options.limit), ({ rows, hasMore, totalPages }) => {
84
- const more = hasMore ?? (totalPages === undefined ? rows.length >= options.limit : options.page < totalPages);
85
- return { rows, page: { hasMore: more, nextCursor: more ? String(options.page + 1) : null } };
86
- });
87
- });
88
- }
89
- /** Extract only a cursor from a next URL confined to this connection's origin and path. */
90
- export function link(options) {
91
- const base = confinementBase("Paging", options.baseUrl);
92
- const prefix = base.pathname.replace(/\/+$/, "");
93
- if (!options.param)
94
- throw new Error("Link paging needs a cursor parameter.");
95
- return Effect.flatMap(Effect.suspend(options.fetch), ({ rows, next }) => {
96
- if (!next)
97
- return Effect.succeed({ rows, page: { hasMore: false, nextCursor: null } });
98
- let url;
99
- try {
100
- url = new URL(next, base);
101
- }
102
- catch {
103
- return badPage("The vendor returned an invalid next-page URL.");
104
- }
105
- if (url.origin !== base.origin ||
106
- !`${url.pathname}/`.startsWith(`${prefix}/`) ||
107
- url.username ||
108
- url.password ||
109
- url.hash)
110
- return badPage("The vendor returned a next-page URL outside this connection.");
111
- const cursors = url.searchParams.getAll(options.param);
112
- if (cursors.length !== 1 || !cursors[0])
113
- return badPage("The vendor returned no unique next-page cursor.");
114
- return Effect.succeed({ rows, page: { hasMore: true, nextCursor: cursors[0] } });
115
- });
116
- }
117
- function position(value, minimum) {
118
- return Number.isSafeInteger(value) && value >= minimum
119
- ? Effect.void
120
- : Effect.fail(new Refusal({
121
- error: new ConnectorCallError("invalid_args", "Paging positions and limits must be whole numbers within their bounds."),
122
- }));
123
- }
124
- function badPage(message) {
125
- return Effect.fail(new Refusal({ error: new ConnectorCallError("connector_call_failed", message, { retryable: false }) }));
126
- }
127
- /** Every row of every page, fetched as the stream is pulled. */
128
- export function allRows(fetchPage) {
129
- return Stream.paginate(undefined, (cursor) => Effect.map(fetchPage(cursor), ({ rows, page }) => [rows, Option.fromNullishOr(page.hasMore ? page.nextCursor : null)]));
130
- }
131
46
  const STRAY_CURSOR = "This cursor belongs to a different call. Pass back the nextCursor from the previous page of this same call, with the same arguments.";
132
47
  /** Resolved, nonextractable keys only: no request promise is retained between calls. */
133
48
  const signingKeys = new WeakMap();
@@ -191,8 +191,7 @@ export function defineApiProvider(definition) {
191
191
  // The declared fact the Writes convention states must match what the write builders send.
192
192
  if (idempotencyKey !== undefined && [...builtTools, ...hatchTools].some(takesIdempotencyKey) !== idempotencyKey)
193
193
  throw new Error(`${metadata.title} declares idempotencyKey: ${idempotencyKey}, but its write tools ${idempotencyKey ? "send no" : "send an"} idempotency key.`);
194
- const usageGuide = guide(options);
195
- const renderedGuide = "content" in usageGuide ? usageGuide : provider.usageGuide(usageGuide);
194
+ const renderedGuide = provider.usageGuide(guide(options));
196
195
  const connector = tracksEveryRequest(api(id, {
197
196
  ...(defaultCallAdmission
198
197
  ? {
@@ -22,7 +22,7 @@ import { causeError, runEdge } from "../runtime/run.js";
22
22
  import { recordUnauthorized, Refusal, RequestRefused, toConnectorCallError, withheldDefect, } from "./errors.js";
23
23
  import { fieldsInput, fieldsProblem, selectFields } from "./fields.js";
24
24
  import { catalogSchema, Integer } from "./json-schema.js";
25
- import { boundCursor, Cursor, PageInfo, StandardPage } from "./paging.js";
25
+ import { boundCursor, Cursor, StandardPage } from "./paging.js";
26
26
  import { Classification, Dispatches, IdempotencyKey, Recovery, ReadTool, WriteCapacity, writeRequestKey, fromConnectorContext, } from "./services.js";
27
27
  const inputs = new WeakMap();
28
28
  const keyed = new WeakSet();
@@ -118,28 +118,6 @@ export function get(connection, spec) {
118
118
  export function list(connection, spec) {
119
119
  if (spec.cursor !== undefined && spec.cursor !== "plain" && spec.cursor !== "bound")
120
120
  throw new Error('A list cursor must be "plain" or "bound".');
121
- if (!("key" in spec))
122
- return standardList(connection, spec);
123
- if (!spec.key || spec.key === "page")
124
- throw new Error("A list needs a row key distinct from page.");
125
- return read(connection, {
126
- ...spec,
127
- output: Schema.Struct({ [spec.key]: Schema.Array(spec.row), page: PageInfo }),
128
- run: (args) => Effect.gen(function* () {
129
- const fields = args;
130
- const supplied = typeof fields.cursor === "string" ? fields.cursor : undefined;
131
- const cursor = supplied !== undefined && spec.cursor === "bound"
132
- ? yield* boundCursor.decode(spec.name, fields, supplied)
133
- : supplied;
134
- const result = yield* spec.page(args, cursor);
135
- const nextCursor = result.page.nextCursor !== null && spec.cursor === "bound"
136
- ? yield* boundCursor.encode(spec.name, fields, result.page.nextCursor)
137
- : result.page.nextCursor;
138
- return { [spec.key]: result.rows, page: { ...result.page, nextCursor } };
139
- }),
140
- });
141
- }
142
- function standardList(connection, spec) {
143
121
  const { default: size, max } = spec.limit;
144
122
  if (spec.total !== undefined && spec.total !== true)
145
123
  throw new Error(`${spec.name} total must opt in with true for a vendor-stated exact count.`);
@@ -4,7 +4,6 @@ import { CONFIG_DEFAULTS } from "./config-defaults.js";
4
4
  import { Effect } from "effect";
5
5
  import { jsonResult } from "./meta-tools.js";
6
6
  import { classifyWriteOutcome } from "./write-outcome.js";
7
- export { classifyWriteOutcome } from "./write-outcome.js";
8
7
  /** Writes one program may send (`execute.maxWrites`), unless configured. */
9
8
  export const DEFAULT_MAX_WRITES = CONFIG_DEFAULTS.execute.maxWrites;
10
9
  /** What a dispatched write's outcome says about whether it landed. */
@@ -35,7 +35,7 @@ export declare function parseServiceAccount(owner: string, input: unknown): Serv
35
35
  /** One delegated token request: whose account, acting as whom, for what. */
36
36
  export interface DelegatedTokenRequest {
37
37
  account: ServiceAccountKey;
38
- subject: string;
38
+ subject?: string | undefined;
39
39
  scopes: readonly string[];
40
40
  }
41
41
  /**
@@ -1,8 +1,7 @@
1
1
  /**
2
- * A Google service account acting for Workspace users through domain-wide
3
- * delegation: the key, the signed JWT-bearer assertion, and the short-lived
4
- * access tokens it buys. Shared by every Workspace product provider; never its
5
- * own export.
2
+ * A Google service account acting as itself or for Workspace users through
3
+ * domain-wide delegation: the key, signed JWT-bearer assertions and short-lived
4
+ * access tokens. Shared by Workspace providers and the optional /google helper.
6
5
  *
7
6
  * Why not the vault. One key serves every Workspace connector a deployment
8
7
  * declares — Gmail, Drive, Docs — and it is a deployment secret in the same
@@ -300,9 +299,17 @@ function tokenAnswer(status, code) {
300
299
  * Map a refused token request to what fixes it. The codes are RFC 6749's and
301
300
  * Google documents what each means for a delegated assertion.
302
301
  */
303
- function tokenFailure(account, scopes, status, headers, payload) {
302
+ function tokenFailure(account, scopes, status, headers, payload, subject) {
304
303
  const code = asRecord(payload)["error"];
305
304
  const said = tokenAnswer(status, code);
305
+ if (subject === undefined) {
306
+ if (code === "unauthorized_client" || code === "access_denied" || code === "invalid_scope" || status === 403) {
307
+ return new ConnectorCallError("provider_permission_denied", `Google refused a service-account token.${said} Check the requested OAuth scopes and the service account's IAM permissions for the target API and resources.`);
308
+ }
309
+ if (code === "invalid_grant") {
310
+ return resendAdvice(new ConnectorCallError("auth_required", `Google refused the service-account assertion.${said} The key may be deleted or disabled, or this host's clock may be minutes off. Check the key and clock.`), " before retrying");
311
+ }
312
+ }
306
313
  const client = account.clientId
307
314
  ? `client ID ${account.clientId} (${account.clientEmail})`
308
315
  : `the client ID of ${account.clientEmail}`;
@@ -325,7 +332,7 @@ function tokenFailure(account, scopes, status, headers, payload) {
325
332
  if (status === 403) {
326
333
  return new ConnectorCallError("provider_permission_denied", `Google refused domain-wide delegation.${said} Ask a Workspace super admin to review ${client} in the Admin console's Manage Domain Wide Delegation entry and grant exactly these scopes: ${scopes.join(",")}.`);
327
334
  }
328
- return new ConnectorCallError("connector_call_failed", `Google's token endpoint refused the delegated assertion with HTTP ${status}.${said}`, { retryable: false });
335
+ return new ConnectorCallError("connector_call_failed", `Google's token endpoint refused the ${subject === undefined ? "service-account" : "delegated"} assertion with HTTP ${status}.${said}`, { retryable: false });
329
336
  }
330
337
  async function mint(account, subject, scopes, ctx) {
331
338
  const issuedAt = Date.now();
@@ -339,7 +346,7 @@ async function mint(account, subject, scopes, ctx) {
339
346
  const parsed = await response.jsonResult();
340
347
  const payload = "value" in parsed ? parsed.value : undefined;
341
348
  if (!response.ok) {
342
- throw verdict(tokenFailure(account, scopes, response.status, response.headers, payload));
349
+ throw verdict(tokenFailure(account, scopes, response.status, response.headers, payload, subject));
343
350
  }
344
351
  const body = asRecord(payload);
345
352
  const accessToken = body["access_token"];
@@ -383,7 +390,12 @@ function keyIdentity(account) {
383
390
  }
384
391
  async function cacheKey({ account, subject, scopes }) {
385
392
  // Workspace addresses are case-insensitive; scope order is not a new grant.
386
- return [account.clientEmail, await keyIdentity(account), subject.toLowerCase(), [...scopes].sort().join(" ")].join("\n");
393
+ return JSON.stringify([
394
+ account.clientEmail,
395
+ await keyIdentity(account),
396
+ subject?.toLowerCase() ?? null,
397
+ [...new Set(scopes)].sort(),
398
+ ]);
387
399
  }
388
400
  function remember(key, minted) {
389
401
  tokens.delete(key);
@@ -1,22 +1,6 @@
1
1
  // A provider's sensitive fields leave every result unless deployment code
2
2
  // returns them; no tool argument can. Shared by the providers whose raw rows
3
3
  // and hatches would otherwise reach what their projections drop.
4
- /** What `raw: true` and the hatches return, under the deployment's `sensitiveFields` choice. */
5
- export function unprojectedResults(sensitiveFields, names) {
6
- if (sensitiveFields === "return")
7
- return (body) => body;
8
- const dropped = new Set(names);
9
- const strip = (value) => {
10
- if (Array.isArray(value))
11
- return value.map(strip);
12
- if (!value || typeof value !== "object")
13
- return value;
14
- return Object.fromEntries(Object.entries(value)
15
- .filter(([name]) => !dropped.has(name))
16
- .map(([name, entry]) => [name, strip(entry)]));
17
- };
18
- return (body) => strip(body);
19
- }
20
4
  /** Refuse anything but the two choices, at construction (INV-11). */
21
5
  export function sensitiveFieldsOption(provider, value) {
22
6
  if (value === undefined)
@@ -66,7 +66,7 @@ const PATH_PATTERN = /^(\/[a-z0-9_]+)+\/?$/;
66
66
  * which reads the calendar without a key, and its embed key. Neither hatch
67
67
  * returns them.
68
68
  */
69
- export function withoutCalendarFeeds(path, body) {
69
+ function withoutCalendarFeeds(path, body) {
70
70
  if (path !== "/events/calendars/list")
71
71
  return body;
72
72
  const strip = (value) => {
@@ -80,7 +80,7 @@ export function withoutCalendarFeeds(path, body) {
80
80
  };
81
81
  return strip(body);
82
82
  }
83
- export function normalizedPath(raw) {
83
+ function normalizedPath(raw) {
84
84
  const path = typeof raw === "string" ? raw : "";
85
85
  if (!PATH_PATTERN.test(path)) {
86
86
  throw new ConnectorCallError("invalid_args", "A Breeze path is relative to /api, begins with '/', and uses only lowercase letters, digits, underscores, and '/' — for example /tags/list_folders. No query string, host, or dot segments.");
@@ -238,7 +238,7 @@ export const CCB_INDEX = new OperationIndex({
238
238
  export const getMe = get("getMe", "/me", { success: Schema.Unknown });
239
239
  export const CcbApi = indexedVendor("CCB", CCB_INDEX, [getMe]);
240
240
  const safety = valueSafety(CCB_VALUE_SAFETY, () => CCB_INDEX);
241
- export const CCB_PAGE_HEADERS = ["x-page", "x-next-page", "x-total-pages", "x-total"];
241
+ const CCB_PAGE_HEADERS = ["x-page", "x-next-page", "x-total-pages", "x-total"];
242
242
  export function ccbPageInfo(headers, page, size, rows) {
243
243
  const number = (name) => {
244
244
  const raw = headers[name];