@zackbart/connecta 0.28.1 → 0.29.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 +788 -5
- package/README.md +62 -42
- package/bin/connecta.mjs +110 -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/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 +283 -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 +368 -224
- 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 +631 -0
- package/dist/config.js +503 -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 +84 -35
- 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 +119 -0
- package/dist/downstream-input.js +323 -0
- package/dist/errors.d.ts +97 -20
- package/dist/errors.js +158 -80
- package/dist/execute.js +453 -305
- 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 +24 -9
- package/dist/executors/quickjs-child.js +3 -4
- package/dist/executors/quickjs-runtime.js +147 -59
- package/dist/executors/quickjs.js +29 -35
- package/dist/favicon.js +1 -2
- package/dist/fix-prompt.js +1 -3
- package/dist/guest-runtime.js +188 -0
- package/dist/guest-types.d.ts +176 -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 +230 -73
- 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 +76 -0
- package/dist/meta-tool-names.d.ts +2 -0
- package/dist/meta-tool-names.js +3 -0
- package/dist/meta-tools.js +285 -209
- 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 +117 -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-source.js +14 -2
- package/dist/{exempt-writes.js → program-writes.js} +55 -18
- 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/{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/{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 +108 -171
- package/dist/registry.js +513 -925
- package/dist/request-state.js +21 -0
- package/dist/resource-read.js +100 -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 +356 -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 +52 -0
- package/dist/sent-secrets.js +474 -0
- package/dist/server.js +28 -71
- package/dist/skills.js +406 -174
- package/dist/sqlite.d.ts +48 -0
- package/dist/sqlite.js +166 -0
- package/dist/storage/keys.js +428 -0
- package/dist/storage/memory.js +7 -0
- package/dist/storage/sql.d.ts +66 -0
- package/dist/storage/sql.js +534 -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 +320 -54
- 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 +137 -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 +434 -0
- package/documentation/architecture.md +664 -231
- package/documentation/auth.md +936 -546
- package/documentation/code-mode.md +501 -312
- package/documentation/meta-tools.md +449 -200
- package/documentation/operator-tests.md +93 -0
- package/documentation/provider-migration-0.29.md +60 -0
- package/examples/worker/AGENTS.md +34 -12
- package/examples/worker/README.md +286 -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 +120 -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 +133 -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,11 @@
|
|
|
1
|
+
// Generated by scripts/generate-providers.mjs; do not edit.
|
|
2
|
+
export const skill = {
|
|
3
|
+
"content": "# Google Forms usage\n\nActs as the signed-in person in Google Forms through Workspace delegation: read and edit their forms, read responses, never submit or delete.\n\nConnection purpose: \n\n## Whose forms\n\nEvery call reaches only the forms the Workspace user deployment config maps\nthe caller to can open. No argument names a user. A call with none mapped\nfails `auth_required`; only an operator can change the mapping. A form id\nthat does not exist and one not shared with that user fail alike, as\n`connector_call_failed`: Google does not say which.\n\n## Finding forms\n\nThere is no list or search here: the Forms API has none. Find a form in\nDrive, or take its id from an edit URL (`/forms/d/<formId>/edit`). The\n`/forms/d/e/…` responder link is not the form id.\n\n## Reading\n\n- Every result stays inside KiB, so it crosses into\n `execute_code` whole. A page that would outgrow that ends early with\n `page.hasMore`; follow `page.nextCursor` rather than raising `limit`.\n- `get_form` lists items with their `questions`: one for a question,\n one per row for a grid, none for sections and media. Answers are keyed by\n those `questionId`s. A large form pages its items (`itemCount` is the\n total), and a grid too large for one page continues on the next: the item\n repeats with `questionsFrom`, the index of its first row there, and\n `moreQuestions` counts rows still to come. A cursor from a form that has\n since changed fails `conflict`.\n Long text is cut with a marker; `raw: true` returns Google's whole Form\n resource, or refuses one too large for a result.\n- `list_responses` labels each answer with its question's title, in form\n order; an answer to a since-deleted question has no title. Answers longer\n than characters end with a truncation marker; `get_response`\n reads one whole. Page with `page.nextCursor` and the same\n `submittedAfter`. To read only new responses, pass the newest\n `lastSubmittedTime` already seen as `submittedAfter`. A cursor\n continues after the last response it returned; if Google's page has\n changed since (new, deleted, or reordered responses), it fails\n `conflict` — start again, or use `submittedAfter`.\n- A response too large for one result comes back cut, with the answers left\n out named in `omittedQuestionIds`; pass them to `get_response` as\n `questionIds`.\n\n## Editing\n\n- `create_form` makes an empty form: Google takes only its title and Drive\n file name at creation. Add a description and questions afterwards.\n- `update_form_info` replaces the title or description.\n- `batch_update_form` is the escape hatch for everything else, and always\n needs approval. It takes Google's own Request objects, applied all or none,\n with the form's current `revisionId` as `requiredRevisionId`: a form\n someone changed since you read it is refused as `conflict`, so re-read it\n and rebuild the edit. Its reply carries the new `revisionId` and each created item's id.\n A reply too large to return whole comes back with `truncated: true`: the\n batch still applied; read the rest with `get_form`. An `updateItem` needs an `updateMask`; read `raw: true` first so the\n item it replaces keeps its grading, images, and navigation.\n- Publishing, sharing, deleting, and watches are not in this connection; the\n owner does those in Forms or Drive. Responses are read-only.\n",
|
|
4
|
+
"instructionsHeading": "Connection instructions",
|
|
5
|
+
"fragments": {
|
|
6
|
+
"guide_0": "# Google Forms usage\n\nActs as the signed-in person in Google Forms through Workspace delegation: read and edit their forms, read responses, never submit or delete.\n\nConnection purpose: ",
|
|
7
|
+
"guide_1": "\n\n## Whose forms\n\nEvery call reaches only the forms the Workspace user deployment config maps\nthe caller to can open. No argument names a user. A call with none mapped\nfails `auth_required`; only an operator can change the mapping. A form id\nthat does not exist and one not shared with that user fail alike, as\n`connector_call_failed`: Google does not say which.\n\n## Finding forms\n\nThere is no list or search here: the Forms API has none. Find a form in\nDrive, or take its id from an edit URL (`/forms/d/<formId>/edit`). The\n`/forms/d/e/…` responder link is not the form id.\n\n## Reading\n\n- Every result stays inside ",
|
|
8
|
+
"guide_2": " KiB, so it crosses into\n `execute_code` whole. A page that would outgrow that ends early with\n `page.hasMore`; follow `page.nextCursor` rather than raising `limit`.\n- `get_form` lists items with their `questions`: one for a question,\n one per row for a grid, none for sections and media. Answers are keyed by\n those `questionId`s. A large form pages its items (`itemCount` is the\n total), and a grid too large for one page continues on the next: the item\n repeats with `questionsFrom`, the index of its first row there, and\n `moreQuestions` counts rows still to come. A cursor from a form that has\n since changed fails `conflict`.\n Long text is cut with a marker; `raw: true` returns Google's whole Form\n resource, or refuses one too large for a result.\n- `list_responses` labels each answer with its question's title, in form\n order; an answer to a since-deleted question has no title. Answers longer\n than ",
|
|
9
|
+
"guide_3": " characters end with a truncation marker; `get_response`\n reads one whole. Page with `page.nextCursor` and the same\n `submittedAfter`. To read only new responses, pass the newest\n `lastSubmittedTime` already seen as `submittedAfter`. A cursor\n continues after the last response it returned; if Google's page has\n changed since (new, deleted, or reordered responses), it fails\n `conflict` — start again, or use `submittedAfter`.\n- A response too large for one result comes back cut, with the answers left\n out named in `omittedQuestionIds`; pass them to `get_response` as\n `questionIds`.\n\n## Editing\n\n- `create_form` makes an empty form: Google takes only its title and Drive\n file name at creation. Add a description and questions afterwards.\n- `update_form_info` replaces the title or description.\n- `batch_update_form` is the escape hatch for everything else, and always\n needs approval. It takes Google's own Request objects, applied all or none,\n with the form's current `revisionId` as `requiredRevisionId`: a form\n someone changed since you read it is refused as `conflict`, so re-read it\n and rebuild the edit. Its reply carries the new `revisionId` and each created item's id.\n A reply too large to return whole comes back with `truncated: true`: the\n batch still applied; read the rest with `get_form`. An `updateItem` needs an `updateMask`; read `raw: true` first so the\n item it replaces keeps its grading, images, and navigation.\n- Publishing, sharing, deleting, and watches are not in this connection; the\n owner does those in Forms or Drive. Responses are read-only.\n"
|
|
10
|
+
}
|
|
11
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type GuardedRequest } from "../../connectors/guarded-fetch.js";
|
|
2
|
+
import type { ConnectorContext } from "../../types.js";
|
|
3
|
+
import { ScopePolicy, type Target } from "./scope.js";
|
|
4
|
+
export interface GitHubApp {
|
|
5
|
+
appId: string | number;
|
|
6
|
+
/** Deployment secret. Omit to read the connector's privateKey vault field. */
|
|
7
|
+
privateKey?: string;
|
|
8
|
+
}
|
|
9
|
+
export type Permissions = Readonly<Record<string, "read" | "write">>;
|
|
10
|
+
export declare class AppAuth {
|
|
11
|
+
readonly app: GitHubApp;
|
|
12
|
+
readonly policy: ScopePolicy;
|
|
13
|
+
private readonly installations;
|
|
14
|
+
private readonly tokens;
|
|
15
|
+
private readonly repositories;
|
|
16
|
+
private readonly grants;
|
|
17
|
+
private readonly orgInstallations;
|
|
18
|
+
private readonly configuredDer?;
|
|
19
|
+
private readonly appId;
|
|
20
|
+
constructor(app: GitHubApp, policy: ScopePolicy);
|
|
21
|
+
private key;
|
|
22
|
+
json(request: GuardedRequest, bearer: string, ctx: ConnectorContext): Promise<unknown>;
|
|
23
|
+
private invalidate;
|
|
24
|
+
rejectToken(bearer: string): Promise<void>;
|
|
25
|
+
private installation;
|
|
26
|
+
private repository;
|
|
27
|
+
private authorizeId;
|
|
28
|
+
token(owner: string, repos: readonly string[] | undefined, permissions: Permissions, ctx: ConnectorContext): Promise<string>;
|
|
29
|
+
private mint;
|
|
30
|
+
checkResult(t: Target, id: unknown, token: string, ctx: ConnectorContext): Promise<void>;
|
|
31
|
+
repo(t: Target, permissions: Permissions, ctx: ConnectorContext): Promise<string>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { guardedFetch, retryAfterMs } from "../../connectors/guarded-fetch.js";
|
|
2
|
+
import { ConnectorCallError } from "../../errors.js";
|
|
3
|
+
import { attachFailureFacts } from "../../operator-record.js";
|
|
4
|
+
import { appJwt, privateKeyDer } from "./key.js";
|
|
5
|
+
import { target } from "./scope.js";
|
|
6
|
+
const MAX_ENTRIES = 256;
|
|
7
|
+
const REFRESH_MARGIN = 60_000;
|
|
8
|
+
const INSTALLATION_TTL = 300_000;
|
|
9
|
+
const REPOSITORY_TTL = 300_000;
|
|
10
|
+
function boundedSet(map, key, value) {
|
|
11
|
+
if (!map.has(key) && map.size >= MAX_ENTRIES)
|
|
12
|
+
map.delete(map.keys().next().value);
|
|
13
|
+
map.set(key, value);
|
|
14
|
+
}
|
|
15
|
+
function record(value) {
|
|
16
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
17
|
+
}
|
|
18
|
+
export class AppAuth {
|
|
19
|
+
app;
|
|
20
|
+
policy;
|
|
21
|
+
// These maps retain completed scalar values, never a request promise,
|
|
22
|
+
// signal, transport, response, or crypto import promise. No disk tokens.
|
|
23
|
+
installations = new Map();
|
|
24
|
+
tokens = new Map();
|
|
25
|
+
repositories = new Map();
|
|
26
|
+
// Configured grants bind once for this provider's lifetime, independent of
|
|
27
|
+
// expiring name lookups and key rotation. Never evict or silently rebind a
|
|
28
|
+
// grant to a new repository that reuses its name. At most 100 config entries.
|
|
29
|
+
grants = new Map();
|
|
30
|
+
orgInstallations = new Map();
|
|
31
|
+
configuredDer;
|
|
32
|
+
appId;
|
|
33
|
+
constructor(app, policy) {
|
|
34
|
+
this.app = app;
|
|
35
|
+
this.policy = policy;
|
|
36
|
+
if (!app ||
|
|
37
|
+
!/^[1-9][0-9]*$/.test(String(app.appId)) ||
|
|
38
|
+
(typeof app.appId !== "string" && typeof app.appId !== "number") ||
|
|
39
|
+
(typeof app.appId === "number" && !Number.isSafeInteger(app.appId))) {
|
|
40
|
+
throw new Error("app.appId to be a positive GitHub App ID.");
|
|
41
|
+
}
|
|
42
|
+
this.appId = String(app.appId);
|
|
43
|
+
if (app.privateKey !== undefined) {
|
|
44
|
+
if (typeof app.privateKey !== "string")
|
|
45
|
+
throw new Error("app.privateKey to be an RSA PEM or omitted for the vault slot.");
|
|
46
|
+
this.configuredDer = privateKeyDer(app.privateKey);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async key(ctx) {
|
|
50
|
+
ctx.signal?.throwIfAborted();
|
|
51
|
+
let der = this.configuredDer;
|
|
52
|
+
if (!der) {
|
|
53
|
+
const pem = await ctx.credential?.get("privateKey");
|
|
54
|
+
if (!pem)
|
|
55
|
+
throw new ConnectorCallError("auth_required", "Configure the GitHub App private key in this connector's vault slot.");
|
|
56
|
+
try {
|
|
57
|
+
der = privateKeyDer(pem);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new ConnectorCallError("auth_required", "Replace the GitHub App private key with a complete RSA PEM.");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", der));
|
|
64
|
+
ctx.signal?.throwIfAborted();
|
|
65
|
+
return {
|
|
66
|
+
der,
|
|
67
|
+
fingerprint: `${this.appId}:${Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("")}`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
async json(request, bearer, ctx) {
|
|
71
|
+
ctx.signal?.throwIfAborted();
|
|
72
|
+
const transport = guardedFetch({
|
|
73
|
+
provider: "GitHub",
|
|
74
|
+
baseUrl: "https://api.github.com",
|
|
75
|
+
maxResponseBytes: 4 * 1024 * 1024,
|
|
76
|
+
headers: {
|
|
77
|
+
Accept: "application/vnd.github+json",
|
|
78
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
79
|
+
"User-Agent": "connecta",
|
|
80
|
+
},
|
|
81
|
+
authenticate: () => ({ Authorization: `Bearer ${bearer}` }),
|
|
82
|
+
});
|
|
83
|
+
return transport(request, ctx, async (response) => {
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
// Dispose of the body without interpreting error text or Content-Type.
|
|
86
|
+
await response.prefix(0);
|
|
87
|
+
const status = response.status;
|
|
88
|
+
const reset = Number(response.headers.get("x-ratelimit-reset"));
|
|
89
|
+
const retry = retryAfterMs(response.headers);
|
|
90
|
+
const wait = retry ?? (Number.isFinite(reset) && reset > 0 ? Math.max(0, reset * 1000 - Date.now()) : undefined);
|
|
91
|
+
const rate = status === 429 ||
|
|
92
|
+
(status === 403 && (retry !== undefined || response.headers.get("x-ratelimit-remaining") === "0"));
|
|
93
|
+
if (status === 401 || (status === 403 && !rate))
|
|
94
|
+
this.invalidate(bearer);
|
|
95
|
+
const code = rate
|
|
96
|
+
? "rate_limited"
|
|
97
|
+
: status === 401 || status === 403
|
|
98
|
+
? "auth_required"
|
|
99
|
+
: status === 409
|
|
100
|
+
? "conflict"
|
|
101
|
+
: status >= 500
|
|
102
|
+
? "unavailable"
|
|
103
|
+
: "connector_call_failed";
|
|
104
|
+
throw attachFailureFacts(new ConnectorCallError(code, rate
|
|
105
|
+
? "GitHub rate limit reached. Honor the retry timing before trying again."
|
|
106
|
+
: code === "auth_required"
|
|
107
|
+
? "Verify the GitHub App key, installation, repository selection and required permissions."
|
|
108
|
+
: code === "conflict"
|
|
109
|
+
? "GitHub rejected a stale version. Read the current state before resubmitting."
|
|
110
|
+
: status === 404
|
|
111
|
+
? "GitHub returned 404; the resource may be absent or inaccessible to this installation."
|
|
112
|
+
: "The GitHub API request failed; downstream error text is withheld.", { retryable: rate || status >= 500, ...(rate && wait !== undefined ? { retryAfterMs: wait } : {}) }), { httpStatus: status });
|
|
113
|
+
}
|
|
114
|
+
const parsed = await response.jsonResult();
|
|
115
|
+
ctx.signal?.throwIfAborted();
|
|
116
|
+
if ("parseError" in parsed)
|
|
117
|
+
throw new ConnectorCallError("connector_call_failed", "GitHub returned malformed JSON; response text is withheld.");
|
|
118
|
+
return parsed.value;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
invalidate(bearer) {
|
|
122
|
+
for (const [key, token] of this.tokens) {
|
|
123
|
+
if (token.value !== bearer)
|
|
124
|
+
continue;
|
|
125
|
+
this.tokens.delete(key);
|
|
126
|
+
for (const [owner, installation] of this.installations) {
|
|
127
|
+
if (installation.id === token.installation)
|
|
128
|
+
this.installations.delete(owner);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async rejectToken(bearer) {
|
|
133
|
+
this.invalidate(bearer);
|
|
134
|
+
}
|
|
135
|
+
async installation(owner, ctx, key) {
|
|
136
|
+
this.policy.owner(owner);
|
|
137
|
+
const cacheKey = `${key.fingerprint}:${owner}`;
|
|
138
|
+
const previous = this.installations.get(cacheKey);
|
|
139
|
+
if (previous && previous.expires > Date.now())
|
|
140
|
+
return previous;
|
|
141
|
+
let jwt;
|
|
142
|
+
try {
|
|
143
|
+
jwt = await appJwt(this.appId, key.der, Date.now());
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
throw new ConnectorCallError("auth_required", "The GitHub App key could not sign an RS256 JWT. Replace the key.");
|
|
147
|
+
}
|
|
148
|
+
let found;
|
|
149
|
+
for (let page = 1; page <= 20; page++) {
|
|
150
|
+
const data = await this.json({ method: "GET", path: "/app/installations", query: { per_page: 100, page } }, jwt, ctx);
|
|
151
|
+
if (!Array.isArray(data))
|
|
152
|
+
throw new ConnectorCallError("connector_call_failed", "GitHub returned an invalid installation list.");
|
|
153
|
+
for (const item of data) {
|
|
154
|
+
const row = record(item);
|
|
155
|
+
const account = record(row.account);
|
|
156
|
+
if (typeof account.login !== "string" || account.login.toLowerCase() !== owner)
|
|
157
|
+
continue;
|
|
158
|
+
if (!Number.isSafeInteger(row.id) || Number(row.id) <= 0 || row.suspended_at != null) {
|
|
159
|
+
throw new ConnectorCallError("auth_required", "Install or unsuspend the GitHub App on the configured owner.");
|
|
160
|
+
}
|
|
161
|
+
found = {
|
|
162
|
+
id: Number(row.id),
|
|
163
|
+
owner,
|
|
164
|
+
all: row.repository_selection === "all",
|
|
165
|
+
organization: account.type === "Organization",
|
|
166
|
+
expires: Date.now() + INSTALLATION_TTL,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (data.length < 100)
|
|
170
|
+
break;
|
|
171
|
+
if (page === 20)
|
|
172
|
+
throw new ConnectorCallError("connector_call_failed", "The GitHub installation list exceeded its paging bound; no partial mapping was cached.");
|
|
173
|
+
}
|
|
174
|
+
if (!found)
|
|
175
|
+
throw new ConnectorCallError("auth_required", "Install the GitHub App on the configured owner and grant the configured repositories.");
|
|
176
|
+
if (this.policy.repositories(owner) === undefined && (!found.all || !found.organization)) {
|
|
177
|
+
throw new ConnectorCallError("auth_required", "Org scopes require an organization installation with All repositories selected, including future repositories.");
|
|
178
|
+
}
|
|
179
|
+
ctx.signal?.throwIfAborted();
|
|
180
|
+
// Compare-and-set by entry identity. A slower request cannot overwrite a
|
|
181
|
+
// completed mapping installed by another request while it was paging.
|
|
182
|
+
const current = this.installations.get(cacheKey);
|
|
183
|
+
const resolved = current !== previous ? (current ?? found) : found;
|
|
184
|
+
if (this.policy.repositories(owner) === undefined) {
|
|
185
|
+
const bound = this.orgInstallations.get(owner);
|
|
186
|
+
if (bound !== undefined && bound !== resolved.id)
|
|
187
|
+
throw new ConnectorCallError("auth_required", "The organization grant is bound to a different installation. Reconfigure after reviewing the replacement installation.");
|
|
188
|
+
this.orgInstallations.set(owner, resolved.id);
|
|
189
|
+
}
|
|
190
|
+
if (this.installations.get(cacheKey) === previous)
|
|
191
|
+
boundedSet(this.installations, cacheKey, resolved);
|
|
192
|
+
return resolved;
|
|
193
|
+
}
|
|
194
|
+
async repository(owner, name, installation, key, ctx) {
|
|
195
|
+
const prefix = `${key.fingerprint}:${installation.id}:`;
|
|
196
|
+
const cacheKey = `${prefix}${owner}/${name}`;
|
|
197
|
+
const previous = this.repositories.get(cacheKey);
|
|
198
|
+
if (previous && previous.expires > Date.now())
|
|
199
|
+
return previous;
|
|
200
|
+
// GitHub requires an installation token for /installation/repositories.
|
|
201
|
+
// Mint with the App JWT, metadata only, and never give this resolver token
|
|
202
|
+
// to the hosted server or a caller-selected REST operation.
|
|
203
|
+
const resolver = await this.mint(owner, installation, key, undefined, { metadata: "read" }, ctx, "resolver");
|
|
204
|
+
const found = new Map();
|
|
205
|
+
const configured = this.policy.scopes.filter((scope) => scope.repo?.startsWith(`${owner}/`));
|
|
206
|
+
for (let page = 1; page <= 100; page++) {
|
|
207
|
+
const data = record(await this.json({ method: "GET", path: "/installation/repositories", query: { per_page: 100, page } }, resolver, ctx));
|
|
208
|
+
if (!Array.isArray(data.repositories))
|
|
209
|
+
throw new ConnectorCallError("connector_call_failed", "GitHub returned an invalid repository list.");
|
|
210
|
+
for (const item of data.repositories) {
|
|
211
|
+
const row = record(item);
|
|
212
|
+
const parts = typeof row.full_name === "string" ? row.full_name.split("/") : [];
|
|
213
|
+
const t = target(parts[0], parts.length === 2 ? parts[1] : undefined);
|
|
214
|
+
if (t.owner !== owner || !Number.isSafeInteger(row.id) || Number(row.id) <= 0)
|
|
215
|
+
throw new ConnectorCallError("connector_call_failed", "GitHub returned an invalid installation repository binding.");
|
|
216
|
+
const fullName = `${t.owner}/${t.repo}`;
|
|
217
|
+
if (t.repo === name ||
|
|
218
|
+
configured.some((scope) => scope.repo === fullName || this.grants.get(scope.repo) === row.id)) {
|
|
219
|
+
found.set(fullName, { ...t, id: Number(row.id), expires: Date.now() + REPOSITORY_TTL });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (data.repositories.length < 100)
|
|
223
|
+
break;
|
|
224
|
+
if (page === 100)
|
|
225
|
+
throw new ConnectorCallError("connector_call_failed", "The installation repository list exceeded its paging bound; no partial mapping was cached.");
|
|
226
|
+
}
|
|
227
|
+
ctx.signal?.throwIfAborted();
|
|
228
|
+
// Commit only complete, uncancelled reads. An earlier completed binding
|
|
229
|
+
// wins even if a slower read observed the old name before a rename.
|
|
230
|
+
for (const grant of configured) {
|
|
231
|
+
const repo = found.get(grant.repo);
|
|
232
|
+
if (!this.grants.has(grant.repo) && repo)
|
|
233
|
+
this.grants.set(grant.repo, repo.id);
|
|
234
|
+
if (!this.grants.has(grant.repo))
|
|
235
|
+
throw new ConnectorCallError("auth_required", "A configured repository grant could not be bound in this installation.");
|
|
236
|
+
if (!repo) {
|
|
237
|
+
const renamed = [...found.values()].find((candidate) => candidate.id === this.grants.get(grant.repo));
|
|
238
|
+
if (renamed)
|
|
239
|
+
found.set(grant.repo, renamed);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
for (const [fullName, repo] of found) {
|
|
243
|
+
const current = this.repositories.get(`${prefix}${fullName}`);
|
|
244
|
+
if (!current || current === previous || current.expires <= Date.now())
|
|
245
|
+
boundedSet(this.repositories, `${prefix}${fullName}`, repo);
|
|
246
|
+
for (const grant of configured)
|
|
247
|
+
if (this.grants.get(grant.repo) === repo.id)
|
|
248
|
+
this.policy.bindAlias(repo, grant);
|
|
249
|
+
}
|
|
250
|
+
const resolved = this.repositories.get(cacheKey);
|
|
251
|
+
if (!resolved || resolved.expires <= Date.now())
|
|
252
|
+
throw new ConnectorCallError("auth_required", "The repository is not reachable in the configured installation. Verify its selection or current name.");
|
|
253
|
+
return resolved;
|
|
254
|
+
}
|
|
255
|
+
authorizeId(repo, permissions) {
|
|
256
|
+
const grant = this.policy.scope(repo, Object.values(permissions).includes("write"), permissions.workflows === "write");
|
|
257
|
+
if (grant.repo && this.grants.get(grant.repo) !== repo.id)
|
|
258
|
+
throw new ConnectorCallError("invalid_args", "This repository name now identifies a different repository outside its bound grant.");
|
|
259
|
+
// An exact grant's restrictions survive a rename beneath an org grant.
|
|
260
|
+
for (const scope of this.policy.scopes) {
|
|
261
|
+
if (scope.repo && this.grants.get(scope.repo) === repo.id) {
|
|
262
|
+
if ((Object.values(permissions).includes("write") && scope.access !== "read-write") ||
|
|
263
|
+
(permissions.workflows === "write" && scope.workflows !== "write")) {
|
|
264
|
+
throw new ConnectorCallError("invalid_args", "The bound repository grant does not permit this operation.");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async token(owner, repos, permissions, ctx) {
|
|
270
|
+
// All routes, including discovery, validate scope before touching a key.
|
|
271
|
+
owner = this.policy.owner(owner);
|
|
272
|
+
if (repos)
|
|
273
|
+
for (const repo of repos)
|
|
274
|
+
this.policy.scope({ owner, repo }, Object.values(permissions).includes("write"), permissions.workflows === "write");
|
|
275
|
+
else if (Object.values(permissions).includes("write"))
|
|
276
|
+
throw new ConnectorCallError("invalid_args", "GitHub writes require exact repository targets.");
|
|
277
|
+
const key = await this.key(ctx);
|
|
278
|
+
const installation = await this.installation(owner, ctx, key);
|
|
279
|
+
const names = repos ?? this.policy.repositories(owner);
|
|
280
|
+
const ids = [];
|
|
281
|
+
if (names)
|
|
282
|
+
for (const name of names) {
|
|
283
|
+
const repo = await this.repository(owner, name, installation, key, ctx);
|
|
284
|
+
this.authorizeId(repo, permissions);
|
|
285
|
+
ids.push(repo.id);
|
|
286
|
+
}
|
|
287
|
+
const repositorySet = names ? [...new Set(ids)].sort((a, b) => a - b) : undefined;
|
|
288
|
+
return this.mint(owner, installation, key, repositorySet, permissions, ctx);
|
|
289
|
+
}
|
|
290
|
+
async mint(owner, installation, key, repositorySet, permissions, ctx, purpose = "operation") {
|
|
291
|
+
const permissionSet = Object.fromEntries(Object.entries(permissions).sort(([a], [b]) => a.localeCompare(b)));
|
|
292
|
+
const cacheKey = JSON.stringify([key.fingerprint, installation.id, repositorySet ?? "all", permissionSet, purpose]);
|
|
293
|
+
const previous = this.tokens.get(cacheKey);
|
|
294
|
+
if (previous && previous.expires > Date.now() + REFRESH_MARGIN)
|
|
295
|
+
return previous.value;
|
|
296
|
+
let jwt;
|
|
297
|
+
try {
|
|
298
|
+
jwt = await appJwt(this.appId, key.der, Date.now());
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
throw new ConnectorCallError("auth_required", "The GitHub App key could not sign an RS256 JWT. Replace the key.");
|
|
302
|
+
}
|
|
303
|
+
let response;
|
|
304
|
+
try {
|
|
305
|
+
response = record(await this.json({
|
|
306
|
+
method: "POST",
|
|
307
|
+
path: `/app/installations/${installation.id}/access_tokens`,
|
|
308
|
+
body: { permissions: permissionSet, ...(repositorySet ? { repository_ids: repositorySet } : {}) },
|
|
309
|
+
}, jwt, ctx));
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
// Re-resolve on the next call, never replay this call. Fence a slow
|
|
313
|
+
// failure so it cannot erase another request's newer installation.
|
|
314
|
+
const ownerKey = `${key.fingerprint}:${owner}`;
|
|
315
|
+
if (this.installations.get(ownerKey) === installation)
|
|
316
|
+
this.installations.delete(ownerKey);
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
const expiry = typeof response.expires_at === "string" ? Date.parse(response.expires_at) : NaN;
|
|
320
|
+
if (typeof response.token !== "string" ||
|
|
321
|
+
!response.token ||
|
|
322
|
+
/[^\x21-\x7e]/.test(response.token) ||
|
|
323
|
+
!Number.isFinite(expiry) ||
|
|
324
|
+
expiry <= Date.now() + REFRESH_MARGIN) {
|
|
325
|
+
throw new ConnectorCallError("auth_required", "GitHub returned an invalid or already expiring installation token.");
|
|
326
|
+
}
|
|
327
|
+
ctx.signal?.throwIfAborted();
|
|
328
|
+
const completed = {
|
|
329
|
+
value: response.token,
|
|
330
|
+
expires: Math.min(expiry, Date.now() + 3_600_000),
|
|
331
|
+
installation: installation.id,
|
|
332
|
+
...(repositorySet ? { repositoryIds: repositorySet } : {}),
|
|
333
|
+
};
|
|
334
|
+
if (this.tokens.get(cacheKey) === previous)
|
|
335
|
+
boundedSet(this.tokens, cacheKey, completed);
|
|
336
|
+
return this.tokens.get(cacheKey)?.value ?? completed.value;
|
|
337
|
+
}
|
|
338
|
+
async checkResult(t, id, token, ctx) {
|
|
339
|
+
this.policy.owner(t.owner);
|
|
340
|
+
if (id === undefined) {
|
|
341
|
+
const repo = record(await this.json({ method: "GET", path: `/repos/${encodeURIComponent(t.owner)}/${encodeURIComponent(t.repo)}` }, token, ctx));
|
|
342
|
+
id = repo.id;
|
|
343
|
+
}
|
|
344
|
+
const key = await this.key(ctx);
|
|
345
|
+
const installation = await this.installation(t.owner, ctx, key);
|
|
346
|
+
const repo = await this.repository(t.owner, t.repo, installation, key, ctx);
|
|
347
|
+
this.authorizeId(repo, {});
|
|
348
|
+
const issued = [...this.tokens.values()].find((entry) => entry.value === token);
|
|
349
|
+
if (!Number.isSafeInteger(id) ||
|
|
350
|
+
id !== repo.id ||
|
|
351
|
+
!issued ||
|
|
352
|
+
issued.installation !== installation.id ||
|
|
353
|
+
(issued.repositoryIds && !issued.repositoryIds.includes(repo.id))) {
|
|
354
|
+
throw new ConnectorCallError("connector_call_failed", "GitHub returned a repository outside the bound scope; results were withheld.");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
async repo(t, permissions, ctx) {
|
|
358
|
+
this.policy.scope(t, Object.values(permissions).includes("write"), permissions.workflows === "write");
|
|
359
|
+
return this.token(t.owner, [t.repo], permissions, ctx);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { ConnectorCallError } from "../../errors.js";
|
|
2
|
+
import { target, workflowPath } from "./scope.js";
|
|
3
|
+
// Exact reviewed ownership map. New upstream names, arguments and methods
|
|
4
|
+
// require review before they can authenticate or dispatch.
|
|
5
|
+
export const HOSTED_ROUTES = {
|
|
6
|
+
get_file_contents: {
|
|
7
|
+
verdict: "read",
|
|
8
|
+
permissions: { "contents": "read" },
|
|
9
|
+
keys: ["fields", "owner", "path", "ref", "repo", "sha"],
|
|
10
|
+
},
|
|
11
|
+
get_commit: {
|
|
12
|
+
verdict: "read",
|
|
13
|
+
permissions: { "contents": "read" },
|
|
14
|
+
keys: ["detail", "owner", "page", "perPage", "repo", "sha"],
|
|
15
|
+
},
|
|
16
|
+
list_commits: {
|
|
17
|
+
verdict: "read",
|
|
18
|
+
permissions: { "contents": "read" },
|
|
19
|
+
keys: ["author", "fields", "owner", "page", "path", "perPage", "repo", "sha", "since", "until"],
|
|
20
|
+
},
|
|
21
|
+
list_branches: { verdict: "read", permissions: { "contents": "read" }, keys: ["owner", "page", "perPage", "repo"] },
|
|
22
|
+
get_tag: { verdict: "read", permissions: { "contents": "read" }, keys: ["owner", "repo", "tag"] },
|
|
23
|
+
list_tags: { verdict: "read", permissions: { "contents": "read" }, keys: ["owner", "page", "perPage", "repo"] },
|
|
24
|
+
get_latest_release: { verdict: "read", permissions: { "contents": "read" }, keys: ["owner", "repo"] },
|
|
25
|
+
get_release_by_tag: { verdict: "read", permissions: { "contents": "read" }, keys: ["owner", "repo", "tag"] },
|
|
26
|
+
list_releases: {
|
|
27
|
+
verdict: "read",
|
|
28
|
+
permissions: { "contents": "read" },
|
|
29
|
+
keys: ["fields", "owner", "page", "perPage", "repo"],
|
|
30
|
+
},
|
|
31
|
+
list_issues: {
|
|
32
|
+
verdict: "read",
|
|
33
|
+
permissions: { "issues": "read", "pull_requests": "read" },
|
|
34
|
+
keys: [
|
|
35
|
+
"after",
|
|
36
|
+
"direction",
|
|
37
|
+
"field_filters",
|
|
38
|
+
"fields",
|
|
39
|
+
"labels",
|
|
40
|
+
"orderBy",
|
|
41
|
+
"owner",
|
|
42
|
+
"perPage",
|
|
43
|
+
"repo",
|
|
44
|
+
"since",
|
|
45
|
+
"state",
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
issue_read: {
|
|
49
|
+
verdict: "read",
|
|
50
|
+
permissions: { "issues": "read" },
|
|
51
|
+
keys: ["issue_number", "method", "owner", "page", "perPage", "repo"],
|
|
52
|
+
methods: ["get", "get_comments", "get_labels"],
|
|
53
|
+
},
|
|
54
|
+
get_label: { verdict: "read", permissions: { "issues": "read" }, keys: ["name", "owner", "repo"] },
|
|
55
|
+
list_pull_requests: {
|
|
56
|
+
verdict: "read",
|
|
57
|
+
permissions: { "pull_requests": "read" },
|
|
58
|
+
keys: ["base", "direction", "fields", "head", "owner", "page", "perPage", "repo", "sort", "state"],
|
|
59
|
+
},
|
|
60
|
+
pull_request_read: {
|
|
61
|
+
verdict: "read",
|
|
62
|
+
permissions: { "pull_requests": "read", "contents": "read", "checks": "read", "statuses": "read" },
|
|
63
|
+
keys: ["after", "method", "owner", "page", "perPage", "pullNumber", "repo"],
|
|
64
|
+
methods: [
|
|
65
|
+
"get",
|
|
66
|
+
"get_diff",
|
|
67
|
+
"get_status",
|
|
68
|
+
"get_files",
|
|
69
|
+
"get_commits",
|
|
70
|
+
"get_review_comments",
|
|
71
|
+
"get_reviews",
|
|
72
|
+
"get_comments",
|
|
73
|
+
"get_check_runs",
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
actions_list: {
|
|
77
|
+
verdict: "read",
|
|
78
|
+
permissions: { "actions": "read" },
|
|
79
|
+
keys: ["method", "owner", "page", "perPage", "repo", "resource_id", "workflow_jobs_filter", "workflow_runs_filter"],
|
|
80
|
+
methods: ["list_workflows", "list_workflow_runs", "list_workflow_jobs", "list_workflow_run_artifacts"],
|
|
81
|
+
},
|
|
82
|
+
actions_get: {
|
|
83
|
+
verdict: "read",
|
|
84
|
+
permissions: { "actions": "read" },
|
|
85
|
+
keys: ["method", "owner", "repo", "resource_id"],
|
|
86
|
+
methods: ["get_workflow", "get_workflow_run", "get_workflow_run_usage", "get_workflow_job"],
|
|
87
|
+
},
|
|
88
|
+
issue_write: {
|
|
89
|
+
verdict: "destructive",
|
|
90
|
+
permissions: { "issues": "write", "pull_requests": "write" },
|
|
91
|
+
keys: [
|
|
92
|
+
"assignees",
|
|
93
|
+
"body",
|
|
94
|
+
"duplicate_of",
|
|
95
|
+
"issue_fields",
|
|
96
|
+
"issue_number",
|
|
97
|
+
"labels",
|
|
98
|
+
"method",
|
|
99
|
+
"milestone",
|
|
100
|
+
"owner",
|
|
101
|
+
"parent_issue_number",
|
|
102
|
+
"parent_owner",
|
|
103
|
+
"parent_repo",
|
|
104
|
+
"repo",
|
|
105
|
+
"state",
|
|
106
|
+
"state_reason",
|
|
107
|
+
"title",
|
|
108
|
+
"type",
|
|
109
|
+
],
|
|
110
|
+
methods: ["create", "update"],
|
|
111
|
+
},
|
|
112
|
+
add_issue_comment: {
|
|
113
|
+
verdict: "write",
|
|
114
|
+
permissions: { "issues": "write", "pull_requests": "write" },
|
|
115
|
+
keys: ["body", "comment_id", "issue_number", "owner", "reaction", "repo"],
|
|
116
|
+
},
|
|
117
|
+
create_pull_request: {
|
|
118
|
+
verdict: "write",
|
|
119
|
+
permissions: { "pull_requests": "write" },
|
|
120
|
+
keys: ["base", "body", "draft", "head", "maintainer_can_modify", "owner", "repo", "reviewers", "title"],
|
|
121
|
+
},
|
|
122
|
+
update_pull_request: {
|
|
123
|
+
verdict: "destructive",
|
|
124
|
+
permissions: { "pull_requests": "write" },
|
|
125
|
+
keys: [
|
|
126
|
+
"base",
|
|
127
|
+
"body",
|
|
128
|
+
"draft",
|
|
129
|
+
"maintainer_can_modify",
|
|
130
|
+
"owner",
|
|
131
|
+
"pullNumber",
|
|
132
|
+
"repo",
|
|
133
|
+
"reviewers",
|
|
134
|
+
"state",
|
|
135
|
+
"title",
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
pull_request_review_write: {
|
|
139
|
+
verdict: "destructive",
|
|
140
|
+
permissions: { "pull_requests": "write" },
|
|
141
|
+
keys: ["body", "commitID", "event", "method", "owner", "pullNumber", "repo", "threadId"],
|
|
142
|
+
methods: ["create", "submit_pending", "delete_pending"],
|
|
143
|
+
},
|
|
144
|
+
create_branch: {
|
|
145
|
+
verdict: "write",
|
|
146
|
+
permissions: { "contents": "write" },
|
|
147
|
+
keys: ["branch", "from_branch", "owner", "repo"],
|
|
148
|
+
},
|
|
149
|
+
create_or_update_file: {
|
|
150
|
+
verdict: "destructive",
|
|
151
|
+
permissions: { "contents": "write" },
|
|
152
|
+
keys: ["allow_symlink_write", "branch", "content", "message", "owner", "path", "repo", "sha"],
|
|
153
|
+
},
|
|
154
|
+
delete_file: {
|
|
155
|
+
verdict: "destructive",
|
|
156
|
+
permissions: { "contents": "write" },
|
|
157
|
+
keys: ["branch", "message", "owner", "path", "repo"],
|
|
158
|
+
},
|
|
159
|
+
push_files: {
|
|
160
|
+
verdict: "destructive",
|
|
161
|
+
permissions: { "contents": "write" },
|
|
162
|
+
keys: ["branch", "files", "message", "owner", "repo"],
|
|
163
|
+
},
|
|
164
|
+
actions_run_trigger: {
|
|
165
|
+
verdict: "destructive",
|
|
166
|
+
permissions: { "actions": "write" },
|
|
167
|
+
keys: ["inputs", "method", "owner", "ref", "repo", "run_id", "workflow_id"],
|
|
168
|
+
methods: ["run_workflow", "rerun_workflow_run", "rerun_failed_jobs", "cancel_workflow_run"],
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
export const HOSTED_CLASSIFICATION = {
|
|
172
|
+
unlisted: "hide",
|
|
173
|
+
tools: Object.fromEntries(Object.entries(HOSTED_ROUTES).map(([name, route]) => [name, route.verdict])),
|
|
174
|
+
};
|
|
175
|
+
export function hostedTarget(name, input, policy) {
|
|
176
|
+
const route = Object.hasOwn(HOSTED_ROUTES, name) ? HOSTED_ROUTES[name] : undefined;
|
|
177
|
+
if (!route)
|
|
178
|
+
throw new ConnectorCallError("invalid_args", "This GitHub tool is not in the reviewed allowlist.");
|
|
179
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
180
|
+
throw new ConnectorCallError("invalid_args", "Supply GitHub arguments as an object.");
|
|
181
|
+
const args = input;
|
|
182
|
+
const t = target(args.owner, args.repo);
|
|
183
|
+
policy.scope(t, route.verdict !== "read");
|
|
184
|
+
if (Object.keys(args).some((key) => !route.keys.includes(key)))
|
|
185
|
+
throw new ConnectorCallError("invalid_args", "Unreviewed GitHub arguments are refused; use the reviewed tool schema.");
|
|
186
|
+
if (route.methods && !route.methods.includes(String(args.method)))
|
|
187
|
+
throw new ConnectorCallError("invalid_args", "This GitHub method is not in the reviewed allowlist.");
|
|
188
|
+
// Secondary repositories and opaque review-thread IDs cannot choose a grant.
|
|
189
|
+
if (args.parent_owner !== undefined ||
|
|
190
|
+
args.parent_repo !== undefined ||
|
|
191
|
+
args.threadId !== undefined ||
|
|
192
|
+
args.comment_id !== undefined) {
|
|
193
|
+
throw new ConnectorCallError("invalid_args", "Cross-repository parent references and opaque comment/thread IDs are not supported. Use the scoped repository operation.");
|
|
194
|
+
}
|
|
195
|
+
if (args.allow_symlink_write === true)
|
|
196
|
+
throw new ConnectorCallError("invalid_args", "Symlink writes cannot establish a workflow-file boundary.");
|
|
197
|
+
if (name === "create_pull_request" && (typeof args.head !== "string" || args.head.includes(":")))
|
|
198
|
+
throw new ConnectorCallError("invalid_args", "Create pull requests from a branch in the target repository; cross-owner heads are not supported.");
|
|
199
|
+
let workflows = false;
|
|
200
|
+
if (["create_or_update_file", "delete_file"].includes(name))
|
|
201
|
+
workflows = workflowPath(args.path);
|
|
202
|
+
if (name === "push_files") {
|
|
203
|
+
if (!Array.isArray(args.files) || args.files.length === 0 || args.files.length > 100)
|
|
204
|
+
throw new ConnectorCallError("invalid_args", "Supply between 1 and 100 file changes.");
|
|
205
|
+
for (const file of args.files) {
|
|
206
|
+
if (!file ||
|
|
207
|
+
typeof file !== "object" ||
|
|
208
|
+
Array.isArray(file) ||
|
|
209
|
+
Object.keys(file).some((key) => !["path", "content"].includes(key)))
|
|
210
|
+
throw new ConnectorCallError("invalid_args", "Each file has only path and content fields.");
|
|
211
|
+
workflows = workflowPath(file.path) || workflows;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (name === "get_file_contents" && args.path !== undefined && args.path !== "")
|
|
215
|
+
workflowPath(args.path);
|
|
216
|
+
policy.scope(t, route.verdict !== "read", workflows);
|
|
217
|
+
return { target: t, permissions: { ...route.permissions, ...(workflows ? { workflows: "write" } : {}) } };
|
|
218
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ProviderOptions } from "../../provider.js";
|
|
2
|
+
import { type GitHubApp } from "./auth.js";
|
|
3
|
+
import { type GitHubScope } from "./scope.js";
|
|
4
|
+
export type { GitHubApp } from "./auth.js";
|
|
5
|
+
export type { GitHubScope } from "./scope.js";
|
|
6
|
+
export declare const GITHUB_MCP_ENDPOINT = "https://api.githubcopilot.com/mcp/";
|
|
7
|
+
export interface GitHubOptions extends ProviderOptions {
|
|
8
|
+
app: GitHubApp;
|
|
9
|
+
/** Mixed org and exact repository grants, with explicit access. */
|
|
10
|
+
scopes: readonly GitHubScope[];
|
|
11
|
+
}
|
|
12
|
+
/** One GitHub App, multiple owner installations, explicit org/repo grants. */
|
|
13
|
+
export declare const github: import("../../provider.js").ProviderFactory<GitHubOptions>;
|