Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
364 changes: 364 additions & 0 deletions e2e/scenarios/google-disabled-api.test.ts

Large diffs are not rendered by default.

72 changes: 72 additions & 0 deletions packages/core/sdk/src/health-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { describe, expect, it } from "@effect/vitest";

import {
candidateIdentityTier,
classifyHttpStatus,
classifyProbeResponse,
rankResponseSample,
sortHealthCheckCandidatesByIdentity,
} from "./health-check";
Expand Down Expand Up @@ -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),
);
}
});
});
68 changes: 62 additions & 6 deletions packages/core/sdk/src/health-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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 "<API> 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<string, unknown>)["error"];
if (error == null || typeof error !== "object") return [];
const markers: string[] = [];
for (const key of ["errors", "details"] as const) {
const entries = (error as Record<string, unknown>)[key];
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
if (entry == null || typeof entry !== "object") continue;
const reason = (entry as Record<string, unknown>)["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. */
Expand Down
1 change: 1 addition & 0 deletions packages/core/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export {
HealthCheckCandidateParameter,
HealthCheckResponseField,
classifyHttpStatus,
classifyProbeResponse,
extractIdentity,
compareHealthCheckCandidates,
candidateIdentityTier,
Expand Down
1 change: 1 addition & 0 deletions packages/core/sdk/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export {
HealthCheckCandidate,
HealthCheckCandidateParameter,
classifyHttpStatus,
classifyProbeResponse,
extractIdentity,
compareHealthCheckCandidates,
candidateIdentityTier,
Expand Down
6 changes: 4 additions & 2 deletions packages/plugins/openapi/src/sdk/backing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
ToolName,
ToolResult,
authToolFailure,
classifyHttpStatus,
classifyProbeResponse,
detectInsufficientScope,
sortHealthCheckCandidatesByIdentity,
extractIdentity,
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions packages/react/src/components/accounts-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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://") ? (
<a
// Stable for a given detail string: parts are positional.
// oxlint-disable-next-line react/no-array-index-key
key={index}
href={part}
target="_blank"
rel="noreferrer"
className="underline underline-offset-2 hover:text-foreground"
>
{part}
</a>
) : (
part
),
)}
</>
);
}

function AccountRow(props: {
readonly connection: Connection;
/** The integration declares scopes this connection was not granted — it must
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 {
Expand All @@ -135,6 +172,14 @@ function AccountRow(props: {
Expired
</Badge>
) : null}
{misconfigured ? (
<Badge
variant="outline"
className="shrink-0 border-amber-600/40 text-amber-600 dark:text-amber-500"
>
API disabled
</Badge>
) : null}
{needsReconsent ? (
<Badge variant="outline" className="shrink-0 border-border text-muted-foreground">
Reconnect to grant access
Expand All @@ -146,6 +191,15 @@ function AccountRow(props: {
{connection.description}
</CardStackEntryDescription>
) : 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.
<p className="mt-1 whitespace-normal text-xs text-muted-foreground [overflow-wrap:anywhere]">
<DetailWithLinks text={probe.detail} />
</p>
) : null}
{needsReconsent ? (
<CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">
This connection wasn't granted all the access this integration now needs.
Expand Down
8 changes: 7 additions & 1 deletion packages/react/src/components/add-account-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className={`flex min-w-0 items-center gap-2 text-xs ${tone} ${font}`}>
Expand Down
27 changes: 20 additions & 7 deletions packages/react/src/lib/health-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HealthStatus, string> = {
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<HealthStatus, string> = {
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",
};
Expand All @@ -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<Exclude<HealthStatus, "unknown">, 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<HealthStatus, "unknown"> | null = null;
for (const status of statuses) {
Expand All @@ -64,6 +76,7 @@ export const HEALTH_BADGE_VARIANT: Record<
> = {
healthy: "secondary",
expired: "destructive",
misconfigured: "outline",
degraded: "outline",
unknown: "outline",
};
Loading