Unified · Split
| @@ -0,0 +1,26 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **Stdio MCP integrations can be edited from the UI** | ||
| 6 | |||
| 7 | The integration Edit sheet showed stdio servers as read-only text and told you | ||
| 8 | to remove and recreate the integration to change its command. Fixing a typo in | ||
| 9 | an argument, moving a server to a new path, or adding a static environment | ||
| 10 | variable meant editing `executor.jsonc` by hand, or losing the integration's | ||
| 11 | connections and tool policies to a delete-and-re-add. | ||
| 12 | |||
| 13 | The sheet now edits the command, its arguments, the working directory, and the | ||
| 14 | declared environment map, staged and applied by the sheet's own Save like the | ||
| 15 | remote editor beside it. Arguments use the same quote-aware parsing as the add | ||
| 16 | flow, so an argument containing spaces survives a round trip. | ||
| 17 | |||
| 18 | The environment field edits the DECLARED static variables only. A stdio server | ||
| 19 | receives those plus a small fixed base set — it does not inherit executor's | ||
| 20 | environment — and secret values still belong to the connection, entered per | ||
| 21 | account against the server's declared `stdio_env` method. | ||
| 22 | |||
| 23 | Saving revises the integration config, which is already enough to rebuild the | ||
| 24 | tool catalog: connections whose catalog predates the revision re-list on their | ||
| 25 | next read, so an edited command's tools are correct without an explicit | ||
| 26 | refresh. | ||
| @@ -0,0 +1,7 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **A disabled upstream API now reports `misconfigured` instead of `expired`** | ||
| 6 | |||
| 7 | A 403 caused by the provider disabling the API (Google SERVICE_DISABLED / accessNotConfigured shapes) is a configuration problem, not a credential problem — reconnecting cannot fix it. Health checks now classify it as a fifth status, `misconfigured`, shown with an amber badge and a link to the provider console instead of a Reconnect prompt. Ordinary 401/403 credential rejections still report `expired`. | ||
| @@ -0,0 +1,13 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/sdk": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Share the OAuth refresh gate across execution stacks so a rotating refresh token is redeemed once. | ||
| 6 | |||
| 7 | The in-flight refresh gate was built inside `createExecutor`, so it only covered one execution stack. A host builds a fresh stack per MCP session, and now per request, so two sessions resolving the same connection each read the same stored refresh token and each believed they were the refresh winner. Against a provider that rotates refresh tokens, the loser redeems a token the winner already spent, and a provider that detects reuse revokes the whole token family: the connection dies and the user has to reauthorize. The first refresh always succeeds, so the fault stayed invisible until a later expiry. | ||
| 8 | |||
| 9 | The gate now hangs off the root database handle, which is the object hosts already share across sessions and requests, so every stack over one handle converges on one gate. Its key includes the tenant, because a gate that spans tenants would otherwise let two tenants collide on one entry. | ||
| 10 | |||
| 11 | The grant also runs on its own detached fiber that callers await, rather than on whichever caller registered it. Sharing an entry across stacks would otherwise share the first caller's cancellation: a disconnected MCP client or an execution deadline would fail every peer waiting on that entry, and would abandon a refresh token the authorization server had already rotated, which is itself a dead connection. A cancelled peer now detaches without touching the grant, and a grant nobody is left waiting on still settles and still persists the rotated token. | ||
| 12 | |||
| 13 | Deduplication covers one database handle in one process. A host that builds a fresh handle per request or per session, and any multi-instance or multi-replica deployment, is out of scope here and still needs database-backed coordination, such as a compare-and-swap on the stored refresh token. | ||
| @@ -42,19 +42,14 @@ import { · 41 unmodified lines | |||
| 42 | PluginsProvider, | 42 | PluginsProvider, |
| 43 | collectTables, | 43 | collectTables, |
| 44 | } from "@executor-js/api/server"; | 44 | } from "@executor-js/api/server"; |
| 45 | import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google"; | ||
| 46 | import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth"; | ||
| 47 | import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; | 45 | import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; |
| 48 | import { | 46 | import { type AnyPlugin } from "@executor-js/sdk"; |
| 49 | IntegrationSlug, | ||
| 50 | type AnyPlugin, | ||
| 51 | type FirstPartyOAuthClientConfig, | ||
| 52 | } from "@executor-js/sdk"; | ||
| 53 | 47 | ||
| 54 | import executorConfig from "../../executor.config"; | 48 | import executorConfig from "../../executor.config"; |
| 55 | import { cloudEnterpriseManagedRollout } from "../analytics/ema-rollout"; | 49 | import { cloudEnterpriseManagedRollout } from "../analytics/ema-rollout"; |
| 56 | import { DbService } from "../db/db"; | 50 | import { DbService } from "../db/db"; |
| 57 | import { cloudDbProviderLayer } from "../db/fuma"; | 51 | import { cloudDbProviderLayer } from "../db/fuma"; |
| 52 | import { firstPartyOAuthClientsFor } from "./first-party-oauth-clients"; | ||
| 58 | 53 | ||
| 59 | export { makeExecutionStack } from "@executor-js/api/server"; | 54 | export { makeExecutionStack } from "@executor-js/api/server"; |
| 60 | 55 | ||
| @@ -95,98 +90,6 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 34 unmodified lines | |||
| 95 | */ | 90 | */ |
| 96 | export const CLOUD_MOUNT_PREFIX = "/api" as const; | 91 | export const CLOUD_MOUNT_PREFIX = "/api" as const; |
| 97 | 92 | ||
| 98 | // Consumer Google launch boundary. Keep this list aligned with the scopes | ||
| 99 | // submitted for the Executor-owned production app: ordinary Workspace services | ||
| 100 | // plus Photos, Meet, and Search Console. Admin, Classroom, YouTube, Apps Script, | ||
| 101 | // BigQuery, and Cloud Resource Manager have materially different audiences or | ||
| 102 | // provider requirements and remain BYO OAuth. The same scope source builds each | ||
| 103 | // catalog auth template, preventing picker/start drift. | ||
| 104 | const GOOGLE_FIRST_PARTY_PRESET_IDS = [ | ||
| 105 | "google-calendar", | ||
| 106 | "google-meet", | ||
| 107 | "google-gmail", | ||
| 108 | "google-sheets", | ||
| 109 | "google-drive", | ||
| 110 | "google-docs", | ||
| 111 | "google-slides", | ||
| 112 | "google-forms", | ||
| 113 | "google-tasks", | ||
| 114 | "google-people", | ||
| 115 | "google-photos-library", | ||
| 116 | "google-photos-picker", | ||
| 117 | "google-search-console", | ||
| 118 | ] as const; | ||
| 119 | |||
| 120 | const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [ | ||
| 121 | ...new Set([ | ||
| 122 | ...GOOGLE_FIRST_PARTY_PRESET_IDS.flatMap(googleCatalogOAuthScopesForPreset), | ||
| 123 | // Connections created before the full-Gmail review retain this declared | ||
| 124 | // scope on reconnect. New Gmail presets request `mail.google.com`. | ||
| 125 | "https://www.googleapis.com/auth/gmail.modify", | ||
| 126 | ]), | ||
| 127 | ]; | ||
| 128 | |||
| 129 | // Executor-owned provider apps, enabled per provider by setting BOTH env vars | ||
| 130 | // (id + secret). Each provider-side registration must list | ||
| 131 | // `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug | ||
| 132 | // travels inside OAuth `state`, so the single static callback serves every org. | ||
| 133 | // | ||
| 134 | // The endpoint URLs default to the real provider; the `_AUTHORIZE_URL` / | ||
| 135 | // `_TOKEN_URL` overrides exist so tests and dev instances can point the app at | ||
| 136 | // an emulated provider (`@executor-js/emulate`) and run the complete flow. | ||
| 137 | // Production leaves them unset. | ||
| 138 | export const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] => [ | ||
| 139 | ...(env.FIRST_PARTY_GITHUB_CLIENT_ID && env.FIRST_PARTY_GITHUB_CLIENT_SECRET | ||
| 140 | ? [ | ||
| 141 | { | ||
| 142 | name: "github", | ||
| 143 | authorizationUrl: | ||
| 144 | env.FIRST_PARTY_GITHUB_AUTHORIZE_URL ?? "https://github.com/login/oauth/authorize", | ||
| 145 | tokenUrl: | ||
| 146 | env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token", | ||
| 147 | clientId: env.FIRST_PARTY_GITHUB_CLIENT_ID, | ||
| 148 | clientSecret: env.FIRST_PARTY_GITHUB_CLIENT_SECRET, | ||
| 149 | integrations: [IntegrationSlug.make("github_rest")], | ||
| 150 | // GitHub App user access tokens do not use classic OAuth scopes; | ||
| 151 | // their capabilities come from the app's registered permissions. | ||
| 152 | authorizationScopes: [], | ||
| 153 | }, | ||
| 154 | ] | ||
| 155 | : []), | ||
| 156 | ...(env.FIRST_PARTY_GOOGLE_CLIENT_ID && env.FIRST_PARTY_GOOGLE_CLIENT_SECRET | ||
| 157 | ? [ | ||
| 158 | { | ||
| 159 | name: "google", | ||
| 160 | authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", | ||
| 161 | tokenUrl: "https://oauth2.googleapis.com/token", | ||
| 162 | clientId: env.FIRST_PARTY_GOOGLE_CLIENT_ID, | ||
| 163 | clientSecret: env.FIRST_PARTY_GOOGLE_CLIENT_SECRET, | ||
| 164 | allowedScopes: GOOGLE_FIRST_PARTY_ALLOWED_SCOPES, | ||
| 165 | // Withdrawn from the connect picker: no new connection is offered the | ||
| 166 | // Executor-owned Google app. The entry stays declared on purpose — | ||
| 167 | // every connection already minted against it keeps refreshing and | ||
| 168 | // reconnecting through it. Deleting this block, or unsetting the env | ||
| 169 | // vars, would strand those connections instead. | ||
| 170 | unlisted: true, | ||
| 171 | }, | ||
| 172 | ] | ||
| 173 | : []), | ||
| 174 | ...(env.FIRST_PARTY_SLACK_CLIENT_ID && env.FIRST_PARTY_SLACK_CLIENT_SECRET | ||
| 175 | ? [ | ||
| 176 | { | ||
| 177 | name: "slack", | ||
| 178 | authorizationUrl: "https://slack.com/oauth/v2_user/authorize", | ||
| 179 | tokenUrl: "https://slack.com/api/oauth.v2.user.access", | ||
| 180 | resource: "https://mcp.slack.com", | ||
| 181 | clientId: env.FIRST_PARTY_SLACK_CLIENT_ID, | ||
| 182 | clientSecret: env.FIRST_PARTY_SLACK_CLIENT_SECRET, | ||
| 183 | integrations: [IntegrationSlug.make("slack")], | ||
| 184 | allowedScopes: slackMcpUserScopes, | ||
| 185 | }, | ||
| 186 | ] | ||
| 187 | : []), | ||
| 188 | ]; | ||
| 189 | |||
| 190 | export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({ | 93 | export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({ |
| 191 | // SSRF / private-network egress guard. Config-driven, NOT a test flag: | 94 | // SSRF / private-network egress guard. Config-driven, NOT a test flag: |
| 192 | // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`); | 95 | // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`); |
| @@ -198,7 +101,7 @@ export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, ( · 5 unmodified lines | |||
| 198 | // WorkOS Vault is cloud's credential storage implementation detail, not a | 101 | // WorkOS Vault is cloud's credential storage implementation detail, not a |
| 199 | // user-selectable provider surface. | 102 | // user-selectable provider surface. |
| 200 | exposeCredentialProviders: false, | 103 | exposeCredentialProviders: false, |
| 201 | firstPartyOAuthClients: cloudFirstPartyOAuthClients(), | 104 | firstPartyOAuthClients: firstPartyOAuthClientsFor(env), |
| 202 | // Workers cancel request-scoped I/O once the response settles; the ambient | 105 | // Workers cancel request-scoped I/O once the response settles; the ambient |
| 203 | // `waitUntil` binds to the in-flight invocation (HTTP request or DO call), | 106 | // `waitUntil` binds to the in-flight invocation (HTTP request or DO call), |
| 204 | // so stale tool-catalog rebuilds that outlive a read still converge. | 107 | // so stale tool-catalog rebuilds that outlive a read still converge. |
| @@ -1,7 +1,100 @@ | |||
| 1 | import { env } from "cloudflare:workers"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { beforeAll, describe, expect, it } from "@effect/vitest"; | ||
| 3 | 2 | ||
| 4 | import { cloudFirstPartyOAuthClients } from "./execution-stack"; | 3 | import { |
| 4 | firstPartyOAuthClientsFor, | ||
| 5 | type FirstPartyOAuthClientEnv, | ||
| 6 | } from "./first-party-oauth-clients"; | ||
| 7 | |||
| 8 | const completeEnv: FirstPartyOAuthClientEnv = { | ||
| 9 | FIRST_PARTY_AIRTABLE_CLIENT_ID: "airtable-id", | ||
| 10 | FIRST_PARTY_AIRTABLE_CLIENT_SECRET: "airtable-secret", | ||
| 11 | FIRST_PARTY_ATLASSIAN_CLIENT_ID: "atlassian-id", | ||
| 12 | FIRST_PARTY_ATLASSIAN_CLIENT_SECRET: "atlassian-secret", | ||
| 13 | FIRST_PARTY_BOX_CLIENT_ID: "box-id", | ||
| 14 | FIRST_PARTY_BOX_CLIENT_SECRET: "box-secret", | ||
| 15 | FIRST_PARTY_CLICKUP_CLIENT_ID: "clickup-id", | ||
| 16 | FIRST_PARTY_CLICKUP_CLIENT_SECRET: "clickup-secret", | ||
| 17 | FIRST_PARTY_FIGMA_CLIENT_ID: "figma-id", | ||
| 18 | FIRST_PARTY_FIGMA_CLIENT_SECRET: "figma-secret", | ||
| 19 | FIRST_PARTY_GITHUB_CLIENT_ID: "github-id", | ||
| 20 | FIRST_PARTY_GITHUB_CLIENT_SECRET: "github-secret", | ||
| 21 | FIRST_PARTY_GITLAB_CLIENT_ID: "gitlab-id", | ||
| 22 | FIRST_PARTY_GITLAB_CLIENT_SECRET: "gitlab-secret", | ||
| 23 | FIRST_PARTY_GOOGLE_CLIENT_ID: "google-id", | ||
| 24 | FIRST_PARTY_GOOGLE_CLIENT_SECRET: "google-secret", | ||
| 25 | FIRST_PARTY_HUBSPOT_CLIENT_ID: "hubspot-id", | ||
| 26 | FIRST_PARTY_HUBSPOT_CLIENT_SECRET: "hubspot-secret", | ||
| 27 | FIRST_PARTY_LINEAR_CLIENT_ID: "linear-id", | ||
| 28 | FIRST_PARTY_LINEAR_CLIENT_SECRET: "linear-secret", | ||
| 29 | FIRST_PARTY_MICROSOFT_CLIENT_ID: "microsoft-id", | ||
| 30 | FIRST_PARTY_MICROSOFT_CLIENT_SECRET: "microsoft-secret", | ||
| 31 | FIRST_PARTY_NOTION_CLIENT_ID: "notion-id", | ||
| 32 | FIRST_PARTY_NOTION_CLIENT_SECRET: "notion-secret", | ||
| 33 | FIRST_PARTY_SLACK_CLIENT_ID: "slack-id", | ||
| 34 | FIRST_PARTY_SLACK_CLIENT_SECRET: "slack-secret", | ||
| 35 | }; | ||
| 36 | |||
| 37 | describe("cloud first-party OAuth clients", () => { | ||
| 38 | it("enables every registered OAuth 2 provider from complete secret pairs", () => { | ||
| 39 | const clients = firstPartyOAuthClientsFor(completeEnv); | ||
| 40 | |||
| 41 | expect(clients.map((client) => client.name)).toEqual([ | ||
| 42 | "airtable", | ||
| 43 | "atlassian", | ||
| 44 | "box", | ||
| 45 | "clickup", | ||
| 46 | "figma", | ||
| 47 | "github", | ||
| 48 | "gitlab", | ||
| 49 | "google", | ||
| 50 | "hubspot", | ||
| 51 | "linear", | ||
| 52 | "microsoft", | ||
| 53 | "notion", | ||
| 54 | "slack", | ||
| 55 | ]); | ||
| 56 | }); | ||
| 57 | |||
| 58 | it("fails closed when either half of a provider secret pair is absent", () => { | ||
| 59 | expect(firstPartyOAuthClientsFor({ FIRST_PARTY_AIRTABLE_CLIENT_ID: "id" })).toEqual([]); | ||
| 60 | expect(firstPartyOAuthClientsFor({ FIRST_PARTY_AIRTABLE_CLIENT_SECRET: "secret" })).toEqual([]); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it("carries provider-specific authorization and token contracts", () => { | ||
| 64 | const byName = new Map( | ||
| 65 | firstPartyOAuthClientsFor(completeEnv).map((client) => [client.name, client]), | ||
| 66 | ); | ||
| 67 | |||
| 68 | expect(byName.get("airtable")).toMatchObject({ | ||
| 69 | tokenEndpointAuthMethod: "basic", | ||
| 70 | }); | ||
| 71 | expect(byName.get("atlassian")).toMatchObject({ | ||
| 72 | tokenRequestFormat: "json", | ||
| 73 | authorizationExtraParams: { audience: "api.atlassian.com", prompt: "consent" }, | ||
| 74 | }); | ||
| 75 | expect(byName.get("figma")).toMatchObject({ | ||
| 76 | tokenEndpointAuthMethod: "basic", | ||
| 77 | allowedScopes: expect.arrayContaining(["folder_metadata:read", "folders:read"]), | ||
| 78 | }); | ||
| 79 | expect(byName.get("hubspot")).toMatchObject({ | ||
| 80 | tokenUrl: "https://api.hubapi.com/oauth/v3/token", | ||
| 81 | authorizationExtraParams: { | ||
| 82 | optional_scope: "content crm.objects.custom.read crm.schemas.custom.read", | ||
| 83 | }, | ||
| 84 | }); | ||
| 85 | expect(byName.get("linear")).toMatchObject({ authorizationScopeSeparator: "," }); | ||
| 86 | expect(byName.get("microsoft")).toMatchObject({ | ||
| 87 | additionalAuthorizationScopes: ["offline_access"], | ||
| 88 | allowedScopes: expect.arrayContaining(["Mail.ReadWrite", "Files.ReadWrite.All"]), | ||
| 89 | }); | ||
| 90 | expect(byName.get("notion")).toMatchObject({ | ||
| 91 | authorizationScopes: [], | ||
| 92 | authorizationExtraParams: { owner: "user" }, | ||
| 93 | tokenEndpointAuthMethod: "basic", | ||
| 94 | tokenRequestFormat: "json", | ||
| 95 | }); | ||
| 96 | }); | ||
| 97 | }); | ||
| 5 | 98 | ||
| 6 | // The reviewed consumer scope boundary of the Executor-owned Google app. | 99 | // The reviewed consumer scope boundary of the Executor-owned Google app. |
| 7 | // | 100 | // |
| @@ -12,13 +105,9 @@ import { cloudFirstPartyOAuthClients } from "./execution-stack"; · 4 unmodified lines | |||
| 12 | // scopes an `oauth.start` requests, and that admin scopes are refused). | 105 | // scopes an `oauth.start` requests, and that admin scopes are refused). |
| 13 | const GOOGLE_SCOPE = (suffix: string) => `https://www.googleapis.com/auth/${suffix}`; | 106 | const GOOGLE_SCOPE = (suffix: string) => `https://www.googleapis.com/auth/${suffix}`; |
| 14 | 107 | ||
| 15 | describe("cloud first-party oauth clients", () => { | 108 | describe("cloud first-party Google app", () => { |
| 16 | beforeAll(() => { | 109 | const google = () => |
| 17 | env.FIRST_PARTY_GOOGLE_CLIENT_ID = "test-google-client"; | 110 | firstPartyOAuthClientsFor(completeEnv).find((client) => client.name === "google"); |
| 18 | env.FIRST_PARTY_GOOGLE_CLIENT_SECRET = "test-google-secret"; | ||
| 19 | }); | ||
| 20 | |||
| 21 | const google = () => cloudFirstPartyOAuthClients().find((client) => client.name === "google"); | ||
| 22 | 111 | ||
| 23 | it("declares the Google app but withholds it from every listing", () => { | 112 | it("declares the Google app but withholds it from every listing", () => { |
| 24 | const client = google(); | 113 | const client = google(); |
| @@ -0,0 +1,336 @@ | |||
| 1 | import { FIGMA_SUPPORTED_OAUTH_SCOPES } from "@executor-js/plugin-openapi/presets"; | ||
| 2 | import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google"; | ||
| 3 | import { | ||
| 4 | MICROSOFT_AUTHORIZATION_URL, | ||
| 5 | MICROSOFT_TOKEN_URL, | ||
| 6 | } from "@executor-js/plugin-openapi/providers/microsoft"; | ||
| 7 | import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth"; | ||
| 8 | import { IntegrationSlug, type FirstPartyOAuthClientConfig } from "@executor-js/sdk"; | ||
| 9 | |||
| 10 | /** Cloud secret bindings that enable host-operated OAuth clients. A provider | ||
| 11 | * is absent unless both values in its pair are present. */ | ||
| 12 | export interface FirstPartyOAuthClientEnv { | ||
| 13 | readonly FIRST_PARTY_AIRTABLE_CLIENT_ID?: string; | ||
| 14 | readonly FIRST_PARTY_AIRTABLE_CLIENT_SECRET?: string; | ||
| 15 | readonly FIRST_PARTY_ATLASSIAN_CLIENT_ID?: string; | ||
| 16 | readonly FIRST_PARTY_ATLASSIAN_CLIENT_SECRET?: string; | ||
| 17 | readonly FIRST_PARTY_BOX_CLIENT_ID?: string; | ||
| 18 | readonly FIRST_PARTY_BOX_CLIENT_SECRET?: string; | ||
| 19 | readonly FIRST_PARTY_CLICKUP_CLIENT_ID?: string; | ||
| 20 | readonly FIRST_PARTY_CLICKUP_CLIENT_SECRET?: string; | ||
| 21 | readonly FIRST_PARTY_FIGMA_CLIENT_ID?: string; | ||
| 22 | readonly FIRST_PARTY_FIGMA_CLIENT_SECRET?: string; | ||
| 23 | readonly FIRST_PARTY_GITHUB_CLIENT_ID?: string; | ||
| 24 | readonly FIRST_PARTY_GITHUB_CLIENT_SECRET?: string; | ||
| 25 | readonly FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string; | ||
| 26 | readonly FIRST_PARTY_GITHUB_TOKEN_URL?: string; | ||
| 27 | readonly FIRST_PARTY_GITLAB_CLIENT_ID?: string; | ||
| 28 | readonly FIRST_PARTY_GITLAB_CLIENT_SECRET?: string; | ||
| 29 | readonly FIRST_PARTY_GOOGLE_CLIENT_ID?: string; | ||
| 30 | readonly FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string; | ||
| 31 | readonly FIRST_PARTY_HUBSPOT_CLIENT_ID?: string; | ||
| 32 | readonly FIRST_PARTY_HUBSPOT_CLIENT_SECRET?: string; | ||
| 33 | readonly FIRST_PARTY_LINEAR_CLIENT_ID?: string; | ||
| 34 | readonly FIRST_PARTY_LINEAR_CLIENT_SECRET?: string; | ||
| 35 | readonly FIRST_PARTY_MICROSOFT_CLIENT_ID?: string; | ||
| 36 | readonly FIRST_PARTY_MICROSOFT_CLIENT_SECRET?: string; | ||
| 37 | readonly FIRST_PARTY_NOTION_CLIENT_ID?: string; | ||
| 38 | readonly FIRST_PARTY_NOTION_CLIENT_SECRET?: string; | ||
| 39 | readonly FIRST_PARTY_SLACK_CLIENT_ID?: string; | ||
| 40 | readonly FIRST_PARTY_SLACK_CLIENT_SECRET?: string; | ||
| 41 | } | ||
| 42 | |||
| 43 | const AIRTABLE_SCOPES = [ | ||
| 44 | "data.recordComments:read", | ||
| 45 | "data.recordComments:write", | ||
| 46 | "data.records:read", | ||
| 47 | "data.records:write", | ||
| 48 | "data.records:manage", | ||
| 49 | "schema.bases:read", | ||
| 50 | "schema.bases:write", | ||
| 51 | "user.email:read", | ||
| 52 | "workspacesAndBases:read", | ||
| 53 | "workspacesAndBases:write", | ||
| 54 | "workspacesAndBases.shares:manage", | ||
| 55 | "webhook:manage", | ||
| 56 | ] as const; | ||
| 57 | |||
| 58 | const ATLASSIAN_SCOPES = [ | ||
| 59 | "read:me", | ||
| 60 | "read:account", | ||
| 61 | "read:jira-work", | ||
| 62 | "manage:jira-project", | ||
| 63 | "manage:jira-configuration", | ||
| 64 | "read:jira-user", | ||
| 65 | "write:jira-work", | ||
| 66 | "manage:jira-webhook", | ||
| 67 | "read:servicedesk-request", | ||
| 68 | "manage:servicedesk-customer", | ||
| 69 | "write:servicedesk-request", | ||
| 70 | "write:confluence-content", | ||
| 71 | "read:confluence-space.summary", | ||
| 72 | "write:confluence-space", | ||
| 73 | "write:confluence-file", | ||
| 74 | "read:confluence-props", | ||
| 75 | "write:confluence-props", | ||
| 76 | "manage:confluence-configuration", | ||
| 77 | "read:confluence-content.all", | ||
| 78 | "read:confluence-content.summary", | ||
| 79 | "search:confluence", | ||
| 80 | "read:confluence-content.permission", | ||
| 81 | "read:confluence-user", | ||
| 82 | "read:confluence-groups", | ||
| 83 | "write:confluence-groups", | ||
| 84 | "offline_access", | ||
| 85 | ] as const; | ||
| 86 | |||
| 87 | const BOX_SCOPES = [ | ||
| 88 | "root_readonly", | ||
| 89 | "root_readwrite", | ||
| 90 | "sign_requests.readwrite", | ||
| 91 | "ai.readwrite", | ||
| 92 | "manage_webhook", | ||
| 93 | "manage_triggers", | ||
| 94 | ] as const; | ||
| 95 | |||
| 96 | const GITLAB_SCOPES = [ | ||
| 97 | "api", | ||
| 98 | "read_api", | ||
| 99 | "read_user", | ||
| 100 | "create_runner", | ||
| 101 | "manage_runner", | ||
| 102 | "k8s_proxy", | ||
| 103 | "mcp", | ||
| 104 | "mcp_orbit", | ||
| 105 | "read_repository", | ||
| 106 | "write_repository", | ||
| 107 | "read_registry", | ||
| 108 | "write_registry", | ||
| 109 | "read_virtual_registry", | ||
| 110 | "write_virtual_registry", | ||
| 111 | "read_observability", | ||
| 112 | "write_observability", | ||
| 113 | "ai_features", | ||
| 114 | "openid", | ||
| 115 | "profile", | ||
| 116 | "email", | ||
| 117 | ] as const; | ||
| 118 | |||
| 119 | const HUBSPOT_REQUIRED_SCOPES = [ | ||
| 120 | "oauth", | ||
| 121 | "account-info.security.read", | ||
| 122 | "cms.domains.read", | ||
| 123 | "cms.domains.write", | ||
| 124 | "crm.export", | ||
| 125 | "crm.import", | ||
| 126 | "crm.lists.read", | ||
| 127 | "crm.lists.write", | ||
| 128 | "crm.objects.companies.read", | ||
| 129 | "crm.objects.companies.write", | ||
| 130 | "crm.objects.contacts.read", | ||
| 131 | "crm.objects.contacts.write", | ||
| 132 | "crm.objects.deals.read", | ||
| 133 | "crm.objects.deals.write", | ||
| 134 | "crm.objects.marketing_events.read", | ||
| 135 | "crm.objects.marketing_events.write", | ||
| 136 | "crm.objects.owners.read", | ||
| 137 | "crm.objects.quotes.read", | ||
| 138 | "crm.objects.quotes.write", | ||
| 139 | "crm.schemas.companies.read", | ||
| 140 | "crm.schemas.companies.write", | ||
| 141 | "crm.schemas.contacts.read", | ||
| 142 | "crm.schemas.contacts.write", | ||
| 143 | "sales-email-read", | ||
| 144 | "settings.users.read", | ||
| 145 | "settings.users.write", | ||
| 146 | "tickets", | ||
| 147 | "timeline", | ||
| 148 | ] as const; | ||
| 149 | |||
| 150 | const HUBSPOT_OPTIONAL_SCOPES = [ | ||
| 151 | "content", | ||
| 152 | "crm.objects.custom.read", | ||
| 153 | "crm.schemas.custom.read", | ||
| 154 | ] as const; | ||
| 155 | |||
| 156 | const MICROSOFT_SCOPES = [ | ||
| 157 | "User.Read", | ||
| 158 | "Calendars.ReadWrite", | ||
| 159 | "Channel.ReadBasic.All", | ||
| 160 | "ChannelMessage.Read.All", | ||
| 161 | "ChannelMessage.Send", | ||
| 162 | "Chat.ReadWrite", | ||
| 163 | "Files.ReadWrite.All", | ||
| 164 | "Mail.ReadWrite", | ||
| 165 | "Mail.Send", | ||
| 166 | "MailboxSettings.ReadWrite", | ||
| 167 | "OnlineMeetings.ReadWrite", | ||
| 168 | "Sites.ReadWrite.All", | ||
| 169 | "Team.ReadBasic.All", | ||
| 170 | "offline_access", | ||
| 171 | ] as const; | ||
| 172 | |||
| 173 | // Consumer Google launch boundary. Keep this list aligned with the scopes | ||
| 174 | // submitted for the Executor-owned production app: ordinary Workspace services | ||
| 175 | // plus Photos, Meet, and Search Console. Admin, Classroom, YouTube, Apps Script, | ||
| 176 | // BigQuery, and Cloud Resource Manager have materially different audiences or | ||
| 177 | // provider requirements and remain BYO OAuth. The same scope source builds each | ||
| 178 | // catalog auth template, preventing picker/start drift. | ||
| 179 | const GOOGLE_FIRST_PARTY_PRESET_IDS = [ | ||
| 180 | "google-calendar", | ||
| 181 | "google-meet", | ||
| 182 | "google-gmail", | ||
| 183 | "google-sheets", | ||
| 184 | "google-drive", | ||
| 185 | "google-docs", | ||
| 186 | "google-slides", | ||
| 187 | "google-forms", | ||
| 188 | "google-tasks", | ||
| 189 | "google-people", | ||
| 190 | "google-photos-library", | ||
| 191 | "google-photos-picker", | ||
| 192 | "google-search-console", | ||
| 193 | ] as const; | ||
| 194 | |||
| 195 | const GOOGLE_ALLOWED_SCOPES: readonly string[] = [ | ||
| 196 | ...new Set([ | ||
| 197 | ...GOOGLE_FIRST_PARTY_PRESET_IDS.flatMap(googleCatalogOAuthScopesForPreset), | ||
| 198 | // Connections created before the full-Gmail review retain this declared | ||
| 199 | // scope on reconnect. New Gmail presets request `mail.google.com`. | ||
| 200 | "https://www.googleapis.com/auth/gmail.modify", | ||
| 201 | ]), | ||
| 202 | ]; | ||
| 203 | |||
| 204 | const client = ( | ||
| 205 | clientId: string | undefined, | ||
| 206 | clientSecret: string | undefined, | ||
| 207 | config: Omit<FirstPartyOAuthClientConfig, "clientId" | "clientSecret">, | ||
| 208 | ): readonly FirstPartyOAuthClientConfig[] => | ||
| 209 | clientId && clientSecret ? [{ ...config, clientId, clientSecret }] : []; | ||
| 210 | |||
| 211 | /** Build the enabled first-party registry from secret bindings. Provider | ||
| 212 | * protocol details and scope ceilings live here so the cloud composition root | ||
| 213 | * cannot drift from the registered production clients. | ||
| 214 | * | ||
| 215 | * Each provider-side registration must list | ||
| 216 | * `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug | ||
| 217 | * travels inside OAuth `state`, so the single static callback serves every | ||
| 218 | * org. | ||
| 219 | * | ||
| 220 | * The endpoint URLs default to the real provider; the `_AUTHORIZE_URL` / | ||
| 221 | * `_TOKEN_URL` overrides exist so tests and dev instances can point the app at | ||
| 222 | * an emulated provider (`@executor-js/emulate`) and run the complete flow. | ||
| 223 | * Production leaves them unset. */ | ||
| 224 | export const firstPartyOAuthClientsFor = ( | ||
| 225 | env: FirstPartyOAuthClientEnv, | ||
| 226 | ): readonly FirstPartyOAuthClientConfig[] => [ | ||
| 227 | ...client(env.FIRST_PARTY_AIRTABLE_CLIENT_ID, env.FIRST_PARTY_AIRTABLE_CLIENT_SECRET, { | ||
| 228 | name: "airtable", | ||
| 229 | authorizationUrl: "https://airtable.com/oauth2/v1/authorize", | ||
| 230 | tokenUrl: "https://airtable.com/oauth2/v1/token", | ||
| 231 | tokenEndpointAuthMethod: "basic", | ||
| 232 | authorizationScopes: AIRTABLE_SCOPES, | ||
| 233 | allowedScopes: AIRTABLE_SCOPES, | ||
| 234 | }), | ||
| 235 | ...client(env.FIRST_PARTY_ATLASSIAN_CLIENT_ID, env.FIRST_PARTY_ATLASSIAN_CLIENT_SECRET, { | ||
| 236 | name: "atlassian", | ||
| 237 | authorizationUrl: "https://auth.atlassian.com/authorize", | ||
| 238 | tokenUrl: "https://auth.atlassian.com/oauth/token", | ||
| 239 | tokenRequestFormat: "json", | ||
| 240 | authorizationScopes: ATLASSIAN_SCOPES, | ||
| 241 | allowedScopes: ATLASSIAN_SCOPES, | ||
| 242 | authorizationExtraParams: { audience: "api.atlassian.com", prompt: "consent" }, | ||
| 243 | }), | ||
| 244 | ...client(env.FIRST_PARTY_BOX_CLIENT_ID, env.FIRST_PARTY_BOX_CLIENT_SECRET, { | ||
| 245 | name: "box", | ||
| 246 | authorizationUrl: "https://account.box.com/api/oauth2/authorize", | ||
| 247 | tokenUrl: "https://api.box.com/oauth2/token", | ||
| 248 | authorizationScopes: BOX_SCOPES, | ||
| 249 | allowedScopes: BOX_SCOPES, | ||
| 250 | }), | ||
| 251 | ...client(env.FIRST_PARTY_CLICKUP_CLIENT_ID, env.FIRST_PARTY_CLICKUP_CLIENT_SECRET, { | ||
| 252 | name: "clickup", | ||
| 253 | authorizationUrl: "https://app.clickup.com/api", | ||
| 254 | tokenUrl: "https://api.clickup.com/api/v2/oauth/token", | ||
| 255 | tokenRequestFormat: "json", | ||
| 256 | authorizationScopes: [], | ||
| 257 | }), | ||
| 258 | ...client(env.FIRST_PARTY_FIGMA_CLIENT_ID, env.FIRST_PARTY_FIGMA_CLIENT_SECRET, { | ||
| 259 | name: "figma", | ||
| 260 | authorizationUrl: "https://www.figma.com/oauth", | ||
| 261 | tokenUrl: "https://api.figma.com/v1/oauth/token", | ||
| 262 | tokenEndpointAuthMethod: "basic", | ||
| 263 | integrations: [IntegrationSlug.make("figma_api")], | ||
| 264 | authorizationScopes: FIGMA_SUPPORTED_OAUTH_SCOPES, | ||
| 265 | allowedScopes: FIGMA_SUPPORTED_OAUTH_SCOPES, | ||
| 266 | }), | ||
| 267 | ...client(env.FIRST_PARTY_GITHUB_CLIENT_ID, env.FIRST_PARTY_GITHUB_CLIENT_SECRET, { | ||
| 268 | name: "github", | ||
| 269 | authorizationUrl: | ||
| 270 | env.FIRST_PARTY_GITHUB_AUTHORIZE_URL ?? "https://github.com/login/oauth/authorize", | ||
| 271 | tokenUrl: env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token", | ||
| 272 | integrations: [IntegrationSlug.make("github_rest")], | ||
| 273 | // GitHub App user access tokens do not use classic OAuth scopes; their | ||
| 274 | // capabilities come from the app's registered permissions. | ||
| 275 | authorizationScopes: [], | ||
| 276 | }), | ||
| 277 | ...client(env.FIRST_PARTY_GITLAB_CLIENT_ID, env.FIRST_PARTY_GITLAB_CLIENT_SECRET, { | ||
| 278 | name: "gitlab", | ||
| 279 | authorizationUrl: "https://gitlab.com/oauth/authorize", | ||
| 280 | tokenUrl: "https://gitlab.com/oauth/token", | ||
| 281 | authorizationScopes: GITLAB_SCOPES, | ||
| 282 | allowedScopes: GITLAB_SCOPES, | ||
| 283 | }), | ||
| 284 | ...client(env.FIRST_PARTY_GOOGLE_CLIENT_ID, env.FIRST_PARTY_GOOGLE_CLIENT_SECRET, { | ||
| 285 | name: "google", | ||
| 286 | authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", | ||
| 287 | tokenUrl: "https://oauth2.googleapis.com/token", | ||
| 288 | allowedScopes: GOOGLE_ALLOWED_SCOPES, | ||
| 289 | // Withdrawn from the connect picker: no new connection is offered the | ||
| 290 | // Executor-owned Google app. The entry stays declared on purpose — every | ||
| 291 | // connection already minted against it keeps refreshing and reconnecting | ||
| 292 | // through it. Deleting this block, or unsetting the env vars, would strand | ||
| 293 | // those connections instead. | ||
| 294 | unlisted: true, | ||
| 295 | }), | ||
| 296 | ...client(env.FIRST_PARTY_HUBSPOT_CLIENT_ID, env.FIRST_PARTY_HUBSPOT_CLIENT_SECRET, { | ||
| 297 | name: "hubspot", | ||
| 298 | authorizationUrl: "https://app.hubspot.com/oauth/authorize", | ||
| 299 | tokenUrl: "https://api.hubapi.com/oauth/v3/token", | ||
| 300 | authorizationScopes: HUBSPOT_REQUIRED_SCOPES, | ||
| 301 | allowedScopes: [...HUBSPOT_REQUIRED_SCOPES, ...HUBSPOT_OPTIONAL_SCOPES], | ||
| 302 | authorizationExtraParams: { optional_scope: HUBSPOT_OPTIONAL_SCOPES.join(" ") }, | ||
| 303 | }), | ||
| 304 | ...client(env.FIRST_PARTY_LINEAR_CLIENT_ID, env.FIRST_PARTY_LINEAR_CLIENT_SECRET, { | ||
| 305 | name: "linear", | ||
| 306 | authorizationUrl: "https://linear.app/oauth/authorize", | ||
| 307 | tokenUrl: "https://api.linear.app/oauth/token", | ||
| 308 | authorizationScopes: ["read", "write"], | ||
| 309 | authorizationScopeSeparator: ",", | ||
| 310 | allowedScopes: ["read", "write"], | ||
| 311 | }), | ||
| 312 | ...client(env.FIRST_PARTY_MICROSOFT_CLIENT_ID, env.FIRST_PARTY_MICROSOFT_CLIENT_SECRET, { | ||
| 313 | name: "microsoft", | ||
| 314 | authorizationUrl: MICROSOFT_AUTHORIZATION_URL, | ||
| 315 | tokenUrl: MICROSOFT_TOKEN_URL, | ||
| 316 | allowedScopes: MICROSOFT_SCOPES, | ||
| 317 | additionalAuthorizationScopes: ["offline_access"], | ||
| 318 | }), | ||
| 319 | ...client(env.FIRST_PARTY_NOTION_CLIENT_ID, env.FIRST_PARTY_NOTION_CLIENT_SECRET, { | ||
| 320 | name: "notion", | ||
| 321 | authorizationUrl: "https://api.notion.com/v1/oauth/authorize", | ||
| 322 | tokenUrl: "https://api.notion.com/v1/oauth/token", | ||
| 323 | authorizationExtraParams: { owner: "user" }, | ||
| 324 | tokenEndpointAuthMethod: "basic", | ||
| 325 | tokenRequestFormat: "json", | ||
| 326 | authorizationScopes: [], | ||
| 327 | }), | ||
| 328 | ...client(env.FIRST_PARTY_SLACK_CLIENT_ID, env.FIRST_PARTY_SLACK_CLIENT_SECRET, { | ||
| 329 | name: "slack", | ||
| 330 | authorizationUrl: "https://slack.com/oauth/v2_user/authorize", | ||
| 331 | tokenUrl: "https://slack.com/api/oauth.v2.user.access", | ||
| 332 | resource: "https://mcp.slack.com", | ||
| 333 | integrations: [IntegrationSlug.make("slack")], | ||
| 334 | allowedScopes: slackMcpUserScopes, | ||
| 335 | }), | ||
| 336 | ]; | ||
| @@ -72,6 +72,16 @@ declare global { · 71 unmodified lines | |||
| 72 | // unset pair simply ships no first-party app for that provider. The | 72 | // unset pair simply ships no first-party app for that provider. The |
| 73 | // registered callback on the provider side must be | 73 | // registered callback on the provider side must be |
| 74 | // `${VITE_PUBLIC_SITE_URL}/api/oauth/callback`. | 74 | // `${VITE_PUBLIC_SITE_URL}/api/oauth/callback`. |
| 75 | FIRST_PARTY_AIRTABLE_CLIENT_ID?: string; | ||
| 76 | FIRST_PARTY_AIRTABLE_CLIENT_SECRET?: string; | ||
| 77 | FIRST_PARTY_ATLASSIAN_CLIENT_ID?: string; | ||
| 78 | FIRST_PARTY_ATLASSIAN_CLIENT_SECRET?: string; | ||
| 79 | FIRST_PARTY_BOX_CLIENT_ID?: string; | ||
| 80 | FIRST_PARTY_BOX_CLIENT_SECRET?: string; | ||
| 81 | FIRST_PARTY_CLICKUP_CLIENT_ID?: string; | ||
| 82 | FIRST_PARTY_CLICKUP_CLIENT_SECRET?: string; | ||
| 83 | FIRST_PARTY_FIGMA_CLIENT_ID?: string; | ||
| 84 | FIRST_PARTY_FIGMA_CLIENT_SECRET?: string; | ||
| 75 | FIRST_PARTY_GITHUB_CLIENT_ID?: string; | 85 | FIRST_PARTY_GITHUB_CLIENT_ID?: string; |
| 76 | FIRST_PARTY_GITHUB_CLIENT_SECRET?: string; | 86 | FIRST_PARTY_GITHUB_CLIENT_SECRET?: string; |
| 77 | // Endpoint overrides for the GitHub first-party app, so tests/dev can | 87 | // Endpoint overrides for the GitHub first-party app, so tests/dev can |
| @@ -79,8 +89,18 @@ declare global { · 1 unmodified line | |||
| 79 | // production (the real github.com endpoints are the defaults). | 89 | // production (the real github.com endpoints are the defaults). |
| 80 | FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string; | 90 | FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string; |
| 81 | FIRST_PARTY_GITHUB_TOKEN_URL?: string; | 91 | FIRST_PARTY_GITHUB_TOKEN_URL?: string; |
| 92 | FIRST_PARTY_GITLAB_CLIENT_ID?: string; | ||
| 93 | FIRST_PARTY_GITLAB_CLIENT_SECRET?: string; | ||
| 82 | FIRST_PARTY_GOOGLE_CLIENT_ID?: string; | 94 | FIRST_PARTY_GOOGLE_CLIENT_ID?: string; |
| 83 | FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string; | 95 | FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string; |
| 96 | FIRST_PARTY_HUBSPOT_CLIENT_ID?: string; | ||
| 97 | FIRST_PARTY_HUBSPOT_CLIENT_SECRET?: string; | ||
| 98 | FIRST_PARTY_LINEAR_CLIENT_ID?: string; | ||
| 99 | FIRST_PARTY_LINEAR_CLIENT_SECRET?: string; | ||
| 100 | FIRST_PARTY_MICROSOFT_CLIENT_ID?: string; | ||
| 101 | FIRST_PARTY_MICROSOFT_CLIENT_SECRET?: string; | ||
| 102 | FIRST_PARTY_NOTION_CLIENT_ID?: string; | ||
| 103 | FIRST_PARTY_NOTION_CLIENT_SECRET?: string; | ||
| 84 | FIRST_PARTY_SLACK_CLIENT_ID?: string; | 104 | FIRST_PARTY_SLACK_CLIENT_ID?: string; |
| 85 | FIRST_PARTY_SLACK_CLIENT_SECRET?: string; | 105 | FIRST_PARTY_SLACK_CLIENT_SECRET?: string; |
| 86 | 106 | ||
| @@ -1,5 +1,6 @@ | |||
| 1 | import { readFileSync } from "node:fs"; | 1 | import { readFileSync } from "node:fs"; |
| 2 | import { resolve } from "node:path"; | 2 | import { createRequire } from "node:module"; |
| 3 | import { dirname, resolve } from "node:path"; | ||
| 3 | import { Readable } from "node:stream"; | 4 | import { Readable } from "node:stream"; |
| 4 | import { fileURLToPath } from "node:url"; | 5 | import { fileURLToPath } from "node:url"; |
| 5 | import { defineConfig, type Plugin } from "vite"; | 6 | import { defineConfig, type Plugin } from "vite"; |
| @@ -42,6 +43,19 @@ const EXECUTOR_GITHUB_URL = ( · 36 unmodified lines | |||
| 42 | const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); | 43 | const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); |
| 43 | const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url)); | 44 | const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url)); |
| 44 | 45 | ||
| 46 | // js-yaml reaches the browser through @executor-js/plugin-openapi's UI, so it | ||
| 47 | // belongs in the pre-bundle below. Vite resolves `optimizeDeps.include` from | ||
| 48 | // `root`, which here is packages/app, and bun's isolated install keeps js-yaml | ||
| 49 | // in the openapi plugin's own node_modules — out of reach of both a plain | ||
| 50 | // "js-yaml" specifier and the "<pkg> > <dep>" form from that directory. Point | ||
| 51 | // the bare specifier at the package the plugin already resolves, so `root` can | ||
| 52 | // name it. It is the same copy either way, so nothing about resolution changes. | ||
| 53 | const JS_YAML_DIR = dirname( | ||
| 54 | createRequire(fileURLToPath(new URL("../../packages/plugins/openapi/", import.meta.url))).resolve( | ||
| 55 | "js-yaml", | ||
| 56 | ), | ||
| 57 | ); | ||
| 58 | |||
| 45 | const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response => | 59 | const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response => |
| 46 | new Response( | 60 | new Response( |
| 47 | JSON.stringify( | 61 | JSON.stringify( |
| @@ -215,6 +229,25 @@ export default defineConfig({ · 167 unmodified lines | |||
| 215 | outDir: resolve(import.meta.dirname, "dist"), | 229 | outDir: resolve(import.meta.dirname, "dist"), |
| 216 | emptyOutDir: true, | 230 | emptyOutDir: true, |
| 217 | }, | 231 | }, |
| 232 | // Deps vite only discovers once a lazy-loaded React chunk actually renders | ||
| 233 | // (e.g. opening the integration Edit sheet, or the MCP/OpenAPI add-source | ||
| 234 | // flow). Discovering one mid-run forces a re-optimize and a full page | ||
| 235 | // reload, which throws away whatever the person was doing — an open sheet | ||
| 236 | // and its form state included. Pre-bundle them at boot so vite never | ||
| 237 | // discovers them mid-run. Keep in sync with apps/host-selfhost and | ||
| 238 | // apps/cloud. | ||
| 239 | optimizeDeps: { | ||
| 240 | include: [ | ||
| 241 | "effect/Match", | ||
| 242 | "effect/Predicate", | ||
| 243 | "effect/Exit", | ||
| 244 | "effect/Option", | ||
| 245 | "effect/Cause", | ||
| 246 | "effect/Data", | ||
| 247 | "effect/Schema", | ||
| 248 | "js-yaml", | ||
| 249 | ], | ||
| 250 | }, | ||
| 218 | define: { | 251 | define: { |
| 219 | "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION), | 252 | "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION), |
| 220 | // The local app IS the npm-installed CLI, so its update card shows the npm | 253 | // The local app IS the npm-installed CLI, so its update card shows the npm |
| @@ -229,6 +262,7 @@ export default defineConfig({ · 8 unmodified lines | |||
| 229 | }, | 262 | }, |
| 230 | resolve: { | 263 | resolve: { |
| 231 | tsconfigPaths: true, | 264 | tsconfigPaths: true, |
| 265 | alias: { "js-yaml": JS_YAML_DIR }, | ||
| 232 | }, | 266 | }, |
| 233 | server: { | 267 | server: { |
| 234 | port: parseInt(process.env.PORT ?? "5173", 10), | 268 | port: parseInt(process.env.PORT ?? "5173", 10), |
| @@ -0,0 +1,138 @@ | |||
| 1 | // Local-only — the integration Edit sheet for a STDIO MCP server, driven in a | ||
| 2 | // real browser. `local` is the only surface that enables stdio MCP | ||
| 3 | // (`dangerouslyAllowStdioMCP: true`), so it is the only place this sheet can be | ||
| 4 | // exercised end to end. | ||
| 5 | // | ||
| 6 | // The sheet used to say "Stdio MCP integrations cannot be edited. Remove and | ||
| 7 | // recreate the integration with the updated command." (#812) — changing a | ||
| 8 | // command meant editing `executor.jsonc` by hand, or losing the integration's | ||
| 9 | // connections and policies to a delete-and-re-add. | ||
| 10 | // | ||
| 11 | // The assertion is deliberately not "the field accepted my text". The scenario | ||
| 12 | // edits the DECLARED env map through the form and then reads the tool catalog: | ||
| 13 | // the fixture advertises `saw_declared_env` only when that variable is present | ||
| 14 | // in the spawned child's environment, so the tool appearing proves the edited | ||
| 15 | // config travelled the whole way — form, config write, respawn, rediscovery. | ||
| 16 | import { fileURLToPath } from "node:url"; | ||
| 17 | |||
| 18 | import { expect } from "@effect/vitest"; | ||
| 19 | import { Effect } from "effect"; | ||
| 20 | import { HttpApiClient } from "effect/unstable/httpapi"; | ||
| 21 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; | ||
| 22 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 23 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | ||
| 24 | |||
| 25 | import { scenario } from "../src/scenario"; | ||
| 26 | import { Browser, Cli, RunDir, Target } from "../src/services"; | ||
| 27 | import { withLocalServer } from "./local-server"; | ||
| 28 | |||
| 29 | const api = composePluginApi([mcpHttpPlugin()] as const); | ||
| 30 | |||
| 31 | const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.meta.url)); | ||
| 32 | |||
| 33 | const SECRET = "s3cr3t-typed-into-the-edit-sheet"; | ||
| 34 | |||
| 35 | scenario( | ||
| 36 | "Local · a stdio MCP server's command and environment are editable from the integration Edit sheet", | ||
| 37 | { timeout: 300_000 }, | ||
| 38 | Effect.gen(function* () { | ||
| 39 | const cli = yield* Cli; | ||
| 40 | const browser = yield* Browser; | ||
| 41 | const target = yield* Target; | ||
| 42 | const runDir = yield* RunDir; | ||
| 43 | const identity = yield* target.newIdentity(); | ||
| 44 | |||
| 45 | yield* withLocalServer(cli, runDir, (server) => | ||
| 46 | Effect.gen(function* () { | ||
| 47 | const client = yield* HttpApiClient.make(api, { | ||
| 48 | baseUrl: new URL("/api", server.origin).toString(), | ||
| 49 | transformClient: HttpClient.mapRequest((request) => | ||
| 50 | HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`), | ||
| 51 | ), | ||
| 52 | }).pipe(Effect.provide(FetchHttpClient.layer)); | ||
| 53 | |||
| 54 | const slug = "e2e-stdio-editable"; | ||
| 55 | |||
| 56 | // A plain stdio server: no declared env, so the env-gated tool is absent | ||
| 57 | // until the edit adds the variable. | ||
| 58 | yield* client.mcp.addServer({ | ||
| 59 | payload: { | ||
| 60 | transport: "stdio", | ||
| 61 | name: "E2E Stdio Editable", | ||
| 62 | command: "node", | ||
| 63 | args: [FIXTURE], | ||
| 64 | slug, | ||
| 65 | }, | ||
| 66 | }); | ||
| 67 | |||
| 68 | const before = yield* client.tools.list({ query: { integration: slug } }); | ||
| 69 | expect( | ||
| 70 | before.map((t) => t.name), | ||
| 71 | "the server starts with its base tool and no declared env", | ||
| 72 | ).toContain("echo_tool"); | ||
| 73 | expect( | ||
| 74 | before.map((t) => t.name), | ||
| 75 | "the env-gated tool is absent before the edit", | ||
| 76 | ).not.toContain("saw_declared_env"); | ||
| 77 | |||
| 78 | yield* browser.session(identity, async ({ page, step }) => { | ||
| 79 | await step("Open the stdio integration from the console", async () => { | ||
| 80 | await page.goto(server.url, { waitUntil: "domcontentloaded" }); | ||
| 81 | await page.getByTestId(`integration-entry-${slug}`).first().click(); | ||
| 82 | // Wait for the detail page's own data, not just its shell: clicking | ||
| 83 | // Edit while the accounts panel is still a skeleton races the layout | ||
| 84 | // shift that lands when it resolves. | ||
| 85 | await page.getByText("default").first().waitFor({ timeout: 30_000 }); | ||
| 86 | await page.getByRole("button", { name: "Edit" }).waitFor({ timeout: 30_000 }); | ||
| 87 | }); | ||
| 88 | |||
| 89 | await step("Open the Edit sheet — the stdio command is editable", async () => { | ||
| 90 | await page.getByRole("button", { name: "Edit" }).click(); | ||
| 91 | await page.getByText("Edit integration").waitFor({ timeout: 30_000 }); | ||
| 92 | await page.getByText("Server command").waitFor({ timeout: 30_000 }); | ||
| 93 | // The read-only dead end this replaces. | ||
| 94 | expect( | ||
| 95 | await page.getByText("Stdio MCP integrations cannot be edited").count(), | ||
| 96 | "the read-only message is gone", | ||
| 97 | ).toBe(0); | ||
| 98 | expect( | ||
| 99 | await page.getByRole("textbox", { name: "Command" }).inputValue(), | ||
| 100 | "the stored command is loaded into the form", | ||
| 101 | ).toBe("node"); | ||
| 102 | }); | ||
| 103 | |||
| 104 | await step("Declare an environment variable and save", async () => { | ||
| 105 | await page | ||
| 106 | .getByRole("textbox", { name: "Environment variables" }) | ||
| 107 | .fill(`EXECUTOR_E2E_SECRET=${SECRET}`); | ||
| 108 | await page.getByRole("button", { name: "Save" }).click(); | ||
| 109 | await page.getByText("Server command").waitFor({ state: "hidden", timeout: 30_000 }); | ||
| 110 | }); | ||
| 111 | }); | ||
| 112 | |||
| 113 | // The edit persisted as the DECLARED static env map on the config. | ||
| 114 | const stored = yield* client.mcp.getServer({ params: { slug } }); | ||
| 115 | expect( | ||
| 116 | stored?.config.transport === "stdio" ? stored.config.env : undefined, | ||
| 117 | "the sheet wrote the declared env map", | ||
| 118 | ).toEqual({ EXECUTOR_E2E_SECRET: SECRET }); | ||
| 119 | expect( | ||
| 120 | stored?.config.transport === "stdio" ? stored.config.command : undefined, | ||
| 121 | "the command it did not touch is unchanged", | ||
| 122 | ).toBe("node"); | ||
| 123 | |||
| 124 | // And it reached the spawned server: the fixture gates this tool's very | ||
| 125 | // existence on that variable being in its own environment. | ||
| 126 | const after = yield* client.tools.list({ query: { integration: slug } }); | ||
| 127 | expect( | ||
| 128 | after.map((t) => t.name), | ||
| 129 | "the edited environment reached the respawned server and the catalog was rebuilt", | ||
| 130 | ).toContain("saw_declared_env"); | ||
| 131 | expect( | ||
| 132 | after.map((t) => t.name), | ||
| 133 | "the rebuild kept the tools that still exist", | ||
| 134 | ).toContain("echo_tool"); | ||
| 135 | }), | ||
| 136 | ); | ||
| 137 | }), | ||
| 138 | ); | ||
| @@ -222,6 +222,48 @@ scenario( · 221 unmodified lines | |||
| 222 | autoTools.map((t) => t.name), | 222 | autoTools.map((t) => t.name), |
| 223 | "auto negotiation falls back to legacy and still discovers tools", | 223 | "auto negotiation falls back to legacy and still discovers tools", |
| 224 | ).toContain("echo_tool"); | 224 | ).toContain("echo_tool"); |
| 225 | expect( | ||
| 226 | autoTools.map((t) => t.name), | ||
| 227 | "this server declared no env, so the env-gated tool is absent to begin with", | ||
| 228 | ).not.toContain("saw_declared_env"); | ||
| 229 | |||
| 230 | // --- Editing a stdio server's config (what the integration Edit sheet | ||
| 231 | // now does instead of telling you to remove and recreate). The tool | ||
| 232 | // catalog is persisted per connection, so a plain config replace is | ||
| 233 | // only enough because core stamps `config_revised_at` on a config | ||
| 234 | // write and every connection whose catalog predates that stamp is | ||
| 235 | // rebuilt on the next read. The edit path therefore needs NO explicit | ||
| 236 | // refresh of its own — this asserts that, so nobody adds one back. | ||
| 237 | // | ||
| 238 | // Adding a declared env var is the lever: the fixture advertises | ||
| 239 | // `saw_declared_env` only when that variable reached the child, so the | ||
| 240 | // tool appearing with no further action proves both halves — the new | ||
| 241 | // config reached the spawn, and the catalog was rebuilt from it. --- | ||
| 242 | const editedConfig = { | ||
| 243 | ...autoStored!.config, | ||
| 244 | env: { EXECUTOR_E2E_SECRET: SECRET }, | ||
| 245 | } as typeof autoStored.config; | ||
| 246 | |||
| 247 | yield* client.mcp.configureServer({ | ||
| 248 | params: { slug: autoSlug }, | ||
| 249 | payload: { config: editedConfig }, | ||
| 250 | }); | ||
| 251 | |||
| 252 | const editedStored = yield* client.mcp.getServer({ params: { slug: autoSlug } }); | ||
| 253 | expect( | ||
| 254 | JSON.stringify(editedStored?.config ?? {}), | ||
| 255 | "the edit persisted, and left the untouched negotiation mode alone", | ||
| 256 | ).toContain('"versionNegotiation":"auto"'); | ||
| 257 | |||
| 258 | const editedTools = yield* client.tools.list({ query: { integration: autoSlug } }); | ||
| 259 | expect( | ||
| 260 | editedTools.map((t) => t.name), | ||
| 261 | "the edited config reached the spawn and the catalog was rediscovered", | ||
| 262 | ).toContain("saw_declared_env"); | ||
| 263 | expect( | ||
| 264 | editedTools.map((t) => t.name), | ||
| 265 | "rediscovery replaced the catalog rather than dropping what still exists", | ||
| 266 | ).toContain("echo_tool"); | ||
| 225 | }), | 267 | }), |
| 226 | { env: DAEMON_ENV }, | 268 | { env: DAEMON_ENV }, |
| 227 | ); | 269 | ); |
| @@ -0,0 +1,364 @@ | |||
| 1 | // A Google 403 for a DISABLED API must NOT read as an expired credential. | ||
| 2 | // | ||
| 3 | // Production case (2026-07-10): a user's "Personal Google" connection showed | ||
| 4 | // the red Expired badge although its OAuth token was alive and refreshing. | ||
| 5 | // The org's Google OAuth client lives in a GCP project without the Gmail / | ||
| 6 | // Drive / Calendar APIs enabled, so the health probe gets Google's | ||
| 7 | // SERVICE_DISABLED 403 ("Gmail API has not been used in project ... or it is | ||
| 8 | // disabled"). The old status-only classifier mapped every 401/403 to | ||
| 9 | // "expired", so a healthy credential rendered as Expired and the UI told the | ||
| 10 | // user to reconnect - advice that cannot fix a disabled upstream API. | ||
| 11 | // | ||
| 12 | // The scenario replays that exact journey against the Google emulator: a real | ||
| 13 | // OAuth grant probes healthy; then the upstream starts answering the probe | ||
| 14 | // operation with Google's real SERVICE_DISABLED body (fault injection - the | ||
| 15 | // token stays valid, exactly like prod). The fixed classifier reads the error | ||
| 16 | // body and reports "misconfigured": on screen that is the amber "API | ||
| 17 | // disabled" badge with Google's own remediation text (including the console | ||
| 18 | // link that enables the API), and no reconnect prompt. | ||
| 19 | import { randomBytes } from "node:crypto"; | ||
| 20 | |||
| 21 | import { expect } from "@effect/vitest"; | ||
| 22 | import { Effect } from "effect"; | ||
| 23 | import type { HttpApiClient } from "effect/unstable/httpapi"; | ||
| 24 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 25 | import { connectEmulator, type EmulatorClient } from "@executor-js/emulate"; | ||
| 26 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | ||
| 27 | import { | ||
| 28 | AuthTemplateSlug, | ||
| 29 | ConnectionName, | ||
| 30 | IntegrationSlug, | ||
| 31 | OAuthClientSlug, | ||
| 32 | } from "@executor-js/sdk/shared"; | ||
| 33 | |||
| 34 | import { createEmulatorInstance } from "../src/emulator-instance"; | ||
| 35 | import { scenario } from "../src/scenario"; | ||
| 36 | import { Api, Browser, Target } from "../src/services"; | ||
| 37 | import type { Identity, Target as TargetShape } from "../src/target"; | ||
| 38 | import type { BrowserSurface } from "../src/surfaces/browser"; | ||
| 39 | |||
| 40 | const api = composePluginApi([openApiHttpPlugin()] as const); | ||
| 41 | type Client = HttpApiClient.ForApi<typeof api>; | ||
| 42 | const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2"); | ||
| 43 | const CONNECTION = ConnectionName.make("main"); | ||
| 44 | |||
| 45 | // The Gmail preset's declared health check, and the probe the fault hijacks. | ||
| 46 | const GMAIL_HEALTH_OPERATION = "gmail.users.labels.list"; | ||
| 47 | |||
| 48 | // Google's real error body for an API that is not enabled in the OAuth | ||
| 49 | // client's GCP project. Verbatim shape from production: HTTP 403 with reason | ||
| 50 | // `accessNotConfigured` - an authorization-configuration error, NOT an | ||
| 51 | // expired/revoked credential. | ||
| 52 | const DISABLED_API_MESSAGE = | ||
| 53 | "Gmail API has not been used in project 000000000000 before or it is disabled. " + | ||
| 54 | "Enable it by visiting https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=000000000000 then retry."; | ||
| 55 | const disabledApiBody = { | ||
| 56 | error: { | ||
| 57 | code: 403, | ||
| 58 | message: DISABLED_API_MESSAGE, | ||
| 59 | errors: [ | ||
| 60 | { message: DISABLED_API_MESSAGE, domain: "usageLimits", reason: "accessNotConfigured" }, | ||
| 61 | ], | ||
| 62 | status: "PERMISSION_DENIED", | ||
| 63 | }, | ||
| 64 | }; | ||
| 65 | |||
| 66 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; | ||
| 67 | |||
| 68 | const decodeHtml = (value: string): string => | ||
| 69 | value | ||
| 70 | .replaceAll("&", "&") | ||
| 71 | .replaceAll(""", '"') | ||
| 72 | .replaceAll("'", "'") | ||
| 73 | .replaceAll("<", "<") | ||
| 74 | .replaceAll(">", ">"); | ||
| 75 | |||
| 76 | const inputFields = (html: string): Record<string, string> => { | ||
| 77 | const fields: Record<string, string> = {}; | ||
| 78 | for (const input of html.matchAll(/<input\b[^>]*>/gi)) { | ||
| 79 | const tag = input[0]; | ||
| 80 | const name = tag.match(/\bname=["']([^"']+)["']/i)?.[1]; | ||
| 81 | const value = tag.match(/\bvalue=["']([^"']*)["']/i)?.[1] ?? ""; | ||
| 82 | if (name) fields[decodeHtml(name)] = decodeHtml(value); | ||
| 83 | } | ||
| 84 | return fields; | ||
| 85 | }; | ||
| 86 | |||
| 87 | const formAction = (html: string, fallback: string): string => { | ||
| 88 | const action = html.match(/<form\b[^>]*\baction=["']([^"']+)["']/i)?.[1]; | ||
| 89 | return action ? decodeHtml(action) : fallback; | ||
| 90 | }; | ||
| 91 | |||
| 92 | const completeGoogleConsent = (authorizationUrl: string) => | ||
| 93 | Effect.promise(async () => { | ||
| 94 | const page = await fetch(authorizationUrl); | ||
| 95 | if (!page.ok) throw new Error(`Google emulator authorize failed: ${page.status}`); | ||
| 96 | const html = await page.text(); | ||
| 97 | const callback = await fetch(formAction(html, authorizationUrl), { | ||
| 98 | method: "POST", | ||
| 99 | body: new URLSearchParams(inputFields(html)), | ||
| 100 | redirect: "manual", | ||
| 101 | }); | ||
| 102 | const location = callback.headers.get("location"); | ||
| 103 | if (callback.status !== 302 || !location) { | ||
| 104 | throw new Error(`Google emulator consent did not redirect: ${callback.status}`); | ||
| 105 | } | ||
| 106 | const code = new URL(location).searchParams.get("code"); | ||
| 107 | if (!code) throw new Error("Google emulator callback did not include a code"); | ||
| 108 | return code; | ||
| 109 | }); | ||
| 110 | |||
| 111 | const createGoogleEmulator = Effect.gen(function* () { | ||
| 112 | const baseUrl = yield* createEmulatorInstance("google", "disabled-api"); | ||
| 113 | const client = yield* Effect.promise(() => connectEmulator({ baseUrl })); | ||
| 114 | return { client, baseUrl }; | ||
| 115 | }); | ||
| 116 | |||
| 117 | const addGooglePresetFromCatalog = ( | ||
| 118 | browser: BrowserSurface, | ||
| 119 | identity: Identity, | ||
| 120 | presetName: string, | ||
| 121 | slug: string, | ||
| 122 | ) => | ||
| 123 | browser.session(identity, async ({ page, step }) => { | ||
| 124 | await step(`Open ${presetName} from the connect catalog`, async () => { | ||
| 125 | await page.goto("/integrations", { waitUntil: "networkidle" }); | ||
| 126 | await page | ||
| 127 | .getByRole("button", { name: /Connect/ }) | ||
| 128 | .first() | ||
| 129 | .click(); | ||
| 130 | const dialog = page.getByRole("dialog", { name: "Connect an integration" }); | ||
| 131 | await dialog.waitFor(); | ||
| 132 | await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName); | ||
| 133 | await dialog.getByRole("link", { name: new RegExp(`^${presetName}\\b`) }).click(); | ||
| 134 | }); | ||
| 135 | |||
| 136 | await step(`Add the ${presetName} integration`, async () => { | ||
| 137 | await page.waitForURL(/\/integrations\/add\/openapi/); | ||
| 138 | await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); | ||
| 139 | const button = page.getByRole("button", { name: "Add integration" }); | ||
| 140 | await button.waitFor({ timeout: 120_000 }); | ||
| 141 | await button.click({ timeout: 120_000 }); | ||
| 142 | await page.waitForURL(new RegExp(`/integrations/${slug}\\b`), { timeout: 120_000 }); | ||
| 143 | }); | ||
| 144 | }); | ||
| 145 | |||
| 146 | const connectGoogleAccount = (input: { | ||
| 147 | readonly client: Client; | ||
| 148 | readonly emulator: EmulatorClient; | ||
| 149 | readonly emulatorBaseUrl: string; | ||
| 150 | readonly target: TargetShape; | ||
| 151 | readonly integration: IntegrationSlug; | ||
| 152 | readonly oauthClient: OAuthClientSlug; | ||
| 153 | }) => | ||
| 154 | Effect.gen(function* () { | ||
| 155 | const redirectUri = new URL("/api/oauth/callback", input.target.baseUrl).toString(); | ||
| 156 | const credential = yield* Effect.promise(() => | ||
| 157 | input.emulator.credentials.mint({ | ||
| 158 | type: "oauth-authorization-code", | ||
| 159 | redirect_uris: [redirectUri], | ||
| 160 | }), | ||
| 161 | ); | ||
| 162 | if (!credential.client_id || !credential.client_secret) { | ||
| 163 | return yield* Effect.die(`Google emulator did not mint an OAuth client`); | ||
| 164 | } | ||
| 165 | |||
| 166 | yield* input.client.openapi.configure({ | ||
| 167 | params: { slug: input.integration }, | ||
| 168 | payload: { baseUrl: input.emulatorBaseUrl }, | ||
| 169 | }); | ||
| 170 | yield* input.client.oauth.createClient({ | ||
| 171 | payload: { | ||
| 172 | owner: "org", | ||
| 173 | slug: input.oauthClient, | ||
| 174 | grant: "authorization_code", | ||
| 175 | authorizationUrl: `${input.emulatorBaseUrl}/o/oauth2/v2/auth`, | ||
| 176 | tokenUrl: `${input.emulatorBaseUrl}/oauth2/token`, | ||
| 177 | clientId: credential.client_id, | ||
| 178 | clientSecret: credential.client_secret, | ||
| 179 | originIntegration: input.integration, | ||
| 180 | }, | ||
| 181 | }); | ||
| 182 | |||
| 183 | const started = yield* input.client.oauth.start({ | ||
| 184 | payload: { | ||
| 185 | client: input.oauthClient, | ||
| 186 | clientOwner: "org", | ||
| 187 | owner: "org", | ||
| 188 | name: CONNECTION, | ||
| 189 | integration: input.integration, | ||
| 190 | template: GOOGLE_AUTH_TEMPLATE, | ||
| 191 | redirectUri, | ||
| 192 | }, | ||
| 193 | }); | ||
| 194 | expect(started.status, "OAuth starts with an emulator redirect").toBe("redirect"); | ||
| 195 | if (started.status !== "redirect") return yield* Effect.die("OAuth unexpectedly connected"); | ||
| 196 | |||
| 197 | const code = yield* completeGoogleConsent(started.authorizationUrl); | ||
| 198 | const completed = yield* input.client.oauth.complete({ | ||
| 199 | payload: { state: started.state, code }, | ||
| 200 | }); | ||
| 201 | expect(completed.integration, "OAuth completion creates the connection").toBe( | ||
| 202 | input.integration, | ||
| 203 | ); | ||
| 204 | }); | ||
| 205 | |||
| 206 | scenario( | ||
| 207 | "Google · a disabled upstream API reads as API disabled with the enable link, not Expired", | ||
| 208 | { timeout: 300_000 }, | ||
| 209 | Effect.gen(function* () { | ||
| 210 | const target = yield* Target; | ||
| 211 | const browser = yield* Browser; | ||
| 212 | const { client: makeClient } = yield* Api; | ||
| 213 | const identity = yield* target.newIdentity(); | ||
| 214 | const client = yield* makeClient(api, identity); | ||
| 215 | const emulator = yield* createGoogleEmulator; | ||
| 216 | const slug = IntegrationSlug.make("google_gmail"); | ||
| 217 | const oauthClient = OAuthClientSlug.make(unique("google_gmail_oauth")); | ||
| 218 | |||
| 219 | yield* Effect.ensuring( | ||
| 220 | Effect.gen(function* () { | ||
| 221 | yield* addGooglePresetFromCatalog(browser, identity, "Gmail", String(slug)); | ||
| 222 | |||
| 223 | const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); | ||
| 224 | expect(stored?.operation, "the Gmail preset declares its labels probe").toBe( | ||
| 225 | GMAIL_HEALTH_OPERATION, | ||
| 226 | ); | ||
| 227 | |||
| 228 | yield* connectGoogleAccount({ | ||
| 229 | client, | ||
| 230 | emulator: emulator.client, | ||
| 231 | emulatorBaseUrl: emulator.baseUrl, | ||
| 232 | target, | ||
| 233 | integration: slug, | ||
| 234 | oauthClient, | ||
| 235 | }); | ||
| 236 | |||
| 237 | // Baseline: the freshly granted token probes healthy against the | ||
| 238 | // emulator - same as prod the day the account was connected. | ||
| 239 | const healthy = yield* client.connections.checkHealth({ | ||
| 240 | params: { owner: "org", integration: slug, name: CONNECTION }, | ||
| 241 | query: { ifStaleMs: 0 }, | ||
| 242 | }); | ||
| 243 | expect(healthy.status, `the fresh grant probes healthy: ${JSON.stringify(healthy)}`).toBe( | ||
| 244 | "healthy", | ||
| 245 | ); | ||
| 246 | |||
| 247 | // The upstream flips: the probe operation now answers with Google's | ||
| 248 | // real SERVICE_DISABLED 403. ONLY that route faults - the OAuth token | ||
| 249 | // endpoints stay live, so the credential itself remains valid and | ||
| 250 | // refreshable, exactly the production condition. Generous `times` | ||
| 251 | // covers the UI's automatic revalidations on top of explicit checks. | ||
| 252 | yield* Effect.promise(() => | ||
| 253 | emulator.client.faults.arm({ | ||
| 254 | match: { operationId: GMAIL_HEALTH_OPERATION }, | ||
| 255 | response: { status: 403, body: disabledApiBody }, | ||
| 256 | times: 100, | ||
| 257 | }), | ||
| 258 | ); | ||
| 259 | yield* Effect.promise(() => emulator.client.ledger.clear()); | ||
| 260 | |||
| 261 | // The fix under test: a 403 that means "enable this API in your GCP | ||
| 262 | // project" classifies as misconfigured, not as a dead credential. | ||
| 263 | const verdict = yield* client.connections.checkHealth({ | ||
| 264 | params: { owner: "org", integration: slug, name: CONNECTION }, | ||
| 265 | query: { ifStaleMs: 0 }, | ||
| 266 | }); | ||
| 267 | expect( | ||
| 268 | verdict.status, | ||
| 269 | `the SERVICE_DISABLED 403 classifies as misconfigured: ${JSON.stringify(verdict)}`, | ||
| 270 | ).toBe("misconfigured"); | ||
| 271 | expect(verdict.httpStatus, "the probe observed the 403").toBe(403); | ||
| 272 | expect( | ||
| 273 | verdict.detail, | ||
| 274 | "the stored detail names the real cause (a disabled API, not an expired token)", | ||
| 275 | ).toContain("has not been used in project"); | ||
| 276 | |||
| 277 | // The 403 came from the armed fault on the probe route - the token | ||
| 278 | // was never rejected by the emulator's auth layer. | ||
| 279 | const ledger = yield* Effect.promise(() => emulator.client.ledger.list(50)); | ||
| 280 | const probeEntry = ledger.find((entry) => entry.operationId === GMAIL_HEALTH_OPERATION); | ||
| 281 | expect(probeEntry?.faulted, "the probe was answered by the armed fault").toBe(true); | ||
| 282 | |||
| 283 | yield* browser.session(identity, async ({ page, step }) => { | ||
| 284 | const connections = page.locator("section").filter({ | ||
| 285 | has: page.getByRole("heading", { level: 3, name: "Connections" }), | ||
| 286 | }); | ||
| 287 | |||
| 288 | await step( | ||
| 289 | "The connection shows the amber API disabled badge - not Expired - with no clicks", | ||
| 290 | async () => { | ||
| 291 | await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); | ||
| 292 | await connections | ||
| 293 | .getByText("API disabled", { exact: true }) | ||
| 294 | .waitFor({ timeout: 30_000 }); | ||
| 295 | await connections.getByLabel("Status: API disabled").waitFor(); | ||
| 296 | // The dead-credential story must be gone entirely. | ||
| 297 | expect( | ||
| 298 | await connections.getByText("Expired", { exact: true }).count(), | ||
| 299 | "no Expired badge on a misconfigured connection", | ||
| 300 | ).toBe(0); | ||
| 301 | }, | ||
| 302 | ); | ||
| 303 | |||
| 304 | await step( | ||
| 305 | "The row carries Google's own instruction, console link included", | ||
| 306 | async () => { | ||
| 307 | const detail = connections.getByText(/has not been used in project/); | ||
| 308 | await detail.waitFor(); | ||
| 309 | const consoleLink = connections.getByRole("link", { | ||
| 310 | name: /console\.developers\.google\.com/, | ||
| 311 | }); | ||
| 312 | await consoleLink.waitFor(); | ||
| 313 | expect( | ||
| 314 | await consoleLink.getAttribute("href"), | ||
| 315 | "the enable-API console link is clickable", | ||
| 316 | ).toContain("console.developers.google.com"); | ||
| 317 | // Visible in FULL, not just present in the DOM: a one-line | ||
| 318 | // truncate keeps the link in the tree while clipping it from | ||
| 319 | // view, which is exactly the regression this guards against. | ||
| 320 | // A clipped element overflows horizontally; a wrapped one | ||
| 321 | // grows taller instead. | ||
| 322 | const clipped = await detail.evaluate( | ||
| 323 | (el) => | ||
| 324 | el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1, | ||
| 325 | ); | ||
| 326 | expect(clipped, "the remediation text wraps instead of clipping").toBe(false); | ||
| 327 | const linkVisible = await consoleLink.evaluate((el) => { | ||
| 328 | const rect = el.getBoundingClientRect(); | ||
| 329 | const detailRect = el.closest("p")?.getBoundingClientRect(); | ||
| 330 | return detailRect ? rect.right <= detailRect.right + 1 : false; | ||
| 331 | }); | ||
| 332 | expect(linkVisible, "the console link sits inside the visible detail box").toBe(true); | ||
| 333 | }, | ||
| 334 | ); | ||
| 335 | |||
| 336 | await step( | ||
| 337 | "Check now explains the disabled API instead of prescribing reconnect", | ||
| 338 | async () => { | ||
| 339 | await connections.locator('button[aria-haspopup="menu"]').click(); | ||
| 340 | await page.getByRole("menuitem", { name: "Check now" }).click(); | ||
| 341 | await page | ||
| 342 | .getByText(/has not been used in project/) | ||
| 343 | .first() | ||
| 344 | .waitFor({ timeout: 30_000 }); | ||
| 345 | expect( | ||
| 346 | await page.getByText("Connection expired, reconnect to restore access").count(), | ||
| 347 | "the reconnect toast never fires for a configuration 403", | ||
| 348 | ).toBe(0); | ||
| 349 | }, | ||
| 350 | ); | ||
| 351 | }); | ||
| 352 | }), | ||
| 353 | Effect.gen(function* () { | ||
| 354 | yield* client.connections | ||
| 355 | .remove({ params: { owner: "org", integration: slug, name: CONNECTION } }) | ||
| 356 | .pipe(Effect.ignore); | ||
| 357 | yield* client.oauth | ||
| 358 | .removeClient({ params: { slug: oauthClient }, payload: { owner: "org" } }) | ||
| 359 | .pipe(Effect.ignore); | ||
| 360 | yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); | ||
| 361 | }), | ||
| 362 | ); | ||
| 363 | }), | ||
| 364 | ); | ||
| @@ -0,0 +1,275 @@ | |||
| 1 | // Selfhost-only: two MCP sessions that hit an expired token at the same moment | ||
| 2 | // must share ONE refresh-token grant, never race two. | ||
| 3 | // | ||
| 4 | // Issue #1520: the in-flight refresh gate lived inside a single execution | ||
| 5 | // stack, but the self-host builds a fresh stack per MCP session, so each | ||
| 6 | // session believed it was the refresh winner and redeemed the same stored | ||
| 7 | // refresh token. Providers that rotate refresh tokens answer the second | ||
| 8 | // redemption with `invalid_grant: refresh token reuse detected` and may revoke | ||
| 9 | // the whole token family — the connection dies and the user must reauthorize. | ||
| 10 | // The first refresh cycle succeeds, so the bug stays invisible until a later | ||
| 11 | // expiry. | ||
| 12 | // | ||
| 13 | // The journey: an OpenAPI integration completes a real authorization-code flow | ||
| 14 | // against a live test authorization server; the upstream then rejects both | ||
| 15 | // sessions' first call with a 401 at the same instant (it holds both requests | ||
| 16 | // until both have arrived, so the contention is forced rather than left to the | ||
| 17 | // scheduler); and the authorization server's own request ledger proves exactly | ||
| 18 | // one refresh grant was issued and both retries carried the same new bearer. | ||
| 19 | import { randomBytes } from "node:crypto"; | ||
| 20 | import { createServer, type ServerResponse } from "node:http"; | ||
| 21 | |||
| 22 | import { expect } from "@effect/vitest"; | ||
| 23 | import { Effect } from "effect"; | ||
| 24 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 25 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | ||
| 26 | import { | ||
| 27 | AuthTemplateSlug, | ||
| 28 | ConnectionName, | ||
| 29 | IntegrationSlug, | ||
| 30 | OAuthClientSlug, | ||
| 31 | } from "@executor-js/sdk/shared"; | ||
| 32 | import { serveOAuthTestServer } from "@executor-js/sdk/testing"; | ||
| 33 | |||
| 34 | import { scenario } from "../src/scenario"; | ||
| 35 | import { Api, Mcp, Target } from "../src/services"; | ||
| 36 | |||
| 37 | const api = composePluginApi([openApiHttpPlugin()] as const); | ||
| 38 | |||
| 39 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; | ||
| 40 | |||
| 41 | /** Both sessions call once, are rejected together, then retry once. */ | ||
| 42 | const SESSIONS = 2; | ||
| 43 | |||
| 44 | type UpstreamHandle = { | ||
| 45 | readonly url: string; | ||
| 46 | readonly bearers: () => readonly string[]; | ||
| 47 | readonly close: () => void; | ||
| 48 | }; | ||
| 49 | |||
| 50 | /** | ||
| 51 | * Upstream that rejects the whole first wave at once. | ||
| 52 | * | ||
| 53 | * The barrier is the point: it holds every session's first call until all have | ||
| 54 | * arrived, then 401s them together, which forces both sessions into a genuinely | ||
| 55 | * simultaneous refresh instead of hoping the scheduler interleaves them. | ||
| 56 | */ | ||
| 57 | const serveUpstream = () => | ||
| 58 | Effect.acquireRelease( | ||
| 59 | Effect.callback<UpstreamHandle>((resume) => { | ||
| 60 | const bearers: string[] = []; | ||
| 61 | const held: ServerResponse[] = []; | ||
| 62 | const server = createServer((request, response) => { | ||
| 63 | if (request.method === "GET" && (request.url ?? "").startsWith("/issues")) { | ||
| 64 | bearers.push((request.headers.authorization ?? "").replace(/^Bearer\s+/i, "")); | ||
| 65 | if (held.length < SESSIONS) { | ||
| 66 | held.push(response); | ||
| 67 | if (held.length === SESSIONS) { | ||
| 68 | for (const rejected of held) { | ||
| 69 | rejected.writeHead(401, { "content-type": "application/json" }); | ||
| 70 | rejected.end(JSON.stringify({ error: "invalid_token" })); | ||
| 71 | } | ||
| 72 | } | ||
| 73 | return; | ||
| 74 | } | ||
| 75 | response.writeHead(200, { "content-type": "application/json" }); | ||
| 76 | response.end(JSON.stringify({ issues: [] })); | ||
| 77 | return; | ||
| 78 | } | ||
| 79 | response.writeHead(404, { "content-type": "application/json" }); | ||
| 80 | response.end(JSON.stringify({ error: "not_found" })); | ||
| 81 | }); | ||
| 82 | server.listen(0, "127.0.0.1", () => { | ||
| 83 | const address = server.address(); | ||
| 84 | const port = typeof address === "object" && address ? address.port : 0; | ||
| 85 | resume( | ||
| 86 | Effect.succeed({ | ||
| 87 | url: `http://127.0.0.1:${port}`, | ||
| 88 | bearers: () => [...bearers], | ||
| 89 | close: () => { | ||
| 90 | server.close(); | ||
| 91 | server.closeAllConnections(); | ||
| 92 | }, | ||
| 93 | }), | ||
| 94 | ); | ||
| 95 | }); | ||
| 96 | }), | ||
| 97 | (server) => Effect.sync(server.close), | ||
| 98 | ); | ||
| 99 | |||
| 100 | const spec = ( | ||
| 101 | baseUrl: string, | ||
| 102 | oauth: { readonly authorizationEndpoint: string; readonly tokenEndpoint: string }, | ||
| 103 | ): string => | ||
| 104 | JSON.stringify({ | ||
| 105 | openapi: "3.0.3", | ||
| 106 | info: { title: "Issues API", version: "1.0.0" }, | ||
| 107 | servers: [{ url: baseUrl }], | ||
| 108 | paths: { | ||
| 109 | "/issues": { | ||
| 110 | get: { | ||
| 111 | operationId: "listIssues", | ||
| 112 | security: [{ oauth: ["issues.read"] }], | ||
| 113 | responses: { "200": { description: "issues" } }, | ||
| 114 | }, | ||
| 115 | }, | ||
| 116 | }, | ||
| 117 | components: { | ||
| 118 | securitySchemes: { | ||
| 119 | oauth: { | ||
| 120 | type: "oauth2", | ||
| 121 | flows: { | ||
| 122 | authorizationCode: { | ||
| 123 | authorizationUrl: oauth.authorizationEndpoint, | ||
| 124 | tokenUrl: oauth.tokenEndpoint, | ||
| 125 | scopes: { "issues.read": "Read issues" }, | ||
| 126 | }, | ||
| 127 | }, | ||
| 128 | }, | ||
| 129 | }, | ||
| 130 | }, | ||
| 131 | }); | ||
| 132 | |||
| 133 | const invokeByAddressCode = (address: string) => ` | ||
| 134 | const segments = ${JSON.stringify(address)}.split(".").slice(1); | ||
| 135 | let node = tools; | ||
| 136 | for (const segment of segments) node = node[segment]; | ||
| 137 | const result = await node({}); | ||
| 138 | return JSON.stringify(result); | ||
| 139 | `; | ||
| 140 | |||
| 141 | const completeAuthorization = (authorizationUrl: string) => | ||
| 142 | Effect.promise(async () => { | ||
| 143 | const authorize = await fetch(authorizationUrl, { redirect: "manual" }); | ||
| 144 | const loginUrl = authorize.headers.get("location"); | ||
| 145 | if (!loginUrl) return null; | ||
| 146 | const login = await fetch(loginUrl, { | ||
| 147 | method: "POST", | ||
| 148 | headers: { authorization: `Basic ${Buffer.from("alice:password").toString("base64")}` }, | ||
| 149 | redirect: "manual", | ||
| 150 | }); | ||
| 151 | const callbackUrl = login.headers.get("location"); | ||
| 152 | if (!callbackUrl) return null; | ||
| 153 | return new URL(callbackUrl).searchParams.get("code"); | ||
| 154 | }); | ||
| 155 | |||
| 156 | scenario( | ||
| 157 | "OAuth refresh · separate MCP sessions share one rotating-token refresh grant", | ||
| 158 | { timeout: 180_000 }, | ||
| 159 | Effect.scoped( | ||
| 160 | Effect.gen(function* () { | ||
| 161 | const target = yield* Target; | ||
| 162 | const { client: makeClient } = yield* Api; | ||
| 163 | const mcp = yield* Mcp; | ||
| 164 | const identity = yield* target.newIdentity(); | ||
| 165 | const client = yield* makeClient(api, identity); | ||
| 166 | const upstream = yield* serveUpstream(); | ||
| 167 | const oauth = yield* serveOAuthTestServer({ scopes: ["issues.read"] }); | ||
| 168 | const slug = unique("refreshcrosssession"); | ||
| 169 | const clientSlug = OAuthClientSlug.make(unique("refreshcrosssessionc")); | ||
| 170 | |||
| 171 | yield* Effect.ensuring( | ||
| 172 | Effect.gen(function* () { | ||
| 173 | yield* client.openapi.addSpec({ | ||
| 174 | payload: { | ||
| 175 | spec: { kind: "blob", value: spec(upstream.url, oauth) }, | ||
| 176 | slug, | ||
| 177 | baseUrl: upstream.url, | ||
| 178 | authenticationTemplate: [ | ||
| 179 | { | ||
| 180 | slug: "oauth", | ||
| 181 | kind: "oauth2", | ||
| 182 | authorizationUrl: oauth.authorizationEndpoint, | ||
| 183 | tokenUrl: oauth.tokenEndpoint, | ||
| 184 | scopes: ["issues.read"], | ||
| 185 | }, | ||
| 186 | ], | ||
| 187 | }, | ||
| 188 | }); | ||
| 189 | yield* client.oauth.createClient({ | ||
| 190 | payload: { | ||
| 191 | owner: "org", | ||
| 192 | slug: clientSlug, | ||
| 193 | grant: "authorization_code", | ||
| 194 | authorizationUrl: oauth.authorizationEndpoint, | ||
| 195 | tokenUrl: oauth.tokenEndpoint, | ||
| 196 | clientId: "test-client", | ||
| 197 | clientSecret: "test-secret", | ||
| 198 | originIntegration: IntegrationSlug.make(slug), | ||
| 199 | }, | ||
| 200 | }); | ||
| 201 | const started = yield* client.oauth.start({ | ||
| 202 | payload: { | ||
| 203 | client: clientSlug, | ||
| 204 | clientOwner: "org", | ||
| 205 | owner: "org", | ||
| 206 | name: ConnectionName.make("main"), | ||
| 207 | integration: IntegrationSlug.make(slug), | ||
| 208 | template: AuthTemplateSlug.make("oauth"), | ||
| 209 | }, | ||
| 210 | }); | ||
| 211 | expect(started.status, "oauth.start redirects to the authorization server").toBe( | ||
| 212 | "redirect", | ||
| 213 | ); | ||
| 214 | if (started.status !== "redirect") return yield* Effect.die("no redirect"); | ||
| 215 | const code = yield* completeAuthorization(started.authorizationUrl); | ||
| 216 | expect(code, "the authorization server issued a callback code").toBeDefined(); | ||
| 217 | if (code == null) return yield* Effect.die("no authorization code"); | ||
| 218 | yield* client.oauth.complete({ payload: { state: started.state, code } }); | ||
| 219 | |||
| 220 | const address = (yield* client.tools.list({ query: {} })) | ||
| 221 | .filter((tool) => String(tool.integration) === slug) | ||
| 222 | .map((tool) => String(tool.address)) | ||
| 223 | .find((tool) => tool.endsWith("listIssues")); | ||
| 224 | expect(address, "the OAuth-protected tool is in the catalog").toBeDefined(); | ||
| 225 | if (!address) return yield* Effect.die("no listIssues tool"); | ||
| 226 | yield* oauth.clearRequests; | ||
| 227 | |||
| 228 | const sessions = Array.from({ length: SESSIONS }, () => mcp.session(identity)); | ||
| 229 | const call = (session: (typeof sessions)[number]) => | ||
| 230 | Effect.gen(function* () { | ||
| 231 | let result = yield* session.call("execute", { code: invokeByAddressCode(address) }); | ||
| 232 | let approvals = 0; | ||
| 233 | while (result.text.includes("executionId:") && approvals < 10) { | ||
| 234 | result = yield* session.approvePaused(result.text); | ||
| 235 | approvals += 1; | ||
| 236 | } | ||
| 237 | // Without a shared gate the loser redeems a retired refresh token | ||
| 238 | // and the authorization server answers invalid_grant, so this is | ||
| 239 | // the assertion that carries the user-visible failure. | ||
| 240 | expect(result.ok, `MCP execute completed: ${result.text.slice(0, 400)}`).toBe(true); | ||
| 241 | }); | ||
| 242 | |||
| 243 | yield* Effect.all(sessions.map(call), { concurrency: "unbounded" }); | ||
| 244 | |||
| 245 | const refreshGrants = (yield* oauth.requests).filter( | ||
| 246 | (request) => | ||
| 247 | request.path === "/token" && request.body.includes("grant_type=refresh_token"), | ||
| 248 | ); | ||
| 249 | expect(refreshGrants, "both sessions joined one refresh grant").toHaveLength(1); | ||
| 250 | const bearers = upstream.bearers(); | ||
| 251 | expect(bearers, "both rejected calls retried after the refresh").toHaveLength( | ||
| 252 | SESSIONS * 2, | ||
| 253 | ); | ||
| 254 | expect(bearers[2], "the first retry used a new bearer").not.toBe(bearers[0]); | ||
| 255 | expect(bearers[3], "both retries used the refreshed bearer").toBe(bearers[2]); | ||
| 256 | }), | ||
| 257 | Effect.gen(function* () { | ||
| 258 | yield* client.connections | ||
| 259 | .remove({ | ||
| 260 | params: { | ||
| 261 | owner: "org", | ||
| 262 | integration: IntegrationSlug.make(slug), | ||
| 263 | name: ConnectionName.make("main"), | ||
| 264 | }, | ||
| 265 | }) | ||
| 266 | .pipe(Effect.ignore); | ||
| 267 | yield* client.oauth | ||
| 268 | .removeClient({ params: { slug: clientSlug }, payload: { owner: "org" } }) | ||
| 269 | .pipe(Effect.ignore); | ||
| 270 | yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); | ||
| 271 | }), | ||
| 272 | ); | ||
| 273 | }), | ||
| 274 | ), | ||
| 275 | ); | ||
| @@ -145,7 +145,7 @@ export const AdminUser = Schema.Struct({ · 144 unmodified lines | |||
| 145 | * forbidden-field lists in `platform-view.test.ts` and `admin-users.test.ts`. | 145 | * forbidden-field lists in `platform-view.test.ts` and `admin-users.test.ts`. |
| 146 | */ | 146 | */ |
| 147 | export const AdminConnectionHealth = Schema.Struct({ | 147 | export const AdminConnectionHealth = Schema.Struct({ |
| 148 | /** The stored verdict: healthy / expired / degraded / unknown. */ | 148 | /** The stored verdict: healthy / expired / misconfigured / degraded / unknown. */ |
| 149 | status: HealthStatus, | 149 | status: HealthStatus, |
| 150 | /** Epoch ms the check ran, so an operator can tell a fresh verdict from a | 150 | /** Epoch ms the check ran, so an operator can tell a fresh verdict from a |
| 151 | * stale one. */ | 151 | * stale one. */ |
| @@ -199,6 +199,47 @@ const PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE = 90; · 198 unmodified lines | |||
| 199 | const PLUGIN_STORAGE_CREATE_ROW_BATCH_SIZE = 90; | 199 | const PLUGIN_STORAGE_CREATE_ROW_BATCH_SIZE = 90; |
| 200 | const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000; | 200 | const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000; |
| 201 | 201 | ||
| 202 | // --------------------------------------------------------------------------- | ||
| 203 | // In-flight OAuth refresh gate — a CROSS-STACK resource. | ||
| 204 | // | ||
| 205 | // Concurrent resolves of one connection must share a single refresh-token | ||
| 206 | // grant: the authorization server rotates the refresh token, so a second | ||
| 207 | // grant redeems a token the first already consumed, and a provider that | ||
| 208 | // detects reuse revokes the whole token family. The first refresh cycle still | ||
| 209 | // succeeds, so the fault hides until a later expiry. | ||
| 210 | // | ||
| 211 | // The gate therefore cannot live on a single execution stack. A host builds a | ||
| 212 | // fresh scoped executor per MCP session (and, since request-scoped stack | ||
| 213 | // builds, per request), so a per-`createExecutor` map put every session in its | ||
| 214 | // own gate and deduplicated nothing. Hanging it off the root DB handle instead | ||
| 215 | // converges every stack over one handle on one map — the same object hosts | ||
| 216 | // already treat as their shared, process-lived resource. | ||
| 217 | // | ||
| 218 | // SCOPE OF THE GUARANTEE: dedup reaches exactly as far as one root DB handle | ||
| 219 | // in one process. A host that hands every scoped executor a FRESH handle keys | ||
| 220 | // a different map each time and gets no dedup — silently, because an unshared | ||
| 221 | // gate still behaves correctly for the one caller holding it. Multi-instance | ||
| 222 | // deployments are outside it for the same reason: a process-local map cannot | ||
| 223 | // see a peer isolate or replica. Both need database-backed coordination | ||
| 224 | // (compare-and-swap on the stored refresh token) rather than a wider map. | ||
| 225 | // | ||
| 226 | // Weakly keyed so the map dies with the handle and a host that opens and drops | ||
| 227 | // handles does not leak one gate per handle. | ||
| 228 | type RefreshGate = Map< | ||
| 229 | string, | ||
| 230 | Deferred.Deferred<string | null, StorageFailure | CredentialResolutionError> | ||
| 231 | >; | ||
| 232 | |||
| 233 | const refreshGateByRootDb = new WeakMap<object, RefreshGate>(); | ||
| 234 | |||
| 235 | const refreshGateFor = (rootDb: object): RefreshGate => { | ||
| 236 | const existing = refreshGateByRootDb.get(rootDb); | ||
| 237 | if (existing) return existing; | ||
| 238 | const created: RefreshGate = new Map(); | ||
| 239 | refreshGateByRootDb.set(rootDb, created); | ||
| 240 | return created; | ||
| 241 | }; | ||
| 242 | |||
| 202 | // --------------------------------------------------------------------------- | 243 | // --------------------------------------------------------------------------- |
| 203 | // Elicitation handler — resolved once at `createExecutor({ onElicitation })` | 244 | // Elicitation handler — resolved once at `createExecutor({ onElicitation })` |
| 204 | // and overridable per `execute`. A tool that requests user input mid-execution | 245 | // and overridable per `execute`. A tool that requests user input mid-execution |
| @@ -1697,6 +1738,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1492 unmodified lines | |||
| 1697 | }); | 1738 | }); |
| 1698 | const rootDbUntyped = "db" in dbInput ? dbInput.db : dbInput; | 1739 | const rootDbUntyped = "db" in dbInput ? dbInput.db : dbInput; |
| 1699 | const closeDb = "db" in dbInput ? dbInput.close : undefined; | 1740 | const closeDb = "db" in dbInput ? dbInput.close : undefined; |
| 1741 | // Shared with every other execution stack built over this same handle — | ||
| 1742 | // see the gate's definition for what that does and does not cover. | ||
| 1743 | const refreshInFlight = refreshGateFor(rootDbUntyped); | ||
| 1700 | yield* Effect.try({ | 1744 | yield* Effect.try({ |
| 1701 | try: () => { | 1745 | try: () => { |
| 1702 | validateExecutorDbTables(tables, rootDbUntyped.internal.tables); | 1746 | validateExecutorDbTables(tables, rootDbUntyped.internal.tables); |
| @@ -1920,17 +1964,18 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 217 unmodified lines | |||
| 1920 | ), | 1964 | ), |
| 1921 | }); | 1965 | }); |
| 1922 | 1966 | ||
| 1923 | // In-flight refresh gate — concurrent resolves of the same connection share | 1967 | // Key for the shared in-flight refresh gate (`refreshInFlight`, bound at |
| 1924 | // one refresh (mirrors v1's refresh deferred-map) so we never fire two | 1968 | // the top of `createExecutor`). The tenant leads because the gate spans |
| 1925 | // refresh-token grants for the same connection in parallel (the AS rotates | 1969 | // every execution stack over one DB handle, so it spans tenants too: |
| 1926 | // the refresh token; the second request would race on a consumed token). | 1970 | // without it, two tenants whose rows agree on owner/subject/integration/ |
| 1927 | const refreshInFlight = new Map< | 1971 | // name would collide on one entry and one tenant's caller could be handed |
| 1928 | string, | 1972 | // the other's access token. JSON.stringify keeps the components |
| 1929 | Effect.Effect<string | null, StorageFailure | CredentialResolutionError> | 1973 | // unambiguous: tenant, subject, integration, and name are opaque strings |
| 1930 | >(); | 1974 | // that may contain any delimiter, so a delimiter-joined key would let |
| 1931 | 1975 | // tenant "a" + subject "user:b" collide with tenant "a:user" + subject | |
| 1976 | // "b" — the same cross-tenant bleed by another route. | ||
| 1932 | const connectionKey = (row: ConnectionRow): string => | 1977 | const connectionKey = (row: ConnectionRow): string => |
| 1933 | `${row.owner}:${row.subject}:${row.integration}:${row.name}`; | 1978 | JSON.stringify([tenant, row.owner, row.subject, row.integration, row.name]); |
| 1934 | 1979 | ||
| 1935 | const loadOAuthClientRow = ( | 1980 | const loadOAuthClientRow = ( |
| 1936 | owner: Owner, | 1981 | owner: Owner, |
| @@ -1955,6 +2000,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 18 unmodified lines | |||
| 1955 | readonly tokenUrl: string; | 2000 | readonly tokenUrl: string; |
| 1956 | readonly grant: string; | 2001 | readonly grant: string; |
| 1957 | readonly resource: string | null; | 2002 | readonly resource: string | null; |
| 2003 | readonly tokenEndpointAuthMethod?: "body" | "basic"; | ||
| 2004 | readonly tokenRequestFormat?: "form" | "json"; | ||
| 1958 | } | 2005 | } |
| 1959 | 2006 | ||
| 1960 | /** What drove a refresh: the pre-call expiry check (`proactive`), or an | 2007 | /** What drove a refresh: the pre-call expiry check (`proactive`), or an |
| @@ -2267,6 +2314,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 306 unmodified lines | |||
| 2267 | // audience the original grant was bound to. Dropping it here | 2314 | // audience the original grant was bound to. Dropping it here |
| 2268 | // made refresh asymmetric with authorize for first-party apps. | 2315 | // made refresh asymmetric with authorize for first-party apps. |
| 2269 | resource: firstParty.resource ?? null, | 2316 | resource: firstParty.resource ?? null, |
| 2317 | ...(firstParty.tokenEndpointAuthMethod === undefined | ||
| 2318 | ? {} | ||
| 2319 | : { tokenEndpointAuthMethod: firstParty.tokenEndpointAuthMethod }), | ||
| 2320 | ...(firstParty.tokenRequestFormat === undefined | ||
| 2321 | ? {} | ||
| 2322 | : { tokenRequestFormat: firstParty.tokenRequestFormat }), | ||
| 2270 | } satisfies RefreshClient; | 2323 | } satisfies RefreshClient; |
| 2271 | } | 2324 | } |
| 2272 | const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner; | 2325 | const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner; |
| @@ -2398,6 +2451,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 125 unmodified lines | |||
| 2398 | // RFC 8707: keep the re-minted token bound to the same resource | 2451 | // RFC 8707: keep the re-minted token bound to the same resource |
| 2399 | // (MCP servers require this on refresh). | 2452 | // (MCP servers require this on refresh). |
| 2400 | resource: clientRow.resource ?? undefined, | 2453 | resource: clientRow.resource ?? undefined, |
| 2454 | clientAuth: clientRow.tokenEndpointAuthMethod, | ||
| 2455 | requestFormat: clientRow.tokenRequestFormat, | ||
| 2401 | endpointUrlPolicy: config.oauthEndpointUrlPolicy, | 2456 | endpointUrlPolicy: config.oauthEndpointUrlPolicy, |
| 2402 | fetch: config.fetch, | 2457 | fetch: config.fetch, |
| 2403 | }).pipe( | 2458 | }).pipe( |
| @@ -2530,26 +2585,42 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 126 unmodified lines | |||
| 2530 | // token; parallel grants would race on a consumed token — v1's refresh | 2585 | // token; parallel grants would race on a consumed token — v1's refresh |
| 2531 | // deferred-map). The gate is cleared once the refresh settles so a later | 2586 | // deferred-map). The gate is cleared once the refresh settles so a later |
| 2532 | // expiry can refresh again. | 2587 | // expiry can refresh again. |
| 2533 | Effect.gen(function* () { | 2588 | Effect.suspend(() => { |
| 2534 | const key = connectionKey(row); | 2589 | const key = connectionKey(row); |
| 2535 | // Joining an in-flight grant is correct for BOTH triggers: whatever | 2590 | // Joining an in-flight grant is correct for BOTH triggers: whatever |
| 2536 | // that peer mints is newer than the token this fiber just saw rejected, | 2591 | // that peer mints is newer than the token this fiber just saw rejected, |
| 2537 | // which is exactly what a reactive retry wants. The gate is cleared on | 2592 | // which is exactly what a reactive retry wants. The gate is cleared on |
| 2538 | // settle, so a 401 arriving after a refresh completed starts a fresh | 2593 | // settle, so a 401 arriving after a refresh completed starts a fresh |
| 2539 | // grant rather than replaying the stale memoized one. | 2594 | // grant rather than replaying a stale result. |
| 2540 | const existing = refreshInFlight.get(key); | 2595 | const existing = refreshInFlight.get(key); |
| 2541 | if (existing) return yield* existing; | 2596 | if (existing) return Deferred.await(existing); |
| 2542 | // `Effect.cached` memoizes the grant onto a deferred: it runs once and | 2597 | |
| 2543 | // replays to every awaiter sharing this entry. | 2598 | // The grant runs on a DETACHED fiber and every caller — including this |
| 2544 | const memoized = yield* Effect.cached(performTokenRefresh(row, provider, trigger)); | 2599 | // one — only awaits its deferred. The entry is shared across execution |
| 2545 | const gated = memoized.pipe( | 2600 | // stacks, so the fiber that registers it is merely the first arrival, |
| 2546 | Effect.ensuring(Effect.sync(() => refreshInFlight.delete(key))), | 2601 | // not an owner. Running the grant ON that fiber would hand it that |
| 2602 | // caller's interruption: a disconnected MCP client, an execution | ||
| 2603 | // deadline or a cancelled tool call would fail every peer awaiting the | ||
| 2604 | // same entry with an interrupt none of them caused and none can act on. | ||
| 2605 | // Awaiting is per-caller, so a cancelled peer detaches without touching | ||
| 2606 | // the grant or its siblings, and a grant nobody is left waiting on | ||
| 2607 | // still settles and still persists the rotated token — which is what | ||
| 2608 | // keeps the next caller off a consumed one. Token requests are bounded | ||
| 2609 | // by `AbortSignal.timeout`, so the detached fiber cannot outlive its | ||
| 2610 | // request. | ||
| 2611 | const deferred = Deferred.makeUnsafe< | ||
| 2612 | string | null, | ||
| 2613 | StorageFailure | CredentialResolutionError | ||
| 2614 | >(); | ||
| 2615 | // Nothing suspends between the lookup above and this registration, so | ||
| 2616 | // check-and-set is atomic against peer fibers and cannot double-fire. | ||
| 2617 | refreshInFlight.set(key, deferred); | ||
| 2618 | const run = performTokenRefresh(row, provider, trigger).pipe( | ||
| 2619 | Effect.exit, | ||
| 2620 | Effect.flatMap((exit) => Deferred.done(deferred, exit)), | ||
| 2621 | Effect.ensuring(Effect.sync(() => void refreshInFlight.delete(key))), | ||
| 2547 | ); | 2622 | ); |
| 2548 | // Re-check after building (a peer fiber may have registered first while | 2623 | return Effect.forkDetach(run).pipe(Effect.andThen(Deferred.await(deferred))); |
| 2549 | // we built ours) so everyone converges on the same shared grant. | ||
| 2550 | const winner = refreshInFlight.get(key) ?? gated; | ||
| 2551 | if (winner === gated) refreshInFlight.set(key, gated); | ||
| 2552 | return yield* winner; | ||
| 2553 | }); | 2624 | }); |
| 2554 | 2625 | ||
| 2555 | // Resolve every named input of a connection (`variable → value`). A | 2626 | // Resolve every named input of a connection (`variable → value`). A |
| @@ -5,6 +5,8 @@ import { describe, expect, it } from "@effect/vitest"; · 4 unmodified lines | |||
| 5 | 5 | ||
| 6 | import { | 6 | import { |
| 7 | candidateIdentityTier, | 7 | candidateIdentityTier, |
| 8 | classifyHttpStatus, | ||
| 9 | classifyProbeResponse, | ||
| 8 | rankResponseSample, | 10 | rankResponseSample, |
| 9 | sortHealthCheckCandidatesByIdentity, | 11 | sortHealthCheckCandidatesByIdentity, |
| 10 | } from "./health-check"; | 12 | } from "./health-check"; |
| @@ -66,3 +68,73 @@ describe("candidateIdentityTier", () => { · 55 unmodified lines | |||
| 66 | ).toBe("user.getAuthUser"); | 68 | ).toBe("user.getAuthUser"); |
| 67 | }); | 69 | }); |
| 68 | }); | 70 | }); |
| 71 | |||
| 72 | // Probe classification: 401/403 mean "credential dead" EXCEPT the | ||
| 73 | // configuration 403 (Google accessNotConfigured / SERVICE_DISABLED), which | ||
| 74 | // must read misconfigured: the token authenticated, the API is disabled in | ||
| 75 | // the OAuth client's project, and reconnecting cannot fix it. This is the | ||
| 76 | // production case behind the "Expired when it is not" report (2026-07-10). | ||
| 77 | describe("classifyProbeResponse", () => { | ||
| 78 | const disabledMessage = | ||
| 79 | "Gmail API has not been used in project 000000000000 before or it is disabled. " + | ||
| 80 | "Enable it by visiting https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=000000000000 then retry."; | ||
| 81 | |||
| 82 | it("classifies Google's classic accessNotConfigured 403 as misconfigured", () => { | ||
| 83 | const body = { | ||
| 84 | error: { | ||
| 85 | code: 403, | ||
| 86 | message: disabledMessage, | ||
| 87 | errors: [ | ||
| 88 | { message: disabledMessage, domain: "usageLimits", reason: "accessNotConfigured" }, | ||
| 89 | ], | ||
| 90 | status: "PERMISSION_DENIED", | ||
| 91 | }, | ||
| 92 | }; | ||
| 93 | expect(classifyProbeResponse(403, body)).toBe("misconfigured"); | ||
| 94 | }); | ||
| 95 | |||
| 96 | it("classifies the newer SERVICE_DISABLED ErrorInfo detail as misconfigured", () => { | ||
| 97 | const body = { | ||
| 98 | error: { | ||
| 99 | code: 403, | ||
| 100 | message: disabledMessage, | ||
| 101 | status: "PERMISSION_DENIED", | ||
| 102 | details: [ | ||
| 103 | { | ||
| 104 | "@type": "type.googleapis.com/google.rpc.ErrorInfo", | ||
| 105 | reason: "SERVICE_DISABLED", | ||
| 106 | domain: "googleapis.com", | ||
| 107 | }, | ||
| 108 | ], | ||
| 109 | }, | ||
| 110 | }; | ||
| 111 | expect(classifyProbeResponse(403, body)).toBe("misconfigured"); | ||
| 112 | }); | ||
| 113 | |||
| 114 | it("keeps a plain 403 (no recognized reason) as expired: false-expired is the safe default", () => { | ||
| 115 | expect(classifyProbeResponse(403, { error: { code: 403, message: "Forbidden" } })).toBe( | ||
| 116 | "expired", | ||
| 117 | ); | ||
| 118 | expect(classifyProbeResponse(403, undefined)).toBe("expired"); | ||
| 119 | expect(classifyProbeResponse(403, "Forbidden")).toBe("expired"); | ||
| 120 | // A permission reason that is NOT a configuration marker stays expired. | ||
| 121 | expect( | ||
| 122 | classifyProbeResponse(403, { | ||
| 123 | error: { errors: [{ reason: "insufficientPermissions" }] }, | ||
| 124 | }), | ||
| 125 | ).toBe("expired"); | ||
| 126 | }); | ||
| 127 | |||
| 128 | it("never rewrites a 401: an authentication failure is a credential problem regardless of body", () => { | ||
| 129 | const body = { error: { errors: [{ reason: "accessNotConfigured" }] } }; | ||
| 130 | expect(classifyProbeResponse(401, body)).toBe("expired"); | ||
| 131 | }); | ||
| 132 | |||
| 133 | it("matches the status-only classifier everywhere else", () => { | ||
| 134 | for (const status of [200, 204, 404, 429, 500, 503]) { | ||
| 135 | expect(classifyProbeResponse(status, { error: {} }), `status ${status}`).toBe( | ||
| 136 | classifyHttpStatus(status), | ||
| 137 | ); | ||
| 138 | } | ||
| 139 | }); | ||
| 140 | }); | ||
| @@ -18,14 +18,24 @@ · 17 unmodified lines | |||
| 18 | import { Schema } from "effect"; | 18 | import { Schema } from "effect"; |
| 19 | 19 | ||
| 20 | // --------------------------------------------------------------------------- | 20 | // --------------------------------------------------------------------------- |
| 21 | // Status: the four states a connection can be in. `expired` is the one this | 21 | // Status: the five states a connection can be in. `expired` is the one this |
| 22 | // whole feature exists for (Google's 7-day dev-token revocation): the credential | 22 | // whole feature exists for (Google's 7-day dev-token revocation): the credential |
| 23 | // authenticated fine yesterday and now returns 401/403. `degraded` covers any | 23 | // authenticated fine yesterday and now returns 401/403. `misconfigured` is the |
| 24 | // other non-2xx (upstream 5xx, a 404 on a mis-picked operation); `unknown` is | 24 | // 403 that is NOT a credential problem: the token authenticated, but the |
| 25 | // "never checked / no health check configured". | 25 | // upstream rejects on configuration (a Google API not enabled in the OAuth |
| 26 | // client's GCP project): the fix is in the provider console, so telling the | ||
| 27 | // user to reconnect would mislead. `degraded` covers any other non-2xx | ||
| 28 | // (upstream 5xx, a 404 on a mis-picked operation); `unknown` is "never checked | ||
| 29 | // / no health check configured". | ||
| 26 | // --------------------------------------------------------------------------- | 30 | // --------------------------------------------------------------------------- |
| 27 | 31 | ||
| 28 | export const HealthStatus = Schema.Literals(["healthy", "expired", "degraded", "unknown"]); | 32 | export const HealthStatus = Schema.Literals([ |
| 33 | "healthy", | ||
| 34 | "expired", | ||
| 35 | "misconfigured", | ||
| 36 | "degraded", | ||
| 37 | "unknown", | ||
| 38 | ]); | ||
| 29 | export type HealthStatus = typeof HealthStatus.Type; | 39 | export type HealthStatus = typeof HealthStatus.Type; |
| 30 | 40 | ||
| 31 | // --------------------------------------------------------------------------- | 41 | // --------------------------------------------------------------------------- |
| @@ -139,13 +149,59 @@ export type HealthCheckCandidate = typeof HealthCheckCandidate.Type; · 107 unmodified lines | |||
| 139 | // --------------------------------------------------------------------------- | 149 | // --------------------------------------------------------------------------- |
| 140 | 150 | ||
| 141 | /** Map an HTTP status to a health state: 2xx healthy, 401/403 expired (the auth | 151 | /** Map an HTTP status to a health state: 2xx healthy, 401/403 expired (the auth |
| 142 | * wall), everything else degraded. */ | 152 | * wall), everything else degraded. Status-only fallback: when the response |
| 153 | * BODY is available, use `classifyProbeResponse` instead, which tells a | ||
| 154 | * credential 403 apart from a configuration 403. */ | ||
| 143 | export const classifyHttpStatus = (status: number): HealthStatus => { | 155 | export const classifyHttpStatus = (status: number): HealthStatus => { |
| 144 | if (status >= 200 && status < 300) return "healthy"; | 156 | if (status >= 200 && status < 300) return "healthy"; |
| 145 | if (status === 401 || status === 403) return "expired"; | 157 | if (status === 401 || status === 403) return "expired"; |
| 146 | return "degraded"; | 158 | return "degraded"; |
| 147 | }; | 159 | }; |
| 148 | 160 | ||
| 161 | // Error `reason` / `status` markers that make a 403 a CONFIGURATION rejection | ||
| 162 | // rather than a credential one. Deliberately narrow and provider-shaped: | ||
| 163 | // Google's error envelope carries `errors[].reason: "accessNotConfigured"` | ||
| 164 | // (message "<API> has not been used in project N before or it is disabled") | ||
| 165 | // and newer surfaces use `status: "PERMISSION_DENIED"` with | ||
| 166 | // `details[].reason: "SERVICE_DISABLED"`. Unrecognized 403s keep the expired | ||
| 167 | // classification: a false "expired" prompts a harmless reconnect, but a false | ||
| 168 | // "misconfigured" would hide a genuinely dead credential. | ||
| 169 | const CONFIGURATION_403_REASONS = new Set(["accessnotconfigured", "service_disabled"]); | ||
| 170 | |||
| 171 | /** Collect candidate `reason` markers from a Google-shaped error envelope: | ||
| 172 | * `error.errors[].reason` (classic) and `error.details[].reason` (newer | ||
| 173 | * google.rpc.ErrorInfo). Tolerant of partial shapes; returns []. */ | ||
| 174 | const errorReasonMarkers = (body: unknown): string[] => { | ||
| 175 | if (body == null || typeof body !== "object") return []; | ||
| 176 | const error = (body as Record<string, unknown>)["error"]; | ||
| 177 | if (error == null || typeof error !== "object") return []; | ||
| 178 | const markers: string[] = []; | ||
| 179 | for (const key of ["errors", "details"] as const) { | ||
| 180 | const entries = (error as Record<string, unknown>)[key]; | ||
| 181 | if (!Array.isArray(entries)) continue; | ||
| 182 | for (const entry of entries) { | ||
| 183 | if (entry == null || typeof entry !== "object") continue; | ||
| 184 | const reason = (entry as Record<string, unknown>)["reason"]; | ||
| 185 | if (typeof reason === "string") markers.push(reason.toLowerCase()); | ||
| 186 | } | ||
| 187 | } | ||
| 188 | return markers; | ||
| 189 | }; | ||
| 190 | |||
| 191 | /** Classify a probe response from its status AND body. Everything is | ||
| 192 | * `classifyHttpStatus` except one carve-out: a 403 whose error body carries a | ||
| 193 | * known configuration reason (Google `accessNotConfigured` / | ||
| 194 | * `SERVICE_DISABLED`) is `misconfigured`, not `expired`: the credential | ||
| 195 | * authenticated; the upstream API is disabled in the OAuth client's project, | ||
| 196 | * and only enabling it there (not reconnecting) fixes it. */ | ||
| 197 | export const classifyProbeResponse = (status: number, body: unknown): HealthStatus => { | ||
| 198 | const byStatus = classifyHttpStatus(status); | ||
| 199 | if (status !== 403 || byStatus !== "expired") return byStatus; | ||
| 200 | return errorReasonMarkers(body).some((reason) => CONFIGURATION_403_REASONS.has(reason)) | ||
| 201 | ? "misconfigured" | ||
| 202 | : "expired"; | ||
| 203 | }; | ||
| 204 | |||
| 149 | /** Resolve a dot-path (`a.b.0.c`) against a JSON value and coerce the leaf to a | 205 | /** Resolve a dot-path (`a.b.0.c`) against a JSON value and coerce the leaf to a |
| 150 | * display string. Numeric segments index arrays. Returns undefined when the | 206 | * display string. Numeric segments index arrays. Returns undefined when the |
| 151 | * path is empty, missing, or lands on a non-scalar. */ | 207 | * path is empty, missing, or lands on a non-scalar. */ |
| @@ -122,6 +122,7 @@ export { · 121 unmodified lines | |||
| 122 | HealthCheckCandidateParameter, | 122 | HealthCheckCandidateParameter, |
| 123 | HealthCheckResponseField, | 123 | HealthCheckResponseField, |
| 124 | classifyHttpStatus, | 124 | classifyHttpStatus, |
| 125 | classifyProbeResponse, | ||
| 125 | extractIdentity, | 126 | extractIdentity, |
| 126 | compareHealthCheckCandidates, | 127 | compareHealthCheckCandidates, |
| 127 | candidateIdentityTier, | 128 | candidateIdentityTier, |
| @@ -187,6 +187,25 @@ export interface FirstPartyOAuthClientConfig { · 186 unmodified lines | |||
| 187 | * GitHub Apps, whose capabilities are configured on the app and whose OAuth | 187 | * GitHub Apps, whose capabilities are configured on the app and whose OAuth |
| 188 | * user-token flow does not use scopes. Omit for normal OAuth clients. */ | 188 | * user-token flow does not use scopes. Omit for normal OAuth clients. */ |
| 189 | readonly authorizationScopes?: readonly string[]; | 189 | readonly authorizationScopes?: readonly string[]; |
| 190 | /** Scopes the host always adds to the integration-declared set. Use this for | ||
| 191 | * provider lifecycle scopes such as Atlassian and Microsoft | ||
| 192 | * `offline_access`; unlike `authorizationScopes`, this preserves the | ||
| 193 | * integration's least-privilege scopes. */ | ||
| 194 | readonly additionalAuthorizationScopes?: readonly string[]; | ||
| 195 | /** Separator used in the authorize URL's `scope` parameter. OAuth defaults | ||
| 196 | * to a space; legacy providers such as Linear require a comma. */ | ||
| 197 | readonly authorizationScopeSeparator?: string; | ||
| 198 | /** Provider-specific, non-secret authorize parameters fixed by the host | ||
| 199 | * registration contract (for example Atlassian's `audience`). These are | ||
| 200 | * merged after generic provider defaults and may intentionally override | ||
| 201 | * them. */ | ||
| 202 | readonly authorizationExtraParams?: Readonly<Record<string, string>>; | ||
| 203 | /** Token endpoint client-auth transport. Omitted means | ||
| 204 | * `client_secret_post`; `basic` sends the secret only in HTTP Basic auth. */ | ||
| 205 | readonly tokenEndpointAuthMethod?: "body" | "basic"; | ||
| 206 | /** Token endpoint request encoding. OAuth defaults to URL-encoded form; | ||
| 207 | * providers such as Atlassian, ClickUp, and Notion require JSON. */ | ||
| 208 | readonly tokenRequestFormat?: "form" | "json"; | ||
| 190 | /** Withdraw the app from every listing surface without retiring it. It stops | 209 | /** Withdraw the app from every listing surface without retiring it. It stops |
| 191 | * appearing in `listClients` — so connect pickers and the agent-facing | 210 | * appearing in `listClients` — so connect pickers and the agent-facing |
| 192 | * client list never offer it — while remaining fully resolvable by slug. | 211 | * client list never offer it — while remaining fully resolvable by slug. |
| @@ -199,6 +199,42 @@ describe("first-party oauth clients", () => { · 198 unmodified lines | |||
| 199 | ), | 199 | ), |
| 200 | ); | 200 | ); |
| 201 | 201 | ||
| 202 | it.effect("applies first-party lifecycle scopes, separators, and authorize parameters", () => | ||
| 203 | Effect.scoped( | ||
| 204 | Effect.gen(function* () { | ||
| 205 | const server = yield* serveOAuthTestServer({ scopes: ["read", "offline_access"] }); | ||
| 206 | const { executor } = yield* makeTestWorkspaceHarness({ | ||
| 207 | plugins, | ||
| 208 | firstPartyOAuthClients: [ | ||
| 209 | { | ||
| 210 | ...firstPartyClientFor(server), | ||
| 211 | allowedScopes: ["read", "offline_access"], | ||
| 212 | additionalAuthorizationScopes: ["offline_access"], | ||
| 213 | authorizationScopeSeparator: ",", | ||
| 214 | authorizationExtraParams: { audience: "api.example.com", prompt: "consent" }, | ||
| 215 | }, | ||
| 216 | ], | ||
| 217 | }); | ||
| 218 | yield* executor.acme.seed(["read"]); | ||
| 219 | |||
| 220 | const started = yield* executor.oauth.start({ | ||
| 221 | owner: "org", | ||
| 222 | client: FIRST_PARTY, | ||
| 223 | clientOwner: "org", | ||
| 224 | name: ConnectionName.make("lifecycle"), | ||
| 225 | integration: INTEG, | ||
| 226 | template: TEMPLATE, | ||
| 227 | }); | ||
| 228 | expect(started.status).toBe("redirect"); | ||
| 229 | if (started.status !== "redirect") return; | ||
| 230 | const authorizationUrl = new URL(started.authorizationUrl); | ||
| 231 | expect(authorizationUrl.searchParams.get("scope")).toBe("read,offline_access"); | ||
| 232 | expect(authorizationUrl.searchParams.get("audience")).toBe("api.example.com"); | ||
| 233 | expect(authorizationUrl.searchParams.get("prompt")).toBe("consent"); | ||
| 234 | }), | ||
| 235 | ), | ||
| 236 | ); | ||
| 237 | |||
| 202 | it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () => | 238 | it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () => |
| 203 | Effect.scoped( | 239 | Effect.scoped( |
| 204 | Effect.gen(function* () { | 240 | Effect.gen(function* () { |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Deferred, Effect, Fiber, Predicate } from "effect"; | 2 | import { Deferred, Effect, Fiber, Predicate } from "effect"; |
| 3 | import { withQueryContext } from "@executor-js/fumadb/query"; | ||
| 3 | 4 | ||
| 4 | import { | 5 | import { |
| 5 | AuthTemplateSlug, | 6 | AuthTemplateSlug, |
| @@ -8,6 +9,8 @@ import { · 2 unmodified lines | |||
| 8 | OAuthClientSlug, | 9 | OAuthClientSlug, |
| 9 | OAuthState, | 10 | OAuthState, |
| 10 | ProviderKey, | 11 | ProviderKey, |
| 12 | Subject, | ||
| 13 | Tenant, | ||
| 11 | ToolAddress, | 14 | ToolAddress, |
| 12 | ToolName, | 15 | ToolName, |
| 13 | } from "./ids"; | 16 | } from "./ids"; |
| @@ -68,6 +71,69 @@ const oauthPlugin = definePlugin(() => ({ · 54 unmodified lines | |||
| 68 | 71 | ||
| 69 | const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const; | 72 | const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const; |
| 70 | 73 | ||
| 74 | // Stated explicitly where a test builds a SECOND root database handle by hand: | ||
| 75 | // both handles must carry the same owner-policy context to address one | ||
| 76 | // connection, so the values cannot be left to `makeTestConfig`'s defaults. | ||
| 77 | const SHARED_STORE_TENANT = "test-tenant"; | ||
| 78 | const SHARED_STORE_SUBJECT = "test-subject"; | ||
| 79 | |||
| 80 | /** The URL a `fetch` double was handed, however the caller spelled it. */ | ||
| 81 | const fetchTarget = (input: Parameters<typeof globalThis.fetch>[0]): string => | ||
| 82 | typeof input === "string" ? input : input instanceof URL ? input.href : input.url; | ||
| 83 | |||
| 84 | /** | ||
| 85 | * A `fetch` that holds token-endpoint requests open AFTER the authorization | ||
| 86 | * server has answered them. | ||
| 87 | * | ||
| 88 | * Parking after the response is the point. The refresh token has been rotated | ||
| 89 | * upstream by then, and the in-flight gate entry is still registered, so a peer | ||
| 90 | * arriving during the park has to resolve against an OPEN grant rather than a | ||
| 91 | * settled one. Parking before the response would prove nothing: the grant would | ||
| 92 | * never reach the server, and a peer that went on to run its own grant would | ||
| 93 | * find the stored token still live and succeed. | ||
| 94 | * | ||
| 95 | * Idle until `arm()`, so connection setup (the authorization-code exchange) | ||
| 96 | * runs through untouched. | ||
| 97 | */ | ||
| 98 | const makeTokenRequestPark = () => { | ||
| 99 | let armed = false; | ||
| 100 | let onSeen: (() => void) | null = null; | ||
| 101 | const seen = new Promise<void>((resolve) => { | ||
| 102 | onSeen = resolve; | ||
| 103 | }); | ||
| 104 | let onRelease: (() => void) | null = null; | ||
| 105 | const parked = new Promise<void>((resolve) => { | ||
| 106 | onRelease = resolve; | ||
| 107 | }); | ||
| 108 | // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: the park wraps the platform fetch and must delegate back to it, which is the only seam that can hold a token request open mid-grant. | ||
| 109 | const platformFetch: typeof globalThis.fetch = globalThis.fetch; | ||
| 110 | const fetch: typeof globalThis.fetch = async (input, init) => { | ||
| 111 | const response = await platformFetch(input, init); | ||
| 112 | if (armed && new URL(fetchTarget(input)).pathname === "/token") { | ||
| 113 | onSeen?.(); | ||
| 114 | await parked; | ||
| 115 | } | ||
| 116 | return response; | ||
| 117 | }; | ||
| 118 | return { | ||
| 119 | fetch, | ||
| 120 | arm: () => { | ||
| 121 | armed = true; | ||
| 122 | }, | ||
| 123 | /** Resolves once a token request has been answered and is being held. */ | ||
| 124 | seen, | ||
| 125 | release: () => onRelease?.(), | ||
| 126 | }; | ||
| 127 | }; | ||
| 128 | |||
| 129 | /** Every refresh-token grant the authorization server was asked for. */ | ||
| 130 | const refreshGrantsIn = ( | ||
| 131 | requests: ReadonlyArray<{ readonly path: string; readonly body: string }>, | ||
| 132 | ) => | ||
| 133 | requests.filter( | ||
| 134 | (request) => request.path === "/token" && request.body.includes("grant_type=refresh_token"), | ||
| 135 | ); | ||
| 136 | |||
| 71 | interface TokenEndpointCall { | 137 | interface TokenEndpointCall { |
| 72 | readonly host: string; | 138 | readonly host: string; |
| 73 | readonly grantType: string | null; | 139 | readonly grantType: string | null; |
| @@ -887,14 +953,344 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 813 unmodified lines | |||
| 887 | ), | 953 | ), |
| 888 | ); | 954 | ); |
| 889 | 955 | ||
| 956 | // Issue #1520, in one process. A self-host builds a FRESH execution stack per | ||
| 957 | // MCP session over ONE database handle, so two sessions resolving the same | ||
| 958 | // connection each read the same stored refresh token and each believe they | ||
| 959 | // are the refresh winner. The authorization server rotates that token, so the | ||
| 960 | // loser redeems one the winner already spent, and a server that detects reuse | ||
| 961 | // revokes the whole family: the connection dies and the user must | ||
| 962 | // reauthorize. The first refresh always succeeds, which is why the fault | ||
| 963 | // stays invisible until a later expiry. | ||
| 964 | it.effect("two execution stacks over one host database share a single refresh grant", () => | ||
| 965 | Effect.scoped( | ||
| 966 | Effect.gen(function* () { | ||
| 967 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | ||
| 968 | const park = makeTokenRequestPark(); | ||
| 969 | |||
| 970 | // One database handle and one credential store under two execution | ||
| 971 | // stacks — what a self-host holds while two MCP sessions are open. | ||
| 972 | const config = { ...makeTestConfig({ plugins }), fetch: park.fetch }; | ||
| 973 | const sessionA = yield* createExecutor(config); | ||
| 974 | const sessionB = yield* createExecutor(config); | ||
| 975 | yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore)); | ||
| 976 | yield* Effect.addFinalizer(() => sessionB.close().pipe(Effect.ignore)); | ||
| 977 | yield* Effect.addFinalizer(() => | ||
| 978 | Effect.promise(() => config.testDb.close()).pipe(Effect.ignore), | ||
| 979 | ); | ||
| 980 | |||
| 981 | yield* sessionA.acme.seed(); | ||
| 982 | yield* sessionA.oauth.createClient({ | ||
| 983 | owner: "org", | ||
| 984 | slug: CLIENT, | ||
| 985 | authorizationUrl: server.authorizationEndpoint, | ||
| 986 | tokenUrl: server.tokenEndpoint, | ||
| 987 | grant: "authorization_code", | ||
| 988 | clientId: "test-client", | ||
| 989 | clientSecret: "test-secret", | ||
| 990 | }); | ||
| 991 | const started = yield* sessionA.oauth.start({ | ||
| 992 | owner: "org", | ||
| 993 | client: CLIENT, | ||
| 994 | clientOwner: "org", | ||
| 995 | name: ConnectionName.make("main"), | ||
| 996 | integration: INTEG, | ||
| 997 | template: TEMPLATE, | ||
| 998 | }); | ||
| 999 | expect(started.status).toBe("redirect"); | ||
| 1000 | if (started.status !== "redirect") return; | ||
| 1001 | const callback = yield* server.completeAuthorizationCodeFlow({ | ||
| 1002 | authorizationUrl: started.authorizationUrl, | ||
| 1003 | }); | ||
| 1004 | yield* sessionA.oauth.complete({ state: started.state, code: callback.code }); | ||
| 1005 | |||
| 1006 | const address = ToolAddress.make("tools.acme.org.main.whoami"); | ||
| 1007 | const original = (yield* sessionA.execute(address, {})) as { token: string }; | ||
| 1008 | |||
| 1009 | // Expire the access token so BOTH stacks must refresh. | ||
| 1010 | yield* Effect.promise(() => | ||
| 1011 | config.db.updateMany("connection", { | ||
| 1012 | where: (b) => b("name", "=", "main"), | ||
| 1013 | set: { expires_at: Date.now() - 60_000 }, | ||
| 1014 | }), | ||
| 1015 | ); | ||
| 1016 | yield* server.clearRequests; | ||
| 1017 | park.arm(); | ||
| 1018 | |||
| 1019 | const first = yield* Effect.forkChild(sessionA.execute(address, {})); | ||
| 1020 | const second = yield* Effect.forkChild(sessionB.execute(address, {})); | ||
| 1021 | // Release only once a grant has been answered and is being held open, | ||
| 1022 | // so the peer resolves against a grant that is still in flight. Without | ||
| 1023 | // the park the peer could arrive after the winner had already settled, | ||
| 1024 | // find a fresh token, refresh nothing, and pass this test for the wrong | ||
| 1025 | // reason. | ||
| 1026 | yield* Effect.promise(() => park.seen); | ||
| 1027 | park.release(); | ||
| 1028 | |||
| 1029 | const firstToken = (yield* Fiber.join(first)) as { token: string }; | ||
| 1030 | const secondToken = (yield* Fiber.join(second)) as { token: string }; | ||
| 1031 | |||
| 1032 | expect(firstToken.token, "the refresh minted a new access token").not.toBe(original.token); | ||
| 1033 | expect(secondToken.token, "both stacks resolved the SAME refreshed token").toBe( | ||
| 1034 | firstToken.token, | ||
| 1035 | ); | ||
| 1036 | expect( | ||
| 1037 | refreshGrantsIn(yield* server.requests), | ||
| 1038 | "one refresh grant for the connection, not one per execution stack", | ||
| 1039 | ).toHaveLength(1); | ||
| 1040 | }), | ||
| 1041 | ), | ||
| 1042 | ); | ||
| 1043 | |||
| 1044 | // The gate spans tenants (one map per root DB handle), so its key must keep | ||
| 1045 | // tenant and subject unambiguous. Both are opaque strings that may contain | ||
| 1046 | // any delimiter: under a colon-joined key, tenant "a" + subject "user:b" and | ||
| 1047 | // tenant "a:user" + subject "b" both flatten to "a:user:user:b:…", so two | ||
| 1048 | // DIFFERENT tenants' refreshes would share one gate entry and one tenant's | ||
| 1049 | // caller would be handed the other tenant's access token. | ||
| 1050 | it.effect( | ||
| 1051 | "colliding tenant/subject pairs never share a refresh gate entry", | ||
| 1052 | () => | ||
| 1053 | Effect.scoped( | ||
| 1054 | Effect.gen(function* () { | ||
| 1055 | const serverA = yield* serveOAuthTestServer({ scopes: ["read"] }); | ||
| 1056 | const serverB = yield* serveOAuthTestServer({ scopes: ["read"] }); | ||
| 1057 | const parkA = makeTokenRequestPark(); | ||
| 1058 | const parkB = makeTokenRequestPark(); | ||
| 1059 | |||
| 1060 | // ONE root DB handle under TWO tenants — the shape a multi-tenant host | ||
| 1061 | // holds — so both executors share one refresh gate. `shared.db` stays | ||
| 1062 | // bound to tenant A's owner-policy context; tenant B's row edits below | ||
| 1063 | // build their own scoped handle by hand. Each tenant gets its OWN | ||
| 1064 | // credential store instance: the memory store keys items without a | ||
| 1065 | // tenant, so sharing one across tenants would cross their tokens at | ||
| 1066 | // the store layer and mask the gate-key collision this test is about. | ||
| 1067 | const pluginsA = [memoryCredentialsPlugin(), oauthPlugin] as const; | ||
| 1068 | const pluginsB = [memoryCredentialsPlugin(), oauthPlugin] as const; | ||
| 1069 | const shared = makeTestConfig({ plugins: pluginsA, tenant: "a", subject: "user:b" }); | ||
| 1070 | const configA = { ...shared, fetch: parkA.fetch }; | ||
| 1071 | const configB = { | ||
| 1072 | ...shared, | ||
| 1073 | plugins: pluginsB, | ||
| 1074 | tenant: Tenant.make("a:user"), | ||
| 1075 | subject: Subject.make("b"), | ||
| 1076 | fetch: parkB.fetch, | ||
| 1077 | }; | ||
| 1078 | const sessionA = yield* createExecutor(configA); | ||
| 1079 | const sessionB = yield* createExecutor(configB); | ||
| 1080 | yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore)); | ||
| 1081 | yield* Effect.addFinalizer(() => sessionB.close().pipe(Effect.ignore)); | ||
| 1082 | yield* Effect.addFinalizer(() => | ||
| 1083 | Effect.promise(() => shared.testDb.close()).pipe(Effect.ignore), | ||
| 1084 | ); | ||
| 1085 | |||
| 1086 | // Each tenant mints its own USER-owned connection (user rows carry the | ||
| 1087 | // session subject, which is what the colliding pair needs) against its | ||
| 1088 | // own authorization server, so token provenance is observable. | ||
| 1089 | const connect = (session: typeof sessionA, server: typeof serverA) => | ||
| 1090 | Effect.gen(function* () { | ||
| 1091 | yield* session.acme.seed(); | ||
| 1092 | yield* session.oauth.createClient({ | ||
| 1093 | owner: "org", | ||
| 1094 | slug: CLIENT, | ||
| 1095 | authorizationUrl: server.authorizationEndpoint, | ||
| 1096 | tokenUrl: server.tokenEndpoint, | ||
| 1097 | grant: "authorization_code", | ||
| 1098 | clientId: "test-client", | ||
| 1099 | clientSecret: "test-secret", | ||
| 1100 | }); | ||
| 1101 | const started = yield* session.oauth.start({ | ||
| 1102 | owner: "user", | ||
| 1103 | client: CLIENT, | ||
| 1104 | clientOwner: "org", | ||
| 1105 | name: ConnectionName.make("mine"), | ||
| 1106 | integration: INTEG, | ||
| 1107 | template: TEMPLATE, | ||
| 1108 | }); | ||
| 1109 | expect(started.status).toBe("redirect"); | ||
| 1110 | if (started.status !== "redirect") return; | ||
| 1111 | const callback = yield* server.completeAuthorizationCodeFlow({ | ||
| 1112 | authorizationUrl: started.authorizationUrl, | ||
| 1113 | }); | ||
| 1114 | yield* session.oauth.complete({ state: started.state, code: callback.code }); | ||
| 1115 | }); | ||
| 1116 | yield* connect(sessionA, serverA); | ||
| 1117 | yield* connect(sessionB, serverB); | ||
| 1118 | |||
| 1119 | const address = ToolAddress.make("tools.acme.user.mine.whoami"); | ||
| 1120 | const originalA = (yield* sessionA.execute(address, {})) as { token: string }; | ||
| 1121 | const originalB = (yield* sessionB.execute(address, {})) as { token: string }; | ||
| 1122 | expect(originalB.token).not.toBe(originalA.token); | ||
| 1123 | |||
| 1124 | // Expire BOTH rows so both tenants must refresh. `shared.db` is bound | ||
| 1125 | // to tenant A; tenant B's partition needs its own scoped handle. | ||
| 1126 | const dbB = withQueryContext(shared.testDb.db, { tenant: "a:user", subject: "b" }); | ||
| 1127 | yield* Effect.promise(() => | ||
| 1128 | shared.db.updateMany("connection", { | ||
| 1129 | where: (b) => b("name", "=", "mine"), | ||
| 1130 | set: { expires_at: Date.now() - 60_000 }, | ||
| 1131 | }), | ||
| 1132 | ); | ||
| 1133 | yield* Effect.promise(() => | ||
| 1134 | dbB.updateMany("connection", { | ||
| 1135 | where: (b) => b("name", "=", "mine"), | ||
| 1136 | set: { expires_at: Date.now() - 60_000 }, | ||
| 1137 | }), | ||
| 1138 | ); | ||
| 1139 | |||
| 1140 | parkA.arm(); | ||
| 1141 | parkB.arm(); | ||
| 1142 | const first = yield* Effect.forkChild(sessionA.execute(address, {})); | ||
| 1143 | // Hold tenant A's grant open so its gate entry is still registered | ||
| 1144 | // when tenant B performs its lookup of the would-be colliding key. | ||
| 1145 | yield* Effect.promise(() => parkA.seen); | ||
| 1146 | const second = yield* Effect.forkChild(sessionB.execute(address, {})); | ||
| 1147 | // With a collision-free key, tenant B misses the gate and sends its | ||
| 1148 | // OWN grant. Under a colliding key it would await tenant A's deferred | ||
| 1149 | // and never reach its server, so cap the wait with a real timer (the | ||
| 1150 | // test clock is virtual, so Effect.sleep would never fire) instead of | ||
| 1151 | // hanging the suite; the assertions below then report the bleed. | ||
| 1152 | yield* Effect.promise(() => | ||
| 1153 | Promise.race([parkB.seen, new Promise((resolve) => setTimeout(resolve, 2_000))]), | ||
| 1154 | ); | ||
| 1155 | parkA.release(); | ||
| 1156 | parkB.release(); | ||
| 1157 | |||
| 1158 | const tokenA = (yield* Fiber.join(first)) as { token: string }; | ||
| 1159 | const tokenB = (yield* Fiber.join(second)) as { token: string }; | ||
| 1160 | |||
| 1161 | expect(tokenA.token, "tenant A refreshed to a new token").not.toBe(originalA.token); | ||
| 1162 | expect(tokenB.token, "tenant B refreshed to a new token").not.toBe(originalB.token); | ||
| 1163 | expect(tokenB.token, "no cross-tenant token bleed").not.toBe(tokenA.token); | ||
| 1164 | expect(yield* serverA.acceptsAccessToken(tokenA.token)).toBe(true); | ||
| 1165 | expect( | ||
| 1166 | yield* serverB.acceptsAccessToken(tokenB.token), | ||
| 1167 | "tenant B's token was minted by tenant B's own authorization server", | ||
| 1168 | ).toBe(true); | ||
| 1169 | expect( | ||
| 1170 | refreshGrantsIn(yield* serverA.requests), | ||
| 1171 | "tenant A ran its own refresh grant", | ||
| 1172 | ).toHaveLength(1); | ||
| 1173 | expect( | ||
| 1174 | refreshGrantsIn(yield* serverB.requests), | ||
| 1175 | "tenant B ran its own refresh grant — two distinct refresh executions", | ||
| 1176 | ).toHaveLength(1); | ||
| 1177 | }), | ||
| 1178 | ), | ||
| 1179 | // Two authorization servers, two full mint flows and two refreshes — about | ||
| 1180 | // twice the cost of the single-tenant gate tests, which sits on the 5s | ||
| 1181 | // default when the test runs cold. | ||
| 1182 | 20_000, | ||
| 1183 | ); | ||
| 1184 | |||
| 1185 | // The gate entry is shared, so the stack that REGISTERS a grant is only the | ||
| 1186 | // first arrival, not its owner. Running the grant on that caller's fiber | ||
| 1187 | // would hand it that caller's interruption — a disconnected MCP client, an | ||
| 1188 | // execution deadline, a cancelled tool call — and abandon a refresh token the | ||
| 1189 | // authorization server has ALREADY rotated. What the store still holds is | ||
| 1190 | // then dead, and the next grant is answered invalid_grant: the interruption | ||
| 1191 | // would have killed the connection. So the grant runs detached, and callers | ||
| 1192 | // only await it. | ||
| 1193 | it.effect("an interrupted first arrival still settles the grant and persists its token", () => | ||
| 1194 | Effect.scoped( | ||
| 1195 | Effect.gen(function* () { | ||
| 1196 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | ||
| 1197 | const park = makeTokenRequestPark(); | ||
| 1198 | |||
| 1199 | const config = { ...makeTestConfig({ plugins }), fetch: park.fetch }; | ||
| 1200 | const sessionA = yield* createExecutor(config); | ||
| 1201 | const sessionB = yield* createExecutor(config); | ||
| 1202 | yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore)); | ||
| 1203 | yield* Effect.addFinalizer(() => sessionB.close().pipe(Effect.ignore)); | ||
| 1204 | yield* Effect.addFinalizer(() => | ||
| 1205 | Effect.promise(() => config.testDb.close()).pipe(Effect.ignore), | ||
| 1206 | ); | ||
| 1207 | |||
| 1208 | yield* sessionA.acme.seed(); | ||
| 1209 | yield* sessionA.oauth.createClient({ | ||
| 1210 | owner: "org", | ||
| 1211 | slug: CLIENT, | ||
| 1212 | authorizationUrl: server.authorizationEndpoint, | ||
| 1213 | tokenUrl: server.tokenEndpoint, | ||
| 1214 | grant: "authorization_code", | ||
| 1215 | clientId: "test-client", | ||
| 1216 | clientSecret: "test-secret", | ||
| 1217 | }); | ||
| 1218 | const started = yield* sessionA.oauth.start({ | ||
| 1219 | owner: "org", | ||
| 1220 | client: CLIENT, | ||
| 1221 | clientOwner: "org", | ||
| 1222 | name: ConnectionName.make("main"), | ||
| 1223 | integration: INTEG, | ||
| 1224 | template: TEMPLATE, | ||
| 1225 | }); | ||
| 1226 | expect(started.status).toBe("redirect"); | ||
| 1227 | if (started.status !== "redirect") return; | ||
| 1228 | const callback = yield* server.completeAuthorizationCodeFlow({ | ||
| 1229 | authorizationUrl: started.authorizationUrl, | ||
| 1230 | }); | ||
| 1231 | yield* sessionA.oauth.complete({ state: started.state, code: callback.code }); | ||
| 1232 | |||
| 1233 | const address = ToolAddress.make("tools.acme.org.main.whoami"); | ||
| 1234 | const original = (yield* sessionA.execute(address, {})) as { token: string }; | ||
| 1235 | yield* Effect.promise(() => | ||
| 1236 | config.db.updateMany("connection", { | ||
| 1237 | where: (b) => b("name", "=", "main"), | ||
| 1238 | set: { expires_at: Date.now() - 60_000 }, | ||
| 1239 | }), | ||
| 1240 | ); | ||
| 1241 | yield* server.clearRequests; | ||
| 1242 | park.arm(); | ||
| 1243 | |||
| 1244 | // The first arrival registers the grant, and its session drops the | ||
| 1245 | // instant the authorization server has rotated the token — the worst | ||
| 1246 | // possible moment, and the one a disconnecting MCP client picks. | ||
| 1247 | const arrival = yield* Effect.forkChild(Effect.exit(sessionA.execute(address, {}))); | ||
| 1248 | yield* Effect.promise(() => park.seen); | ||
| 1249 | yield* Fiber.interrupt(arrival); | ||
| 1250 | park.release(); | ||
| 1251 | |||
| 1252 | // That the grant finishes at all, with nobody left waiting on it, is | ||
| 1253 | // the property under test: a rotated token that is never persisted is a | ||
| 1254 | // dead connection. | ||
| 1255 | const persisted = yield* Effect.promise(async () => { | ||
| 1256 | for (let attempt = 0; attempt < 500; attempt += 1) { | ||
| 1257 | const row = await config.db.findFirst("connection", { | ||
| 1258 | where: (b) => b("name", "=", "main"), | ||
| 1259 | }); | ||
| 1260 | const expiresAt = row?.expires_at; | ||
| 1261 | if (expiresAt != null && Number(expiresAt) > Date.now()) return true; | ||
| 1262 | await new Promise((resolve) => setTimeout(resolve, 10)); | ||
| 1263 | } | ||
| 1264 | return false; | ||
| 1265 | }); | ||
| 1266 | expect(persisted, "the detached grant settled and persisted its rotated token").toBe(true); | ||
| 1267 | |||
| 1268 | const recovered = (yield* sessionB.execute(address, {})) as { token: string }; | ||
| 1269 | expect(recovered.token, "the peer stack resolved the rotated token").not.toBe( | ||
| 1270 | original.token, | ||
| 1271 | ); | ||
| 1272 | expect( | ||
| 1273 | yield* server.acceptsAccessToken(recovered.token), | ||
| 1274 | "and the authorization server still honours it", | ||
| 1275 | ).toBe(true); | ||
| 1276 | expect( | ||
| 1277 | refreshGrantsIn(yield* server.requests), | ||
| 1278 | "the interrupted arrival's grant settled, so no second grant was needed", | ||
| 1279 | ).toHaveLength(1); | ||
| 1280 | }), | ||
| 1281 | ), | ||
| 1282 | ); | ||
| 1283 | |||
| 890 | // Two product instances, one connection, one credential store. The in-flight | 1284 | // Two product instances, one connection, one credential store. The in-flight |
| 891 | // refresh gate serialises refreshes WITHIN an instance and cannot see across | 1285 | // refresh gate serialises refreshes across every execution stack over ONE |
| 892 | // them, so nothing but the store itself stands between two refreshers and | 1286 | // root database handle and cannot see past it, so between two INSTANCES — |
| 893 | // the same rotated token. The provider below opens a seam exactly where the | 1287 | // two replicas, two isolates — nothing but the store itself stands between |
| 894 | // danger is — between the read of the stored refresh token and whatever the | 1288 | // two refreshers and the same rotated token. The provider below opens a seam |
| 895 | // reader writes next — because a probe that "tests" the store by rewriting | 1289 | // exactly where the danger is — between the read of the stored refresh token |
| 896 | // the value it just read would put the spent token back over the peer's | 1290 | // and whatever the reader writes next — because a probe that "tests" the |
| 897 | // rotated one, and kill the connection it was added to protect. | 1291 | // store by rewriting the value it just read would put the spent token back |
| 1292 | // over the peer's rotated one, and kill the connection it was added to | ||
| 1293 | // protect. | ||
| 898 | it.effect( | 1294 | it.effect( |
| 899 | "a refresher paused after reading the stored token never writes it back over a peer's rotated one", | 1295 | "a refresher paused after reading the stored token never writes it back over a peer's rotated one", |
| 900 | () => | 1296 | () => |
| @@ -936,12 +1332,30 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 35 unmodified lines | |||
| 936 | // One database and one credential store, two executors over them — | 1332 | // One database and one credential store, two executors over them — |
| 937 | // the deployment this race needs and the one a single harness | 1333 | // the deployment this race needs and the one a single harness |
| 938 | // cannot express. | 1334 | // cannot express. |
| 1335 | // | ||
| 1336 | // Each executor gets its OWN root database handle onto that one | ||
| 1337 | // database, because that handle is what identifies an instance: the | ||
| 1338 | // in-flight refresh gate is shared per handle, so two executors over | ||
| 1339 | // the SAME handle are two execution stacks in one instance and the | ||
| 1340 | // second would simply join the first's grant — closing the very | ||
| 1341 | // window this test exists to open. A second replica holds a second | ||
| 1342 | // handle, which is what the extra `withQueryContext` wrapper is. | ||
| 939 | const config = { | 1343 | const config = { |
| 940 | ...makeTestConfig({ plugins: [oauthPlugin] as const }), | 1344 | ...makeTestConfig({ |
| 1345 | plugins: [oauthPlugin] as const, | ||
| 1346 | tenant: SHARED_STORE_TENANT, | ||
| 1347 | subject: SHARED_STORE_SUBJECT, | ||
| 1348 | }), | ||
| 941 | providers: [sharedStore], | 1349 | providers: [sharedStore], |
| 942 | }; | 1350 | }; |
| 943 | const instanceA = yield* createExecutor(config); | 1351 | const instanceA = yield* createExecutor(config); |
| 944 | const instanceB = yield* createExecutor(config); | 1352 | const instanceB = yield* createExecutor({ |
| 1353 | ...config, | ||
| 1354 | db: withQueryContext(config.testDb.db, { | ||
| 1355 | tenant: SHARED_STORE_TENANT, | ||
| 1356 | subject: SHARED_STORE_SUBJECT, | ||
| 1357 | }), | ||
| 1358 | }); | ||
| 945 | yield* Effect.addFinalizer(() => | 1359 | yield* Effect.addFinalizer(() => |
| 946 | Effect.promise(() => config.testDb.close()).pipe(Effect.ignore), | 1360 | Effect.promise(() => config.testDb.close()).pipe(Effect.ignore), |
| 947 | ); | 1361 | ); |
| @@ -6,7 +6,8 @@ · 5 unmodified lines | |||
| 6 | // --------------------------------------------------------------------------- | 6 | // --------------------------------------------------------------------------- |
| 7 | 7 | ||
| 8 | import { describe, expect, it } from "@effect/vitest"; | 8 | import { describe, expect, it } from "@effect/vitest"; |
| 9 | import { Cause, Effect, Exit, Ref } from "effect"; | 9 | import { Cause, Effect, Exit, Ref, Schema } from "effect"; |
| 10 | import type * as Tracer from "effect/Tracer"; | ||
| 10 | import { HttpServerResponse } from "effect/unstable/http"; | 11 | import { HttpServerResponse } from "effect/unstable/http"; |
| 11 | 12 | ||
| 12 | import { | 13 | import { |
| @@ -34,8 +35,11 @@ interface TokenCall { · 21 unmodified lines | |||
| 34 | readonly url: string; | 35 | readonly url: string; |
| 35 | readonly headers: Readonly<Record<string, string>>; | 36 | readonly headers: Readonly<Record<string, string>>; |
| 36 | readonly body: URLSearchParams; | 37 | readonly body: URLSearchParams; |
| 38 | readonly jsonBody: unknown; | ||
| 37 | } | 39 | } |
| 38 | 40 | ||
| 41 | const decodeJsonBody = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 42 | |||
| 39 | type TokenHandler = (call: TokenCall) => HttpServerResponse.HttpServerResponse; | 43 | type TokenHandler = (call: TokenCall) => HttpServerResponse.HttpServerResponse; |
| 40 | 44 | ||
| 41 | const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse => | 45 | const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse => |
| @@ -52,6 +56,9 @@ const serveTokenEndpoint = (handler: TokenHandler) => · 10 unmodified lines | |||
| 52 | url: request.url ?? "/", | 56 | url: request.url ?? "/", |
| 53 | headers: request.headers, | 57 | headers: request.headers, |
| 54 | body: new URLSearchParams(bodyText), | 58 | body: new URLSearchParams(bodyText), |
| 59 | jsonBody: request.headers["content-type"]?.startsWith("application/json") | ||
| 60 | ? decodeJsonBody(bodyText) | ||
| 61 | : null, | ||
| 55 | }; | 62 | }; |
| 56 | yield* Ref.update(calls, (all) => [...all, call]); | 63 | yield* Ref.update(calls, (all) => [...all, call]); |
| 57 | return handler(call); | 64 | return handler(call); |
| @@ -103,6 +110,45 @@ const tokenResponse = · 45 unmodified lines | |||
| 103 | () => | 110 | () => |
| 104 | json(200, body); | 111 | json(200, body); |
| 105 | 112 | ||
| 113 | /** Records each span's attributes and its ending exit, so a test can assert on | ||
| 114 | * exactly what telemetry would export for the token-request span. */ | ||
| 115 | interface RecordedSpan { | ||
| 116 | readonly attributes: Map<string, unknown>; | ||
| 117 | endExit?: unknown; | ||
| 118 | } | ||
| 119 | |||
| 120 | const makeRecordingTracer = (spans: Map<string, RecordedSpan>): Tracer.Tracer => ({ | ||
| 121 | span: (options) => { | ||
| 122 | const record: RecordedSpan = { attributes: new Map() }; | ||
| 123 | spans.set(options.name, record); | ||
| 124 | let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime }; | ||
| 125 | return { | ||
| 126 | _tag: "Span", | ||
| 127 | name: options.name, | ||
| 128 | spanId: "0000000000000001", | ||
| 129 | traceId: "00000000000000000000000000000001", | ||
| 130 | parent: options.parent, | ||
| 131 | annotations: options.annotations, | ||
| 132 | get status() { | ||
| 133 | return status; | ||
| 134 | }, | ||
| 135 | attributes: record.attributes, | ||
| 136 | links: options.links, | ||
| 137 | sampled: options.sampled, | ||
| 138 | kind: options.kind, | ||
| 139 | end: (endTime, exit) => { | ||
| 140 | record.endExit = exit; | ||
| 141 | status = { _tag: "Ended", startTime: options.startTime, endTime, exit }; | ||
| 142 | }, | ||
| 143 | attribute: (key, value) => { | ||
| 144 | record.attributes.set(key, value); | ||
| 145 | }, | ||
| 146 | event: () => undefined, | ||
| 147 | addLinks: () => undefined, | ||
| 148 | }; | ||
| 149 | }, | ||
| 150 | }); | ||
| 151 | |||
| 106 | const tokenResponseFetch = | 152 | const tokenResponseFetch = |
| 107 | (body: unknown): typeof globalThis.fetch => | 153 | (body: unknown): typeof globalThis.fetch => |
| 108 | async () => | 154 | async () => |
| @@ -260,6 +306,56 @@ describe("buildAuthorizationUrl", () => { · 151 unmodified lines | |||
| 260 | }); | 306 | }); |
| 261 | 307 | ||
| 262 | describe("exchangeAuthorizationCode", () => { | 308 | describe("exchangeAuthorizationCode", () => { |
| 309 | it.effect("supports JSON token exchange with HTTP Basic client authentication", () => | ||
| 310 | withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => | ||
| 311 | Effect.gen(function* () { | ||
| 312 | yield* exchangeAuthorizationCode({ | ||
| 313 | tokenUrl, | ||
| 314 | clientId: "cid", | ||
| 315 | clientSecret: "csecret", | ||
| 316 | redirectUrl: "https://app.example.com/cb", | ||
| 317 | codeVerifier: "verifier", | ||
| 318 | code: "abc", | ||
| 319 | clientAuth: "basic", | ||
| 320 | requestFormat: "json", | ||
| 321 | }); | ||
| 322 | const call = (yield* calls)[0]!; | ||
| 323 | expect(call.headers["content-type"]).toBe("application/json"); | ||
| 324 | expect(call.headers["authorization"]).toBe("Basic Y2lkOmNzZWNyZXQ="); | ||
| 325 | expect(call.jsonBody).toEqual({ | ||
| 326 | grant_type: "authorization_code", | ||
| 327 | code: "abc", | ||
| 328 | redirect_uri: "https://app.example.com/cb", | ||
| 329 | code_verifier: "verifier", | ||
| 330 | }); | ||
| 331 | }), | ||
| 332 | ), | ||
| 333 | ); | ||
| 334 | |||
| 335 | it.effect("supports JSON token exchange with client credentials in the body", () => | ||
| 336 | withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => | ||
| 337 | Effect.gen(function* () { | ||
| 338 | yield* exchangeAuthorizationCode({ | ||
| 339 | tokenUrl, | ||
| 340 | clientId: "cid", | ||
| 341 | clientSecret: "csecret", | ||
| 342 | redirectUrl: "https://app.example.com/cb", | ||
| 343 | codeVerifier: "verifier", | ||
| 344 | code: "abc", | ||
| 345 | requestFormat: "json", | ||
| 346 | }); | ||
| 347 | expect((yield* calls)[0]!.jsonBody).toEqual({ | ||
| 348 | grant_type: "authorization_code", | ||
| 349 | code: "abc", | ||
| 350 | redirect_uri: "https://app.example.com/cb", | ||
| 351 | code_verifier: "verifier", | ||
| 352 | client_id: "cid", | ||
| 353 | client_secret: "csecret", | ||
| 354 | }); | ||
| 355 | }), | ||
| 356 | ), | ||
| 357 | ); | ||
| 358 | |||
| 263 | it.effect("posts form-urlencoded body with grant_type=authorization_code and PKCE verifier", () => | 359 | it.effect("posts form-urlencoded body with grant_type=authorization_code and PKCE verifier", () => |
| 264 | withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => | 360 | withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => |
| 265 | Effect.gen(function* () { | 361 | Effect.gen(function* () { |
| @@ -765,6 +861,9 @@ describe("exchangeAuthorizationCode", () => { · 499 unmodified lines | |||
| 765 | }), | 861 | }), |
| 766 | ); | 862 | ); |
| 767 | 863 | ||
| 864 | // Non-secret description text must keep propagating even from a confidential | ||
| 865 | // client: the scrub below removes ONLY the exact submitted secret, never the | ||
| 866 | // AS's verdict prose around it. | ||
| 768 | it.effect("propagates RFC 6749 error_description text in the OAuth2Error", () => | 867 | it.effect("propagates RFC 6749 error_description text in the OAuth2Error", () => |
| 769 | withTokenEndpoint( | 868 | withTokenEndpoint( |
| 770 | () => | 869 | () => |
| @@ -778,6 +877,7 @@ describe("exchangeAuthorizationCode", () => { · 7 unmodified lines | |||
| 778 | exchangeAuthorizationCode({ | 877 | exchangeAuthorizationCode({ |
| 779 | tokenUrl, | 878 | tokenUrl, |
| 780 | clientId: "cid", | 879 | clientId: "cid", |
| 880 | clientSecret: "csecret", | ||
| 781 | redirectUrl: "https://cb", | 881 | redirectUrl: "https://cb", |
| 782 | codeVerifier: "v", | 882 | codeVerifier: "v", |
| 783 | code: "c", | 883 | code: "c", |
| @@ -786,10 +886,67 @@ describe("exchangeAuthorizationCode", () => { · 2 unmodified lines | |||
| 786 | expect(Exit.isFailure(exit)).toBe(true); | 886 | expect(Exit.isFailure(exit)).toBe(true); |
| 787 | if (!Exit.isFailure(exit)) return; | 887 | if (!Exit.isFailure(exit)) return; |
| 788 | expect(JSON.stringify(exit.cause)).toContain("Code expired"); | 888 | expect(JSON.stringify(exit.cause)).toContain("Code expired"); |
| 889 | const failure = Cause.squash(exit.cause) as OAuth2Error; | ||
| 890 | expect(failure.error).toBe("invalid_grant"); | ||
| 789 | }), | 891 | }), |
| 790 | ), | 892 | ), |
| 791 | ); | 893 | ); |
| 792 | 894 | ||
| 895 | // The review's canary for the first-party secret leak. A provider that | ||
| 896 | // echoes the submitted client_secret inside error_description would carry a | ||
| 897 | // DEPLOYMENT-WIDE credential into the OAuth2Error message — and from there | ||
| 898 | // into OAuthCompleteError, the popup's browser-visible errorDetails, and the | ||
| 899 | // token-request span. Assert on the WHOLE rendered failure, not just the | ||
| 900 | // message: what a log line or a `JSON.stringify` prints includes the retained | ||
| 901 | // rejection, so an echo surviving anywhere in the cause is still a leak. | ||
| 902 | it.effect("scrubs a client secret echoed in error_description from the whole failure", () => { | ||
| 903 | const spans = new Map<string, RecordedSpan>(); | ||
| 904 | return withTokenEndpoint( | ||
| 905 | () => | ||
| 906 | json(400, { | ||
| 907 | error: "invalid_client", | ||
| 908 | error_description: | ||
| 909 | "authentication failed for secret SECRET-CANARY-must-not-escape, check your credentials", | ||
| 910 | }), | ||
| 911 | ({ tokenUrl }) => | ||
| 912 | Effect.gen(function* () { | ||
| 913 | const exit = yield* Effect.exit( | ||
| 914 | exchangeAuthorizationCode({ | ||
| 915 | tokenUrl, | ||
| 916 | clientId: "cid", | ||
| 917 | clientSecret: "SECRET-CANARY-must-not-escape", | ||
| 918 | redirectUrl: "https://cb", | ||
| 919 | codeVerifier: "v", | ||
| 920 | code: "c", | ||
| 921 | }), | ||
| 922 | ); | ||
| 923 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 924 | if (!Exit.isFailure(exit)) return; | ||
| 925 | for (const rendered of [Cause.pretty(exit.cause), JSON.stringify(exit.cause)]) { | ||
| 926 | expect(rendered).not.toContain("SECRET-CANARY-must-not-escape"); | ||
| 927 | } | ||
| 928 | const failure = Cause.squash(exit.cause) as OAuth2Error; | ||
| 929 | expect(failure).toBeInstanceOf(OAuth2Error); | ||
| 930 | // Masked, not dropped: the verdict prose around the secret survives, | ||
| 931 | // and so does everything classification and the span read. | ||
| 932 | expect(failure.message).toContain("[redacted]"); | ||
| 933 | expect(failure.message).toContain("check your credentials"); | ||
| 934 | expect(failure.message).not.toContain("SECRET-CANARY-must-not-escape"); | ||
| 935 | expect(failure.error).toBe("invalid_client"); | ||
| 936 | expect(failure.status).toBe(400); | ||
| 937 | // The token-request span — attributes AND ending exit — is what the | ||
| 938 | // exporter sees; the echo must not survive there either. | ||
| 939 | const tokenSpan = spans.get("executor.oauth.token_request"); | ||
| 940 | expect(tokenSpan?.attributes.get("executor.oauth.error_code")).toBe("invalid_client"); | ||
| 941 | const spanRendered = JSON.stringify({ | ||
| 942 | attributes: [...(tokenSpan?.attributes ?? [])], | ||
| 943 | exit: tokenSpan?.endExit, | ||
| 944 | }); | ||
| 945 | expect(spanRendered).not.toContain("SECRET-CANARY-must-not-escape"); | ||
| 946 | }), | ||
| 947 | ).pipe(Effect.withTracer(makeRecordingTracer(spans))); | ||
| 948 | }); | ||
| 949 | |||
| 793 | it.effect("includes HTTP status and body preview for non-OAuth token endpoint errors", () => | 950 | it.effect("includes HTTP status and body preview for non-OAuth token endpoint errors", () => |
| 794 | withTokenEndpoint( | 951 | withTokenEndpoint( |
| 795 | () => HttpServerResponse.text("route not found", { status: 404 }), | 952 | () => HttpServerResponse.text("route not found", { status: 404 }), |
| @@ -1192,6 +1349,31 @@ describe("exchangeClientCredentials", () => { · 396 unmodified lines | |||
| 1192 | }); | 1349 | }); |
| 1193 | 1350 | ||
| 1194 | describe("refreshAccessToken", () => { | 1351 | describe("refreshAccessToken", () => { |
| 1352 | it.effect("persists provider-compatible JSON refresh rotation requests", () => | ||
| 1353 | withTokenEndpoint( | ||
| 1354 | tokenResponse({ ...validRefreshBody, refresh_token: "rotated" }), | ||
| 1355 | ({ tokenUrl, calls }) => | ||
| 1356 | Effect.gen(function* () { | ||
| 1357 | const result = yield* refreshAccessToken({ | ||
| 1358 | tokenUrl, | ||
| 1359 | clientId: "cid", | ||
| 1360 | clientSecret: "csecret", | ||
| 1361 | refreshToken: "old", | ||
| 1362 | scopes: ["read", "offline_access"], | ||
| 1363 | requestFormat: "json", | ||
| 1364 | }); | ||
| 1365 | expect(result.refresh_token).toBe("rotated"); | ||
| 1366 | expect((yield* calls)[0]!.jsonBody).toEqual({ | ||
| 1367 | grant_type: "refresh_token", | ||
| 1368 | refresh_token: "old", | ||
| 1369 | scope: "read offline_access", | ||
| 1370 | client_id: "cid", | ||
| 1371 | client_secret: "csecret", | ||
| 1372 | }); | ||
| 1373 | }), | ||
| 1374 | ), | ||
| 1375 | ); | ||
| 1376 | |||
| 1195 | it.effect("normalizes Slack's comma-delimited scopes on refresh", () => | 1377 | it.effect("normalizes Slack's comma-delimited scopes on refresh", () => |
| 1196 | Effect.gen(function* () { | 1378 | Effect.gen(function* () { |
| 1197 | const result = yield* refreshAccessToken({ | 1379 | const result = yield* refreshAccessToken({ |
| @@ -641,8 +641,23 @@ const oauthErrorFromResponseBody = ( · 640 unmodified lines | |||
| 641 | }); | 641 | }); |
| 642 | }; | 642 | }; |
| 643 | 643 | ||
| 644 | const toOAuth2Error = (cause: unknown): OAuth2Error => { | 644 | /** Exact-string scrub of the submitted client secret from text bound for an |
| 645 | * `OAuth2Error`. A misbehaving authorization server can echo the credential it | ||
| 646 | * was just sent back inside `error_description` — a field that is deliberately | ||
| 647 | * previewable everywhere else in this module because it is the AS's | ||
| 648 | * human-readable verdict. The message it lands in reaches the OAuth popup's | ||
| 649 | * browser-visible error details, persisted connection health, and telemetry, | ||
| 650 | * and for a first-party client the secret is deployment-wide. The helper knows | ||
| 651 | * exactly which string it submitted, so this is a targeted replacement of that | ||
| 652 | * one value, not a heuristic. */ | ||
| 653 | const redactSubmittedClientSecret = ( | ||
| 654 | text: string, | ||
| 655 | clientSecret: string | null | undefined, | ||
| 656 | ): string => (clientSecret ? text.replaceAll(clientSecret, "[redacted]") : text); | ||
| 657 | |||
| 658 | const toOAuth2Error = (cause: unknown, clientSecret?: string | null): OAuth2Error => { | ||
| 645 | if (isOAuth2Error(cause)) return cause; | 659 | if (isOAuth2Error(cause)) return cause; |
| 660 | const scrub = (text: string): string => redactSubmittedClientSecret(text, clientSecret); | ||
| 646 | if (typeof cause === "object" && cause !== null) { | 661 | if (typeof cause === "object" && cause !== null) { |
| 647 | const c = cause as { | 662 | const c = cause as { |
| 648 | error?: unknown; | 663 | error?: unknown; |
| @@ -657,8 +672,10 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 8 unmodified lines | |||
| 657 | ? c.message | 672 | ? c.message |
| 658 | : undefined; | 673 | : undefined; |
| 659 | return new OAuth2Error({ | 674 | return new OAuth2Error({ |
| 660 | message: `OAuth token exchange failed: ${description ?? code ?? "unknown error"}`, | 675 | message: scrub(`OAuth token exchange failed: ${description ?? code ?? "unknown error"}`), |
| 661 | error: code, | 676 | // The code is scrubbed too: a conform envelope's `error` is an arbitrary |
| 677 | // string the AS chose, and it flows into the token-request span attribute. | ||
| 678 | error: code === undefined ? undefined : scrub(code), | ||
| 662 | cause, | 679 | cause, |
| 663 | }); | 680 | }); |
| 664 | } | 681 | } |
| @@ -674,13 +691,18 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 9 unmodified lines | |||
| 674 | * | 691 | * |
| 675 | * `fallbackMessage` is for the paths that hold the error Response directly | 692 | * `fallbackMessage` is for the paths that hold the error Response directly |
| 676 | * rather than catching a thrown oauth4webapi error: there is no library | 693 | * rather than catching a thrown oauth4webapi error: there is no library |
| 677 | * message to build on, so the caller names the step instead. */ | 694 | * message to build on, so the caller names the step instead. |
| 695 | * | ||
| 696 | * `clientSecret` is the secret the failed request submitted, when the client | ||
| 697 | * is confidential. Every constructed message is scrubbed of it, because the | ||
| 698 | * body it is built from is the AS's and may echo the credential back. */ | ||
| 678 | const toOAuth2ErrorWithHttpSummary = ( | 699 | const toOAuth2ErrorWithHttpSummary = ( |
| 679 | cause: unknown, | 700 | cause: unknown, |
| 680 | options?: { readonly fallbackMessage?: string }, | 701 | options?: { readonly fallbackMessage?: string; readonly clientSecret?: string | null }, |
| 681 | ): Effect.Effect<OAuth2Error> => { | 702 | ): Effect.Effect<OAuth2Error> => { |
| 682 | if (isOAuth2Error(cause)) return Effect.succeed(cause); | 703 | if (isOAuth2Error(cause)) return Effect.succeed(cause); |
| 683 | const base = toOAuth2Error(cause); | 704 | const scrub = (text: string): string => redactSubmittedClientSecret(text, options?.clientSecret); |
| 705 | const base = toOAuth2Error(cause, options?.clientSecret); | ||
| 684 | const response = responseFromOAuthErrorCause(cause); | 706 | const response = responseFromOAuthErrorCause(cause); |
| 685 | if (!response) { | 707 | if (!response) { |
| 686 | // No Response, but possibly a body the library already parsed off one it | 708 | // No Response, but possibly a body the library already parsed off one it |
| @@ -704,8 +726,8 @@ const toOAuth2ErrorWithHttpSummary = ( · 17 unmodified lines | |||
| 704 | // exactly the leak the message allowlist exists to prevent. The other | 726 | // exactly the leak the message allowlist exists to prevent. The other |
| 705 | // branches keep their cause because theirs is diagnostic, not a body. | 727 | // branches keep their cause because theirs is diagnostic, not a body. |
| 706 | new OAuth2Error({ | 728 | new OAuth2Error({ |
| 707 | message: `${options?.fallbackMessage ?? base.message} (${summary.join("; ")})`, | 729 | message: scrub(`${options?.fallbackMessage ?? base.message} (${summary.join("; ")})`), |
| 708 | error: base.error ?? envelope?.error, | 730 | error: base.error ?? (envelope?.error === undefined ? undefined : scrub(envelope.error)), |
| 709 | status: PARSED_BODY_CAUSE_STATUS, | 731 | status: PARSED_BODY_CAUSE_STATUS, |
| 710 | }), | 732 | }), |
| 711 | ); | 733 | ); |
| @@ -727,20 +749,37 @@ const toOAuth2ErrorWithHttpSummary = ( · 15 unmodified lines | |||
| 727 | : `${headline}: ${recovered.code}${ | 749 | : `${headline}: ${recovered.code}${ |
| 728 | recovered.description === undefined ? "" : ` — ${recovered.description}` | 750 | recovered.description === undefined ? "" : ` — ${recovered.description}` |
| 729 | }`; | 751 | }`; |
| 752 | const code = base.error ?? recovered?.code; | ||
| 753 | // The diagnostic rejection is dropped when the submitted secret is visible | ||
| 754 | // in its serialised form — oauth4webapi's ResponseBodyError carries the | ||
| 755 | // AS's `error_description` as an own enumerable field, so anything that | ||
| 756 | // renders the WHOLE failure (`Cause.pretty`, `JSON.stringify`) would | ||
| 757 | // replay the echo straight past the message scrub above. Everything | ||
| 758 | // classification reads — status, code, HTTP summary — is already lifted | ||
| 759 | // out, mirroring the malformed-200 branch's treatment of token-bearing | ||
| 760 | // rejections; the ordinary no-echo failure keeps its cause untouched. | ||
| 761 | const causeEchoesSecret = | ||
| 762 | Boolean(options?.clientSecret) && safeStringify(cause).includes(options?.clientSecret ?? ""); | ||
| 730 | return new OAuth2Error({ | 763 | return new OAuth2Error({ |
| 731 | message: `${described} (${summary})`, | 764 | message: scrub(`${described} (${summary})`), |
| 732 | error: base.error ?? recovered?.code, | 765 | error: code === undefined ? undefined : scrub(code), |
| 733 | // Carried even when no code was recovered: the status is what tells a | 766 | // Carried even when no code was recovered: the status is what tells a |
| 734 | // caller whether the AS refused (4xx — permanent, stop) or stumbled (5xx | 767 | // caller whether the AS refused (4xx — permanent, stop) or stumbled (5xx |
| 735 | // — retry). Most real refusals arrive with no code at all. | 768 | // — retry). Most real refusals arrive with no code at all. |
| 736 | status: response.status, | 769 | status: response.status, |
| 737 | cause, | 770 | ...(causeEchoesSecret ? {} : { cause }), |
| 738 | }); | 771 | }); |
| 739 | }); | 772 | }); |
| 740 | }; | 773 | }; |
| 741 | 774 | ||
| 742 | const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2Error> => | 775 | /** Curried on the submitted client secret so every `Effect.catch` site names |
| 743 | toOAuth2ErrorWithHttpSummary(cause).pipe(Effect.flatMap((error) => Effect.fail(error))); | 776 | * the credential its request carried — the scrub cannot work without it. */ |
| 777 | const failOAuth2WithHttpSummary = | ||
| 778 | (clientSecret: string | null | undefined) => | ||
| 779 | (cause: unknown): Effect.Effect<never, OAuth2Error> => | ||
| 780 | toOAuth2ErrorWithHttpSummary(cause, { clientSecret }).pipe( | ||
| 781 | Effect.flatMap((error) => Effect.fail(error)), | ||
| 782 | ); | ||
| 744 | 783 | ||
| 745 | /** Fail from a token-endpoint error Response the caller holds directly — the | 784 | /** Fail from a token-endpoint error Response the caller holds directly — the |
| 746 | * `genericTokenEndpointRequest` paths, where oauth4webapi hands back the raw | 785 | * `genericTokenEndpointRequest` paths, where oauth4webapi hands back the raw |
| @@ -751,8 +790,9 @@ const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2E · 4 unmodified lines | |||
| 751 | const failOAuth2FromErrorResponse = ( | 790 | const failOAuth2FromErrorResponse = ( |
| 752 | response: Response, | 791 | response: Response, |
| 753 | fallbackMessage: string, | 792 | fallbackMessage: string, |
| 793 | clientSecret: string | null | undefined, | ||
| 754 | ): Effect.Effect<never, OAuth2Error> => | 794 | ): Effect.Effect<never, OAuth2Error> => |
| 755 | toOAuth2ErrorWithHttpSummary(response, { fallbackMessage }).pipe( | 795 | toOAuth2ErrorWithHttpSummary(response, { fallbackMessage, clientSecret }).pipe( |
| 756 | Effect.flatMap((error) => Effect.fail(error)), | 796 | Effect.flatMap((error) => Effect.fail(error)), |
| 757 | ); | 797 | ); |
| 758 | 798 | ||
| @@ -1069,6 +1109,9 @@ export type ExchangeAuthorizationCodeInput = { · 310 unmodified lines | |||
| 1069 | readonly codeVerifier: string; | 1109 | readonly codeVerifier: string; |
| 1070 | readonly code: string; | 1110 | readonly code: string; |
| 1071 | readonly clientAuth?: ClientAuthMethod; | 1111 | readonly clientAuth?: ClientAuthMethod; |
| 1112 | /** Encoding required by the provider's token endpoint. OAuth defaults to | ||
| 1113 | * URL-encoded form; a small set of providers require a JSON object. */ | ||
| 1114 | readonly requestFormat?: "form" | "json"; | ||
| 1072 | readonly idTokenSigningAlgValuesSupported?: readonly string[]; | 1115 | readonly idTokenSigningAlgValuesSupported?: readonly string[]; |
| 1073 | /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on | 1116 | /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on |
| 1074 | * the token request when the client knows the resource it intends | 1117 | * the token request when the client knows the resource it intends |
| @@ -1079,6 +1122,59 @@ export type ExchangeAuthorizationCodeInput = { · 4 unmodified lines | |||
| 1079 | readonly fetch?: typeof globalThis.fetch; | 1122 | readonly fetch?: typeof globalThis.fetch; |
| 1080 | }; | 1123 | }; |
| 1081 | 1124 | ||
| 1125 | const base64BasicCredentials = (clientId: string, clientSecret: string): string => { | ||
| 1126 | const bytes = new TextEncoder().encode(`${clientId}:${clientSecret}`); | ||
| 1127 | let binary = ""; | ||
| 1128 | for (const byte of bytes) binary += String.fromCharCode(byte); | ||
| 1129 | return globalThis.btoa(binary); | ||
| 1130 | }; | ||
| 1131 | |||
| 1132 | const jsonTokenEndpointRequest = async (input: { | ||
| 1133 | readonly tokenUrl: string; | ||
| 1134 | readonly clientId: string; | ||
| 1135 | readonly clientSecret?: string | null; | ||
| 1136 | readonly clientAuth: ClientAuthMethod; | ||
| 1137 | readonly grantType: "authorization_code" | "refresh_token"; | ||
| 1138 | readonly parameters: Readonly<Record<string, string>>; | ||
| 1139 | readonly timeoutMs?: number; | ||
| 1140 | readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; | ||
| 1141 | readonly fetch?: typeof globalThis.fetch; | ||
| 1142 | }): Promise<Response> => { | ||
| 1143 | const tokenUrl = assertSupportedOAuthEndpointUrl( | ||
| 1144 | input.tokenUrl, | ||
| 1145 | "Token URL", | ||
| 1146 | input.endpointUrlPolicy, | ||
| 1147 | ); | ||
| 1148 | const headers = new Headers({ | ||
| 1149 | accept: "application/json", | ||
| 1150 | "content-type": "application/json", | ||
| 1151 | }); | ||
| 1152 | const confidential = Boolean(input.clientSecret); | ||
| 1153 | if (confidential && input.clientAuth === "basic") { | ||
| 1154 | headers.set( | ||
| 1155 | "authorization", | ||
| 1156 | `Basic ${base64BasicCredentials(input.clientId, input.clientSecret ?? "")}`, | ||
| 1157 | ); | ||
| 1158 | } | ||
| 1159 | const body = { | ||
| 1160 | grant_type: input.grantType, | ||
| 1161 | ...input.parameters, | ||
| 1162 | ...(confidential && input.clientAuth === "basic" | ||
| 1163 | ? {} | ||
| 1164 | : { | ||
| 1165 | client_id: input.clientId, | ||
| 1166 | ...(confidential ? { client_secret: input.clientSecret ?? "" } : {}), | ||
| 1167 | }), | ||
| 1168 | }; | ||
| 1169 | // oxlint-disable-next-line executor/no-raw-fetch -- boundary: provider token exchange is the SDK's HTTP boundary and preserves its injected fetch seam | ||
| 1170 | return await (input.fetch ?? globalThis.fetch)(tokenUrl, { | ||
| 1171 | method: "POST", | ||
| 1172 | headers, | ||
| 1173 | body: JSON.stringify(body), | ||
| 1174 | signal: AbortSignal.timeout(input.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS), | ||
| 1175 | }); | ||
| 1176 | }; | ||
| 1177 | |||
| 1082 | export const exchangeAuthorizationCode = ( | 1178 | export const exchangeAuthorizationCode = ( |
| 1083 | input: ExchangeAuthorizationCodeInput, | 1179 | input: ExchangeAuthorizationCodeInput, |
| 1084 | ): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => | 1180 | ): Effect.Effect<OAuth2TokenResponse, OAuth2Error> => |
| @@ -1106,24 +1202,37 @@ export const exchangeAuthorizationCode = ( · 21 unmodified lines | |||
| 1106 | if (input.resource) { | 1202 | if (input.resource) { |
| 1107 | params.set("resource", input.resource); | 1203 | params.set("resource", input.resource); |
| 1108 | } | 1204 | } |
| 1109 | const response = await oauth.genericTokenEndpointRequest( | 1205 | const response = |
| 1110 | as, | 1206 | input.requestFormat === "json" |
| 1111 | client, | 1207 | ? await jsonTokenEndpointRequest({ |
| 1112 | clientAuth, | 1208 | tokenUrl: input.tokenUrl, |
| 1113 | "authorization_code", | 1209 | clientId: input.clientId, |
| 1114 | params, | 1210 | clientSecret: input.clientSecret, |
| 1115 | oauth4webapiRequestOptions( | 1211 | clientAuth: input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD, |
| 1116 | input.tokenUrl, | 1212 | grantType: "authorization_code", |
| 1117 | input.timeoutMs, | 1213 | parameters: Object.fromEntries(params), |
| 1118 | input.endpointUrlPolicy, | 1214 | timeoutMs: input.timeoutMs, |
| 1119 | input.fetch, | 1215 | endpointUrlPolicy: input.endpointUrlPolicy, |
| 1120 | ), | 1216 | fetch: input.fetch, |
| 1121 | ); | 1217 | }) |
| 1218 | : await oauth.genericTokenEndpointRequest( | ||
| 1219 | as, | ||
| 1220 | client, | ||
| 1221 | clientAuth, | ||
| 1222 | "authorization_code", | ||
| 1223 | params, | ||
| 1224 | oauth4webapiRequestOptions( | ||
| 1225 | input.tokenUrl, | ||
| 1226 | input.timeoutMs, | ||
| 1227 | input.endpointUrlPolicy, | ||
| 1228 | input.fetch, | ||
| 1229 | ), | ||
| 1230 | ); | ||
| 1122 | return await processTokenEndpointResponse(as, client, response); | 1231 | return await processTokenEndpointResponse(as, client, response); |
| 1123 | }, | 1232 | }, |
| 1124 | catch: (cause) => cause, | 1233 | catch: (cause) => cause, |
| 1125 | }).pipe( | 1234 | }).pipe( |
| 1126 | Effect.catch(failOAuth2WithHttpSummary), | 1235 | Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)), |
| 1127 | withTokenRequestSpan({ | 1236 | withTokenRequestSpan({ |
| 1128 | grantType: "authorization_code", | 1237 | grantType: "authorization_code", |
| 1129 | tokenUrl: input.tokenUrl, | 1238 | tokenUrl: input.tokenUrl, |
| @@ -1186,7 +1295,7 @@ export const exchangeClientCredentials = ( · 56 unmodified lines | |||
| 1186 | }, | 1295 | }, |
| 1187 | catch: (cause) => cause, | 1296 | catch: (cause) => cause, |
| 1188 | }).pipe( | 1297 | }).pipe( |
| 1189 | Effect.catch(failOAuth2WithHttpSummary), | 1298 | Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)), |
| 1190 | withTokenRequestSpan({ | 1299 | withTokenRequestSpan({ |
| 1191 | grantType: "client_credentials", | 1300 | grantType: "client_credentials", |
| 1192 | tokenUrl: input.tokenUrl, | 1301 | tokenUrl: input.tokenUrl, |
| @@ -1208,6 +1317,9 @@ export type RefreshAccessTokenInput = { · 15 unmodified lines | |||
| 1208 | readonly scopes?: readonly string[]; | 1317 | readonly scopes?: readonly string[]; |
| 1209 | readonly scopeSeparator?: string; | 1318 | readonly scopeSeparator?: string; |
| 1210 | readonly clientAuth?: ClientAuthMethod; | 1319 | readonly clientAuth?: ClientAuthMethod; |
| 1320 | /** Encoding required by the provider's token endpoint. OAuth defaults to | ||
| 1321 | * URL-encoded form; a small set of providers require a JSON object. */ | ||
| 1322 | readonly requestFormat?: "form" | "json"; | ||
| 1211 | readonly idTokenSigningAlgValuesSupported?: readonly string[]; | 1323 | readonly idTokenSigningAlgValuesSupported?: readonly string[]; |
| 1212 | /** RFC 8707 Resource Indicator — MCP spec MUST-requires this on | 1324 | /** RFC 8707 Resource Indicator — MCP spec MUST-requires this on |
| 1213 | * refresh requests so the new access token's audience is bound to | 1325 | * refresh requests so the new access token's audience is bound to |
| @@ -1241,6 +1353,26 @@ export const refreshAccessToken = ( · 27 unmodified lines | |||
| 1241 | } | 1353 | } |
| 1242 | const additionalParameters = | 1354 | const additionalParameters = |
| 1243 | Array.from(extraParams.keys()).length > 0 ? extraParams : undefined; | 1355 | Array.from(extraParams.keys()).length > 0 ? extraParams : undefined; |
| 1356 | if (input.requestFormat === "json") { | ||
| 1357 | const response = await jsonTokenEndpointRequest({ | ||
| 1358 | tokenUrl: input.tokenUrl, | ||
| 1359 | clientId: input.clientId, | ||
| 1360 | clientSecret: input.clientSecret, | ||
| 1361 | clientAuth: input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD, | ||
| 1362 | grantType: "refresh_token", | ||
| 1363 | parameters: { | ||
| 1364 | refresh_token: input.refreshToken, | ||
| 1365 | ...(input.scopes && input.scopes.length > 0 | ||
| 1366 | ? { scope: input.scopes.join(input.scopeSeparator ?? " ") } | ||
| 1367 | : {}), | ||
| 1368 | ...(input.resource ? { resource: input.resource } : {}), | ||
| 1369 | }, | ||
| 1370 | timeoutMs: input.timeoutMs, | ||
| 1371 | endpointUrlPolicy: input.endpointUrlPolicy, | ||
| 1372 | fetch: input.fetch, | ||
| 1373 | }); | ||
| 1374 | return await processTokenEndpointResponse(as, client, response); | ||
| 1375 | } | ||
| 1244 | const response = await oauth.refreshTokenGrantRequest( | 1376 | const response = await oauth.refreshTokenGrantRequest( |
| 1245 | as, | 1377 | as, |
| 1246 | client, | 1378 | client, |
| @@ -1265,7 +1397,7 @@ export const refreshAccessToken = ( · 18 unmodified lines | |||
| 1265 | }, | 1397 | }, |
| 1266 | catch: (cause) => cause, | 1398 | catch: (cause) => cause, |
| 1267 | }).pipe( | 1399 | }).pipe( |
| 1268 | Effect.catch(failOAuth2WithHttpSummary), | 1400 | Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)), |
| 1269 | withTokenRequestSpan({ | 1401 | withTokenRequestSpan({ |
| 1270 | grantType: "refresh_token", | 1402 | grantType: "refresh_token", |
| 1271 | tokenUrl: input.tokenUrl, | 1403 | tokenUrl: input.tokenUrl, |
| @@ -1375,10 +1507,14 @@ export const exchangeSubjectTokenForIdJag = ( · 103 unmodified lines | |||
| 1375 | ); | 1507 | ); |
| 1376 | }, | 1508 | }, |
| 1377 | catch: (cause) => cause, | 1509 | catch: (cause) => cause, |
| 1378 | }).pipe(Effect.catch(failOAuth2WithHttpSummary)); | 1510 | }).pipe(Effect.catch(failOAuth2WithHttpSummary(input.clientSecret))); |
| 1379 | 1511 | ||
| 1380 | if (!response.ok) { | 1512 | if (!response.ok) { |
| 1381 | return yield* failOAuth2FromErrorResponse(response, "ID-JAG token exchange was rejected"); | 1513 | return yield* failOAuth2FromErrorResponse( |
| 1514 | response, | ||
| 1515 | "ID-JAG token exchange was rejected", | ||
| 1516 | input.clientSecret, | ||
| 1517 | ); | ||
| 1382 | } | 1518 | } |
| 1383 | 1519 | ||
| 1384 | // Nothing else reads this body, so it is consumed directly. A read failure | 1520 | // Nothing else reads this body, so it is consumed directly. A read failure |
| @@ -1485,7 +1621,7 @@ export const redeemIdJagAssertion = ( · 100 unmodified lines | |||
| 1485 | }, | 1621 | }, |
| 1486 | catch: (cause) => cause, | 1622 | catch: (cause) => cause, |
| 1487 | }).pipe( | 1623 | }).pipe( |
| 1488 | Effect.catch(failOAuth2WithHttpSummary), | 1624 | Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)), |
| 1489 | withTokenRequestSpan({ | 1625 | withTokenRequestSpan({ |
| 1490 | grantType: JWT_BEARER_GRANT_TYPE, | 1626 | grantType: JWT_BEARER_GRANT_TYPE, |
| 1491 | tokenUrl: input.tokenUrl, | 1627 | tokenUrl: input.tokenUrl, |
| @@ -516,6 +516,8 @@ interface LoadedOAuthClient { · 515 unmodified lines | |||
| 516 | /** Resolved literal secret (read from the provider via the stored item id). */ | 516 | /** Resolved literal secret (read from the provider via the stored item id). */ |
| 517 | readonly clientSecret: string; | 517 | readonly clientSecret: string; |
| 518 | readonly resource: string | null; | 518 | readonly resource: string | null; |
| 519 | readonly tokenEndpointAuthMethod?: "body" | "basic"; | ||
| 520 | readonly tokenRequestFormat?: "form" | "json"; | ||
| 519 | } | 521 | } |
| 520 | 522 | ||
| 521 | /** Where an OAuth app's client secret is stored in the default writable | 523 | /** Where an OAuth app's client secret is stored in the default writable |
| @@ -618,6 +620,8 @@ export const loadedFirstPartyClient = ( · 96 unmodified lines | |||
| 618 | readonly clientId: string; | 620 | readonly clientId: string; |
| 619 | readonly clientSecret: string; | 621 | readonly clientSecret: string; |
| 620 | readonly resource: string | null; | 622 | readonly resource: string | null; |
| 623 | readonly tokenEndpointAuthMethod?: "body" | "basic"; | ||
| 624 | readonly tokenRequestFormat?: "form" | "json"; | ||
| 621 | } => ({ | 625 | } => ({ |
| 622 | slug: String(firstPartyOAuthClientSlug(config.name)), | 626 | slug: String(firstPartyOAuthClientSlug(config.name)), |
| 623 | authorizationUrl: config.authorizationUrl, | 627 | authorizationUrl: config.authorizationUrl, |
| @@ -626,6 +630,12 @@ export const loadedFirstPartyClient = ( · 2 unmodified lines | |||
| 626 | clientId: config.clientId, | 630 | clientId: config.clientId, |
| 627 | clientSecret: config.clientSecret, | 631 | clientSecret: config.clientSecret, |
| 628 | resource: config.resource ?? null, | 632 | resource: config.resource ?? null, |
| 633 | ...(config.tokenEndpointAuthMethod === undefined | ||
| 634 | ? {} | ||
| 635 | : { tokenEndpointAuthMethod: config.tokenEndpointAuthMethod }), | ||
| 636 | ...(config.tokenRequestFormat === undefined | ||
| 637 | ? {} | ||
| 638 | : { tokenRequestFormat: config.tokenRequestFormat }), | ||
| 629 | }); | 639 | }); |
| 630 | 640 | ||
| 631 | export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { | 641 | export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { |
| @@ -1697,6 +1707,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1065 unmodified lines | |||
| 1697 | : scopePolicy.kind === "discover" | 1707 | : scopePolicy.kind === "discover" |
| 1698 | ? requestedScopes | 1708 | ? requestedScopes |
| 1699 | : yield* filterAuthorizationCodeScopes(client, requestedScopes); | 1709 | : yield* filterAuthorizationCodeScopes(client, requestedScopes); |
| 1710 | const completeAuthorizationScopes = dedupeScopes([ | ||
| 1711 | ...authorizationRequestedScopes, | ||
| 1712 | ...(firstParty?.additionalAuthorizationScopes ?? []), | ||
| 1713 | ]); | ||
| 1700 | 1714 | ||
| 1701 | // authorization_code: persist a session + build the authorize URL. | 1715 | // authorization_code: persist a session + build the authorize URL. |
| 1702 | const verifier = createPkceCodeVerifier(); | 1716 | const verifier = createPkceCodeVerifier(); |
| @@ -1758,7 +1772,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 55 unmodified lines | |||
| 1758 | payload: { | 1772 | payload: { |
| 1759 | owner: input.owner, | 1773 | owner: input.owner, |
| 1760 | clientOwner: input.clientOwner, | 1774 | clientOwner: input.clientOwner, |
| 1761 | requestedScopes: authorizationRequestedScopes, | 1775 | requestedScopes: completeAuthorizationScopes, |
| 1762 | }, | 1776 | }, |
| 1763 | expires_at: expiresAt, | 1777 | expires_at: expiresAt, |
| 1764 | created_at: now, | 1778 | created_at: now, |
| @@ -1771,14 +1785,18 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 6 unmodified lines | |||
| 1771 | authorizationUrl: client.authorizationUrl, | 1785 | authorizationUrl: client.authorizationUrl, |
| 1772 | clientId: client.clientId, | 1786 | clientId: client.clientId, |
| 1773 | redirectUrl: flowRedirectUri, | 1787 | redirectUrl: flowRedirectUri, |
| 1774 | scopes: authorizationRequestedScopes, | 1788 | scopes: completeAuthorizationScopes, |
| 1775 | state: providerState, | 1789 | state: providerState, |
| 1776 | codeChallenge: challenge, | 1790 | codeChallenge: challenge, |
| 1791 | scopeSeparator: firstParty?.authorizationScopeSeparator, | ||
| 1777 | resource: client.resource ?? undefined, | 1792 | resource: client.resource ?? undefined, |
| 1778 | // Provider quirks (Google: access_type=offline + prompt=consent) — | 1793 | // Provider quirks (Google: access_type=offline + prompt=consent) — |
| 1779 | // without these Google returns no refresh token and won't re-consent | 1794 | // without these Google returns no refresh token and won't re-consent |
| 1780 | // to widen scopes on reconnect. | 1795 | // to widen scopes on reconnect. |
| 1781 | extraParams: providerAuthorizeExtras(client.authorizationUrl), | 1796 | extraParams: { |
| 1797 | ...providerAuthorizeExtras(client.authorizationUrl), | ||
| 1798 | ...(firstParty?.authorizationExtraParams ?? {}), | ||
| 1799 | }, | ||
| 1782 | endpointUrlPolicy: deps.endpointUrlPolicy, | 1800 | endpointUrlPolicy: deps.endpointUrlPolicy, |
| 1783 | }), | 1801 | }), |
| 1784 | catch: (cause) => | 1802 | catch: (cause) => |
| @@ -1897,6 +1915,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 112 unmodified lines | |||
| 1897 | redirectUrl: session.redirectUrl, | 1915 | redirectUrl: session.redirectUrl, |
| 1898 | codeVerifier: session.pkceVerifier, | 1916 | codeVerifier: session.pkceVerifier, |
| 1899 | code: input.code, | 1917 | code: input.code, |
| 1918 | clientAuth: client.tokenEndpointAuthMethod, | ||
| 1919 | requestFormat: client.tokenRequestFormat, | ||
| 1900 | resource: client.resource ?? undefined, | 1920 | resource: client.resource ?? undefined, |
| 1901 | endpointUrlPolicy: deps.endpointUrlPolicy, | 1921 | endpointUrlPolicy: deps.endpointUrlPolicy, |
| 1902 | fetch, | 1922 | fetch, |
| @@ -134,6 +134,7 @@ export { · 133 unmodified lines | |||
| 134 | HealthCheckCandidate, | 134 | HealthCheckCandidate, |
| 135 | HealthCheckCandidateParameter, | 135 | HealthCheckCandidateParameter, |
| 136 | classifyHttpStatus, | 136 | classifyHttpStatus, |
| 137 | classifyProbeResponse, | ||
| 137 | extractIdentity, | 138 | extractIdentity, |
| 138 | compareHealthCheckCandidates, | 139 | compareHealthCheckCandidates, |
| 139 | candidateIdentityTier, | 140 | candidateIdentityTier, |
| @@ -41,6 +41,7 @@ import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields"; · 40 unmodified lines | |||
| 41 | import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor"; | 41 | import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor"; |
| 42 | import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers"; | 42 | import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers"; |
| 43 | import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config"; | 43 | import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config"; |
| 44 | import { parseStdioArgs } from "./stdio-fields"; | ||
| 44 | import { isProbableMcpEndpoint } from "./probe-url"; | 45 | import { isProbableMcpEndpoint } from "./probe-url"; |
| 45 | import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode"; | 46 | import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode"; |
| 46 | import { mcpPresets, type McpPreset } from "../sdk/presets"; | 47 | import { mcpPresets, type McpPreset } from "../sdk/presets"; |
| @@ -62,19 +63,6 @@ function findPreset(id: string | undefined): McpPreset | undefined { · 15 unmodified lines | |||
| 62 | return mcpPresets.find((p) => p.id === id); | 63 | return mcpPresets.find((p) => p.id === id); |
| 63 | } | 64 | } |
| 64 | 65 | ||
| 65 | // Splits the raw args field into tokens, honoring double-quoted groups so an | ||
| 66 | // argument with spaces stays intact. | ||
| 67 | function parseStdioArgs(raw: string): string[] { | ||
| 68 | if (!raw.trim()) return []; | ||
| 69 | const args: string[] = []; | ||
| 70 | const regex = /[^\s"]+|"([^"]*)"/g; | ||
| 71 | let match; | ||
| 72 | while ((match = regex.exec(raw)) !== null) { | ||
| 73 | args.push(match[1] ?? match[0]); | ||
| 74 | } | ||
| 75 | return args; | ||
| 76 | } | ||
| 77 | |||
| 78 | // --------------------------------------------------------------------------- | 66 | // --------------------------------------------------------------------------- |
| 79 | // State machine (remote flow) | 67 | // State machine (remote flow) |
| 80 | // --------------------------------------------------------------------------- | 68 | // --------------------------------------------------------------------------- |
| @@ -13,10 +13,16 @@ import { · 12 unmodified lines | |||
| 13 | type AuthMethodRow, | 13 | type AuthMethodRow, |
| 14 | type AuthMethodSeed, | 14 | type AuthMethodSeed, |
| 15 | } from "@executor-js/react/components/auth-method-list-editor"; | 15 | } from "@executor-js/react/components/auth-method-list-editor"; |
| 16 | import { Badge } from "@executor-js/react/components/badge"; | 16 | import { |
| 17 | import { FormErrorAlert } from "@executor-js/react/lib/integration-add"; | 17 | CardStack, |
| 18 | CardStackContent, | ||
| 19 | CardStackEntryField, | ||
| 20 | } from "@executor-js/react/components/card-stack"; | ||
| 21 | import { Input } from "@executor-js/react/components/input"; | ||
| 22 | import { Textarea } from "@executor-js/react/components/textarea"; | ||
| 23 | import { errorMessageFromExit, FormErrorAlert } from "@executor-js/react/lib/integration-add"; | ||
| 18 | 24 | ||
| 19 | import { configureMcpAuth, mcpServerAtom } from "./atoms"; | 25 | import { configureMcpAuth, configureMcpServer, mcpServerAtom } from "./atoms"; |
| 20 | import type { | 26 | import type { |
| 21 | McpAuthMethod, | 27 | McpAuthMethod, |
| 22 | McpCanonicalAuthMethodInput, | 28 | McpCanonicalAuthMethodInput, |
| @@ -27,6 +33,7 @@ import { · 4 unmodified lines | |||
| 27 | mcpAuthMethodInputFromEditorValue, | 33 | mcpAuthMethodInputFromEditorValue, |
| 28 | mcpWireAuthInput, | 34 | mcpWireAuthInput, |
| 29 | } from "./auth-method-config"; | 35 | } from "./auth-method-config"; |
| 36 | import { formatStdioArgs, formatStdioEnv, parseStdioArgs, parseStdioEnv } from "./stdio-fields"; | ||
| 30 | 37 | ||
| 31 | type McpServer = { | 38 | type McpServer = { |
| 32 | readonly slug: IntegrationSlug; | 39 | readonly slug: IntegrationSlug; |
| @@ -174,30 +181,160 @@ function RemoteEdit(props: { · 141 unmodified lines | |||
| 174 | } | 181 | } |
| 175 | 182 | ||
| 176 | // --------------------------------------------------------------------------- | 183 | // --------------------------------------------------------------------------- |
| 177 | // Stdio read-only view | 184 | // Stdio edit — the command, its arguments, the working directory, and the |
| 185 | // DECLARED static environment. Secret env vars are not edited here: a stdio | ||
| 186 | // server declares them as a `stdio_env` auth method and their values live on | ||
| 187 | // the connection, managed from the integration page's accounts hub. Changes | ||
| 188 | // are staged and applied by the sheet's Save, like the remote editor above. | ||
| 178 | // --------------------------------------------------------------------------- | 189 | // --------------------------------------------------------------------------- |
| 179 | 190 | ||
| 180 | function StdioReadOnly(props: { | 191 | type McpStdioConfig = Extract<McpIntegrationConfig, { transport: "stdio" }>; |
| 181 | server: McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> }; | 192 | |
| 193 | /** Order-independent identity for a declared env map, so re-ordering the lines | ||
| 194 | * in the field is not reported as a change. */ | ||
| 195 | const envIdentity = (env: Readonly<Record<string, string>> | undefined): string => | ||
| 196 | Object.entries(env ?? {}) | ||
| 197 | .sort(([a], [b]) => a.localeCompare(b)) | ||
| 198 | .map(([key, value]) => `${key}=${value}`) | ||
| 199 | .join("\n"); | ||
| 200 | |||
| 201 | function StdioEdit(props: { | ||
| 202 | server: McpServer & { config: McpStdioConfig }; | ||
| 203 | onPendingChange?: EditSheetSectionProps["onPendingChange"]; | ||
| 182 | }) { | 204 | }) { |
| 183 | const { command, args } = props.server.config; | 205 | const { server } = props; |
| 206 | const doConfigure = useAtomSet(configureMcpServer, { mode: "promiseExit" }); | ||
| 207 | |||
| 208 | const [command, setCommand] = useState(server.config.command); | ||
| 209 | const [args, setArgs] = useState(() => formatStdioArgs(server.config.args)); | ||
| 210 | const [cwd, setCwd] = useState(server.config.cwd ?? ""); | ||
| 211 | const [env, setEnv] = useState(() => formatStdioEnv(server.config.env)); | ||
| 212 | const [error, setError] = useState<string | null>(null); | ||
| 213 | |||
| 214 | // The edited config. `configureServer` replaces the whole blob, so anything | ||
| 215 | // this form does not surface (`versionNegotiation`, `authenticationTemplate`) | ||
| 216 | // is carried through untouched. Empty optional fields are omitted rather than | ||
| 217 | // written as `undefined`. | ||
| 218 | const edited = useMemo<McpStdioConfig>(() => { | ||
| 219 | const { args: _args, cwd: _cwd, env: _env, ...rest } = server.config; | ||
| 220 | const nextArgs = parseStdioArgs(args); | ||
| 221 | const nextEnv = parseStdioEnv(env); | ||
| 222 | const nextCwd = cwd.trim(); | ||
| 223 | return { | ||
| 224 | ...rest, | ||
| 225 | command: command.trim(), | ||
| 226 | ...(nextArgs.length > 0 ? { args: nextArgs } : {}), | ||
| 227 | ...(Object.keys(nextEnv).length > 0 ? { env: nextEnv } : {}), | ||
| 228 | ...(nextCwd !== "" ? { cwd: nextCwd } : {}), | ||
| 229 | }; | ||
| 230 | }, [args, command, cwd, env, server.config]); | ||
| 231 | |||
| 232 | const changed = | ||
| 233 | edited.command !== server.config.command || | ||
| 234 | formatStdioArgs(edited.args) !== formatStdioArgs(server.config.args) || | ||
| 235 | (edited.cwd ?? "") !== (server.config.cwd ?? "") || | ||
| 236 | envIdentity(edited.env) !== envIdentity(server.config.env); | ||
| 237 | |||
| 238 | // Staged apply, run by the sheet's Save. Persisting the config re-runs | ||
| 239 | // discovery on every connection, so the tool catalog matches the new command. | ||
| 240 | const applyStaged = useCallback(async (): Promise<EditSheetApplyResult> => { | ||
| 241 | setError(null); | ||
| 242 | if (edited.command === "") { | ||
| 243 | setError("A command is required."); | ||
| 244 | return { ok: false }; | ||
| 245 | } | ||
| 246 | const exit = await doConfigure({ | ||
| 247 | params: { slug: server.slug }, | ||
| 248 | payload: { config: edited }, | ||
| 249 | reactivityKeys: integrationWriteKeys, | ||
| 250 | }); | ||
| 251 | if (Exit.isFailure(exit)) { | ||
| 252 | setError(errorMessageFromExit(exit, "Failed to update the server command")); | ||
| 253 | return { ok: false }; | ||
| 254 | } | ||
| 255 | return { ok: true, summary: "Server command updated." }; | ||
| 256 | }, [doConfigure, edited, server.slug]); | ||
| 257 | |||
| 258 | const onPendingChangeRef = useRef(props.onPendingChange); | ||
| 259 | onPendingChangeRef.current = props.onPendingChange; | ||
| 260 | useEffect(() => { | ||
| 261 | onPendingChangeRef.current?.(changed ? applyStaged : null); | ||
| 262 | return () => onPendingChangeRef.current?.(null); | ||
| 263 | }, [changed, applyStaged]); | ||
| 264 | |||
| 184 | return ( | 265 | return ( |
| 185 | <div className="space-y-3 border-t border-border/60 pt-5"> | 266 | <div className="space-y-4 border-t border-border/60 pt-5"> |
| 186 | <div className="space-y-1"> | 267 | <div className="space-y-1"> |
| 187 | <p className="text-sm font-medium text-foreground">Server command</p> | 268 | <p className="text-sm font-medium text-foreground">Server command</p> |
| 188 | <p className="text-xs text-muted-foreground"> | 269 | <p className="text-xs text-muted-foreground"> |
| 189 | Stdio MCP integrations cannot be edited. Remove and recreate the integration with the | 270 | Changes apply when you save. The server's tools are then rediscovered with the new |
| 190 | updated command. | 271 | command. |
| 191 | </p> | 272 | </p> |
| 192 | </div> | 273 | </div> |
| 193 | <div className="flex items-center gap-3 rounded-md border border-border/60 bg-muted/40 px-3 py-2"> | 274 | |
| 194 | <p className="min-w-0 flex-1 truncate font-mono text-xs text-foreground"> | 275 | <CardStack> |
| 195 | {command} {(args ?? []).join(" ")} | 276 | <CardStackContent className="border-t-0"> |
| 196 | </p> | 277 | <CardStackEntryField |
| 197 | <Badge variant="secondary" className="text-xs"> | 278 | label="Command" |
| 198 | stdio | 279 | description="- The executable to run (e.g. npx, uvx, node)." |
| 199 | </Badge> | 280 | > |
| 200 | </div> | 281 | <Input |
| 282 | value={command} | ||
| 283 | aria-label="Command" | ||
| 284 | onChange={(e) => setCommand((e.target as HTMLInputElement).value)} | ||
| 285 | placeholder="npx" | ||
| 286 | className="font-mono text-sm" | ||
| 287 | /> | ||
| 288 | </CardStackEntryField> | ||
| 289 | |||
| 290 | <CardStackEntryField | ||
| 291 | label="Arguments" | ||
| 292 | description="- Space-separated arguments passed to the command." | ||
| 293 | > | ||
| 294 | <Input | ||
| 295 | value={args} | ||
| 296 | aria-label="Arguments" | ||
| 297 | onChange={(e) => setArgs((e.target as HTMLInputElement).value)} | ||
| 298 | placeholder="-y chrome-devtools-mcp@latest" | ||
| 299 | className="font-mono text-sm" | ||
| 300 | /> | ||
| 301 | </CardStackEntryField> | ||
| 302 | |||
| 303 | <CardStackEntryField | ||
| 304 | label="Working directory" | ||
| 305 | description="- Optional. Where the command runs." | ||
| 306 | > | ||
| 307 | <Input | ||
| 308 | value={cwd} | ||
| 309 | aria-label="Working directory" | ||
| 310 | onChange={(e) => setCwd((e.target as HTMLInputElement).value)} | ||
| 311 | placeholder="/path/to/server" | ||
| 312 | className="font-mono text-sm" | ||
| 313 | /> | ||
| 314 | </CardStackEntryField> | ||
| 315 | |||
| 316 | <CardStackEntryField | ||
| 317 | label="Environment variables" | ||
| 318 | description="- One KEY=value per line. The server receives these plus a small base set; it does not inherit executor's environment." | ||
| 319 | > | ||
| 320 | <Textarea | ||
| 321 | value={env} | ||
| 322 | aria-label="Environment variables" | ||
| 323 | onChange={(e) => setEnv((e.target as HTMLTextAreaElement).value)} | ||
| 324 | placeholder={"LOG_LEVEL=debug\nREGION=eu-west-1"} | ||
| 325 | className="font-mono text-sm" | ||
| 326 | /> | ||
| 327 | </CardStackEntryField> | ||
| 328 | </CardStackContent> | ||
| 329 | </CardStack> | ||
| 330 | |||
| 331 | <p className="text-xs text-muted-foreground"> | ||
| 332 | Secret values do not belong here. A server that needs an API key declares its variable name | ||
| 333 | as an authentication method, and the value is entered per connection from the accounts | ||
| 334 | panel. | ||
| 335 | </p> | ||
| 336 | |||
| 337 | {error && <FormErrorAlert message={error} />} | ||
| 201 | </div> | 338 | </div> |
| 202 | ); | 339 | ); |
| 203 | } | 340 | } |
| @@ -219,10 +356,9 @@ export default function EditMcpIntegration({ · 15 unmodified lines | |||
| 219 | 356 | ||
| 220 | if (server.config.transport === "stdio") { | 357 | if (server.config.transport === "stdio") { |
| 221 | return ( | 358 | return ( |
| 222 | <StdioReadOnly | 359 | <StdioEdit |
| 223 | server={ | 360 | server={server as McpServer & { config: McpStdioConfig }} |
| 224 | server as McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> } | 361 | {...(onPendingChange ? { onPendingChange } : {})} |
| 225 | } | ||
| 226 | /> | 362 | /> |
| 227 | ); | 363 | ); |
| 228 | } | 364 | } |
| @@ -0,0 +1,70 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { formatStdioArgs, formatStdioEnv, parseStdioArgs, parseStdioEnv } from "./stdio-fields"; | ||
| 4 | |||
| 5 | describe("parseStdioArgs", () => { | ||
| 6 | it("splits on whitespace", () => { | ||
| 7 | expect(parseStdioArgs("-y chrome-devtools-mcp@latest")).toEqual([ | ||
| 8 | "-y", | ||
| 9 | "chrome-devtools-mcp@latest", | ||
| 10 | ]); | ||
| 11 | }); | ||
| 12 | |||
| 13 | it("keeps a double-quoted group intact", () => { | ||
| 14 | expect(parseStdioArgs('--dir "/Users/a b/mcp" --port 3000')).toEqual([ | ||
| 15 | "--dir", | ||
| 16 | "/Users/a b/mcp", | ||
| 17 | "--port", | ||
| 18 | "3000", | ||
| 19 | ]); | ||
| 20 | }); | ||
| 21 | |||
| 22 | it("returns nothing for a blank field", () => { | ||
| 23 | expect(parseStdioArgs(" ")).toEqual([]); | ||
| 24 | }); | ||
| 25 | }); | ||
| 26 | |||
| 27 | describe("formatStdioArgs", () => { | ||
| 28 | it("re-quotes tokens containing whitespace so the round trip is lossless", () => { | ||
| 29 | const args = ["--dir", "/Users/a b/mcp", "--port", "3000"]; | ||
| 30 | expect(parseStdioArgs(formatStdioArgs(args))).toEqual(args); | ||
| 31 | }); | ||
| 32 | |||
| 33 | it("renders an absent list as an empty field", () => { | ||
| 34 | expect(formatStdioArgs(undefined)).toBe(""); | ||
| 35 | }); | ||
| 36 | }); | ||
| 37 | |||
| 38 | describe("parseStdioEnv", () => { | ||
| 39 | it("reads one KEY=value per line", () => { | ||
| 40 | expect(parseStdioEnv("LOG_LEVEL=debug\nREGION=eu-west-1")).toEqual({ | ||
| 41 | LOG_LEVEL: "debug", | ||
| 42 | REGION: "eu-west-1", | ||
| 43 | }); | ||
| 44 | }); | ||
| 45 | |||
| 46 | it("splits on the first = only, so a value may contain =", () => { | ||
| 47 | expect(parseStdioEnv("DSN=postgres://u:p@host/db?a=b")).toEqual({ | ||
| 48 | DSN: "postgres://u:p@host/db?a=b", | ||
| 49 | }); | ||
| 50 | }); | ||
| 51 | |||
| 52 | it("keeps a declared-but-empty value", () => { | ||
| 53 | expect(parseStdioEnv("NO_COLOR=")).toEqual({ NO_COLOR: "" }); | ||
| 54 | }); | ||
| 55 | |||
| 56 | it("drops blank lines, comments, and lines with no key", () => { | ||
| 57 | expect(parseStdioEnv("\n# a comment\n=orphan\nA=1\n")).toEqual({ A: "1" }); | ||
| 58 | }); | ||
| 59 | }); | ||
| 60 | |||
| 61 | describe("formatStdioEnv", () => { | ||
| 62 | it("round-trips a declared map", () => { | ||
| 63 | const env = { LOG_LEVEL: "debug", DSN: "postgres://u:p@host/db?a=b" }; | ||
| 64 | expect(parseStdioEnv(formatStdioEnv(env))).toEqual(env); | ||
| 65 | }); | ||
| 66 | |||
| 67 | it("renders an absent map as an empty field", () => { | ||
| 68 | expect(formatStdioEnv(undefined)).toBe(""); | ||
| 69 | }); | ||
| 70 | }); | ||
| @@ -0,0 +1,51 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Text ↔ config codecs for the stdio fields, shared by the add flow | ||
| 3 | // (`AddMcpIntegration`) and the edit sheet (`EditMcpIntegration`). Both render | ||
| 4 | // the same single-line arguments field and the same KEY=value environment | ||
| 5 | // block, so the parsing lives in one place and round-trips: a config that is | ||
| 6 | // formatted and re-parsed is unchanged. | ||
| 7 | // --------------------------------------------------------------------------- | ||
| 8 | |||
| 9 | /** Split the raw arguments field into tokens, honoring double-quoted groups so | ||
| 10 | * an argument with spaces stays intact. */ | ||
| 11 | export function parseStdioArgs(raw: string): string[] { | ||
| 12 | if (!raw.trim()) return []; | ||
| 13 | const args: string[] = []; | ||
| 14 | const regex = /[^\s"]+|"([^"]*)"/g; | ||
| 15 | let match; | ||
| 16 | while ((match = regex.exec(raw)) !== null) { | ||
| 17 | args.push(match[1] ?? match[0]); | ||
| 18 | } | ||
| 19 | return args; | ||
| 20 | } | ||
| 21 | |||
| 22 | /** Render stored arguments back into the single-line field, re-quoting any | ||
| 23 | * token that contains whitespace so `parseStdioArgs` recovers it. */ | ||
| 24 | export function formatStdioArgs(args: readonly string[] | undefined): string { | ||
| 25 | return (args ?? []).map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" "); | ||
| 26 | } | ||
| 27 | |||
| 28 | /** Parse the KEY=value block into the declared static env map. Splits on the | ||
| 29 | * FIRST `=` only, so a value may itself contain `=`. Blank lines, `#` | ||
| 30 | * comments, and lines without a key are dropped; an empty value is kept | ||
| 31 | * (declaring a variable as empty is meaningful to a subprocess). */ | ||
| 32 | export function parseStdioEnv(raw: string): Record<string, string> { | ||
| 33 | const env: Record<string, string> = {}; | ||
| 34 | for (const line of raw.split("\n")) { | ||
| 35 | const trimmed = line.trim(); | ||
| 36 | if (trimmed === "" || trimmed.startsWith("#")) continue; | ||
| 37 | const separator = trimmed.indexOf("="); | ||
| 38 | if (separator <= 0) continue; | ||
| 39 | const key = trimmed.slice(0, separator).trim(); | ||
| 40 | if (key === "") continue; | ||
| 41 | env[key] = trimmed.slice(separator + 1).trim(); | ||
| 42 | } | ||
| 43 | return env; | ||
| 44 | } | ||
| 45 | |||
| 46 | /** Render the stored env map back into the KEY=value block. */ | ||
| 47 | export function formatStdioEnv(env: Readonly<Record<string, string>> | undefined): string { | ||
| 48 | return Object.entries(env ?? {}) | ||
| 49 | .map(([key, value]) => `${key}=${value}`) | ||
| 50 | .join("\n"); | ||
| 51 | } | ||
| @@ -7,7 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | ToolName, | 7 | ToolName, |
| 8 | ToolResult, | 8 | ToolResult, |
| 9 | authToolFailure, | 9 | authToolFailure, |
| 10 | classifyHttpStatus, | 10 | classifyProbeResponse, |
| 11 | detectInsufficientScope, | 11 | detectInsufficientScope, |
| 12 | sortHealthCheckCandidatesByIdentity, | 12 | sortHealthCheckCandidatesByIdentity, |
| 13 | extractIdentity, | 13 | extractIdentity, |
| @@ -973,7 +973,9 @@ export const checkHealthOpenApi = (input: { · 959 unmodified lines | |||
| 973 | } satisfies HealthCheckResult; | 973 | } satisfies HealthCheckResult; |
| 974 | } | 974 | } |
| 975 | 975 | ||
| 976 | const status = classifyHttpStatus(probe.result.status); | 976 | // Body-aware: a configuration 403 (Google accessNotConfigured / |
| 977 | // SERVICE_DISABLED) reads misconfigured, not expired. | ||
| 978 | const status = classifyProbeResponse(probe.result.status, probe.result.error); | ||
| 977 | const rawIdentity = | 979 | const rawIdentity = |
| 978 | status === "healthy" ? extractIdentity(probe.result.data, spec.identityField) : undefined; | 980 | status === "healthy" ? extractIdentity(probe.result.data, spec.identityField) : undefined; |
| 979 | // The identity is read straight off the raw body, so unlike the sample it | 981 | // The identity is read straight off the raw body, so unlike the sample it |
| @@ -40,10 +40,10 @@ export const FIGMA_SUPPORTED_OAUTH_SCOPES = [ · 39 unmodified lines | |||
| 40 | "file_dev_resources:write", | 40 | "file_dev_resources:write", |
| 41 | "file_metadata:read", | 41 | "file_metadata:read", |
| 42 | "file_versions:read", | 42 | "file_versions:read", |
| 43 | "folder_metadata:read", | ||
| 44 | "folders:read", | ||
| 43 | "library_assets:read", | 45 | "library_assets:read", |
| 44 | "library_content:read", | 46 | "library_content:read", |
| 45 | "project_metadata:read", | ||
| 46 | "projects:read", | ||
| 47 | "team_library_content:read", | 47 | "team_library_content:read", |
| 48 | "webhooks:read", | 48 | "webhooks:read", |
| 49 | "webhooks:write", | 49 | "webhooks:write", |
| @@ -70,6 +70,35 @@ import { · 69 unmodified lines | |||
| 70 | 70 | ||
| 71 | const OWNERS: readonly Owner[] = ["org", "user"]; | 71 | const OWNERS: readonly Owner[] = ["org", "user"]; |
| 72 | 72 | ||
| 73 | /** Render a health-check detail with any bare https URL as a clickable link. | ||
| 74 | * Exists for the misconfigured verdict, whose detail is the provider's own | ||
| 75 | * remediation text (Google's includes the console URL that enables the | ||
| 76 | * disabled API); the rest of the string stays plain text. */ | ||
| 77 | function DetailWithLinks(props: { readonly text: string }) { | ||
| 78 | const parts = props.text.split(/(https:\/\/[^\s,;)]+)/g); | ||
| 79 | return ( | ||
| 80 | <> | ||
| 81 | {parts.map((part, index) => | ||
| 82 | part.startsWith("https://") ? ( | ||
| 83 | <a | ||
| 84 | // Stable for a given detail string: parts are positional. | ||
| 85 | // oxlint-disable-next-line react/no-array-index-key | ||
| 86 | key={index} | ||
| 87 | href={part} | ||
| 88 | target="_blank" | ||
| 89 | rel="noreferrer" | ||
| 90 | className="underline underline-offset-2 hover:text-foreground" | ||
| 91 | > | ||
| 92 | {part} | ||
| 93 | </a> | ||
| 94 | ) : ( | ||
| 95 | part | ||
| 96 | ), | ||
| 97 | )} | ||
| 98 | </> | ||
| 99 | ); | ||
| 100 | } | ||
| 101 | |||
| 73 | // The confirm dialog names the connection the way the row does — stored | 102 | // The confirm dialog names the connection the way the row does — stored |
| 74 | // identity label first, connection name otherwise. (No live probe identity | 103 | // identity label first, connection name otherwise. (No live probe identity |
| 75 | // here; that state lives inside the row.) | 104 | // here; that state lives inside the row.) |
| @@ -115,6 +144,10 @@ function AccountRow(props: { · 39 unmodified lines | |||
| 115 | const displayLabel = identity ?? String(connection.name); | 144 | const displayLabel = identity ?? String(connection.name); |
| 116 | 145 | ||
| 117 | const expired = status === "expired"; | 146 | const expired = status === "expired"; |
| 147 | // `misconfigured` is deliberately NOT folded into `needsHealthAttention`: it | ||
| 148 | // gets its own amber "API disabled" badge and its own link-rendered detail | ||
| 149 | // below, because the remediation is a console visit, not a reconnect. | ||
| 150 | const misconfigured = status === "misconfigured"; | ||
| 118 | const needsHealthAttention = status === "expired" || status === "degraded"; | 151 | const needsHealthAttention = status === "expired" || status === "degraded"; |
| 119 | const healthDetail = needsHealthAttention ? probe?.detail : undefined; | 152 | const healthDetail = needsHealthAttention ? probe?.detail : undefined; |
| 120 | const missingOAuthScopes = connection.missingOAuthScopes ?? []; | 153 | const missingOAuthScopes = connection.missingOAuthScopes ?? []; |
| @@ -135,6 +168,13 @@ function AccountRow(props: { · 14 unmodified lines | |||
| 135 | ); | 168 | ); |
| 136 | } else if (exit.value.status === "expired") { | 169 | } else if (exit.value.status === "expired") { |
| 137 | toast.error("Connection expired, reconnect to restore access"); | 170 | toast.error("Connection expired, reconnect to restore access"); |
| 171 | } else if (exit.value.status === "misconfigured") { | ||
| 172 | // NOT a reconnect prompt: the credential is fine; the upstream API is | ||
| 173 | // disabled where the OAuth client lives. The detail carries the | ||
| 174 | // provider's own instruction (with a console link for Google). | ||
| 175 | toast.warning( | ||
| 176 | exit.value.detail ?? "An upstream API is disabled for this connection's OAuth client", | ||
| 177 | ); | ||
| 138 | } else if (exit.value.status === "degraded") { | 178 | } else if (exit.value.status === "degraded") { |
| 139 | toast.warning(exit.value.detail ?? "Connection check returned an error"); | 179 | toast.warning(exit.value.detail ?? "Connection check returned an error"); |
| 140 | } else { | 180 | } else { |
| @@ -157,6 +197,14 @@ function AccountRow(props: { · 16 unmodified lines | |||
| 157 | {HEALTH_STATUS_LABEL[status]} | 197 | {HEALTH_STATUS_LABEL[status]} |
| 158 | </Badge> | 198 | </Badge> |
| 159 | ) : null} | 199 | ) : null} |
| 200 | {misconfigured ? ( | ||
| 201 | <Badge | ||
| 202 | variant="outline" | ||
| 203 | className="shrink-0 border-amber-600/40 text-amber-600 dark:text-amber-500" | ||
| 204 | > | ||
| 205 | API disabled | ||
| 206 | </Badge> | ||
| 207 | ) : null} | ||
| 160 | {needsReconsent ? ( | 208 | {needsReconsent ? ( |
| 161 | <Badge variant="outline" className="shrink-0 border-border text-muted-foreground"> | 209 | <Badge variant="outline" className="shrink-0 border-border text-muted-foreground"> |
| 162 | Reconnect to grant access | 210 | Reconnect to grant access |
| @@ -168,6 +216,15 @@ function AccountRow(props: { · 5 unmodified lines | |||
| 168 | {connection.description} | 216 | {connection.description} |
| 169 | </CardStackEntryDescription> | 217 | </CardStackEntryDescription> |
| 170 | ) : null} | 218 | ) : null} |
| 219 | {misconfigured && probe?.detail ? ( | ||
| 220 | // Not CardStackEntryDescription: that truncates to one line, and this | ||
| 221 | // text IS the remediation (the enable-API console link must stay | ||
| 222 | // visible in full). Wrap instead; break anywhere so the long URL | ||
| 223 | // cannot overflow the row. | ||
| 224 | <p className="mt-1 whitespace-normal text-xs text-muted-foreground [overflow-wrap:anywhere]"> | ||
| 225 | <DetailWithLinks text={probe.detail} /> | ||
| 226 | </p> | ||
| 227 | ) : null} | ||
| 171 | {healthDetail ? ( | 228 | {healthDetail ? ( |
| 172 | <CardStackEntryDescription className="mt-1 overflow-visible whitespace-normal text-clip text-xs text-muted-foreground"> | 229 | <CardStackEntryDescription className="mt-1 overflow-visible whitespace-normal text-clip text-xs text-muted-foreground"> |
| 173 | {healthDetail} | 230 | {healthDetail} |
| @@ -1048,7 +1048,13 @@ function HealthStatusLine(props: { · 1047 unmodified lines | |||
| 1048 | }) { | 1048 | }) { |
| 1049 | const { status, identity, detail, httpStatus } = props.result; | 1049 | const { status, identity, detail, httpStatus } = props.result; |
| 1050 | const healthy = status === "healthy"; | 1050 | const healthy = status === "healthy"; |
| 1051 | const tone = healthy ? "text-muted-foreground" : "text-destructive"; | 1051 | // Misconfigured is amber everywhere (see health-display.ts): the credential |
| 1052 | // being validated is fine, so the verdict must not read as a key failure. | ||
| 1053 | const tone = healthy | ||
| 1054 | ? "text-muted-foreground" | ||
| 1055 | : status === "misconfigured" | ||
| 1056 | ? "text-amber-600 dark:text-amber-500" | ||
| 1057 | : "text-destructive"; | ||
| 1052 | const font = props.variant === "response" ? "font-mono" : ""; | 1058 | const font = props.variant === "response" ? "font-mono" : ""; |
| 1053 | return ( | 1059 | return ( |
| 1054 | <div className={`flex min-w-0 items-center gap-2 text-xs ${tone} ${font}`}> | 1060 | <div className={`flex min-w-0 items-center gap-2 text-xs ${tone} ${font}`}> |
| @@ -6,19 +6,26 @@ · 5 unmodified lines | |||
| 6 | 6 | ||
| 7 | import type { HealthStatus } from "@executor-js/sdk/shared"; | 7 | import type { HealthStatus } from "@executor-js/sdk/shared"; |
| 8 | 8 | ||
| 9 | /** State form: badge text, indicator tooltips, "what is the current state". */ | 9 | /** State form: badge text, indicator tooltips, "what is the current state". |
| 10 | * `misconfigured` reads "API disabled": the one concrete cause it detects | ||
| 11 | * today (an upstream API not enabled in the OAuth client's project), named | ||
| 12 | * plainly so the user looks at the provider console, not the credential. */ | ||
| 10 | export const HEALTH_STATUS_LABEL: Record<HealthStatus, string> = { | 13 | export const HEALTH_STATUS_LABEL: Record<HealthStatus, string> = { |
| 11 | healthy: "Healthy", | 14 | healthy: "Healthy", |
| 12 | expired: "Expired", | 15 | expired: "Expired", |
| 16 | misconfigured: "API disabled", | ||
| 13 | degraded: "Degraded", | 17 | degraded: "Degraded", |
| 14 | unknown: "Unchecked", | 18 | unknown: "Unchecked", |
| 15 | }; | 19 | }; |
| 16 | 20 | ||
| 17 | /** Text tone per status, for verdict lines and previews. Same per-status color | 21 | /** Text tone per status, for verdict lines and previews. Same per-status color |
| 18 | * decision as the indicator dots below; change them together. */ | 22 | * decision as the indicator dots below; change them together. `misconfigured` |
| 23 | * shares degraded's amber: it needs attention, but the credential is fine, so | ||
| 24 | * it must not carry expired's destructive red. */ | ||
| 19 | export const HEALTH_TEXT_CLASS: Record<HealthStatus, string> = { | 25 | export const HEALTH_TEXT_CLASS: Record<HealthStatus, string> = { |
| 20 | healthy: "text-emerald-600 dark:text-emerald-400", | 26 | healthy: "text-emerald-600 dark:text-emerald-400", |
| 21 | expired: "text-destructive", | 27 | expired: "text-destructive", |
| 28 | misconfigured: "text-amber-600 dark:text-amber-500", | ||
| 22 | degraded: "text-amber-600 dark:text-amber-500", | 29 | degraded: "text-amber-600 dark:text-amber-500", |
| 23 | unknown: "text-muted-foreground", | 30 | unknown: "text-muted-foreground", |
| 24 | }; | 31 | }; |
| @@ -32,22 +39,27 @@ export const HEALTH_INDICATOR_COLOR: Record< · 7 unmodified lines | |||
| 32 | > = { | 39 | > = { |
| 33 | healthy: { dot: "bg-emerald-500", ring: "ring-emerald-500/70" }, | 40 | healthy: { dot: "bg-emerald-500", ring: "ring-emerald-500/70" }, |
| 34 | expired: { dot: "bg-destructive", ring: "ring-destructive/70" }, | 41 | expired: { dot: "bg-destructive", ring: "ring-destructive/70" }, |
| 42 | misconfigured: { dot: "bg-amber-500", ring: "ring-amber-500/70" }, | ||
| 35 | degraded: { dot: "bg-amber-500", ring: "ring-amber-500/70" }, | 43 | degraded: { dot: "bg-amber-500", ring: "ring-amber-500/70" }, |
| 36 | unknown: { dot: "bg-muted-foreground/40", ring: "ring-muted-foreground/40" }, | 44 | unknown: { dot: "bg-muted-foreground/40", ring: "ring-muted-foreground/40" }, |
| 37 | }; | 45 | }; |
| 38 | 46 | ||
| 39 | /** Severity for worst-of aggregation. `unknown` is excluded on purpose: a | 47 | /** Severity for worst-of aggregation. `unknown` is excluded on purpose: a |
| 40 | * never-probed connection carries no signal, so it must not drag a group | 48 | * never-probed connection carries no signal, so it must not drag a group |
| 41 | * verdict in either direction. */ | 49 | * verdict in either direction. `misconfigured` outranks `degraded` (a named |
| 50 | * configuration break beats a generic error) but not `expired` (a dead | ||
| 51 | * credential blocks everything). */ | ||
| 42 | const HEALTH_SEVERITY: Record<Exclude<HealthStatus, "unknown">, number> = { | 52 | const HEALTH_SEVERITY: Record<Exclude<HealthStatus, "unknown">, number> = { |
| 43 | healthy: 1, | 53 | healthy: 1, |
| 44 | degraded: 2, | 54 | degraded: 2, |
| 45 | expired: 3, | 55 | misconfigured: 3, |
| 56 | expired: 4, | ||
| 46 | }; | 57 | }; |
| 47 | 58 | ||
| 48 | /** Collapse many connection statuses to the group's worst: expired > degraded | 59 | /** Collapse many connection statuses to the group's worst: expired > |
| 49 | * > healthy. `unknown` entries are ignored; when nothing else remains (empty | 60 | * misconfigured > degraded > healthy. `unknown` entries are ignored; when |
| 50 | * input or all unknown) there is no verdict and the caller renders nothing. */ | 61 | * nothing else remains (empty input or all unknown) there is no verdict and |
| 62 | * the caller renders nothing. */ | ||
| 51 | export const worstHealthStatus = (statuses: readonly HealthStatus[]): HealthStatus | null => { | 63 | export const worstHealthStatus = (statuses: readonly HealthStatus[]): HealthStatus | null => { |
| 52 | let worst: Exclude<HealthStatus, "unknown"> | null = null; | 64 | let worst: Exclude<HealthStatus, "unknown"> | null = null; |
| 53 | for (const status of statuses) { | 65 | for (const status of statuses) { |
| @@ -64,6 +76,7 @@ export const HEALTH_BADGE_VARIANT: Record< · 10 unmodified lines | |||
| 64 | > = { | 76 | > = { |
| 65 | healthy: "secondary", | 77 | healthy: "secondary", |
| 66 | expired: "destructive", | 78 | expired: "destructive", |
| 79 | misconfigured: "outline", | ||
| 67 | degraded: "outline", | 80 | degraded: "outline", |
| 68 | unknown: "outline", | 81 | unknown: "outline", |
| 69 | }; | 82 | }; |