From a546d2c3e976e3ee8af471869e5a4b8e9db2c152 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:19:38 -0700 Subject: [PATCH 1/3] e2e: repro Google SERVICE_DISABLED 403 misclassified as expired connection --- .../google-disabled-api-expired.test.ts | 323 ++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 e2e/scenarios/google-disabled-api-expired.test.ts diff --git a/e2e/scenarios/google-disabled-api-expired.test.ts b/e2e/scenarios/google-disabled-api-expired.test.ts new file mode 100644 index 000000000..8febf04cf --- /dev/null +++ b/e2e/scenarios/google-disabled-api-expired.test.ts @@ -0,0 +1,323 @@ +// Repro: a Google 403 for a DISABLED API reads as an expired credential. +// +// Production case (2026-07-10): a user's "Personal Google" connection showed +// the red Expired badge although its OAuth token was alive and refreshing. +// The org's Google OAuth client lives in a GCP project without the Gmail / +// Drive / Calendar APIs enabled, so the health probe gets Google's +// SERVICE_DISABLED 403 ("Gmail API has not been used in project ... or it is +// disabled"). `classifyHttpStatus` maps every 401/403 to "expired", so a +// perfectly healthy credential renders as Expired and the UI tells the user +// to reconnect - which cannot fix a disabled upstream API. +// +// The scenario replays that exact journey against the Google emulator: a real +// OAuth grant probes healthy; then the upstream starts answering the probe +// operation with Google's real SERVICE_DISABLED body (fault injection - the +// token stays valid, exactly like prod); the connection now reads Expired on +// screen with the reconnect toast. The assertions pin today's (wrong) +// classification on purpose: when the classifier learns to tell "API disabled +// in the project" from "credential expired", this scenario is the one that +// must change. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import type { HttpApiClient } from "effect/unstable/httpapi"; +import { composePluginApi } from "@executor-js/api/server"; +import { connectEmulator, type EmulatorClient } from "@executor-js/emulate"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, +} from "@executor-js/sdk/shared"; + +import { createEmulatorInstance } from "../src/emulator-instance"; +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; +import type { Identity, Target as TargetShape } from "../src/target"; +import type { BrowserSurface } from "../src/surfaces/browser"; + +const api = composePluginApi([openApiHttpPlugin()] as const); +type Client = HttpApiClient.ForApi; +const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2"); +const CONNECTION = ConnectionName.make("main"); + +// The Gmail preset's declared health check, and the probe the fault hijacks. +const GMAIL_HEALTH_OPERATION = "gmail.users.labels.list"; + +// Google's real error body for an API that is not enabled in the OAuth +// client's GCP project. Verbatim shape from production: HTTP 403 with reason +// `accessNotConfigured` - an authorization-configuration error, NOT an +// expired/revoked credential. +const DISABLED_API_MESSAGE = + "Gmail API has not been used in project 000000000000 before or it is disabled. " + + "Enable it by visiting https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=000000000000 then retry."; +const disabledApiBody = { + error: { + code: 403, + message: DISABLED_API_MESSAGE, + errors: [ + { message: DISABLED_API_MESSAGE, domain: "usageLimits", reason: "accessNotConfigured" }, + ], + status: "PERMISSION_DENIED", + }, +}; + +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; + +const decodeHtml = (value: string): string => + value + .replaceAll("&", "&") + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">"); + +const inputFields = (html: string): Record => { + const fields: Record = {}; + for (const input of html.matchAll(/]*>/gi)) { + const tag = input[0]; + const name = tag.match(/\bname=["']([^"']+)["']/i)?.[1]; + const value = tag.match(/\bvalue=["']([^"']*)["']/i)?.[1] ?? ""; + if (name) fields[decodeHtml(name)] = decodeHtml(value); + } + return fields; +}; + +const formAction = (html: string, fallback: string): string => { + const action = html.match(/]*\baction=["']([^"']+)["']/i)?.[1]; + return action ? decodeHtml(action) : fallback; +}; + +const completeGoogleConsent = (authorizationUrl: string) => + Effect.promise(async () => { + const page = await fetch(authorizationUrl); + if (!page.ok) throw new Error(`Google emulator authorize failed: ${page.status}`); + const html = await page.text(); + const callback = await fetch(formAction(html, authorizationUrl), { + method: "POST", + body: new URLSearchParams(inputFields(html)), + redirect: "manual", + }); + const location = callback.headers.get("location"); + if (callback.status !== 302 || !location) { + throw new Error(`Google emulator consent did not redirect: ${callback.status}`); + } + const code = new URL(location).searchParams.get("code"); + if (!code) throw new Error("Google emulator callback did not include a code"); + return code; + }); + +const createGoogleEmulator = Effect.gen(function* () { + const baseUrl = yield* createEmulatorInstance("google", "disabled-api"); + const client = yield* Effect.promise(() => connectEmulator({ baseUrl })); + return { client, baseUrl }; +}); + +const addGooglePresetFromCatalog = ( + browser: BrowserSurface, + identity: Identity, + presetName: string, + slug: string, +) => + browser.session(identity, async ({ page, step }) => { + await step(`Open ${presetName} from the connect catalog`, async () => { + await page.goto("/integrations", { waitUntil: "networkidle" }); + await page + .getByRole("button", { name: /Connect/ }) + .first() + .click(); + const dialog = page.getByRole("dialog", { name: "Connect an integration" }); + await dialog.waitFor(); + await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName); + await dialog.getByRole("link", { name: new RegExp(`^${presetName}\\b`) }).click(); + }); + + await step(`Add the ${presetName} integration`, async () => { + await page.waitForURL(/\/integrations\/add\/openapi/); + await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); + const button = page.getByRole("button", { name: "Add integration" }); + await button.waitFor({ timeout: 120_000 }); + await button.click({ timeout: 120_000 }); + await page.waitForURL(new RegExp(`/integrations/${slug}\\b`), { timeout: 120_000 }); + }); + }); + +const connectGoogleAccount = (input: { + readonly client: Client; + readonly emulator: EmulatorClient; + readonly emulatorBaseUrl: string; + readonly target: TargetShape; + readonly integration: IntegrationSlug; + readonly oauthClient: OAuthClientSlug; +}) => + Effect.gen(function* () { + const redirectUri = new URL("/api/oauth/callback", input.target.baseUrl).toString(); + const credential = yield* Effect.promise(() => + input.emulator.credentials.mint({ + type: "oauth-authorization-code", + redirect_uris: [redirectUri], + }), + ); + if (!credential.client_id || !credential.client_secret) { + return yield* Effect.die(`Google emulator did not mint an OAuth client`); + } + + yield* input.client.openapi.configure({ + params: { slug: input.integration }, + payload: { baseUrl: input.emulatorBaseUrl }, + }); + yield* input.client.oauth.createClient({ + payload: { + owner: "org", + slug: input.oauthClient, + grant: "authorization_code", + authorizationUrl: `${input.emulatorBaseUrl}/o/oauth2/v2/auth`, + tokenUrl: `${input.emulatorBaseUrl}/oauth2/token`, + clientId: credential.client_id, + clientSecret: credential.client_secret, + originIntegration: input.integration, + }, + }); + + const started = yield* input.client.oauth.start({ + payload: { + client: input.oauthClient, + clientOwner: "org", + owner: "org", + name: CONNECTION, + integration: input.integration, + template: GOOGLE_AUTH_TEMPLATE, + redirectUri, + }, + }); + expect(started.status, "OAuth starts with an emulator redirect").toBe("redirect"); + if (started.status !== "redirect") return yield* Effect.die("OAuth unexpectedly connected"); + + const code = yield* completeGoogleConsent(started.authorizationUrl); + const completed = yield* input.client.oauth.complete({ + payload: { state: started.state, code }, + }); + expect(completed.integration, "OAuth completion creates the connection").toBe( + input.integration, + ); + }); + +scenario( + "Google · repro: a disabled upstream API reads as an expired connection (SERVICE_DISABLED 403)", + { timeout: 300_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const emulator = yield* createGoogleEmulator; + const slug = IntegrationSlug.make("google_gmail"); + const oauthClient = OAuthClientSlug.make(unique("google_gmail_oauth")); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* addGooglePresetFromCatalog(browser, identity, "Gmail", String(slug)); + + const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); + expect(stored?.operation, "the Gmail preset declares its labels probe").toBe( + GMAIL_HEALTH_OPERATION, + ); + + yield* connectGoogleAccount({ + client, + emulator: emulator.client, + emulatorBaseUrl: emulator.baseUrl, + target, + integration: slug, + oauthClient, + }); + + // Baseline: the freshly granted token probes healthy against the + // emulator - same as prod the day the account was connected. + const healthy = yield* client.connections.checkHealth({ + params: { owner: "org", integration: slug, name: CONNECTION }, + query: { ifStaleMs: 0 }, + }); + expect(healthy.status, `the fresh grant probes healthy: ${JSON.stringify(healthy)}`).toBe( + "healthy", + ); + + // The upstream flips: the probe operation now answers with Google's + // real SERVICE_DISABLED 403. ONLY that route faults - the OAuth token + // endpoints stay live, so the credential itself remains valid and + // refreshable, exactly the production condition. Generous `times` + // covers the UI's automatic revalidations on top of explicit checks. + yield* Effect.promise(() => + emulator.client.faults.arm({ + match: { operationId: GMAIL_HEALTH_OPERATION }, + response: { status: 403, body: disabledApiBody }, + times: 100, + }), + ); + yield* Effect.promise(() => emulator.client.ledger.clear()); + + // THE BUG, pinned: a 403 that means "enable this API in your GCP + // project" classifies as an expired credential. + const verdict = yield* client.connections.checkHealth({ + params: { owner: "org", integration: slug, name: CONNECTION }, + query: { ifStaleMs: 0 }, + }); + expect( + verdict.status, + "BUG: the SERVICE_DISABLED 403 classifies as an expired credential", + ).toBe("expired"); + expect(verdict.httpStatus, "the probe observed the 403").toBe(403); + expect( + verdict.detail, + "the stored detail names the real cause (a disabled API, not an expired token)", + ).toContain("has not been used in project"); + + // The 403 came from the armed fault on the probe route - the token + // was never rejected by the emulator's auth layer. + const ledger = yield* Effect.promise(() => emulator.client.ledger.list(50)); + const probeEntry = ledger.find((entry) => entry.operationId === GMAIL_HEALTH_OPERATION); + expect(probeEntry?.faulted, "the probe was answered by the armed fault").toBe(true); + + yield* browser.session(identity, async ({ page, step }) => { + const connections = page.locator("section").filter({ + has: page.getByRole("heading", { level: 3, name: "Connections" }), + }); + + await step( + "The healthy-yesterday connection now shows Expired, with no clicks", + async () => { + await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await connections.getByText("Expired", { exact: true }).waitFor({ timeout: 30_000 }); + await connections.getByLabel("Status: Expired").waitFor(); + }, + ); + + await step( + "Check now tells the user to reconnect - advice that cannot fix a disabled API", + async () => { + await connections.locator('button[aria-haspopup="menu"]').click(); + await page.getByRole("menuitem", { name: "Check now" }).click(); + // The misleading UX in one line: the toast prescribes reconnecting + // while the upstream error says "enable the API in your project". + await page + .getByText("Connection expired, reconnect to restore access") + .waitFor({ timeout: 30_000 }); + }, + ); + }); + }), + Effect.gen(function* () { + yield* client.connections + .remove({ params: { owner: "org", integration: slug, name: CONNECTION } }) + .pipe(Effect.ignore); + yield* client.oauth + .removeClient({ params: { slug: oauthClient }, payload: { owner: "org" } }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ); + }), +); From ee67b0ef81e7028d39cc0a5eb33f3fa2af8fed33 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:37:37 -0700 Subject: [PATCH 2/3] health: classify Google disabled-API 403 as misconfigured, not expired A SERVICE_DISABLED / accessNotConfigured 403 means the API is disabled in the OAuth client's project; the credential is fine. Probes now classify it as a new 'misconfigured' status: amber 'API disabled' badge, the provider's own remediation text (console link clickable) on the row, and no reconnect prompt. --- ...ed.test.ts => google-disabled-api.test.ts} | 66 +++++++++++------ packages/core/sdk/src/health-check.test.ts | 72 +++++++++++++++++++ packages/core/sdk/src/health-check.ts | 68 ++++++++++++++++-- packages/core/sdk/src/index.ts | 1 + packages/core/sdk/src/shared.ts | 1 + packages/plugins/openapi/src/sdk/backing.ts | 6 +- .../react/src/components/accounts-section.tsx | 50 +++++++++++++ .../src/components/add-account-modal.tsx | 8 ++- packages/react/src/lib/health-display.ts | 27 +++++-- 9 files changed, 262 insertions(+), 37 deletions(-) rename e2e/scenarios/{google-disabled-api-expired.test.ts => google-disabled-api.test.ts} (82%) diff --git a/e2e/scenarios/google-disabled-api-expired.test.ts b/e2e/scenarios/google-disabled-api.test.ts similarity index 82% rename from e2e/scenarios/google-disabled-api-expired.test.ts rename to e2e/scenarios/google-disabled-api.test.ts index 8febf04cf..b0d94532a 100644 --- a/e2e/scenarios/google-disabled-api-expired.test.ts +++ b/e2e/scenarios/google-disabled-api.test.ts @@ -1,22 +1,21 @@ -// Repro: a Google 403 for a DISABLED API reads as an expired credential. +// A Google 403 for a DISABLED API must NOT read as an expired credential. // // Production case (2026-07-10): a user's "Personal Google" connection showed // the red Expired badge although its OAuth token was alive and refreshing. // The org's Google OAuth client lives in a GCP project without the Gmail / // Drive / Calendar APIs enabled, so the health probe gets Google's // SERVICE_DISABLED 403 ("Gmail API has not been used in project ... or it is -// disabled"). `classifyHttpStatus` maps every 401/403 to "expired", so a -// perfectly healthy credential renders as Expired and the UI tells the user -// to reconnect - which cannot fix a disabled upstream API. +// disabled"). The old status-only classifier mapped every 401/403 to +// "expired", so a healthy credential rendered as Expired and the UI told the +// user to reconnect - advice that cannot fix a disabled upstream API. // // The scenario replays that exact journey against the Google emulator: a real // OAuth grant probes healthy; then the upstream starts answering the probe // operation with Google's real SERVICE_DISABLED body (fault injection - the -// token stays valid, exactly like prod); the connection now reads Expired on -// screen with the reconnect toast. The assertions pin today's (wrong) -// classification on purpose: when the classifier learns to tell "API disabled -// in the project" from "credential expired", this scenario is the one that -// must change. +// token stays valid, exactly like prod). The fixed classifier reads the error +// body and reports "misconfigured": on screen that is the amber "API +// disabled" badge with Google's own remediation text (including the console +// link that enables the API), and no reconnect prompt. import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; @@ -205,7 +204,7 @@ const connectGoogleAccount = (input: { }); scenario( - "Google · repro: a disabled upstream API reads as an expired connection (SERVICE_DISABLED 403)", + "Google · a disabled upstream API reads as API disabled with the enable link, not Expired", { timeout: 300_000 }, Effect.gen(function* () { const target = yield* Target; @@ -259,16 +258,16 @@ scenario( ); yield* Effect.promise(() => emulator.client.ledger.clear()); - // THE BUG, pinned: a 403 that means "enable this API in your GCP - // project" classifies as an expired credential. + // The fix under test: a 403 that means "enable this API in your GCP + // project" classifies as misconfigured, not as a dead credential. const verdict = yield* client.connections.checkHealth({ params: { owner: "org", integration: slug, name: CONNECTION }, query: { ifStaleMs: 0 }, }); expect( verdict.status, - "BUG: the SERVICE_DISABLED 403 classifies as an expired credential", - ).toBe("expired"); + `the SERVICE_DISABLED 403 classifies as misconfigured: ${JSON.stringify(verdict)}`, + ).toBe("misconfigured"); expect(verdict.httpStatus, "the probe observed the 403").toBe(403); expect( verdict.detail, @@ -287,24 +286,49 @@ scenario( }); await step( - "The healthy-yesterday connection now shows Expired, with no clicks", + "The connection shows the amber API disabled badge - not Expired - with no clicks", async () => { await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); - await connections.getByText("Expired", { exact: true }).waitFor({ timeout: 30_000 }); - await connections.getByLabel("Status: Expired").waitFor(); + await connections + .getByText("API disabled", { exact: true }) + .waitFor({ timeout: 30_000 }); + await connections.getByLabel("Status: API disabled").waitFor(); + // The dead-credential story must be gone entirely. + expect( + await connections.getByText("Expired", { exact: true }).count(), + "no Expired badge on a misconfigured connection", + ).toBe(0); + }, + ); + + await step( + "The row carries Google's own instruction, console link included", + async () => { + await connections.getByText(/has not been used in project/).waitFor(); + const consoleLink = connections.getByRole("link", { + name: /console\.developers\.google\.com/, + }); + await consoleLink.waitFor(); + expect( + await consoleLink.getAttribute("href"), + "the enable-API console link is clickable", + ).toContain("console.developers.google.com"); }, ); await step( - "Check now tells the user to reconnect - advice that cannot fix a disabled API", + "Check now explains the disabled API instead of prescribing reconnect", async () => { await connections.locator('button[aria-haspopup="menu"]').click(); await page.getByRole("menuitem", { name: "Check now" }).click(); - // The misleading UX in one line: the toast prescribes reconnecting - // while the upstream error says "enable the API in your project". await page - .getByText("Connection expired, reconnect to restore access") + .getByText(/has not been used in project/) + .first() .waitFor({ timeout: 30_000 }); + expect( + await page.getByText("Connection expired, reconnect to restore access").count(), + "the reconnect toast never fires for a configuration 403", + ).toBe(0); }, ); }); diff --git a/packages/core/sdk/src/health-check.test.ts b/packages/core/sdk/src/health-check.test.ts index b0bcb2610..61b652267 100644 --- a/packages/core/sdk/src/health-check.test.ts +++ b/packages/core/sdk/src/health-check.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it } from "@effect/vitest"; import { candidateIdentityTier, + classifyHttpStatus, + classifyProbeResponse, rankResponseSample, sortHealthCheckCandidatesByIdentity, } from "./health-check"; @@ -66,3 +68,73 @@ describe("candidateIdentityTier", () => { ).toBe("user.getAuthUser"); }); }); + +// Probe classification: 401/403 mean "credential dead" EXCEPT the +// configuration 403 (Google accessNotConfigured / SERVICE_DISABLED), which +// must read misconfigured: the token authenticated, the API is disabled in +// the OAuth client's project, and reconnecting cannot fix it. This is the +// production case behind the "Expired when it is not" report (2026-07-10). +describe("classifyProbeResponse", () => { + const disabledMessage = + "Gmail API has not been used in project 000000000000 before or it is disabled. " + + "Enable it by visiting https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=000000000000 then retry."; + + it("classifies Google's classic accessNotConfigured 403 as misconfigured", () => { + const body = { + error: { + code: 403, + message: disabledMessage, + errors: [ + { message: disabledMessage, domain: "usageLimits", reason: "accessNotConfigured" }, + ], + status: "PERMISSION_DENIED", + }, + }; + expect(classifyProbeResponse(403, body)).toBe("misconfigured"); + }); + + it("classifies the newer SERVICE_DISABLED ErrorInfo detail as misconfigured", () => { + const body = { + error: { + code: 403, + message: disabledMessage, + status: "PERMISSION_DENIED", + details: [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + reason: "SERVICE_DISABLED", + domain: "googleapis.com", + }, + ], + }, + }; + expect(classifyProbeResponse(403, body)).toBe("misconfigured"); + }); + + it("keeps a plain 403 (no recognized reason) as expired: false-expired is the safe default", () => { + expect(classifyProbeResponse(403, { error: { code: 403, message: "Forbidden" } })).toBe( + "expired", + ); + expect(classifyProbeResponse(403, undefined)).toBe("expired"); + expect(classifyProbeResponse(403, "Forbidden")).toBe("expired"); + // A permission reason that is NOT a configuration marker stays expired. + expect( + classifyProbeResponse(403, { + error: { errors: [{ reason: "insufficientPermissions" }] }, + }), + ).toBe("expired"); + }); + + it("never rewrites a 401: an authentication failure is a credential problem regardless of body", () => { + const body = { error: { errors: [{ reason: "accessNotConfigured" }] } }; + expect(classifyProbeResponse(401, body)).toBe("expired"); + }); + + it("matches the status-only classifier everywhere else", () => { + for (const status of [200, 204, 404, 429, 500, 503]) { + expect(classifyProbeResponse(status, { error: {} }), `status ${status}`).toBe( + classifyHttpStatus(status), + ); + } + }); +}); diff --git a/packages/core/sdk/src/health-check.ts b/packages/core/sdk/src/health-check.ts index d553f994f..2551879cf 100644 --- a/packages/core/sdk/src/health-check.ts +++ b/packages/core/sdk/src/health-check.ts @@ -18,14 +18,24 @@ import { Schema } from "effect"; // --------------------------------------------------------------------------- -// Status: the four states a connection can be in. `expired` is the one this +// Status: the five states a connection can be in. `expired` is the one this // whole feature exists for (Google's 7-day dev-token revocation): the credential -// authenticated fine yesterday and now returns 401/403. `degraded` covers any -// other non-2xx (upstream 5xx, a 404 on a mis-picked operation); `unknown` is -// "never checked / no health check configured". +// authenticated fine yesterday and now returns 401/403. `misconfigured` is the +// 403 that is NOT a credential problem: the token authenticated, but the +// upstream rejects on configuration (a Google API not enabled in the OAuth +// client's GCP project): the fix is in the provider console, so telling the +// user to reconnect would mislead. `degraded` covers any other non-2xx +// (upstream 5xx, a 404 on a mis-picked operation); `unknown` is "never checked +// / no health check configured". // --------------------------------------------------------------------------- -export const HealthStatus = Schema.Literals(["healthy", "expired", "degraded", "unknown"]); +export const HealthStatus = Schema.Literals([ + "healthy", + "expired", + "misconfigured", + "degraded", + "unknown", +]); export type HealthStatus = typeof HealthStatus.Type; // --------------------------------------------------------------------------- @@ -139,13 +149,59 @@ export type HealthCheckCandidate = typeof HealthCheckCandidate.Type; // --------------------------------------------------------------------------- /** Map an HTTP status to a health state: 2xx healthy, 401/403 expired (the auth - * wall), everything else degraded. */ + * wall), everything else degraded. Status-only fallback: when the response + * BODY is available, use `classifyProbeResponse` instead, which tells a + * credential 403 apart from a configuration 403. */ export const classifyHttpStatus = (status: number): HealthStatus => { if (status >= 200 && status < 300) return "healthy"; if (status === 401 || status === 403) return "expired"; return "degraded"; }; +// Error `reason` / `status` markers that make a 403 a CONFIGURATION rejection +// rather than a credential one. Deliberately narrow and provider-shaped: +// Google's error envelope carries `errors[].reason: "accessNotConfigured"` +// (message " has not been used in project N before or it is disabled") +// and newer surfaces use `status: "PERMISSION_DENIED"` with +// `details[].reason: "SERVICE_DISABLED"`. Unrecognized 403s keep the expired +// classification: a false "expired" prompts a harmless reconnect, but a false +// "misconfigured" would hide a genuinely dead credential. +const CONFIGURATION_403_REASONS = new Set(["accessnotconfigured", "service_disabled"]); + +/** Collect candidate `reason` markers from a Google-shaped error envelope: + * `error.errors[].reason` (classic) and `error.details[].reason` (newer + * google.rpc.ErrorInfo). Tolerant of partial shapes; returns []. */ +const errorReasonMarkers = (body: unknown): string[] => { + if (body == null || typeof body !== "object") return []; + const error = (body as Record)["error"]; + if (error == null || typeof error !== "object") return []; + const markers: string[] = []; + for (const key of ["errors", "details"] as const) { + const entries = (error as Record)[key]; + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + if (entry == null || typeof entry !== "object") continue; + const reason = (entry as Record)["reason"]; + if (typeof reason === "string") markers.push(reason.toLowerCase()); + } + } + return markers; +}; + +/** Classify a probe response from its status AND body. Everything is + * `classifyHttpStatus` except one carve-out: a 403 whose error body carries a + * known configuration reason (Google `accessNotConfigured` / + * `SERVICE_DISABLED`) is `misconfigured`, not `expired`: the credential + * authenticated; the upstream API is disabled in the OAuth client's project, + * and only enabling it there (not reconnecting) fixes it. */ +export const classifyProbeResponse = (status: number, body: unknown): HealthStatus => { + const byStatus = classifyHttpStatus(status); + if (status !== 403 || byStatus !== "expired") return byStatus; + return errorReasonMarkers(body).some((reason) => CONFIGURATION_403_REASONS.has(reason)) + ? "misconfigured" + : "expired"; +}; + /** Resolve a dot-path (`a.b.0.c`) against a JSON value and coerce the leaf to a * display string. Numeric segments index arrays. Returns undefined when the * path is empty, missing, or lands on a non-scalar. */ diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 73a8e9f6c..2b8ac8699 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -114,6 +114,7 @@ export { HealthCheckCandidateParameter, HealthCheckResponseField, classifyHttpStatus, + classifyProbeResponse, extractIdentity, compareHealthCheckCandidates, candidateIdentityTier, diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index e78b56577..d90dd14f3 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -118,6 +118,7 @@ export { HealthCheckCandidate, HealthCheckCandidateParameter, classifyHttpStatus, + classifyProbeResponse, extractIdentity, compareHealthCheckCandidates, candidateIdentityTier, diff --git a/packages/plugins/openapi/src/sdk/backing.ts b/packages/plugins/openapi/src/sdk/backing.ts index 4791cd040..556c208ef 100644 --- a/packages/plugins/openapi/src/sdk/backing.ts +++ b/packages/plugins/openapi/src/sdk/backing.ts @@ -7,7 +7,7 @@ import { ToolName, ToolResult, authToolFailure, - classifyHttpStatus, + classifyProbeResponse, detectInsufficientScope, sortHealthCheckCandidatesByIdentity, extractIdentity, @@ -962,7 +962,9 @@ export const checkHealthOpenApi = (input: { } satisfies HealthCheckResult; } - const status = classifyHttpStatus(probe.result.status); + // Body-aware: a configuration 403 (Google accessNotConfigured / + // SERVICE_DISABLED) reads misconfigured, not expired. + const status = classifyProbeResponse(probe.result.status, probe.result.error); const identity = status === "healthy" ? extractIdentity(probe.result.data, spec.identityField) : undefined; // Sample the returned body ONLY on a healthy probe: the sample exists to diff --git a/packages/react/src/components/accounts-section.tsx b/packages/react/src/components/accounts-section.tsx index 7cbdf6d1e..21fc5b982 100644 --- a/packages/react/src/components/accounts-section.tsx +++ b/packages/react/src/components/accounts-section.tsx @@ -60,6 +60,35 @@ import { const OWNERS: readonly Owner[] = ["org", "user"]; +/** Render a health-check detail with any bare https URL as a clickable link. + * Exists for the misconfigured verdict, whose detail is the provider's own + * remediation text (Google's includes the console URL that enables the + * disabled API); the rest of the string stays plain text. */ +function DetailWithLinks(props: { readonly text: string }) { + const parts = props.text.split(/(https:\/\/[^\s,;)]+)/g); + return ( + <> + {parts.map((part, index) => + part.startsWith("https://") ? ( + + {part} + + ) : ( + part + ), + )} + + ); +} + function AccountRow(props: { readonly connection: Connection; /** The integration declares scopes this connection was not granted — it must @@ -95,6 +124,7 @@ function AccountRow(props: { const displayLabel = identity ?? String(connection.name); const expired = status === "expired"; + const misconfigured = status === "misconfigured"; const missingOAuthScopes = connection.missingOAuthScopes ?? []; const handleCheck = async () => { @@ -113,6 +143,13 @@ function AccountRow(props: { ); } else if (exit.value.status === "expired") { toast.error("Connection expired, reconnect to restore access"); + } else if (exit.value.status === "misconfigured") { + // NOT a reconnect prompt: the credential is fine; the upstream API is + // disabled where the OAuth client lives. The detail carries the + // provider's own instruction (with a console link for Google). + toast.warning( + exit.value.detail ?? "An upstream API is disabled for this connection's OAuth client", + ); } else if (exit.value.status === "degraded") { toast.warning(exit.value.detail ?? "Connection check returned an error"); } else { @@ -135,6 +172,14 @@ function AccountRow(props: { Expired ) : null} + {misconfigured ? ( + + API disabled + + ) : null} {needsReconsent ? ( Reconnect to grant access @@ -146,6 +191,11 @@ function AccountRow(props: { {connection.description} ) : null} + {misconfigured && probe?.detail ? ( + + + + ) : null} {needsReconsent ? ( This connection wasn't granted all the access this integration now needs. diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index 7259b199c..66340208e 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -928,7 +928,13 @@ function HealthStatusLine(props: { }) { const { status, identity, detail, httpStatus } = props.result; const healthy = status === "healthy"; - const tone = healthy ? "text-muted-foreground" : "text-destructive"; + // Misconfigured is amber everywhere (see health-display.ts): the credential + // being validated is fine, so the verdict must not read as a key failure. + const tone = healthy + ? "text-muted-foreground" + : status === "misconfigured" + ? "text-amber-600 dark:text-amber-500" + : "text-destructive"; const font = props.variant === "response" ? "font-mono" : ""; return (
diff --git a/packages/react/src/lib/health-display.ts b/packages/react/src/lib/health-display.ts index acd3fce45..d87dbaf07 100644 --- a/packages/react/src/lib/health-display.ts +++ b/packages/react/src/lib/health-display.ts @@ -6,19 +6,26 @@ import type { HealthStatus } from "@executor-js/sdk/shared"; -/** State form: badge text, indicator tooltips, "what is the current state". */ +/** State form: badge text, indicator tooltips, "what is the current state". + * `misconfigured` reads "API disabled": the one concrete cause it detects + * today (an upstream API not enabled in the OAuth client's project), named + * plainly so the user looks at the provider console, not the credential. */ export const HEALTH_STATUS_LABEL: Record = { healthy: "Healthy", expired: "Expired", + misconfigured: "API disabled", degraded: "Degraded", unknown: "Unchecked", }; /** Text tone per status, for verdict lines and previews. Same per-status color - * decision as the indicator dots below; change them together. */ + * decision as the indicator dots below; change them together. `misconfigured` + * shares degraded's amber: it needs attention, but the credential is fine, so + * it must not carry expired's destructive red. */ export const HEALTH_TEXT_CLASS: Record = { healthy: "text-emerald-600 dark:text-emerald-400", expired: "text-destructive", + misconfigured: "text-amber-600 dark:text-amber-500", degraded: "text-amber-600 dark:text-amber-500", unknown: "text-muted-foreground", }; @@ -32,22 +39,27 @@ export const HEALTH_INDICATOR_COLOR: Record< > = { healthy: { dot: "bg-emerald-500", ring: "ring-emerald-500/70" }, expired: { dot: "bg-destructive", ring: "ring-destructive/70" }, + misconfigured: { dot: "bg-amber-500", ring: "ring-amber-500/70" }, degraded: { dot: "bg-amber-500", ring: "ring-amber-500/70" }, unknown: { dot: "bg-muted-foreground/40", ring: "ring-muted-foreground/40" }, }; /** Severity for worst-of aggregation. `unknown` is excluded on purpose: a * never-probed connection carries no signal, so it must not drag a group - * verdict in either direction. */ + * verdict in either direction. `misconfigured` outranks `degraded` (a named + * configuration break beats a generic error) but not `expired` (a dead + * credential blocks everything). */ const HEALTH_SEVERITY: Record, number> = { healthy: 1, degraded: 2, - expired: 3, + misconfigured: 3, + expired: 4, }; -/** Collapse many connection statuses to the group's worst: expired > degraded - * > healthy. `unknown` entries are ignored; when nothing else remains (empty - * input or all unknown) there is no verdict and the caller renders nothing. */ +/** Collapse many connection statuses to the group's worst: expired > + * misconfigured > degraded > healthy. `unknown` entries are ignored; when + * nothing else remains (empty input or all unknown) there is no verdict and + * the caller renders nothing. */ export const worstHealthStatus = (statuses: readonly HealthStatus[]): HealthStatus | null => { let worst: Exclude | null = null; for (const status of statuses) { @@ -64,6 +76,7 @@ export const HEALTH_BADGE_VARIANT: Record< > = { healthy: "secondary", expired: "destructive", + misconfigured: "outline", degraded: "outline", unknown: "outline", }; From d87e84f46cde26840af1db2e6fef97012c493f9c Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:54:12 -0700 Subject: [PATCH 3/3] health: wrap the misconfigured detail instead of truncating it The remediation text is the payload - the enable-API console link was clipped by the one-line truncate. The scenario now asserts the text wraps un-clipped and the link sits inside the visible box. --- e2e/scenarios/google-disabled-api.test.ts | 19 ++++++++++++++++++- .../react/src/components/accounts-section.tsx | 8 ++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/e2e/scenarios/google-disabled-api.test.ts b/e2e/scenarios/google-disabled-api.test.ts index b0d94532a..ccd18abdd 100644 --- a/e2e/scenarios/google-disabled-api.test.ts +++ b/e2e/scenarios/google-disabled-api.test.ts @@ -304,7 +304,8 @@ scenario( await step( "The row carries Google's own instruction, console link included", async () => { - await connections.getByText(/has not been used in project/).waitFor(); + const detail = connections.getByText(/has not been used in project/); + await detail.waitFor(); const consoleLink = connections.getByRole("link", { name: /console\.developers\.google\.com/, }); @@ -313,6 +314,22 @@ scenario( await consoleLink.getAttribute("href"), "the enable-API console link is clickable", ).toContain("console.developers.google.com"); + // Visible in FULL, not just present in the DOM: a one-line + // truncate keeps the link in the tree while clipping it from + // view, which is exactly the regression this guards against. + // A clipped element overflows horizontally; a wrapped one + // grows taller instead. + const clipped = await detail.evaluate( + (el) => + el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1, + ); + expect(clipped, "the remediation text wraps instead of clipping").toBe(false); + const linkVisible = await consoleLink.evaluate((el) => { + const rect = el.getBoundingClientRect(); + const detailRect = el.closest("p")?.getBoundingClientRect(); + return detailRect ? rect.right <= detailRect.right + 1 : false; + }); + expect(linkVisible, "the console link sits inside the visible detail box").toBe(true); }, ); diff --git a/packages/react/src/components/accounts-section.tsx b/packages/react/src/components/accounts-section.tsx index 21fc5b982..2aef258ec 100644 --- a/packages/react/src/components/accounts-section.tsx +++ b/packages/react/src/components/accounts-section.tsx @@ -192,9 +192,13 @@ function AccountRow(props: { ) : null} {misconfigured && probe?.detail ? ( - + // Not CardStackEntryDescription: that truncates to one line, and this + // text IS the remediation (the enable-API console link must stay + // visible in full). Wrap instead; break anywhere so the long URL + // cannot overflow the row. +

- +

) : null} {needsReconsent ? (