Skip to content

Commit d6b3eac

Browse files
committed
feat: connection health checks (liveness) with OpenAPI backing
A connection can declare ONE authenticated operation that answers "is this credential still alive?". The probe runs the operation, maps the HTTP status to a health state (2xx healthy, 401/403 expired, other degraded, unset unknown), and reports it. Built for short-lived OAuth tokens (Google's 7-day dev-token revocation): a credential that authenticated yesterday and now 401s reads as expired. Core owns the shared vocabulary (HealthStatus, HealthCheckSpec, HealthCheckResult, HealthCheckCandidate, classifyHttpStatus, candidate ranking); plugins own which operation runs, stored in their opaque integration config and picked the same way auth methods are. Dispatch adds integrations.healthCheck.{get,candidates,set} and connections.{checkHealth,validate}. OpenAPI backing: list ranked candidates (non-destructive GET first), persist the chosen spec (merging over the raw config so provider supersets keep their keys), and run the probe against a resolved credential. React surfaces: - a per-connection status dot + "Check now", - a self-hiding operation editor (a searchable freeform combobox over the whole spec), - and "Check the key works" in the Add Connection modal: probe the pasted key before saving. When no health check is configured yet, the user picks a read-only operation inline; a healthy probe saves it as the integration's health check. Covered by e2e: a saved connection flips healthy -> expired when its key stops working; an unconfigured integration reports unknown; the operation picker filters a hundreds-long candidate list on both the edit sheet and the add screen; and Add Connection checks the key against an inline-picked operation and persists it.
1 parent 78aa871 commit d6b3eac

28 files changed

Lines changed: 2557 additions & 39 deletions

e2e/scenarios/health-checks-ui.test.ts

Lines changed: 432 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
// Cross-target: connection health checks, the feature that answers "has this
2+
// credential expired?" (the Google 7-day dev-token case) in one declared probe.
3+
// Entirely through the typed client:
4+
//
5+
// 1. register an OpenAPI integration whose `GET /me` is auth-gated,
6+
// 2. CONFIGURE a health check by picking that operation (the same flow the
7+
// user drives in the editor: list candidates, ranked GET-first, then set),
8+
// 3. CHECK a SAVED connection and watch its status flip healthy -> expired
9+
// when the stored key stops working,
10+
// 4. confirm a connection with no configured check reports `unknown`.
11+
//
12+
// The upstream API is a real node:http server started inside the scenario on
13+
// 127.0.0.1 that gates `GET /me` on a bearer token: a generic "bring your own
14+
// OpenAPI" integration, so the generic openapi health-check path is exercised
15+
// rather than any one provider's quirks.
16+
import { randomBytes } from "node:crypto";
17+
import { createServer } from "node:http";
18+
19+
import { expect } from "@effect/vitest";
20+
import { Effect } from "effect";
21+
import type { HttpApiClient } from "effect/unstable/httpapi";
22+
import { composePluginApi } from "@executor-js/api/server";
23+
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
24+
import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
25+
26+
import { scenario } from "../src/scenario";
27+
import { Api, Target } from "../src/services";
28+
29+
const api = composePluginApi([openApiHttpPlugin()] as const);
30+
type Client = HttpApiClient.ForApi<typeof api>;
31+
32+
const TEMPLATE = AuthTemplateSlug.make("apiKey");
33+
const ACCOUNT_EMAIL = "alice@example.com";
34+
35+
const newSlug = (prefix: string) =>
36+
IntegrationSlug.make(`${prefix}-${randomBytes(4).toString("hex")}`);
37+
38+
/** OpenAPI 3 spec with an auth-gated GET (`/me`, the obvious health check) plus a
39+
* destructive POST so the candidate ranking has something to sort the GET ahead
40+
* of. */
41+
const identitySpec = (baseUrl: string): string =>
42+
JSON.stringify({
43+
openapi: "3.0.3",
44+
info: { title: "Identity API", version: "1.0.0" },
45+
servers: [{ url: baseUrl }],
46+
paths: {
47+
"/me": {
48+
get: {
49+
operationId: "getMe",
50+
summary: "The current account",
51+
responses: {
52+
"200": {
53+
description: "The authenticated account",
54+
content: {
55+
"application/json": {
56+
schema: {
57+
type: "object",
58+
properties: { email: { type: "string" }, login: { type: "string" } },
59+
},
60+
},
61+
},
62+
},
63+
},
64+
},
65+
},
66+
"/messages": {
67+
post: {
68+
operationId: "sendMessage",
69+
summary: "Send a message",
70+
responses: { "201": { description: "created" } },
71+
},
72+
},
73+
},
74+
});
75+
76+
/** A real node:http identity API on 127.0.0.1. `GET /me` returns the account
77+
* JSON only when the bearer token matches `validToken`; any other token is a
78+
* 401 (the "the dev token got revoked" case the health check classifies as
79+
* expired). Closed by the scope's finalizer. */
80+
const serveIdentityApi = (validToken: string) =>
81+
Effect.acquireRelease(
82+
Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => {
83+
const server = createServer((request, response) => {
84+
const authorized = request.headers["authorization"] === `Bearer ${validToken}`;
85+
if (request.method === "GET" && (request.url ?? "").startsWith("/me")) {
86+
if (!authorized) {
87+
response.writeHead(401, { "content-type": "application/json" });
88+
response.end(JSON.stringify({ error: "invalid_token" }));
89+
return;
90+
}
91+
response.writeHead(200, { "content-type": "application/json" });
92+
response.end(JSON.stringify({ email: ACCOUNT_EMAIL, login: "alice" }));
93+
return;
94+
}
95+
response.writeHead(404, { "content-type": "application/json" });
96+
response.end(JSON.stringify({ error: "not_found" }));
97+
});
98+
server.listen(0, "127.0.0.1", () => {
99+
const address = server.address();
100+
const port = typeof address === "object" && address ? address.port : 0;
101+
resume(
102+
Effect.succeed({
103+
url: `http://127.0.0.1:${port}`,
104+
close: () => {
105+
server.close();
106+
server.closeAllConnections();
107+
},
108+
}),
109+
);
110+
});
111+
}),
112+
(server) => Effect.sync(server.close),
113+
);
114+
115+
/** Register the identity integration against `baseUrl` with a bearer-token auth
116+
* method (single `token` input → connection `value`). Returns the slug. */
117+
const registerIdentityIntegration = (client: Client, slug: IntegrationSlug, baseUrl: string) =>
118+
client.openapi.addSpec({
119+
payload: {
120+
spec: { kind: "blob", value: identitySpec(baseUrl) },
121+
slug,
122+
baseUrl,
123+
authenticationTemplate: [
124+
{
125+
slug: "apiKey",
126+
type: "apiKey",
127+
headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
128+
},
129+
],
130+
},
131+
});
132+
133+
/** The stored operation name for the GET probe (openapi prefixes it by tag, e.g.
134+
* `me.getMe`), discovered the same way the editor does: from the ranked
135+
* candidate list. */
136+
const getMeOperation = (client: Client, slug: IntegrationSlug) =>
137+
Effect.gen(function* () {
138+
const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } });
139+
const getMe = candidates.find((candidate) => candidate.method === "get");
140+
if (!getMe) return yield* Effect.die("identity spec exposed no GET candidate");
141+
return getMe.operation;
142+
});
143+
144+
scenario(
145+
"Health checks · the editor ranks the non-destructive GET ahead of the destructive POST",
146+
{},
147+
Effect.scoped(
148+
Effect.gen(function* () {
149+
const target = yield* Target;
150+
const { client: makeClient } = yield* Api;
151+
const identity = yield* target.newIdentity();
152+
const client = yield* makeClient(api, identity);
153+
const goodToken = `gk_${randomBytes(8).toString("hex")}`;
154+
const server = yield* serveIdentityApi(goodToken);
155+
const slug = newSlug("hc-rank");
156+
157+
yield* Effect.ensuring(
158+
Effect.gen(function* () {
159+
yield* registerIdentityIntegration(client, slug, server.url);
160+
161+
// The editor offers the integration's operations, ranked so the
162+
// non-destructive GET endpoint floats to the top.
163+
const candidates = yield* client.integrations.healthCheckCandidates({
164+
params: { slug },
165+
});
166+
const get = candidates.find((candidate) => candidate.method === "get");
167+
const post = candidates.find((candidate) => candidate.method === "post");
168+
if (!get || !post) {
169+
return yield* Effect.die("identity spec should expose a GET and a POST candidate");
170+
}
171+
// Operations are stored tag-prefixed (e.g. `me.getMe`); match the suffix.
172+
expect(get.operation.split(".").at(-1), "the GET is offered").toBe("getMe");
173+
expect(post.operation.split(".").at(-1), "the destructive POST is offered").toBe(
174+
"sendMessage",
175+
);
176+
expect(
177+
candidates[0]?.operation,
178+
"the non-destructive GET ranks ahead of the destructive POST",
179+
).toBe(get.operation);
180+
expect(get.destructive, "the GET probe is non-destructive").toBe(false);
181+
expect(post.destructive, "the POST is flagged destructive").toBe(true);
182+
183+
// Pick it: just the operation (a pure liveness probe).
184+
yield* client.integrations.healthCheckSet({
185+
params: { slug },
186+
payload: { spec: { operation: get.operation } },
187+
});
188+
const stored = yield* client.integrations.healthCheckGet({ params: { slug } });
189+
expect(stored, "the chosen health check round-trips").toEqual({
190+
operation: get.operation,
191+
});
192+
}),
193+
client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore),
194+
);
195+
}),
196+
),
197+
);
198+
199+
scenario(
200+
"Health checks · a saved connection reports healthy, then expired when its key stops working",
201+
{},
202+
Effect.scoped(
203+
Effect.gen(function* () {
204+
const target = yield* Target;
205+
const { client: makeClient } = yield* Api;
206+
const identity = yield* target.newIdentity();
207+
const client = yield* makeClient(api, identity);
208+
const goodToken = `gk_${randomBytes(8).toString("hex")}`;
209+
const server = yield* serveIdentityApi(goodToken);
210+
const slug = newSlug("hc-saved");
211+
const name = ConnectionName.make("main");
212+
213+
yield* Effect.ensuring(
214+
Effect.gen(function* () {
215+
yield* registerIdentityIntegration(client, slug, server.url);
216+
const operation = yield* getMeOperation(client, slug);
217+
yield* client.integrations.healthCheckSet({
218+
params: { slug },
219+
payload: { spec: { operation } },
220+
});
221+
222+
// A connection holding the live key checks out healthy.
223+
yield* client.connections.create({
224+
payload: {
225+
owner: "org",
226+
name,
227+
integration: slug,
228+
template: TEMPLATE,
229+
value: goodToken,
230+
},
231+
});
232+
const healthy = yield* client.connections.checkHealth({
233+
params: { owner: "org", integration: slug, name },
234+
});
235+
expect(healthy.status, "the saved connection's live key is healthy").toBe("healthy");
236+
expect(healthy.httpStatus, "the saved probe saw the 200").toBe(200);
237+
238+
// Re-creating the same (owner, integration, name) replaces the stored
239+
// key in place: now the connection holds a key the server rejects.
240+
yield* client.connections.create({
241+
payload: {
242+
owner: "org",
243+
name,
244+
integration: slug,
245+
template: TEMPLATE,
246+
value: "rotated-away",
247+
},
248+
});
249+
const expired = yield* client.connections.checkHealth({
250+
params: { owner: "org", integration: slug, name },
251+
});
252+
expect(expired.status, "the same connection now reads as expired").toBe("expired");
253+
expect(expired.httpStatus, "the saved probe saw the 401").toBe(401);
254+
}),
255+
Effect.gen(function* () {
256+
yield* client.connections
257+
.remove({ params: { owner: "org", integration: slug, name } })
258+
.pipe(Effect.ignore);
259+
yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
260+
}),
261+
);
262+
}),
263+
),
264+
);
265+
266+
scenario(
267+
"Health checks · a connection with no configured check reports unknown, not a failure",
268+
{},
269+
Effect.scoped(
270+
Effect.gen(function* () {
271+
const target = yield* Target;
272+
const { client: makeClient } = yield* Api;
273+
const identity = yield* target.newIdentity();
274+
const client = yield* makeClient(api, identity);
275+
const goodToken = `gk_${randomBytes(8).toString("hex")}`;
276+
const server = yield* serveIdentityApi(goodToken);
277+
const slug = newSlug("hc-unknown");
278+
const name = ConnectionName.make("main");
279+
280+
yield* Effect.ensuring(
281+
Effect.gen(function* () {
282+
// No healthCheckSet: the integration declares no probe.
283+
yield* registerIdentityIntegration(client, slug, server.url);
284+
expect(
285+
yield* client.integrations.healthCheckGet({ params: { slug } }),
286+
"an integration with no configured check reports none",
287+
).toBeNull();
288+
289+
yield* client.connections.create({
290+
payload: {
291+
owner: "org",
292+
name,
293+
integration: slug,
294+
template: TEMPLATE,
295+
value: goodToken,
296+
},
297+
});
298+
const result = yield* client.connections.checkHealth({
299+
params: { owner: "org", integration: slug, name },
300+
});
301+
expect(result.status, "with no check configured the status is unknown").toBe("unknown");
302+
expect(result.detail ?? "", "the result explains why it is unknown").toContain(
303+
"No health check configured",
304+
);
305+
}),
306+
Effect.gen(function* () {
307+
yield* client.connections
308+
.remove({ params: { owner: "org", integration: slug, name } })
309+
.pipe(Effect.ignore);
310+
yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
311+
}),
312+
);
313+
}),
314+
),
315+
);

packages/core/api/src/connections/api.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
ConnectionName,
1717
ConnectionNotFoundError,
1818
CredentialProviderNotRegisteredError,
19+
HealthCheckResult,
20+
HealthCheckSpec,
1921
IntegrationNotFoundError,
2022
IntegrationSlug,
2123
InternalError,
@@ -108,6 +110,31 @@ const CreateConnectionPayload = Schema.Struct({
108110
),
109111
);
110112

113+
// Validate an in-flight credential WITHOUT saving it (the key-first connect
114+
// flow). Same origin shape as create, plus an optional `spec` override so the
115+
// editor can preview a candidate against a live key. No `name` — the point is
116+
// to derive one from the identity the probe returns.
117+
const ValidateConnectionPayload = Schema.Struct({
118+
owner: Owner,
119+
integration: IntegrationSlug,
120+
template: AuthTemplateSlug,
121+
spec: Schema.optional(HealthCheckSpec),
122+
value: Schema.optional(Schema.String),
123+
values: Schema.optional(Schema.Record(Schema.String, Schema.String)),
124+
from: Schema.optional(
125+
Schema.Struct({
126+
provider: ProviderKey,
127+
id: ProviderItemId,
128+
}),
129+
),
130+
}).check(
131+
Schema.makeFilter((payload) =>
132+
[payload.value, payload.values, payload.from].filter(Predicate.isNotUndefined).length === 1
133+
? undefined
134+
: "Expected exactly one credential origin",
135+
),
136+
);
137+
111138
// ---------------------------------------------------------------------------
112139
// Query — optional list filters.
113140
// ---------------------------------------------------------------------------
@@ -186,4 +213,25 @@ export const ConnectionsApi = HttpApiGroup.make("connections")
186213
success: Schema.Array(ToolResponse),
187214
error: [InternalError, ConnectionNotFound, IntegrationNotFound],
188215
}),
216+
)
217+
// Run the integration's declared health check against a SAVED connection: is
218+
// this credential still alive (Google's 7-day dev-token revocation), and whose
219+
// account is it? Returns a classified status + optional identity, never an
220+
// error for an auth wall (that surfaces as `status: "expired"`).
221+
.add(
222+
HttpApiEndpoint.post("checkHealth", "/connections/:owner/:integration/:name/health", {
223+
params: ConnectionParams,
224+
success: HealthCheckResult,
225+
error: [InternalError, ConnectionNotFound, IntegrationNotFound],
226+
}),
227+
)
228+
// Run the health check against an IN-FLIGHT credential without saving it (the
229+
// key-first connect flow): confirm the pasted key works and surface the
230+
// identity the UI derives a connection name from before anything persists.
231+
.add(
232+
HttpApiEndpoint.post("validate", "/connections/validate", {
233+
payload: ValidateConnectionPayload,
234+
success: HealthCheckResult,
235+
error: [InternalError, IntegrationNotFound],
236+
}),
189237
);

0 commit comments

Comments
 (0)