@zackbart/connecta 0.28.1 → 0.30.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 +855 -5
- package/README.md +125 -116
- package/bin/connecta.mjs +121 -139
- package/bin/meta-tool-names.d.mts +2 -0
- package/bin/meta-tool-names.mjs +9 -0
- package/bin/version.mjs +3 -0
- package/dist/access-tokens.js +53 -63
- package/dist/activity-request.js +8 -0
- package/dist/activity.d.ts +76 -42
- package/dist/activity.js +110 -42
- package/dist/argument-redaction.js +230 -0
- package/dist/auth/clerk-sdk/client.d.ts +65 -0
- package/dist/auth/clerk-sdk/client.js +5 -0
- package/dist/auth/clerk-sdk/generated.js +1845 -0
- package/dist/auth/clerk-transport.js +19 -0
- package/dist/auth/clerk.d.ts +17 -7
- package/dist/auth/clerk.js +189 -107
- package/dist/auth/cloudflare-access.js +25 -26
- package/dist/auth/downstream-client-metadata.d.ts +29 -0
- package/dist/auth/downstream-client-metadata.js +133 -0
- package/dist/auth/downstream-oauth.js +1584 -1580
- package/dist/auth/downstream-revocation.js +106 -0
- package/dist/auth/oauth-token-response.js +231 -0
- package/dist/auth/oauth-v2-migration.js +116 -0
- package/dist/auth/static-oauth.js +93 -71
- package/dist/auth-elicitation.js +288 -0
- package/dist/authorization-handoff.js +111 -0
- package/dist/branding.d.ts +8 -16
- package/dist/branding.js +26 -59
- package/dist/byte-read-response.js +39 -0
- package/dist/call-admission.js +12 -16
- package/dist/catalog-cache.js +739 -0
- package/dist/catalog-drift.d.ts +50 -15
- package/dist/catalog-drift.js +365 -128
- package/dist/catalog-fingerprint.js +2 -4
- package/dist/catalog-freshness.js +13 -0
- package/dist/catalog-limits.js +2 -2
- package/dist/catalog-service.js +409 -225
- package/dist/catalog.js +43 -84
- package/dist/concurrency.js +3 -4
- package/dist/config-defaults.d.ts +39 -0
- package/dist/config-defaults.js +46 -0
- package/dist/config-schema.d.ts +181 -0
- package/dist/config-schema.js +435 -0
- package/dist/config-value-sources.js +102 -0
- package/dist/config.d.ts +633 -0
- package/dist/config.js +508 -0
- package/dist/connector-access.d.ts +1 -0
- package/dist/connector-access.js +13 -15
- package/dist/connector-caller.d.ts +8 -0
- package/dist/connector-caller.js +15 -6
- package/dist/connector-scope.js +1 -1
- package/dist/connectors/api-connector.d.ts +59 -7
- package/dist/connectors/api-connector.js +106 -19
- package/dist/connectors/api.js +4 -1
- package/dist/connectors/guarded-fetch.d.ts +144 -0
- package/dist/connectors/guarded-fetch.js +87 -38
- package/dist/connectors/negotiation-cache.js +46 -0
- package/dist/connectors/option-shapes.js +27 -0
- package/dist/connectors/remote-mcp.d.ts +30 -4
- package/dist/connectors/remote-mcp.js +1774 -320
- package/dist/connectors/resource-uri.js +335 -0
- package/dist/credential-contract.d.ts +10 -0
- package/dist/credential-rules.js +1 -3
- package/dist/credential-url.js +108 -0
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +27 -18
- package/dist/d1.d.ts +130 -0
- package/dist/d1.js +290 -0
- package/dist/describe-config.d.ts +165 -0
- package/dist/describe-config.js +315 -0
- package/dist/described.js +106 -0
- package/dist/downstream-input-context.js +128 -0
- package/dist/downstream-input.js +325 -0
- package/dist/errors.d.ts +132 -31
- package/dist/errors.js +175 -87
- package/dist/execute.js +798 -318
- package/dist/executor-admission.d.ts +1 -0
- package/dist/executor-admission.js +11 -15
- package/dist/executor-contract.d.ts +20 -0
- package/dist/executor-contract.js +48 -0
- package/dist/executor-result.js +30 -13
- package/dist/executors/quickjs-child.js +15 -9
- package/dist/executors/quickjs-protocol.js +5 -6
- package/dist/executors/quickjs-runtime.js +152 -59
- package/dist/executors/quickjs.js +49 -39
- package/dist/favicon.js +1 -2
- package/dist/fix-prompt.js +1 -3
- package/dist/guest-runtime.js +238 -0
- package/dist/guest-types.d.ts +218 -0
- package/dist/guest-types.js +1 -0
- package/dist/html-security.js +24 -0
- package/dist/identity.js +1 -3
- package/dist/inbound-credential.js +52 -0
- package/dist/index.d.ts +24 -256
- package/dist/index.js +65 -478
- package/dist/invocation-auth.js +69 -0
- package/dist/invocation.js +262 -79
- package/dist/json-schema.d.ts +1 -1
- package/dist/mcp-client-context.js +35 -0
- package/dist/mcp-result.js +11 -7
- package/dist/meta-output.js +61 -0
- package/dist/meta-tool-names.d.ts +2 -0
- package/dist/meta-tool-names.js +3 -0
- package/dist/meta-tools.js +419 -272
- package/dist/module-contracts.d.ts +8 -23
- package/dist/node.d.ts +0 -2
- package/dist/node.js +2 -2
- package/dist/oauth-callback-outcome.js +1 -3
- package/dist/oauth-handoff.js +194 -0
- package/dist/oauth-sealing.js +20 -0
- package/dist/operator-record.d.ts +137 -0
- package/dist/operator-record.js +420 -0
- package/dist/operator-ui/assets.js +31 -0
- package/dist/operator-ui/contract.d.ts +72 -0
- package/dist/operator-ui/contract.js +1 -0
- package/dist/operator-ui/fix-prompts.d.ts +18 -0
- package/dist/operator-ui/generated.js +5 -4
- package/dist/operator-ui/model.d.ts +12 -87
- package/dist/operator-ui/model.js +2 -9
- package/dist/operator-ui/setup-commands.js +1 -3
- package/dist/operator-ui/view.d.ts +323 -0
- package/dist/operator-ui/view.js +62 -189
- package/dist/page-styles.js +2 -3
- package/dist/program-result.js +5 -0
- package/dist/program-source.js +14 -2
- package/dist/{exempt-writes.js → program-writes.js} +60 -19
- package/dist/provider.d.ts +142 -0
- package/dist/provider.js +187 -0
- package/dist/providers/_shared/google/result-size.js +57 -0
- package/dist/providers/_shared/google/service-account.d.ts +58 -0
- package/dist/providers/_shared/google/service-account.js +500 -0
- package/dist/providers/_shared/google/workspace.d.ts +276 -0
- package/dist/providers/_shared/google/workspace.js +518 -0
- package/dist/providers/_shared/request-token.js +83 -0
- package/dist/providers/{basecamp.d.ts → basecamp/index.d.ts} +6 -28
- package/dist/providers/basecamp/index.js +997 -0
- package/dist/providers/basecamp/skill.generated.js +9 -0
- package/dist/providers/{breeze.d.ts → breeze/index.d.ts} +2 -2
- package/dist/providers/{breeze.js → breeze/index.js} +184 -140
- package/dist/providers/breeze/skill.generated.js +9 -0
- package/dist/providers/{ccb.d.ts → ccb/index.d.ts} +2 -2
- package/dist/providers/{ccb.js → ccb/index.js} +271 -116
- package/dist/providers/ccb/skill.generated.js +9 -0
- package/dist/providers/{cloudflare.d.ts → cloudflare/index.d.ts} +8 -7
- package/dist/providers/{cloudflare.js → cloudflare/index.js} +171 -738
- package/dist/providers/cloudflare/skill.generated.js +10 -0
- package/dist/providers/docs/index.d.ts +54 -0
- package/dist/providers/docs/index.js +1237 -0
- package/dist/providers/docs/skill.generated.js +9 -0
- package/dist/providers/drive/index.d.ts +53 -0
- package/dist/providers/drive/index.js +1523 -0
- package/dist/providers/drive/skill.generated.js +9 -0
- package/dist/providers/forms/index.d.ts +54 -0
- package/dist/providers/forms/index.js +1217 -0
- package/dist/providers/forms/skill.generated.js +11 -0
- package/dist/providers/github/auth.d.ts +32 -0
- package/dist/providers/github/auth.js +361 -0
- package/dist/providers/github/hosted.js +218 -0
- package/dist/providers/github/index.d.ts +13 -0
- package/dist/providers/github/index.js +140 -0
- package/dist/providers/github/key.js +100 -0
- package/dist/providers/github/rest.js +321 -0
- package/dist/providers/github/scope.d.ts +29 -0
- package/dist/providers/github/scope.js +124 -0
- package/dist/providers/github/skill.generated.js +8 -0
- package/dist/providers/gmail/index.d.ts +51 -0
- package/dist/providers/gmail/index.js +1436 -0
- package/dist/providers/gmail/skill.generated.js +9 -0
- package/dist/providers/infisical/index.d.ts +7 -0
- package/dist/providers/infisical/index.js +713 -0
- package/dist/providers/infisical/skill.generated.js +8 -0
- package/dist/providers/{linear.d.ts → linear/index.d.ts} +11 -14
- package/dist/providers/linear/index.js +352 -0
- package/dist/providers/linear/skill.generated.js +8 -0
- package/dist/providers/{mixpanel.d.ts → mixpanel/index.d.ts} +5 -10
- package/dist/providers/mixpanel/index.js +416 -0
- package/dist/providers/mixpanel/skill.generated.js +9 -0
- package/dist/providers/{notion.d.ts → notion/index.d.ts} +8 -8
- package/dist/providers/{notion.js → notion/index.js} +226 -239
- package/dist/providers/notion/skill.generated.js +10 -0
- package/dist/providers/{overflow.d.ts → overflow/index.d.ts} +2 -2
- package/dist/providers/{overflow.js → overflow/index.js} +74 -111
- package/dist/providers/overflow/skill.generated.js +8 -0
- package/dist/providers/{planning-center.d.ts → planning-center/index.d.ts} +2 -2
- package/dist/providers/{planning-center.js → planning-center/index.js} +264 -104
- package/dist/providers/planning-center/skill.generated.js +10 -0
- package/dist/providers/{revenuecat.d.ts → revenuecat/index.d.ts} +5 -10
- package/dist/providers/revenuecat/index.js +557 -0
- package/dist/providers/revenuecat/skill.generated.js +11 -0
- package/dist/providers/sheets/index.d.ts +54 -0
- package/dist/providers/sheets/index.js +1237 -0
- package/dist/providers/sheets/skill.generated.js +10 -0
- package/dist/providers/slides/index.d.ts +53 -0
- package/dist/providers/slides/index.js +2645 -0
- package/dist/providers/slides/skill.generated.js +9 -0
- package/dist/providers/{stripe.d.ts → stripe/index.d.ts} +5 -12
- package/dist/providers/stripe/index.js +266 -0
- package/dist/providers/stripe/skill.generated.js +11 -0
- package/dist/providers/{tithely.d.ts → tithely/index.d.ts} +2 -2
- package/dist/providers/{tithely.js → tithely/index.js} +60 -91
- package/dist/providers/tithely/skill.generated.js +8 -0
- package/dist/providers/{vercel.d.ts → vercel/index.d.ts} +8 -8
- package/dist/providers/vercel/index.js +1038 -0
- package/dist/providers/vercel/mcp-ownership.js +212 -0
- package/dist/providers/vercel/skill.generated.js +11 -0
- package/dist/registry.d.ts +110 -171
- package/dist/registry.js +588 -927
- package/dist/request-state.js +21 -0
- package/dist/resource-read.js +103 -0
- package/dist/result-shapes.js +17 -49
- package/dist/retired-toolkits.js +1 -1
- package/dist/routes/access-tokens.js +3 -6
- package/dist/routes/activity-context.js +21 -0
- package/dist/routes/activity-disclosure.js +40 -0
- package/dist/routes/activity.js +59 -21
- package/dist/routes/actor-labels.js +3 -50
- package/dist/routes/connect.js +221 -0
- package/dist/routes/credentials.js +15 -27
- package/dist/routes/mcp.js +365 -130
- package/dist/routes/oauth-client-metadata.js +15 -0
- package/dist/routes/oauth-management.js +45 -107
- package/dist/routes/oauth.js +110 -57
- package/dist/routes/operator.js +3 -3
- package/dist/routes/shared.d.ts +25 -64
- package/dist/routes/shared.js +160 -80
- package/dist/routes/skills.js +30 -0
- package/dist/routes/ui-config.js +225 -0
- package/dist/routes/ui-data.js +40 -35
- package/dist/routes/ui.js +66 -56
- package/dist/runtime/admission.js +4 -2
- package/dist/runtime/call-admission.js +3 -1
- package/dist/runtime/connector-scope.js +1 -4
- package/dist/runtime/run.js +10 -39
- package/dist/runtime/services.js +12 -117
- package/dist/runtime/shared-read.js +1 -3
- package/dist/runtime/storage.js +8 -1
- package/dist/sent-secrets.d.ts +93 -0
- package/dist/sent-secrets.js +1013 -0
- package/dist/server.js +28 -71
- package/dist/skills.js +416 -174
- package/dist/sqlite.d.ts +48 -0
- package/dist/sqlite.js +181 -0
- package/dist/storage/keys.js +443 -0
- package/dist/storage/memory.js +16 -4
- package/dist/storage/sql.d.ts +66 -0
- package/dist/storage/sql.js +543 -0
- package/dist/timeout.js +4 -2
- package/dist/tool-name.js +17 -0
- package/dist/tool-safety.d.ts +9 -35
- package/dist/tool-safety.js +20 -40
- package/dist/types.d.ts +336 -56
- package/dist/types.js +0 -1
- package/dist/ui.d.ts +7 -19
- package/dist/ui.js +76 -90
- package/dist/url-safety.js +3 -6
- package/dist/usage-guide.js +146 -0
- package/dist/validate.d.ts +7 -0
- package/dist/validate.js +309 -47
- package/dist/version.d.ts +1 -7
- package/dist/version.js +3 -7
- package/dist/worker.d.ts +11 -0
- package/dist/worker.js +435 -0
- package/documentation/README.md +63 -0
- package/documentation/architecture.md +696 -231
- package/documentation/auth.md +1069 -546
- package/documentation/code-mode.md +662 -356
- package/documentation/deploying.md +146 -0
- package/documentation/integrating.md +159 -0
- package/documentation/meta-tools.md +570 -216
- package/documentation/operating.md +135 -0
- package/documentation/operator-tests.md +97 -0
- package/documentation/operator-ui.md +132 -0
- package/documentation/provider-migration-0.29.md +65 -0
- package/examples/worker/AGENTS.md +34 -12
- package/examples/worker/README.md +293 -255
- package/examples/worker/kv-to-d1.wrangler.jsonc +26 -0
- package/examples/worker/scripts/copy-kv-to-d1.mjs +162 -0
- package/examples/worker/src/connecta.config.ts +151 -0
- package/examples/worker/src/index.ts +14 -197
- package/examples/worker/wrangler.jsonc +15 -35
- package/package.json +126 -54
- package/templates/node/.dockerignore +1 -2
- package/templates/node/.env.example +20 -21
- package/templates/node/AGENTS.md +21 -17
- package/templates/node/Dockerfile +3 -5
- package/templates/node/README.md +140 -58
- package/templates/node/docker-compose.yml +11 -12
- package/templates/node/package.json +4 -2
- package/templates/node/src/connecta.config.ts +119 -0
- package/templates/node/src/index.ts +9 -154
- package/templates/node/src/provision-token.ts +17 -0
- package/AGENTS.md +0 -170
- package/dist/artifacts/connector.d.ts +0 -58
- package/dist/artifacts/connector.js +0 -730
- package/dist/artifacts/document.js +0 -86
- package/dist/artifacts/guide.js +0 -140
- package/dist/artifacts/html-scan.js +0 -329
- package/dist/artifacts/json.js +0 -78
- package/dist/artifacts/kv-store.d.ts +0 -20
- package/dist/artifacts/kv-store.js +0 -140
- package/dist/artifacts/markdown.js +0 -478
- package/dist/artifacts/module.d.ts +0 -37
- package/dist/artifacts/module.js +0 -66
- package/dist/artifacts/operations.d.ts +0 -197
- package/dist/artifacts/operations.js +0 -1002
- package/dist/artifacts/refresh.d.ts +0 -55
- package/dist/artifacts/refresh.js +0 -236
- package/dist/artifacts/routes.js +0 -206
- package/dist/artifacts/types.d.ts +0 -214
- package/dist/artifacts/types.js +0 -22
- package/dist/artifacts/validate.d.ts +0 -62
- package/dist/artifacts/validate.js +0 -651
- package/dist/artifacts.d.ts +0 -6
- package/dist/artifacts.js +0 -9
- package/dist/auth/bearer.d.ts +0 -11
- package/dist/auth/bearer.js +0 -50
- package/dist/providers/basecamp.js +0 -445
- package/dist/providers/linear.js +0 -217
- package/dist/providers/mixpanel.js +0 -247
- package/dist/providers/revenuecat.js +0 -317
- package/dist/providers/stripe.js +0 -253
- package/dist/providers/vercel.js +0 -1253
- package/dist/storage/file.d.ts +0 -15
- package/dist/storage/file.js +0 -455
- package/ethos.md +0 -93
- package/examples/worker/src/cloudflare-kv.ts +0 -43
- package/examples/worker/src/d1-activity-row.ts +0 -155
- package/examples/worker/src/d1-activity.ts +0 -147
- package/examples/worker/src/d1-storage.ts +0 -138
- package/examples/worker/src/r2-artifact-blobs.ts +0 -29
- package/templates/node/src/file-activity.ts +0 -182
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Workspace access as the person calling, with no per-user consent:
|
|
3
|
+
* a service account with domain-wide delegation mints a token *as* the user
|
|
4
|
+
* the deployment maps the admitted caller to. The foundation each Workspace
|
|
5
|
+
* product provider (`gmail()` first) builds on; never its own export.
|
|
6
|
+
*
|
|
7
|
+
* Whose account. `ConnectorContext` carries no identity, and a deployment's
|
|
8
|
+
* own connector never learns its caller (`src/connector-caller.ts`). A
|
|
9
|
+
* maintained Workspace provider is the second sanctioned reader of that
|
|
10
|
+
* channel (PRINCIPLES.md INV-3, #678): deployment config supplies
|
|
11
|
+
* `subject`, a function from the admitted `AuthenticatedIdentity` to a
|
|
12
|
+
* Workspace address, and the provider reads the identity core attached beside
|
|
13
|
+
* the context. No tool argument, header, or program can name the subject —
|
|
14
|
+
* every product's base URL is confined beneath `users/me`, or its product's
|
|
15
|
+
* equivalent, so the token's subject is the only account a request reaches.
|
|
16
|
+
* A call with no admitted caller, one an open deployment admitted without
|
|
17
|
+
* authentication (the mapping is never asked about it), or one the mapping
|
|
18
|
+
* answers `undefined` for, fails `auth_required` before anything leaves the
|
|
19
|
+
* host. Catalogs need no subject: tools are static.
|
|
20
|
+
*
|
|
21
|
+
* A product builds on three calls: `workspaceConnection(factory, options)` at
|
|
22
|
+
* construction, `googleWorkspaceClient({...})` once, and the client's `json`,
|
|
23
|
+
* `bytes`, or `text` per request — plus `googleReasonsOf(error)` where a
|
|
24
|
+
* product must decide on a Google reason the shared mapping leaves generic.
|
|
25
|
+
* Nothing else here is the products' to touch.
|
|
26
|
+
*
|
|
27
|
+
* Setup, once per Workspace, shared by every product:
|
|
28
|
+
*
|
|
29
|
+
* 1. In a Google Cloud project, enable each product's API (Gmail API, Google
|
|
30
|
+
* Drive API, …).
|
|
31
|
+
* 2. Create a service account. It needs no IAM role: delegation, not project
|
|
32
|
+
* permission, is what reaches user data.
|
|
33
|
+
* 3. Create a JSON key for it. An organization policy
|
|
34
|
+
* (`iam.disableServiceAccountKeyCreation`, on by default for newer
|
|
35
|
+
* organizations) may refuse; an org policy administrator can override it
|
|
36
|
+
* for this project alone.
|
|
37
|
+
* 4. Copy the service account's numeric client ID (Details → Advanced
|
|
38
|
+
* settings, or the key's `client_id`).
|
|
39
|
+
* 5. As a Workspace super admin: Admin console → Security → Access and data
|
|
40
|
+
* control → API controls → Manage Domain Wide Delegation → Add new, with
|
|
41
|
+
* that client ID and *exactly* the scopes each product provider lists, comma
|
|
42
|
+
* separated. A grant can take up to 24 hours to apply.
|
|
43
|
+
* 6. Pass the key to the provider from the deployment's secrets, and map the
|
|
44
|
+
* admitted identity to a Workspace address in `subject`.
|
|
45
|
+
*/
|
|
46
|
+
import { guardedFetch, retryAfterMs } from "../../../connectors/guarded-fetch.js";
|
|
47
|
+
import { keys, optionsOf } from "../../../config-schema.js";
|
|
48
|
+
import { callerOf } from "../../../connector-caller.js";
|
|
49
|
+
import { CALL_ADMISSION } from "../../../connectors/option-shapes.js";
|
|
50
|
+
import { ConnectorCallError } from "../../../errors.js";
|
|
51
|
+
import { delegatedToken, forgetDelegatedToken, parseServiceAccount, } from "./service-account.js";
|
|
52
|
+
/** The closed options every Workspace provider accepts; see `assertKnownOptions`. */
|
|
53
|
+
export const GOOGLE_WORKSPACE_OPTIONS = optionsOf()({
|
|
54
|
+
...keys("purpose", "subject", "title", "instructions", "maxResultBytes", "baseUrl"),
|
|
55
|
+
// A string is the key's JSON; only the object form has keys to check.
|
|
56
|
+
serviceAccount: optionsOf()(keys("clientEmail", "privateKey", "clientId")),
|
|
57
|
+
callAdmission: CALL_ADMISSION,
|
|
58
|
+
});
|
|
59
|
+
/** A plausible mailbox: one `@`, no whitespace or control characters. */
|
|
60
|
+
const ADDRESS = /^[^\s@<>()",;:\\[\]]+@[^\s@<>()",;:\\[\]]+\.[^\s@<>()",;:\\[\]]+$/;
|
|
61
|
+
/** Validate the shared options at construction; throws on a structural mistake. */
|
|
62
|
+
export function workspaceConnection(factory, options) {
|
|
63
|
+
const purpose = options?.purpose?.trim();
|
|
64
|
+
if (!purpose)
|
|
65
|
+
throw new Error(`${factory}() requires a non-empty purpose.`);
|
|
66
|
+
const account = parseServiceAccount(`${factory}()`, options.serviceAccount);
|
|
67
|
+
const subject = options.subject;
|
|
68
|
+
if (typeof subject === "string") {
|
|
69
|
+
if (!ADDRESS.test(subject.trim())) {
|
|
70
|
+
throw new Error(`${factory}() subject must be a Workspace email address or a function of the caller.`);
|
|
71
|
+
}
|
|
72
|
+
return { purpose, account, subject: subject.trim() };
|
|
73
|
+
}
|
|
74
|
+
if (typeof subject !== "function") {
|
|
75
|
+
throw new Error(`${factory}() requires subject: a function from the admitted identity to a Workspace address, or one fixed address. Delegated access never guesses whose account to open.`);
|
|
76
|
+
}
|
|
77
|
+
return { purpose, account, subject };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The Workspace address this call acts as, resolved from config and the
|
|
81
|
+
* admitted caller only. Throws before any network call when there is none.
|
|
82
|
+
*/
|
|
83
|
+
async function subjectFor(provider, subject, ctx) {
|
|
84
|
+
if (typeof subject === "string")
|
|
85
|
+
return subject;
|
|
86
|
+
const caller = callerOf(ctx);
|
|
87
|
+
if (!caller) {
|
|
88
|
+
throw new ConnectorCallError("auth_required", `${provider} acts as the signed-in Workspace user, and this call has no admitted caller (a scheduled run, or a context no request admitted). Use a fixed subject for work with no caller.`);
|
|
89
|
+
}
|
|
90
|
+
// An open deployment admits every request as the anonymous actor. That is
|
|
91
|
+
// a caller, but not anyone, and the mapping never gets to decide otherwise.
|
|
92
|
+
if (!caller.authenticated) {
|
|
93
|
+
throw new ConnectorCallError("auth_required", `${provider} acts as the signed-in Workspace user, and this deployment admitted the call without authentication. Configure inbound auth, or a fixed subject for shared use.`);
|
|
94
|
+
}
|
|
95
|
+
let mapped;
|
|
96
|
+
try {
|
|
97
|
+
mapped = await subject(caller.identity, ctx.signal ? { signal: ctx.signal } : {});
|
|
98
|
+
}
|
|
99
|
+
catch (cause) {
|
|
100
|
+
if (ctx.signal?.aborted)
|
|
101
|
+
throw ctx.signal.reason;
|
|
102
|
+
throw new ConnectorCallError("connector_call_failed", `The deployment's ${provider} subject mapping threw while resolving this caller's Workspace account.`, { retryable: false, cause });
|
|
103
|
+
}
|
|
104
|
+
if (mapped === undefined) {
|
|
105
|
+
throw new ConnectorCallError("auth_required", `This caller has no Google Workspace account mapped for ${provider}. An operator maps signed-in identities to Workspace addresses in the deployment's subject config; nothing a call sends can choose one.`);
|
|
106
|
+
}
|
|
107
|
+
if (typeof mapped !== "string" || !ADDRESS.test(mapped.trim())) {
|
|
108
|
+
throw new ConnectorCallError("connector_call_failed", `The deployment's ${provider} subject mapping returned something that is not an email address.`, { retryable: false });
|
|
109
|
+
}
|
|
110
|
+
return mapped.trim();
|
|
111
|
+
}
|
|
112
|
+
// --- API failures ------------------------------------------------------------------
|
|
113
|
+
function asRecord(value) {
|
|
114
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
115
|
+
}
|
|
116
|
+
function asArray(value) {
|
|
117
|
+
return Array.isArray(value) ? value : [];
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The machine-readable reasons in a Google API error body: the legacy
|
|
121
|
+
* `errors[].reason`, the AIP-193 `details[].reason`, and the canonical
|
|
122
|
+
* `status`. Classification reads these and nothing else; `message` is prose
|
|
123
|
+
* for the reader.
|
|
124
|
+
*/
|
|
125
|
+
function reasonsOf(error) {
|
|
126
|
+
const reasons = new Set();
|
|
127
|
+
for (const entry of [...asArray(error["errors"]), ...asArray(error["details"])]) {
|
|
128
|
+
const reason = asRecord(entry)["reason"];
|
|
129
|
+
if (typeof reason === "string")
|
|
130
|
+
reasons.add(reason);
|
|
131
|
+
}
|
|
132
|
+
if (typeof error["status"] === "string")
|
|
133
|
+
reasons.add(error["status"]);
|
|
134
|
+
return reasons;
|
|
135
|
+
}
|
|
136
|
+
/** The canonical and legacy spellings of a refused precondition. */
|
|
137
|
+
const PRECONDITION_REASONS = ["FAILED_PRECONDITION", "failedPrecondition"];
|
|
138
|
+
/** Concurrency aborts, which a revision-checked write also answers with. */
|
|
139
|
+
const ABORTED_REASONS = ["ABORTED", "aborted"];
|
|
140
|
+
/** A reason token as Google spells one; anything else is not passed on. */
|
|
141
|
+
const REASON_TOKEN = /^[A-Za-z0-9_.]{1,64}$/;
|
|
142
|
+
/** More than this many reasons on one error is noise, not information. */
|
|
143
|
+
const MAX_REASONS = 8;
|
|
144
|
+
/** Google's reasons for each failure this layer mapped, beside the error. */
|
|
145
|
+
const googleReasons = new WeakMap();
|
|
146
|
+
/**
|
|
147
|
+
* Google's machine-readable reasons for a failure the shared client mapped:
|
|
148
|
+
* `errors[].reason`, `details[].reason`, and the canonical `status`, in that
|
|
149
|
+
* order, deduplicated, at most eight, and only tokens of `[A-Za-z0-9_.]` up to
|
|
150
|
+
* 64 characters — never a message or any other part of the payload. An empty
|
|
151
|
+
* array for anything else, including a failure that never reached Google.
|
|
152
|
+
*
|
|
153
|
+
* For a product that needs a decision the shared mapping does not make, such
|
|
154
|
+
* as Drive telling `cannotDownloadAbusiveFile` from another refusal. Classify
|
|
155
|
+
* on these, never on the error's message.
|
|
156
|
+
*/
|
|
157
|
+
export function googleReasonsOf(error) {
|
|
158
|
+
return error instanceof ConnectorCallError ? (googleReasons.get(error) ?? []) : [];
|
|
159
|
+
}
|
|
160
|
+
const googleOutcomes = new WeakMap();
|
|
161
|
+
function outcome(error, facts) {
|
|
162
|
+
if (error !== null && typeof error === "object")
|
|
163
|
+
googleOutcomes.set(error, Object.freeze({ ...facts }));
|
|
164
|
+
return error;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* How far the request behind a failure from `json`, `bytes`, or `text` got —
|
|
168
|
+
* see {@link GoogleOutcome}. `undefined` for anything the client did not
|
|
169
|
+
* throw. Read it beside the error, the way `googleReasonsOf` is read; the
|
|
170
|
+
* error itself already carries the right retry verdict.
|
|
171
|
+
*/
|
|
172
|
+
export function googleOutcomeOf(error) {
|
|
173
|
+
return error !== null && typeof error === "object" ? googleOutcomes.get(error) : undefined;
|
|
174
|
+
}
|
|
175
|
+
function apiFailure(context, status, headers, payload, options) {
|
|
176
|
+
const failure = classifyApiFailure(context, status, headers, payload, options);
|
|
177
|
+
const error = asRecord(asRecord(payload)["error"]);
|
|
178
|
+
const reasons = [...reasonsOf(error)].filter((reason) => REASON_TOKEN.test(reason)).slice(0, MAX_REASONS);
|
|
179
|
+
googleReasons.set(failure, Object.freeze(reasons));
|
|
180
|
+
return failure;
|
|
181
|
+
}
|
|
182
|
+
function classifyApiFailure(context, status, headers, payload, options) {
|
|
183
|
+
const { provider, api, scopes } = context;
|
|
184
|
+
const error = asRecord(asRecord(payload)["error"]);
|
|
185
|
+
const message = typeof error["message"] === "string" ? error["message"].trim().slice(0, 500) : "";
|
|
186
|
+
const detail = message ? `${provider}: ${message}` : `${provider} returned HTTP ${status}.`;
|
|
187
|
+
const reasons = reasonsOf(error);
|
|
188
|
+
const precondition = PRECONDITION_REASONS.some((reason) => reasons.has(reason));
|
|
189
|
+
// Order matters. Google's own specific reasons decide first, whatever the
|
|
190
|
+
// status and whatever the request asked for: a guarded write refused
|
|
191
|
+
// because the API is off, or because of a quota, must not read as "re-read
|
|
192
|
+
// and retry". Only a refused precondition or an abort with nothing more
|
|
193
|
+
// specific to say is left for the revision guard, then for the status.
|
|
194
|
+
if (status === 401) {
|
|
195
|
+
return new ConnectorCallError("auth_required", `${detail} Google rejected a freshly minted delegated token. The domain-wide delegation grant or the service account may have been revoked.`);
|
|
196
|
+
}
|
|
197
|
+
// The API is off in the service account's project.
|
|
198
|
+
if (reasons.has("accessNotConfigured") || reasons.has("SERVICE_DISABLED")) {
|
|
199
|
+
return new ConnectorCallError("connector_call_failed", `${detail} The ${api} is not enabled in the service account's Google Cloud project; an operator enables it there.`, { retryable: false });
|
|
200
|
+
}
|
|
201
|
+
if (status === 429 ||
|
|
202
|
+
reasons.has("rateLimitExceeded") ||
|
|
203
|
+
reasons.has("userRateLimitExceeded") ||
|
|
204
|
+
reasons.has("RATE_LIMIT_EXCEEDED")) {
|
|
205
|
+
const wait = retryAfterMs(headers);
|
|
206
|
+
return new ConnectorCallError("rate_limited", `${detail} Google meters this API per user and per project; wait before retrying.`, wait === undefined ? {} : { retryAfterMs: wait });
|
|
207
|
+
}
|
|
208
|
+
if (reasons.has("insufficientPermissions") || reasons.has("ACCESS_TOKEN_SCOPE_INSUFFICIENT")) {
|
|
209
|
+
return new ConnectorCallError("provider_permission_denied", `${detail} The delegated token lacks a scope this call needs. The Admin console's domain-wide delegation entry must list exactly: ${scopes.join(",")}.`);
|
|
210
|
+
}
|
|
211
|
+
// Where Google names the refusal precisely, so does the message.
|
|
212
|
+
if (reasons.has("exportSizeLimitExceeded")) {
|
|
213
|
+
return new ConnectorCallError("connector_call_failed", `${detail} The item is larger than Google will export in this format. Download the original file instead, or export a smaller part.`, { retryable: false });
|
|
214
|
+
}
|
|
215
|
+
if (reasons.has("domainPolicy")) {
|
|
216
|
+
return new ConnectorCallError("provider_permission_denied", `${detail} A Workspace domain policy forbids this action for this account; only a Workspace administrator can change that.`, { retryable: false });
|
|
217
|
+
}
|
|
218
|
+
if (reasons.has("insufficientFilePermissions") || reasons.has("forbidden")) {
|
|
219
|
+
return new ConnectorCallError("provider_permission_denied", `${detail} This account does not have the permission on this item that the action needs — it may be shared view-only, or not shared with this user. Its owner can grant more.`, { retryable: false });
|
|
220
|
+
}
|
|
221
|
+
// A write that named the revision it was made against hears "the item moved
|
|
222
|
+
// on" as a refused precondition or an abort. Fixed words, so no provider
|
|
223
|
+
// ever matches on Google's prose or on this file's.
|
|
224
|
+
if (options.revisionGuarded === true &&
|
|
225
|
+
(status === 400 || status === 409) &&
|
|
226
|
+
(precondition || ABORTED_REASONS.some((reason) => reasons.has(reason)))) {
|
|
227
|
+
return new ConnectorCallError("conflict", `${provider} refused the write because the item changed after the revision it named. Re-read it for the current revision, reapply the change, and retry.`);
|
|
228
|
+
}
|
|
229
|
+
if (status === 403) {
|
|
230
|
+
return new ConnectorCallError("provider_permission_denied", `${detail} Google refused the request; the account may lack access to this item, or a Workspace policy may block it. Google does not say which. Ask the item owner to grant access or a Workspace administrator to review the policy.`, { retryable: false });
|
|
231
|
+
}
|
|
232
|
+
if (status === 404) {
|
|
233
|
+
return context.notFound === "absent"
|
|
234
|
+
? new ConnectorCallError("not_found", `${detail} Re-read the id from a list or search tool.`)
|
|
235
|
+
: new ConnectorCallError("connector_call_failed", `${detail} The id is unknown or not visible to this account; Google does not distinguish them.`, { retryable: false });
|
|
236
|
+
}
|
|
237
|
+
// Google uses the same reason for a product switched off for this user
|
|
238
|
+
// (Gmail's "Mail service not enabled") and for a stale write, so the
|
|
239
|
+
// classification asserts neither; Google's own message says which.
|
|
240
|
+
if (status === 400 && precondition) {
|
|
241
|
+
return new ConnectorCallError("connector_call_failed", `${detail} Google refused the request's precondition; its message says which.`, { retryable: false });
|
|
242
|
+
}
|
|
243
|
+
if (status === 400 || status === 422) {
|
|
244
|
+
return new ConnectorCallError("invalid_args", detail);
|
|
245
|
+
}
|
|
246
|
+
if (status >= 500) {
|
|
247
|
+
const wait = retryAfterMs(headers);
|
|
248
|
+
return new ConnectorCallError("unavailable", `${detail} Google is failing upstream.`, wait === undefined ? {} : { retryAfterMs: wait });
|
|
249
|
+
}
|
|
250
|
+
return new ConnectorCallError("connector_call_failed", detail, { retryable: false });
|
|
251
|
+
}
|
|
252
|
+
/** The token the API refused, carried out of the mapper for one replay. */
|
|
253
|
+
class TokenRejected extends Error {
|
|
254
|
+
failure;
|
|
255
|
+
constructor(failure) {
|
|
256
|
+
super(failure.message);
|
|
257
|
+
this.failure = failure;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** A Range request against an empty file: Google's verified "nothing here". */
|
|
261
|
+
function isEmptyRange(response) {
|
|
262
|
+
return response.status === 416 && /^bytes \*\/0$/i.test(response.headers.get("content-range")?.trim() ?? "");
|
|
263
|
+
}
|
|
264
|
+
function charsetOf(contentType) {
|
|
265
|
+
return /charset="?([^";\s]+)"?/i.exec(contentType ?? "")?.[1] ?? "utf-8";
|
|
266
|
+
}
|
|
267
|
+
/** Build the delegated, confined, Google-error-mapped transport for one product. */
|
|
268
|
+
export function googleWorkspaceClient(options) {
|
|
269
|
+
const { provider, scopes, connection } = options;
|
|
270
|
+
const failure = {
|
|
271
|
+
provider,
|
|
272
|
+
api: options.api,
|
|
273
|
+
scopes,
|
|
274
|
+
notFound: options.notFound,
|
|
275
|
+
};
|
|
276
|
+
// One resolution per call context: a tool that sends several requests acts
|
|
277
|
+
// as one account throughout, and the mapping runs once rather than per
|
|
278
|
+
// request. The context dies with its call, and so does the entry.
|
|
279
|
+
const subjects = new WeakMap();
|
|
280
|
+
const tokenRequest = async (ctx) => {
|
|
281
|
+
let subject = subjects.get(ctx);
|
|
282
|
+
if (!subject) {
|
|
283
|
+
subject = subjectFor(provider, connection.subject, ctx);
|
|
284
|
+
subjects.set(ctx, subject);
|
|
285
|
+
}
|
|
286
|
+
return { account: connection.account, subject: await subject, scopes };
|
|
287
|
+
};
|
|
288
|
+
// The transport is bound to one token, so the request that a 401 answers
|
|
289
|
+
// names exactly the token to forget. Building one validates the base URL
|
|
290
|
+
// and ceiling at construction, where a structural mistake belongs.
|
|
291
|
+
const transport = (token, facts = { dispatched: false }) => guardedFetch({
|
|
292
|
+
provider,
|
|
293
|
+
baseUrl: options.baseUrl,
|
|
294
|
+
maxResponseBytes: options.maxResponseBytes,
|
|
295
|
+
authenticate: () => ({ Authorization: `Bearer ${token}` }),
|
|
296
|
+
// The one place a request leaves and an answer arrives, recorded as
|
|
297
|
+
// they happen so a failure can say how far the request got.
|
|
298
|
+
fetch: async (url, init) => {
|
|
299
|
+
facts.dispatched = true;
|
|
300
|
+
const response = await fetch(url, init);
|
|
301
|
+
// Recorded before the transport validates anything: a ceiling or a
|
|
302
|
+
// redirect check can fail an answer, but not change what it said.
|
|
303
|
+
facts.status = response.status;
|
|
304
|
+
facts.headers = response.headers;
|
|
305
|
+
return response;
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
transport("");
|
|
309
|
+
/**
|
|
310
|
+
* What a failure means for a request that may have changed something. A
|
|
311
|
+
* write Google accepted, or one that left with no answer, may have landed:
|
|
312
|
+
* telling the agent to retry it invites a duplicate, so it is reported as
|
|
313
|
+
* an uncertain outcome that is not retried — the same verdict core reaches
|
|
314
|
+
* for a trusted-pool program write (`write_outcome_unknown`). A read is safe to
|
|
315
|
+
* repeat and keeps its retryable classification.
|
|
316
|
+
*/
|
|
317
|
+
/**
|
|
318
|
+
* A 5xx answering a write that is not idempotent. Google can fail after a
|
|
319
|
+
* change commits, so "unavailable, retry" would invite a duplicate insert
|
|
320
|
+
* or a second draft: the outcome is unknown and the write is not retried,
|
|
321
|
+
* whatever reason the 5xx names. Its reasons stay readable beside it.
|
|
322
|
+
*/
|
|
323
|
+
function serverErrorOnWrite(mapped, status, method, requestOptions) {
|
|
324
|
+
// Any 5xx, whatever reason it names: a quota reason on a 503 does not
|
|
325
|
+
// prove the write was turned away before it committed. Only a 429, or a
|
|
326
|
+
// 4xx naming a quota, is a rejection that applied nothing.
|
|
327
|
+
if (status < 500 || method === "GET" || requestOptions.idempotent === true) {
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
const unknown = new ConnectorCallError("connector_call_failed", `${provider} answered HTTP ${status} after receiving the request, so its outcome is unknown — it may have been applied. Re-read its target before repeating it.`, { retryable: false, cause: mapped });
|
|
331
|
+
const reasons = googleReasons.get(mapped);
|
|
332
|
+
if (reasons)
|
|
333
|
+
googleReasons.set(unknown, reasons);
|
|
334
|
+
return unknown;
|
|
335
|
+
}
|
|
336
|
+
// The errors built below keep no cause: a stream's or parser's error can
|
|
337
|
+
// quote the body it failed on, and the phase and status already say what
|
|
338
|
+
// happened (#695).
|
|
339
|
+
function settled(error, facts, method, ctx, requestOptions) {
|
|
340
|
+
const write = method !== "GET";
|
|
341
|
+
const status = facts.status;
|
|
342
|
+
if (facts.serverError) {
|
|
343
|
+
return outcome(error, { dispatched: true, phase: "server-error", ...(status === undefined ? {} : { status }) });
|
|
344
|
+
}
|
|
345
|
+
if (facts.refused) {
|
|
346
|
+
return outcome(error, { dispatched: true, phase: "refused", ...(status === undefined ? {} : { status }) });
|
|
347
|
+
}
|
|
348
|
+
// An error status keeps its meaning whatever went wrong reading its body
|
|
349
|
+
// — a body past the ceiling, a stream that broke off: the status alone
|
|
350
|
+
// still says what to do next.
|
|
351
|
+
if (status !== undefined && status >= 400 && !ctx.signal?.aborted) {
|
|
352
|
+
const mapped = apiFailure(failure, status, facts.headers ?? new Headers(), undefined, requestOptions);
|
|
353
|
+
const unknown = serverErrorOnWrite(mapped, status, method, requestOptions);
|
|
354
|
+
return unknown
|
|
355
|
+
? outcome(unknown, { dispatched: true, status, phase: "server-error" })
|
|
356
|
+
: outcome(mapped, { dispatched: true, status, phase: "refused" });
|
|
357
|
+
}
|
|
358
|
+
if (!facts.dispatched)
|
|
359
|
+
return outcome(error, { dispatched: false, phase: "before-send" });
|
|
360
|
+
// The caller's own cancellation is core's to classify, not this layer's —
|
|
361
|
+
// but what Google had already said by then is still what it said.
|
|
362
|
+
if (ctx.signal?.aborted) {
|
|
363
|
+
const phase = status === undefined
|
|
364
|
+
? "awaiting-response"
|
|
365
|
+
: status >= 500 && write && requestOptions.idempotent !== true
|
|
366
|
+
? "server-error"
|
|
367
|
+
: status >= 400
|
|
368
|
+
? "refused"
|
|
369
|
+
: status >= 300
|
|
370
|
+
? "redirected"
|
|
371
|
+
: "reading-body";
|
|
372
|
+
return outcome(error, { dispatched: true, phase, ...(status === undefined ? {} : { status }) });
|
|
373
|
+
}
|
|
374
|
+
if (status === undefined) {
|
|
375
|
+
const mapped = write
|
|
376
|
+
? new ConnectorCallError("connector_call_failed", `${provider} was sent the request but no answer came back, so it may or may not have been applied. Re-read its target before repeating it.`, { retryable: false })
|
|
377
|
+
: error;
|
|
378
|
+
return outcome(mapped, { dispatched: true, phase: "awaiting-response" });
|
|
379
|
+
}
|
|
380
|
+
// A redirect, which the transport refuses to follow. It shows neither
|
|
381
|
+
// that a write applied nor that it did not, so a write's outcome is
|
|
382
|
+
// unknown — never "probably applied". A read keeps the transport's own
|
|
383
|
+
// refusal.
|
|
384
|
+
if (status >= 300 && status < 400) {
|
|
385
|
+
const redirected = write
|
|
386
|
+
? new ConnectorCallError("connector_call_failed", `${provider} answered HTTP ${status} with a redirect, which this connection does not follow, so whether the request was applied is unknown. Re-read its target before repeating it.`, { retryable: false })
|
|
387
|
+
: error;
|
|
388
|
+
return outcome(redirected, { dispatched: true, status, phase: "redirected" });
|
|
389
|
+
}
|
|
390
|
+
// Google accepted the request — a 2xx — and its reply broke off,
|
|
391
|
+
// overflowed, or would not parse.
|
|
392
|
+
const mapped = write
|
|
393
|
+
? new ConnectorCallError("connector_call_failed", `${provider} accepted the request but its reply could not be read; the change probably applied. Re-read before repeating it.`, { retryable: false })
|
|
394
|
+
: error instanceof ConnectorCallError
|
|
395
|
+
? error
|
|
396
|
+
: new ConnectorCallError("unavailable", `${provider}'s reply broke off while it was being read; reading again is safe.`);
|
|
397
|
+
return outcome(mapped, { dispatched: true, status, phase: "reading-body" });
|
|
398
|
+
}
|
|
399
|
+
async function call(request, ctx, accept, read, requestOptions = {}) {
|
|
400
|
+
// A 401 is answered by sending the same request again, so its body must
|
|
401
|
+
// survive being sent twice. A stream is consumed by the first send; it
|
|
402
|
+
// is a provider's wiring mistake, refused before anything leaves.
|
|
403
|
+
if (typeof ReadableStream !== "undefined" && request.rawBody instanceof ReadableStream) {
|
|
404
|
+
throw outcome(new TypeError(`${provider}: a Workspace request body must be replayable — pass a string, Uint8Array, ArrayBuffer, Blob, FormData, or URLSearchParams, never a ReadableStream.`), { dispatched: false, phase: "before-send" });
|
|
405
|
+
}
|
|
406
|
+
const headed = { ...request, headers: { Accept: accept, ...request.headers } };
|
|
407
|
+
for (let attempt = 0;; attempt += 1) {
|
|
408
|
+
const facts = { dispatched: false };
|
|
409
|
+
let delegated;
|
|
410
|
+
let token;
|
|
411
|
+
try {
|
|
412
|
+
// Subject first: no caller, or no mapped account, fails before any
|
|
413
|
+
// token is minted or request sent.
|
|
414
|
+
delegated = await tokenRequest(ctx);
|
|
415
|
+
token = await delegatedToken(delegated, ctx);
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
throw outcome(error, { dispatched: false, phase: "before-send" });
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
return await transport(token, facts)(headed, ctx, async (response) => {
|
|
422
|
+
// Every 3xx — the ones the transport refuses and the 300, 304, 305,
|
|
423
|
+
// and 306 it lets through — is a redirect before it is anything
|
|
424
|
+
// else: no body or reason it carries makes it a refusal.
|
|
425
|
+
if (response.status >= 300 && response.status < 400) {
|
|
426
|
+
throw new ConnectorCallError("connector_call_failed", `${provider} answered HTTP ${response.status} with a redirect; this connection talks to exactly one origin and never forwards its credential to another.`, { retryable: false });
|
|
427
|
+
}
|
|
428
|
+
// A bounded read answers an empty file's 416 as an empty result.
|
|
429
|
+
if (!response.ok && !(headed.prefixOnly === true && isEmptyRange(response))) {
|
|
430
|
+
const parsed = await response.jsonResult();
|
|
431
|
+
const payload = "value" in parsed ? parsed.value : undefined;
|
|
432
|
+
const mapped = apiFailure(failure, response.status, response.headers, payload, requestOptions);
|
|
433
|
+
const unknown = serverErrorOnWrite(mapped, response.status, headed.method, requestOptions);
|
|
434
|
+
if (unknown) {
|
|
435
|
+
facts.serverError = true;
|
|
436
|
+
throw unknown;
|
|
437
|
+
}
|
|
438
|
+
facts.refused = true;
|
|
439
|
+
throw response.status === 401 ? new TokenRejected(mapped) : mapped;
|
|
440
|
+
}
|
|
441
|
+
return await read(response);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
// Every 401 Google sent earns the refresh and the one replay, whether
|
|
446
|
+
// its body was read or failed the transport's checks first: what the
|
|
447
|
+
// status said about the token does not depend on the body.
|
|
448
|
+
// A caller that has left gets neither: the 401 it saw is reported as
|
|
449
|
+
// it was, and no new token is minted for a request nobody awaits.
|
|
450
|
+
const failed = error instanceof TokenRejected ? error.failure : error;
|
|
451
|
+
const unauthorized = !ctx.signal?.aborted && (error instanceof TokenRejected || facts.status === 401);
|
|
452
|
+
if (!unauthorized)
|
|
453
|
+
throw settled(failed, facts, headed.method, ctx, requestOptions);
|
|
454
|
+
// Only the token this request carried; a newer one stays.
|
|
455
|
+
await forgetDelegatedToken(delegated, token);
|
|
456
|
+
if (attempt > 0 || ctx.signal?.aborted) {
|
|
457
|
+
throw settled(failed, facts, headed.method, ctx, requestOptions);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return {
|
|
463
|
+
json: (request, ctx, requestOptions) => call(request, ctx, "application/json", async (response) => {
|
|
464
|
+
// Read first, parse second: a body that breaks off while it is being
|
|
465
|
+
// read is a transport failure (a GET may read again), and only text
|
|
466
|
+
// that arrived whole and still is not JSON is malformed.
|
|
467
|
+
const body = await response.text();
|
|
468
|
+
if (body.trim() === "")
|
|
469
|
+
return undefined;
|
|
470
|
+
try {
|
|
471
|
+
return JSON.parse(body);
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
throw new ConnectorCallError("connector_call_failed", `${provider} returned a successful response that is not JSON.`, { retryable: false });
|
|
475
|
+
}
|
|
476
|
+
}, requestOptions),
|
|
477
|
+
bytes: (request, ctx, accept = "*/*", requestOptions) => read(request, ctx, accept, requestOptions),
|
|
478
|
+
text: async (request, ctx, accept = "text/plain, */*", requestOptions) => {
|
|
479
|
+
const { bytes, ...rest } = await read(request, ctx, accept, requestOptions);
|
|
480
|
+
let text;
|
|
481
|
+
try {
|
|
482
|
+
// A cut prefix may end inside a code point; streaming decode drops
|
|
483
|
+
// the incomplete tail instead of inventing a replacement character.
|
|
484
|
+
text = new TextDecoder(charsetOf(rest.contentType)).decode(bytes, { stream: rest.truncated });
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
text = new TextDecoder().decode(bytes, { stream: rest.truncated });
|
|
488
|
+
}
|
|
489
|
+
return { text, ...rest };
|
|
490
|
+
},
|
|
491
|
+
};
|
|
492
|
+
/** The body as bytes: whole, or a bounded prefix when `maxBytes` is set. */
|
|
493
|
+
async function read(request, ctx, accept, requestOptions = {}) {
|
|
494
|
+
const { maxBytes } = requestOptions;
|
|
495
|
+
if (maxBytes !== undefined && (!Number.isInteger(maxBytes) || maxBytes < 0)) {
|
|
496
|
+
throw outcome(new TypeError(`${provider}: maxBytes must be a whole number of bytes, zero or more.`), {
|
|
497
|
+
dispatched: false,
|
|
498
|
+
phase: "before-send",
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
const bounded = maxBytes !== undefined;
|
|
502
|
+
return call(bounded ? { ...request, prefixOnly: true } : request, ctx, accept, async (response) => {
|
|
503
|
+
const facts = {
|
|
504
|
+
contentType: response.headers.get("content-type") ?? undefined,
|
|
505
|
+
status: response.status,
|
|
506
|
+
contentRange: response.headers.get("content-range") ?? undefined,
|
|
507
|
+
};
|
|
508
|
+
if (!bounded)
|
|
509
|
+
return { bytes: await response.bytes(), truncated: false, ...facts };
|
|
510
|
+
if (!response.ok) {
|
|
511
|
+
// Only the empty-file 416 reaches here; its body is not the file.
|
|
512
|
+
await response.prefix(0);
|
|
513
|
+
return { bytes: new Uint8Array(), truncated: false, ...facts };
|
|
514
|
+
}
|
|
515
|
+
return { ...(await response.prefix(maxBytes)), ...facts };
|
|
516
|
+
}, requestOptions);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** In-memory tokens and credential-bound login flights shared only inside one request. */
|
|
2
|
+
import { ConnectorCallError } from "../../errors.js";
|
|
3
|
+
const LOGIN_TIMEOUT_MS = 15_000;
|
|
4
|
+
const MAX_CACHED_TOKENS = 512;
|
|
5
|
+
/** Each provider instance owns its cache, so no credential crosses an API origin. */
|
|
6
|
+
export function requestTokenCache(source) {
|
|
7
|
+
const tokens = new Map();
|
|
8
|
+
const requests = new WeakMap();
|
|
9
|
+
function start(auth, ctx) {
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
const timer = setTimeout(() => controller.abort(new ConnectorCallError("unavailable", "Machine identity login timed out.")), LOGIN_TIMEOUT_MS);
|
|
12
|
+
const flight = { controller, waiters: 0, settled: false, promise: Promise.resolve("") };
|
|
13
|
+
flight.promise = auth
|
|
14
|
+
.mint({ ...ctx, signal: controller.signal })
|
|
15
|
+
.then((minted) => {
|
|
16
|
+
// An abandoned login must not seed a token after cancellation.
|
|
17
|
+
controller.signal.throwIfAborted();
|
|
18
|
+
tokens.delete(auth.key);
|
|
19
|
+
tokens.set(auth.key, minted);
|
|
20
|
+
while (tokens.size > MAX_CACHED_TOKENS)
|
|
21
|
+
tokens.delete(tokens.keys().next().value);
|
|
22
|
+
return minted.accessToken;
|
|
23
|
+
})
|
|
24
|
+
.finally(() => {
|
|
25
|
+
flight.settled = true;
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
});
|
|
28
|
+
// All waiters may cancel before the transport observes its abort.
|
|
29
|
+
void flight.promise.catch(() => { });
|
|
30
|
+
return flight;
|
|
31
|
+
}
|
|
32
|
+
async function follow(flight, signal) {
|
|
33
|
+
signal?.throwIfAborted();
|
|
34
|
+
flight.waiters++;
|
|
35
|
+
let onAbort;
|
|
36
|
+
try {
|
|
37
|
+
const aborted = new Promise((_, reject) => {
|
|
38
|
+
if (!signal)
|
|
39
|
+
return;
|
|
40
|
+
onAbort = () => reject(signal.reason);
|
|
41
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
42
|
+
});
|
|
43
|
+
return await Promise.race([flight.promise, aborted]);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
if (onAbort)
|
|
47
|
+
signal?.removeEventListener("abort", onAbort);
|
|
48
|
+
flight.waiters--;
|
|
49
|
+
if (!flight.settled && flight.waiters === 0)
|
|
50
|
+
flight.controller.abort();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
async token(ctx, rejected) {
|
|
55
|
+
ctx.signal?.throwIfAborted();
|
|
56
|
+
const auth = await source(ctx);
|
|
57
|
+
ctx.signal?.throwIfAborted();
|
|
58
|
+
const cached = tokens.get(auth.key);
|
|
59
|
+
if (cached && cached.accessToken !== rejected && cached.expiresAt > Date.now())
|
|
60
|
+
return cached.accessToken;
|
|
61
|
+
const scope = ctx.requestScope ?? ctx;
|
|
62
|
+
let perCredential = requests.get(scope);
|
|
63
|
+
if (!perCredential) {
|
|
64
|
+
perCredential = new Map();
|
|
65
|
+
requests.set(scope, perCredential);
|
|
66
|
+
}
|
|
67
|
+
let request = perCredential.get(auth.key);
|
|
68
|
+
if (!request) {
|
|
69
|
+
request = {};
|
|
70
|
+
perCredential.set(auth.key, request);
|
|
71
|
+
}
|
|
72
|
+
if (rejected !== undefined) {
|
|
73
|
+
// Keep the recovery outcome for the request, including failures. Later
|
|
74
|
+
// 401s can never turn this into a fresh-login loop.
|
|
75
|
+
request.recovery ??= start(auth, ctx);
|
|
76
|
+
return await follow(request.recovery, ctx.signal);
|
|
77
|
+
}
|
|
78
|
+
if (!request.initial || request.initial.settled || request.initial.controller.signal.aborted)
|
|
79
|
+
request.initial = start(auth, ctx);
|
|
80
|
+
return await follow(request.initial, ctx.signal);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ConnectorCallAdmissionPolicy } from "../../types.js";
|
|
2
2
|
/**
|
|
3
3
|
* Basecamp's one hosted MCP endpoint, streamable HTTP.
|
|
4
4
|
*
|
|
@@ -21,18 +21,8 @@ export interface BasecampOptions {
|
|
|
21
21
|
authScope?: "shared" | "personal";
|
|
22
22
|
/** Which Basecamp account this is and what decisions it answers. */
|
|
23
23
|
purpose: string;
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
* controls: a public JSON document whose `client_id` is its own URL, whose
|
|
27
|
-
* `redirect_uris` include `<publicUrl>/oauth/callback/<connector id>`, with
|
|
28
|
-
* `token_endpoint_auth_method: "none"` and the `authorization_code` and
|
|
29
|
-
* `refresh_token` grants. Basecamp advertises dynamic client registration
|
|
30
|
-
* but restricts it for HTTPS redirect URIs, which every deployed connecta
|
|
31
|
-
* has; its authorization server accepts a metadata-document client id
|
|
32
|
-
* instead (`client_id_metadata_document_supported: true`). Connecta does not
|
|
33
|
-
* serve one of its own yet, so the deployment hosts it.
|
|
34
|
-
*/
|
|
35
|
-
clientMetadataUrl: string;
|
|
24
|
+
/** Optional external CIMD URL. Defaults to this deployment's public HTTPS document. */
|
|
25
|
+
clientMetadataUrl?: string;
|
|
36
26
|
/** Account-specific conventions appended to the maintained provider guide. */
|
|
37
27
|
instructions?: string;
|
|
38
28
|
/** Connector-specific inline result limit; omit to inherit the deployment. */
|
|
@@ -47,19 +37,7 @@ export interface BasecampOptions {
|
|
|
47
37
|
*/
|
|
48
38
|
callAdmission?: ConnectorCallAdmissionPolicy;
|
|
49
39
|
}
|
|
50
|
-
/**
|
|
51
|
-
* One release-reviewed manifest, used both to classify a live tool and as the
|
|
52
|
-
* baseline the runtime drift check compares against, so the annotation a
|
|
53
|
-
* caller gets and the verdict a check reads can never disagree (P13). Names
|
|
54
|
-
* and verdicts only — no schemas are vendored; the live `tools/list` response
|
|
55
|
-
* stays authoritative.
|
|
56
|
-
*
|
|
57
|
-
* Basecamp publishes no tool inventory, so this list has no public document
|
|
58
|
-
* to be checked against before a release. Catalog drift is visible at runtime
|
|
59
|
-
* only — as unclassified-tool and unserved-name counts on connector status and
|
|
60
|
-
* `/health` — and the credential-free provider check reads the OAuth
|
|
61
|
-
* discovery metadata the server does publish instead.
|
|
62
|
-
*/
|
|
63
|
-
export declare const BASECAMP_VETTED_CATALOG: import("../catalog-drift.js").VettedCatalog;
|
|
64
40
|
/** A maintained Basecamp hosted-MCP connection. */
|
|
65
|
-
export declare
|
|
41
|
+
export declare const basecamp: import("../../provider.js").ProviderFactory<BasecampOptions>;
|
|
42
|
+
/** @deprecated Read `basecamp.definition.classify` instead. Kept for existing imports. */
|
|
43
|
+
export declare const BASECAMP_VETTED_CATALOG: import("../../catalog-drift.js").VettedCatalog;
|