Skip to content

Commit 15950e9

Browse files
committed
feat: connection health checks and identity
A connection can declare one authenticated operation that answers two questions at once: is this credential still alive, and (optionally) whose account is it? Built for the Google 7-day dev-OAuth case. - Shared health-check vocabulary in core (spec/candidate/result, status classification, identity extraction, response-field projection) and the plugin hooks + dispatch wiring. - OpenAPI implementation: ranked candidate listing, response-field projection (union-aware, breadth-first), a searchable operation picker, a typed identity picker, a live preview that shows the real response, and key-first connect. - Per-connection status dot + Check now; no token-expiry timing shown (it refreshes), so status comes only from a live probe.
1 parent 622971f commit 15950e9

28 files changed

Lines changed: 3711 additions & 115 deletions

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

Lines changed: 821 additions & 0 deletions
Large diffs are not rendered by default.

e2e/scenarios/health-checks.test.ts

Lines changed: 453 additions & 0 deletions
Large diffs are not rendered by default.

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
);

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import {
77
type Connection,
88
type ConnectionRef,
99
type CreateConnectionInput,
10+
type HealthCheckResult,
1011
type Tool,
12+
type ValidateConnectionInput,
1113
} from "@executor-js/sdk";
1214

1315
import { ExecutorApi } from "../api";
@@ -38,6 +40,15 @@ const toolToResponse = (t: Tool) => ({
3840
description: t.description,
3941
});
4042

43+
const toHealthResponse = (r: HealthCheckResult) => ({
44+
status: r.status,
45+
checkedAt: r.checkedAt,
46+
...(r.httpStatus !== undefined ? { httpStatus: r.httpStatus } : {}),
47+
...(r.identity !== undefined ? { identity: r.identity } : {}),
48+
...(r.detail !== undefined ? { detail: r.detail } : {}),
49+
...(r.responseSample !== undefined ? { responseSample: r.responseSample } : {}),
50+
});
51+
4152
export const ConnectionsHandlers = HttpApiBuilder.group(ExecutorApi, "connections", (handlers) =>
4253
handlers
4354
.handle("list", ({ query }) =>
@@ -130,5 +141,30 @@ export const ConnectionsHandlers = HttpApiBuilder.group(ExecutorApi, "connection
130141
return tools.map(toolToResponse);
131142
}),
132143
),
144+
)
145+
.handle("checkHealth", ({ params: path }) =>
146+
capture(
147+
Effect.gen(function* () {
148+
const executor = yield* ExecutorService;
149+
const result = yield* executor.connections.checkHealth({
150+
owner: path.owner,
151+
integration: path.integration,
152+
name: path.name,
153+
});
154+
return toHealthResponse(result);
155+
}),
156+
),
157+
)
158+
.handle("validate", ({ payload }) =>
159+
capture(
160+
Effect.gen(function* () {
161+
const executor = yield* ExecutorService;
162+
// The payload mirrors `ValidateConnectionInput`: owner/integration/
163+
// template/spec plus a single credential origin (`value` | `values` |
164+
// `from`). Pass it through verbatim.
165+
const result = yield* executor.connections.validate(payload as ValidateConnectionInput);
166+
return toHealthResponse(result);
167+
}),
168+
),
133169
),
134170
);

packages/core/api/src/handlers/integrations.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,5 +79,30 @@ export const IntegrationsHandlers = HttpApiBuilder.group(ExecutorApi, "integrati
7979
}));
8080
}),
8181
),
82+
)
83+
.handle("healthCheckGet", ({ params: path }) =>
84+
capture(
85+
Effect.gen(function* () {
86+
const executor = yield* ExecutorService;
87+
return yield* executor.integrations.healthCheck.get(path.slug);
88+
}),
89+
),
90+
)
91+
.handle("healthCheckCandidates", ({ params: path }) =>
92+
capture(
93+
Effect.gen(function* () {
94+
const executor = yield* ExecutorService;
95+
return yield* executor.integrations.healthCheck.candidates(path.slug);
96+
}),
97+
),
98+
)
99+
.handle("healthCheckSet", ({ params: path, payload }) =>
100+
capture(
101+
Effect.gen(function* () {
102+
const executor = yield* ExecutorService;
103+
yield* executor.integrations.healthCheck.set(path.slug, payload.spec);
104+
return { ok: true };
105+
}),
106+
),
82107
),
83108
);

packages/core/api/src/integrations/api.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
1212
import { Schema } from "effect";
1313
import {
14+
HealthCheckCandidate,
15+
HealthCheckSpec,
1416
IntegrationDetectionResult,
1517
IntegrationNotFoundError,
1618
IntegrationRemovalNotAllowedError,
@@ -88,6 +90,12 @@ const DetectRequest = Schema.Struct({
8890
url: Schema.String.check(Schema.isMaxLength(2_048)),
8991
});
9092

93+
// Set (or clear, with null) the integration's declared health check. The spec
94+
// lives inside the owning plugin's opaque config; core only routes it.
95+
const SetHealthCheckPayload = Schema.Struct({
96+
spec: Schema.NullOr(HealthCheckSpec),
97+
});
98+
9199
// ---------------------------------------------------------------------------
92100
// Error schemas with HTTP status annotations
93101
// ---------------------------------------------------------------------------
@@ -136,4 +144,30 @@ export const IntegrationsApi = HttpApiGroup.make("integrations")
136144
success: Schema.Array(IntegrationDetectionResult),
137145
error: InternalError,
138146
}),
147+
)
148+
// The integration's currently declared health check (null when none is set).
149+
.add(
150+
HttpApiEndpoint.get("healthCheckGet", "/integrations/:slug/health-check", {
151+
params: IntegrationParams,
152+
success: Schema.NullOr(HealthCheckSpec),
153+
error: InternalError,
154+
}),
155+
)
156+
// Operations the user can pick as the health check, ranked non-destructive +
157+
// fewest-required-args first so the obvious identity endpoint floats to the top.
158+
.add(
159+
HttpApiEndpoint.get("healthCheckCandidates", "/integrations/:slug/health-check/candidates", {
160+
params: IntegrationParams,
161+
success: Schema.Array(HealthCheckCandidate),
162+
error: [InternalError, IntegrationNotFound],
163+
}),
164+
)
165+
// Persist (or clear) the integration's health check.
166+
.add(
167+
HttpApiEndpoint.put("healthCheckSet", "/integrations/:slug/health-check", {
168+
params: IntegrationParams,
169+
payload: SetHealthCheckPayload,
170+
success: Schema.Struct({ ok: Schema.Boolean }),
171+
error: [InternalError, IntegrationNotFound],
172+
}),
139173
);

packages/core/sdk/src/connection.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
ProviderItemId,
99
ProviderKey,
1010
} from "./ids";
11+
import type { HealthCheckSpec } from "./health-check";
1112

1213
/* A Connection is THE saved credential — secret, account, and connection are one
1314
* concept — bound to exactly ONE integration (born wired; there is no unwired
@@ -95,6 +96,19 @@ export type CreateConnectionInput = {
9596
readonly description?: string | null;
9697
} & ConnectionValueInput;
9798

99+
/** Validate an in-flight credential WITHOUT saving it (the key-first connect
100+
* flow). Resolves the pasted value(s) the same way `create` would, then runs
101+
* the integration's declared health check so the UI can confirm the key works
102+
* and derive a connection name from the returned identity before anything is
103+
* persisted. `spec` overrides the integration's declared check (used by the
104+
* editor to preview a candidate against a live key). */
105+
export type ValidateConnectionInput = {
106+
readonly owner: Owner;
107+
readonly integration: IntegrationSlug;
108+
readonly template: AuthTemplateSlug;
109+
readonly spec?: HealthCheckSpec;
110+
} & ConnectionValueInput;
111+
98112
/** Edit a connection's user-curated metadata. Only the provided fields change;
99113
* credentials and OAuth lifecycle are untouchable here (recreate or refresh
100114
* instead). */

0 commit comments

Comments
 (0)