Skip to content

Commit d58fd6e

Browse files
committed
feat: derive connection account info from the health-check probe
Layer account identity on top of the liveness probe. A health check can now name a response field whose value identifies the connected account, so the same probe that answers "is this alive?" also answers "whose account is this?". Core: HealthCheckSpec gains an optional identityField (a dot-path into the response body); HealthCheckResult carries the extracted identity plus a bounded responseSample of the actual returned fields; HealthCheckCandidate carries the operation's projected responseFields. New pure helpers: extractIdentity (resolve a dot-path, numeric segments index arrays), projectResponseFields (enumerate a response schema's scalar leaves breadth-first, merging every allOf/oneOf/anyOf branch so discriminated-union fields aren't dropped), and extractResponseFields (walk a real body for the live preview). OpenAPI backing extracts the identity on a healthy probe and projects each candidate's response fields. React: a typed identity-field picker fed by those fields, a live preview that probes a pasted test key and shows the real response (path -> value) plus what the identity resolves to, the account row labels itself with the probed identity, and the connect modal goes key-first (validate the pasted key, then auto-fill the name from the derived identity). Covered by e2e: validating a key derives the identity; a saved connection's probe surfaces the account then drops it once expired; the identity picker surfaces a shallow scalar and a second-union-branch-only field across a discriminated union; the edit sheet and connect modal drive the identity flow in the browser. Also restores the OAuth-client deep-link prefill in the connect modal that the key-first reorder had dropped.
1 parent 59fd53f commit d58fd6e

13 files changed

Lines changed: 1643 additions & 128 deletions

File tree

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

Lines changed: 581 additions & 12 deletions
Large diffs are not rendered by default.

e2e/scenarios/health-checks.test.ts

Lines changed: 161 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
// 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:
2+
// credential expired?" (the Google 7-day dev-token case) and "whose account is
3+
// this?" in one declared probe. Entirely through the typed client:
44
//
55
// 1. register an OpenAPI integration whose `GET /me` is auth-gated,
66
// 2. CONFIGURE a health check by picking that operation (the same flow the
77
// 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`.
8+
// 3. VALIDATE a pasted key without saving it (the key-first connect flow) and
9+
// watch the probe derive the connection identity from the live response,
10+
// 4. CHECK a SAVED connection and watch its status flip healthy -> expired
11+
// when the stored key stops working.
1112
//
1213
// The upstream API is a real node:http server started inside the scenario on
1314
// 127.0.0.1 that gates `GET /me` on a bearer token: a generic "bring your own
@@ -30,14 +31,14 @@ const api = composePluginApi([openApiHttpPlugin()] as const);
3031
type Client = HttpApiClient.ForApi<typeof api>;
3132

3233
const TEMPLATE = AuthTemplateSlug.make("apiKey");
33-
const ACCOUNT_EMAIL = "alice@example.com";
34+
const IDENTITY = "alice@example.com";
3435

3536
const newSlug = (prefix: string) =>
3637
IntegrationSlug.make(`${prefix}-${randomBytes(4).toString("hex")}`);
3738

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. */
39+
/** OpenAPI 3 spec with an auth-gated identity GET (`/me`, the obvious health
40+
* check) plus a destructive POST so the candidate ranking has something to sort
41+
* the GET ahead of. */
4142
const identitySpec = (baseUrl: string): string =>
4243
JSON.stringify({
4344
openapi: "3.0.3",
@@ -73,6 +74,72 @@ const identitySpec = (baseUrl: string): string =>
7374
},
7475
});
7576

77+
/** OpenAPI 3 spec whose `GET /me` response mirrors Vercel's `getAuthUser`: the
78+
* account is a `oneOf` of two object variants, the obvious identity scalars
79+
* (`email`, `id`) sit behind a large nested object, and one field (`limited`)
80+
* exists only on the second variant. A naive walker that follows only the first
81+
* union branch (and descends the nested object until a field cap) drops both
82+
* `user.email` and `user.limited`; the projector must merge branches and emit
83+
* shallow fields first. No live server needed (candidate projection is static). */
84+
const discriminatedUnionSpec = (baseUrl: string): string => {
85+
// 60 nested scalars, listed before `email`, so a depth-first walk blows the
86+
// field cap inside `profile` before it ever reaches the top-level identity.
87+
const profileProps: Record<string, unknown> = {};
88+
for (let i = 0; i < 60; i++) profileProps[`field${i}`] = { type: "string" };
89+
return JSON.stringify({
90+
openapi: "3.0.3",
91+
info: { title: "Union Identity API", version: "1.0.0" },
92+
servers: [{ url: baseUrl }],
93+
paths: {
94+
"/me": {
95+
get: {
96+
operationId: "getAuthUser",
97+
summary: "The current account",
98+
responses: {
99+
"200": {
100+
description: "The authenticated account",
101+
content: {
102+
"application/json": {
103+
schema: {
104+
type: "object",
105+
properties: {
106+
user: {
107+
oneOf: [
108+
{ $ref: "#/components/schemas/AccountFull" },
109+
{ $ref: "#/components/schemas/AccountLimited" },
110+
],
111+
},
112+
},
113+
},
114+
},
115+
},
116+
},
117+
},
118+
},
119+
},
120+
},
121+
components: {
122+
schemas: {
123+
AccountFull: {
124+
type: "object",
125+
properties: {
126+
profile: { type: "object", properties: profileProps },
127+
email: { type: "string" },
128+
id: { type: "string" },
129+
},
130+
},
131+
AccountLimited: {
132+
type: "object",
133+
properties: {
134+
email: { type: "string" },
135+
limited: { type: "boolean" },
136+
},
137+
},
138+
},
139+
},
140+
});
141+
};
142+
76143
/** A real node:http identity API on 127.0.0.1. `GET /me` returns the account
77144
* JSON only when the bearer token matches `validToken`; any other token is a
78145
* 401 (the "the dev token got revoked" case the health check classifies as
@@ -89,7 +156,7 @@ const serveIdentityApi = (validToken: string) =>
89156
return;
90157
}
91158
response.writeHead(200, { "content-type": "application/json" });
92-
response.end(JSON.stringify({ email: ACCOUNT_EMAIL, login: "alice" }));
159+
response.end(JSON.stringify({ email: IDENTITY, login: "alice" }));
93160
return;
94161
}
95162
response.writeHead(404, { "content-type": "application/json" });
@@ -130,9 +197,9 @@ const registerIdentityIntegration = (client: Client, slug: IntegrationSlug, base
130197
},
131198
});
132199

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. */
200+
/** The stored operation name for the GET identity probe (openapi prefixes it by
201+
* tag, e.g. `me.getMe`), discovered the same way the editor does: from the
202+
* ranked candidate list. */
136203
const getMeOperation = (client: Client, slug: IntegrationSlug) =>
137204
Effect.gen(function* () {
138205
const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } });
@@ -142,7 +209,7 @@ const getMeOperation = (client: Client, slug: IntegrationSlug) =>
142209
});
143210

144211
scenario(
145-
"Health checks · the editor ranks the non-destructive GET ahead of the destructive POST",
212+
"Health checks · configuring a check, then validating a key derives the connection identity",
146213
{},
147214
Effect.scoped(
148215
Effect.gen(function* () {
@@ -152,14 +219,14 @@ scenario(
152219
const client = yield* makeClient(api, identity);
153220
const goodToken = `gk_${randomBytes(8).toString("hex")}`;
154221
const server = yield* serveIdentityApi(goodToken);
155-
const slug = newSlug("hc-rank");
222+
const slug = newSlug("hc-validate");
156223

157224
yield* Effect.ensuring(
158225
Effect.gen(function* () {
159226
yield* registerIdentityIntegration(client, slug, server.url);
160227

161228
// The editor offers the integration's operations, ranked so the
162-
// non-destructive GET endpoint floats to the top.
229+
// non-destructive GET identity endpoint floats to the top.
163230
const candidates = yield* client.integrations.healthCheckCandidates({
164231
params: { slug },
165232
});
@@ -169,26 +236,45 @@ scenario(
169236
return yield* Effect.die("identity spec should expose a GET and a POST candidate");
170237
}
171238
// 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");
239+
expect(get.operation.split(".").at(-1), "the identity GET is offered").toBe("getMe");
173240
expect(post.operation.split(".").at(-1), "the destructive POST is offered").toBe(
174241
"sendMessage",
175242
);
176243
expect(
177244
candidates[0]?.operation,
178245
"the non-destructive GET ranks ahead of the destructive POST",
179246
).toBe(get.operation);
180-
expect(get.destructive, "the GET probe is non-destructive").toBe(false);
247+
expect(get.destructive, "the GET identity probe is non-destructive").toBe(false);
181248
expect(post.destructive, "the POST is flagged destructive").toBe(true);
249+
const operation = get.operation;
182250

183-
// Pick it: just the operation (a pure liveness probe).
251+
// Pick it: the operation plus the dot-path to the identity field.
184252
yield* client.integrations.healthCheckSet({
185253
params: { slug },
186-
payload: { spec: { operation: get.operation } },
254+
payload: { spec: { operation, identityField: "email" } },
187255
});
188256
const stored = yield* client.integrations.healthCheckGet({ params: { slug } });
189257
expect(stored, "the chosen health check round-trips").toEqual({
190-
operation: get.operation,
258+
operation,
259+
identityField: "email",
191260
});
261+
262+
// Key-first connect: a pasted key is probed WITHOUT saving, and the
263+
// probe surfaces the identity the UI fills the connection name from.
264+
const healthy = yield* client.connections.validate({
265+
payload: { owner: "org", integration: slug, template: TEMPLATE, value: goodToken },
266+
});
267+
expect(healthy.status, "a live key validates as healthy").toBe("healthy");
268+
expect(healthy.httpStatus, "the probe saw the 200").toBe(200);
269+
expect(healthy.identity, "the identity is derived from the response body").toBe(IDENTITY);
270+
271+
// A revoked / wrong key validates as expired, with no identity.
272+
const expired = yield* client.connections.validate({
273+
payload: { owner: "org", integration: slug, template: TEMPLATE, value: "wrong-key" },
274+
});
275+
expect(expired.status, "a rejected key validates as expired").toBe("expired");
276+
expect(expired.httpStatus, "the probe saw the 401").toBe(401);
277+
expect(expired.identity, "no identity is surfaced for a rejected key").toBeUndefined();
192278
}),
193279
client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore),
194280
);
@@ -216,10 +302,10 @@ scenario(
216302
const operation = yield* getMeOperation(client, slug);
217303
yield* client.integrations.healthCheckSet({
218304
params: { slug },
219-
payload: { spec: { operation } },
305+
payload: { spec: { operation, identityField: "email" } },
220306
});
221307

222-
// A connection holding the live key checks out healthy.
308+
// A connection holding the live key checks out healthy, identity and all.
223309
yield* client.connections.create({
224310
payload: {
225311
owner: "org",
@@ -234,6 +320,7 @@ scenario(
234320
});
235321
expect(healthy.status, "the saved connection's live key is healthy").toBe("healthy");
236322
expect(healthy.httpStatus, "the saved probe saw the 200").toBe(200);
323+
expect(healthy.identity, "the saved probe derives the account identity").toBe(IDENTITY);
237324

238325
// Re-creating the same (owner, integration, name) replaces the stored
239326
// key in place: now the connection holds a key the server rejects.
@@ -251,6 +338,7 @@ scenario(
251338
});
252339
expect(expired.status, "the same connection now reads as expired").toBe("expired");
253340
expect(expired.httpStatus, "the saved probe saw the 401").toBe(401);
341+
expect(expired.identity, "an expired connection surfaces no identity").toBeUndefined();
254342
}),
255343
Effect.gen(function* () {
256344
yield* client.connections
@@ -263,6 +351,56 @@ scenario(
263351
),
264352
);
265353

354+
scenario(
355+
"Health checks · the identity picker surfaces shallow fields across a discriminated union",
356+
{},
357+
Effect.scoped(
358+
Effect.gen(function* () {
359+
const target = yield* Target;
360+
const { client: makeClient } = yield* Api;
361+
const identity = yield* target.newIdentity();
362+
const client = yield* makeClient(api, identity);
363+
const slug = newSlug("hc-union");
364+
365+
yield* Effect.ensuring(
366+
Effect.gen(function* () {
367+
yield* client.openapi.addSpec({
368+
payload: {
369+
spec: { kind: "blob", value: discriminatedUnionSpec("https://union.example.com") },
370+
slug,
371+
baseUrl: "https://union.example.com",
372+
authenticationTemplate: [
373+
{
374+
slug: "apiKey",
375+
type: "apiKey",
376+
headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
377+
},
378+
],
379+
},
380+
});
381+
382+
// The identity picker is fed by the GET candidate's projected response
383+
// fields. They must include the shallow identity scalar even though it
384+
// sits behind a 60-field nested object...
385+
const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } });
386+
const get = candidates.find((candidate) => candidate.method === "get");
387+
if (!get) return yield* Effect.die("union spec exposed no GET candidate");
388+
const paths = (get.responseFields ?? []).map((field) => field.path);
389+
expect(paths, "the shallow identity scalar is offered, not starved by nesting").toContain(
390+
"user.email",
391+
);
392+
// ...and the field that exists ONLY on the second union variant, proving
393+
// every branch contributes (not just the first).
394+
expect(paths, "a field unique to the second union branch is offered").toContain(
395+
"user.limited",
396+
);
397+
}),
398+
client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore),
399+
);
400+
}),
401+
),
402+
);
403+
266404
scenario(
267405
"Health checks · a connection with no configured check reports unknown, not a failure",
268406
{},

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ const toHealthResponse = (r: HealthCheckResult) => ({
4444
status: r.status,
4545
checkedAt: r.checkedAt,
4646
...(r.httpStatus !== undefined ? { httpStatus: r.httpStatus } : {}),
47+
...(r.identity !== undefined ? { identity: r.identity } : {}),
4748
...(r.detail !== undefined ? { detail: r.detail } : {}),
49+
...(r.responseSample !== undefined ? { responseSample: r.responseSample } : {}),
4850
});
4951

5052
export const ConnectionsHandlers = HttpApiBuilder.group(ExecutorApi, "connections", (handlers) =>

0 commit comments

Comments
 (0)