History

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.

Rhys Sullivan committed 4 months ago

Commit ee67b0e1 parent a546d2c9 files · +262 −37Browse files

Unified · Split

9 files
  1. e2e/scenarios/google-disabled-api.test.ts +45 −21
  2. packages/core/sdk/src/health-check.test.ts +72 −0
  3. packages/core/sdk/src/health-check.ts +62 −6
  4. packages/core/sdk/src/index.ts +1 −0
  5. packages/core/sdk/src/shared.ts +1 −0
  6. packages/plugins/openapi/src/sdk/backing.ts +4 −2
  7. packages/react/src/components/accounts-section.tsx +50 −0
  8. packages/react/src/components/add-account-modal.tsx +7 −1
  9. packages/react/src/lib/health-display.ts +20 −7
e2e/scenarios/google-disabled-api.test.tsrenamed from e2e/scenarios/google-disabled-api-expired.test.ts+45−21
@@ -1,22 +1,21 @@
1// Repro: a Google 403 for a DISABLED API reads as an expired credential.1// A Google 403 for a DISABLED API must NOT read as an expired credential.
2//2//
3// Production case (2026-07-10): a user's "Personal Google" connection showed3// 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.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 /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's6// 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 is7// SERVICE_DISABLED 403 ("Gmail API has not been used in project ... or it is
8// disabled"). `classifyHttpStatus` maps every 401/403 to "expired", so a8// disabled"). The old status-only classifier mapped every 401/403 to
9// perfectly healthy credential renders as Expired and the UI tells the user9// "expired", so a healthy credential rendered as Expired and the UI told the
10// to reconnect - which cannot fix a disabled upstream API.10// user to reconnect - advice that cannot fix a disabled upstream API.
11//11//
12// The scenario replays that exact journey against the Google emulator: a real12// The scenario replays that exact journey against the Google emulator: a real
13// OAuth grant probes healthy; then the upstream starts answering the probe13// OAuth grant probes healthy; then the upstream starts answering the probe
14// operation with Google's real SERVICE_DISABLED body (fault injection - the14// operation with Google's real SERVICE_DISABLED body (fault injection - the
15// token stays valid, exactly like prod); the connection now reads Expired on15// token stays valid, exactly like prod). The fixed classifier reads the error
16// screen with the reconnect toast. The assertions pin today's (wrong)16// body and reports "misconfigured": on screen that is the amber "API
17// classification on purpose: when the classifier learns to tell "API disabled17// disabled" badge with Google's own remediation text (including the console
18// in the project" from "credential expired", this scenario is the one that18// link that enables the API), and no reconnect prompt.
19// must change.
20import { randomBytes } from "node:crypto";19import { randomBytes } from "node:crypto";
21 20
22import { expect } from "@effect/vitest";21import { expect } from "@effect/vitest";
@@ -205,7 +204,7 @@ const connectGoogleAccount = (input: { · 182 unmodified lines
205 });204 });
206 205
207scenario(206scenario(
208 "Google · repro: a disabled upstream API reads as an expired connection (SERVICE_DISABLED 403)",207 "Google · a disabled upstream API reads as API disabled with the enable link, not Expired",
209 { timeout: 300_000 },208 { timeout: 300_000 },
210 Effect.gen(function* () {209 Effect.gen(function* () {
211 const target = yield* Target;210 const target = yield* Target;
@@ -259,16 +258,16 @@ scenario( · 47 unmodified lines
259 );258 );
260 yield* Effect.promise(() => emulator.client.ledger.clear());259 yield* Effect.promise(() => emulator.client.ledger.clear());
261 260
262 // THE BUG, pinned: a 403 that means "enable this API in your GCP261 // The fix under test: a 403 that means "enable this API in your GCP
263 // project" classifies as an expired credential.262 // project" classifies as misconfigured, not as a dead credential.
264 const verdict = yield* client.connections.checkHealth({263 const verdict = yield* client.connections.checkHealth({
265 params: { owner: "org", integration: slug, name: CONNECTION },264 params: { owner: "org", integration: slug, name: CONNECTION },
266 query: { ifStaleMs: 0 },265 query: { ifStaleMs: 0 },
267 });266 });
268 expect(267 expect(
269 verdict.status,268 verdict.status,
270 "BUG: the SERVICE_DISABLED 403 classifies as an expired credential",269 `the SERVICE_DISABLED 403 classifies as misconfigured: ${JSON.stringify(verdict)}`,
271 ).toBe("expired");270 ).toBe("misconfigured");
272 expect(verdict.httpStatus, "the probe observed the 403").toBe(403);271 expect(verdict.httpStatus, "the probe observed the 403").toBe(403);
273 expect(272 expect(
274 verdict.detail,273 verdict.detail,
@@ -287,24 +286,49 @@ scenario( · 12 unmodified lines
287 });286 });
288 287
289 await step(288 await step(
290 "The healthy-yesterday connection now shows Expired, with no clicks",289 "The connection shows the amber API disabled badge - not Expired - with no clicks",
291 async () => {290 async () => {
292 await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });291 await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
293 await connections.getByText("Expired", { exact: true }).waitFor({ timeout: 30_000 });292 await connections
294 await connections.getByLabel("Status: Expired").waitFor();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 await connections.getByText(/has not been used in project/).waitFor();
308 const consoleLink = connections.getByRole("link", {
309 name: /console\.developers\.google\.com/,
310 });
311 await consoleLink.waitFor();
312 expect(
313 await consoleLink.getAttribute("href"),
314 "the enable-API console link is clickable",
315 ).toContain("console.developers.google.com");
295 },316 },
296 );317 );
297 318
298 await step(319 await step(
299 "Check now tells the user to reconnect - advice that cannot fix a disabled API",320 "Check now explains the disabled API instead of prescribing reconnect",
300 async () => {321 async () => {
301 await connections.locator('button[aria-haspopup="menu"]').click();322 await connections.locator('button[aria-haspopup="menu"]').click();
302 await page.getByRole("menuitem", { name: "Check now" }).click();323 await page.getByRole("menuitem", { name: "Check now" }).click();
303 // The misleading UX in one line: the toast prescribes reconnecting
304 // while the upstream error says "enable the API in your project".
305 await page324 await page
306 .getByText("Connection expired, reconnect to restore access")325 .getByText(/has not been used in project/)
326 .first()
307 .waitFor({ timeout: 30_000 });327 .waitFor({ timeout: 30_000 });
328 expect(
329 await page.getByText("Connection expired, reconnect to restore access").count(),
330 "the reconnect toast never fires for a configuration 403",
331 ).toBe(0);
308 },332 },
309 );333 );
310 });334 });
packages/core/sdk/src/health-check.test.ts+72−0
@@ -5,6 +5,8 @@ import { describe, expect, it } from "@effect/vitest"; · 4 unmodified lines
5 5
6import {6import {
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).
77describe("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});
packages/core/sdk/src/health-check.ts+62−6
@@ -18,14 +18,24 @@ · 17 unmodified lines
18import { Schema } from "effect";18import { Schema } from "effect";
19 19
20// ---------------------------------------------------------------------------20// ---------------------------------------------------------------------------
21// Status: the four states a connection can be in. `expired` is the one this21// 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 credential22// whole feature exists for (Google's 7-day dev-token revocation): the credential
23// authenticated fine yesterday and now returns 401/403. `degraded` covers any23// 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` is24// 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
28export const HealthStatus = Schema.Literals(["healthy", "expired", "degraded", "unknown"]);32export const HealthStatus = Schema.Literals([
33 "healthy",
34 "expired",
35 "misconfigured",
36 "degraded",
37 "unknown",
38]);
29export type HealthStatus = typeof HealthStatus.Type;39export 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 auth151/** 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. */
143export const classifyHttpStatus = (status: number): HealthStatus => {155export 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.
169const 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 []. */
174const 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. */
197export 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 a205/** 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 the206 * 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. */
packages/core/sdk/src/index.ts+1−0
@@ -114,6 +114,7 @@ export { · 113 unmodified lines
114 HealthCheckCandidateParameter,114 HealthCheckCandidateParameter,
115 HealthCheckResponseField,115 HealthCheckResponseField,
116 classifyHttpStatus,116 classifyHttpStatus,
117 classifyProbeResponse,
117 extractIdentity,118 extractIdentity,
118 compareHealthCheckCandidates,119 compareHealthCheckCandidates,
119 candidateIdentityTier,120 candidateIdentityTier,
packages/core/sdk/src/shared.ts+1−0
@@ -118,6 +118,7 @@ export { · 117 unmodified lines
118 HealthCheckCandidate,118 HealthCheckCandidate,
119 HealthCheckCandidateParameter,119 HealthCheckCandidateParameter,
120 classifyHttpStatus,120 classifyHttpStatus,
121 classifyProbeResponse,
121 extractIdentity,122 extractIdentity,
122 compareHealthCheckCandidates,123 compareHealthCheckCandidates,
123 candidateIdentityTier,124 candidateIdentityTier,
packages/plugins/openapi/src/sdk/backing.ts+4−2
@@ -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,
@@ -962,7 +962,9 @@ export const checkHealthOpenApi = (input: { · 948 unmodified lines
962 } satisfies HealthCheckResult;962 } satisfies HealthCheckResult;
963 }963 }
964 964
965 const status = classifyHttpStatus(probe.result.status);965 // Body-aware: a configuration 403 (Google accessNotConfigured /
966 // SERVICE_DISABLED) reads misconfigured, not expired.
967 const status = classifyProbeResponse(probe.result.status, probe.result.error);
966 const identity =968 const identity =
967 status === "healthy" ? extractIdentity(probe.result.data, spec.identityField) : undefined;969 status === "healthy" ? extractIdentity(probe.result.data, spec.identityField) : undefined;
968 // Sample the returned body ONLY on a healthy probe: the sample exists to970 // Sample the returned body ONLY on a healthy probe: the sample exists to
packages/react/src/components/accounts-section.tsx+50−0
@@ -60,6 +60,35 @@ import { · 59 unmodified lines
60 60
61const OWNERS: readonly Owner[] = ["org", "user"];61const OWNERS: readonly Owner[] = ["org", "user"];
62 62
63/** Render a health-check detail with any bare https URL as a clickable link.
64 * Exists for the misconfigured verdict, whose detail is the provider's own
65 * remediation text (Google's includes the console URL that enables the
66 * disabled API); the rest of the string stays plain text. */
67function DetailWithLinks(props: { readonly text: string }) {
68 const parts = props.text.split(/(https:\/\/[^\s,;)]+)/g);
69 return (
70 <>
71 {parts.map((part, index) =>
72 part.startsWith("https://") ? (
73 <a
74 // Stable for a given detail string: parts are positional.
75 // oxlint-disable-next-line react/no-array-index-key
76 key={index}
77 href={part}
78 target="_blank"
79 rel="noreferrer"
80 className="underline underline-offset-2 hover:text-foreground"
81 >
82 {part}
83 </a>
84 ) : (
85 part
86 ),
87 )}
88 </>
89 );
90}
91
63function AccountRow(props: {92function AccountRow(props: {
64 readonly connection: Connection;93 readonly connection: Connection;
65 /** The integration declares scopes this connection was not granted — it must94 /** The integration declares scopes this connection was not granted — it must
@@ -95,6 +124,7 @@ function AccountRow(props: { · 29 unmodified lines
95 const displayLabel = identity ?? String(connection.name);124 const displayLabel = identity ?? String(connection.name);
96 125
97 const expired = status === "expired";126 const expired = status === "expired";
127 const misconfigured = status === "misconfigured";
98 const missingOAuthScopes = connection.missingOAuthScopes ?? [];128 const missingOAuthScopes = connection.missingOAuthScopes ?? [];
99 129
100 const handleCheck = async () => {130 const handleCheck = async () => {
@@ -113,6 +143,13 @@ function AccountRow(props: { · 12 unmodified lines
113 );143 );
114 } else if (exit.value.status === "expired") {144 } else if (exit.value.status === "expired") {
115 toast.error("Connection expired, reconnect to restore access");145 toast.error("Connection expired, reconnect to restore access");
146 } else if (exit.value.status === "misconfigured") {
147 // NOT a reconnect prompt: the credential is fine; the upstream API is
148 // disabled where the OAuth client lives. The detail carries the
149 // provider's own instruction (with a console link for Google).
150 toast.warning(
151 exit.value.detail ?? "An upstream API is disabled for this connection's OAuth client",
152 );
116 } else if (exit.value.status === "degraded") {153 } else if (exit.value.status === "degraded") {
117 toast.warning(exit.value.detail ?? "Connection check returned an error");154 toast.warning(exit.value.detail ?? "Connection check returned an error");
118 } else {155 } else {
@@ -135,6 +172,14 @@ function AccountRow(props: { · 16 unmodified lines
135 Expired172 Expired
136 </Badge>173 </Badge>
137 ) : null}174 ) : null}
175 {misconfigured ? (
176 <Badge
177 variant="outline"
178 className="shrink-0 border-amber-600/40 text-amber-600 dark:text-amber-500"
179 >
180 API disabled
181 </Badge>
182 ) : null}
138 {needsReconsent ? (183 {needsReconsent ? (
139 <Badge variant="outline" className="shrink-0 border-border text-muted-foreground">184 <Badge variant="outline" className="shrink-0 border-border text-muted-foreground">
140 Reconnect to grant access185 Reconnect to grant access
@@ -146,6 +191,11 @@ function AccountRow(props: { · 5 unmodified lines
146 {connection.description}191 {connection.description}
147 </CardStackEntryDescription>192 </CardStackEntryDescription>
148 ) : null}193 ) : null}
194 {misconfigured && probe?.detail ? (
195 <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">
196 <DetailWithLinks text={probe.detail} />
197 </CardStackEntryDescription>
198 ) : null}
149 {needsReconsent ? (199 {needsReconsent ? (
150 <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">200 <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">
151 This connection wasn't granted all the access this integration now needs.201 This connection wasn't granted all the access this integration now needs.
packages/react/src/components/add-account-modal.tsx+7−1
@@ -928,7 +928,13 @@ function HealthStatusLine(props: { · 927 unmodified lines
928}) {928}) {
929 const { status, identity, detail, httpStatus } = props.result;929 const { status, identity, detail, httpStatus } = props.result;
930 const healthy = status === "healthy";930 const healthy = status === "healthy";
931 const tone = healthy ? "text-muted-foreground" : "text-destructive";931 // Misconfigured is amber everywhere (see health-display.ts): the credential
932 // being validated is fine, so the verdict must not read as a key failure.
933 const tone = healthy
934 ? "text-muted-foreground"
935 : status === "misconfigured"
936 ? "text-amber-600 dark:text-amber-500"
937 : "text-destructive";
932 const font = props.variant === "response" ? "font-mono" : "";938 const font = props.variant === "response" ? "font-mono" : "";
933 return (939 return (
934 <div className={`flex min-w-0 items-center gap-2 text-xs ${tone} ${font}`}>940 <div className={`flex min-w-0 items-center gap-2 text-xs ${tone} ${font}`}>
packages/react/src/lib/health-display.ts+20−7
@@ -6,19 +6,26 @@ · 5 unmodified lines
6 6
7import type { HealthStatus } from "@executor-js/sdk/shared";7import 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. */
10export const HEALTH_STATUS_LABEL: Record<HealthStatus, string> = {13export 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 color21/** 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. */
19export const HEALTH_TEXT_CLASS: Record<HealthStatus, string> = {25export 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: a47/** Severity for worst-of aggregation. `unknown` is excluded on purpose: a
40 * never-probed connection carries no signal, so it must not drag a group48 * 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). */
42const HEALTH_SEVERITY: Record<Exclude<HealthStatus, "unknown">, number> = {52const 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 > degraded59/** Collapse many connection statuses to the group's worst: expired >
49 * > healthy. `unknown` entries are ignored; when nothing else remains (empty60 * 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. */
51export const worstHealthStatus = (statuses: readonly HealthStatus[]): HealthStatus | null => {63export 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};