From 1952ca80c37410b35d056b55aa858524372aed52 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Thu, 25 Jun 2026 13:43:34 -0700 Subject: [PATCH] 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: the editor gains a typed identity-field picker fed by those fields and a live preview (probe a pasted test key, see the real response plus what the identity resolves to); the account row labels itself with the probed identity; and the Add Connection "check the key works" flow gains the identity field (in the inline picker) and auto-fills the connection name from the probed 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 editor live preview and the Add Connection name-derivation drive the identity flow in the browser. --- e2e/scenarios/health-checks-ui.test.ts | 203 +++++++++++- e2e/scenarios/health-checks.test.ts | 184 +++++++++-- packages/core/api/src/handlers/connections.ts | 2 + packages/core/sdk/src/health-check.ts | 298 ++++++++++++++++- packages/core/sdk/src/index.ts | 5 + packages/core/sdk/src/shared.ts | 1 + .../openapi/src/react/AddOpenApiSource.tsx | 16 +- .../src/react/OpenApiAccountsPanel.tsx | 23 +- packages/plugins/openapi/src/sdk/backing.ts | 25 +- packages/plugins/openapi/src/sdk/preview.ts | 54 +++- .../react/src/components/accounts-section.tsx | 14 +- .../src/components/add-account-modal.tsx | 44 ++- .../src/components/health-check-editor.tsx | 306 ++++++++++++++++-- 13 files changed, 1088 insertions(+), 87 deletions(-) diff --git a/e2e/scenarios/health-checks-ui.test.ts b/e2e/scenarios/health-checks-ui.test.ts index c6f25fdf1..c38b9022b 100644 --- a/e2e/scenarios/health-checks-ui.test.ts +++ b/e2e/scenarios/health-checks-ui.test.ts @@ -27,7 +27,7 @@ import type { HttpApiClient } from "effect/unstable/httpapi"; import type { Page } from "playwright"; import { composePluginApi } from "@executor-js/api/server"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; -import { IntegrationSlug } from "@executor-js/sdk/shared"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; @@ -35,6 +35,9 @@ import { Api, Browser, Target } from "../src/services"; const api = composePluginApi([openApiHttpPlugin()] as const); type Client = HttpApiClient.ForApi; +const TEMPLATE = AuthTemplateSlug.make("apiKey"); +const IDENTITY = "alice@example.com"; + const newSlug = (prefix: string) => IntegrationSlug.make(`${prefix}-${randomBytes(4).toString("hex")}`); @@ -128,6 +131,47 @@ const registerIdentityIntegration = (client: Client, slug: IntegrationSlug, base }, }); +/** Like `serveIdentityApi`, but with a `revoke()` that flips the key off so a + * saved connection's previously-good key stops working mid-session (the editor + * scenario's healthy -> expired transition). */ +const serveMutableIdentityApi = (validToken: string) => + Effect.acquireRelease( + Effect.callback<{ + readonly url: string; + readonly revoke: () => void; + readonly close: () => void; + }>((resume) => { + let live = true; + const server = createServer((request, response) => { + const authorized = live && request.headers["authorization"] === `Bearer ${validToken}`; + if (request.method === "GET" && (request.url ?? "").startsWith("/me")) { + response.writeHead(authorized ? 200 : 401, { "content-type": "application/json" }); + response.end(JSON.stringify(authorized ? { email: IDENTITY } : { error: "x" })); + return; + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "not_found" })); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resume( + Effect.succeed({ + url: `http://127.0.0.1:${port}`, + revoke: () => { + live = false; + }, + close: () => { + server.close(); + server.closeAllConnections(); + }, + }), + ); + }); + }), + (server) => Effect.sync(server.close), + ); + /** The stored operation name for the GET probe (openapi prefixes it by tag), * discovered the same way the editor does: from the ranked candidate list. */ const getMeOperation = (client: Client, slug: IntegrationSlug) => @@ -571,3 +615,160 @@ scenario( }), ), ); + +// =========================================================================== +// Edit sheet WITH identity (identity layer): pick the operation + identity field +// by mouse, live-preview the response, then drive "Check now" on a saved +// connection healthy -> expired once the upstream revokes the key. +// =========================================================================== + +scenario( + "Health checks (UI) · edit sheet with identity: preview the response, then healthy then expired", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const goodToken = `gk_${randomBytes(8).toString("hex")}`; + const server = yield* serveMutableIdentityApi(goodToken); + const slug = newSlug("hc-ui-id"); + const name = ConnectionName.make("main"); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* registerIdentityIntegration(client, slug, server.url); + const operation = yield* getMeOperation(client, slug); + yield* client.connections.create({ + payload: { + owner: "org", + name, + integration: slug, + template: TEMPLATE, + value: goodToken, + }, + }); + + yield* browser.session(identity, async ({ page, step }) => { + const connections = page.locator("section").filter({ + has: page.getByRole("heading", { level: 3, name: "Connections" }), + }); + const menuTrigger = connections.locator('button[aria-haspopup="menu"]'); + + await step("Open the integration's connections", async () => { + await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await connections.getByText("main", { exact: true }).waitFor(); + await page.getByRole("heading", { level: 3, name: "Health check" }).waitFor(); + }); + + await step( + "Pick the GET identity call and its email identity field by mouse", + async () => { + await page.getByRole("button", { name: "Set up" }).click(); + await clickComboboxOption(page, "health-check-operation", "getMe"); + await clickComboboxOption(page, "health-check-identity", "email"); + }, + ); + + await step("Live preview a pasted key: status, response, and identity", async () => { + const sheet = page.getByRole("dialog"); + await page.locator("#health-check-preview-key").fill(goodToken); + await sheet.getByRole("button", { name: "Preview", exact: true }).click(); + await sheet.getByText("Response", { exact: true }).waitFor({ timeout: 30_000 }); + await sheet.getByText("Resolves to:").waitFor(); + await sheet.getByText(IDENTITY).first().waitFor(); + }); + + await step("Save the health check", async () => { + await page.getByRole("button", { name: "Save", exact: true }).click(); + await page.locator("#health-check-operation").waitFor({ state: "hidden" }); + }); + + await step("Check the live connection: healthy, and whose account it is", async () => { + await menuTrigger.click(); + await page.getByRole("menuitem", { name: "Check now" }).click(); + await connections.getByText(IDENTITY).waitFor({ timeout: 30_000 }); + await connections.getByLabel("Status: Healthy").waitFor(); + }); + + await step("The upstream revokes the key: the connection reads expired", async () => { + server.revoke(); + await menuTrigger.click(); + await page.getByRole("menuitem", { name: "Check now" }).click(); + await connections.getByText("Expired", { exact: true }).waitFor({ timeout: 30_000 }); + await connections.getByLabel("Status: Expired").waitFor(); + }); + }); + + const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); + expect(stored).toEqual({ operation, identityField: "email" }); + }), + Effect.gen(function* () { + yield* client.connections + .remove({ params: { owner: "org", integration: slug, name } }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ); + }), + ), +); + +// =========================================================================== +// Add Connection, check configured WITH identity (identity layer): checking the +// key derives the connection name from the probed identity. +// =========================================================================== + +scenario( + "Health checks (UI) · Add Connection derives the connection name from the probed identity", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const goodToken = `gk_${randomBytes(8).toString("hex")}`; + const server = yield* serveIdentityApi(goodToken); + const slug = newSlug("hc-ui-name"); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* registerIdentityIntegration(client, slug, server.url); + const operation = yield* getMeOperation(client, slug); + yield* client.integrations.healthCheckSet({ + params: { slug }, + payload: { spec: { operation, identityField: "email" } }, + }); + + yield* browser.session(identity, async ({ page, step }) => { + const dialog = page.getByRole("dialog"); + + await step("Open the Add Connection modal", async () => { + await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await page.getByRole("button", { name: "Add connection", exact: true }).click(); + await page.getByRole("heading", { name: /Add connection/ }).waitFor(); + }); + + await step("A valid key checks healthy and names the connection", async () => { + await dialog.getByPlaceholder("paste the value / token").fill(goodToken); + await dialog.getByRole("button", { name: "Check the key works" }).click(); + await dialog.getByText("Healthy").waitFor({ timeout: 30_000 }); + await page.waitForFunction( + (expected) => + (document.querySelector("#connection-name") as HTMLInputElement | null)?.value === + expected, + IDENTITY, + { timeout: 10_000 }, + ); + }); + }); + }), + client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), + ); + }), + ), +); diff --git a/e2e/scenarios/health-checks.test.ts b/e2e/scenarios/health-checks.test.ts index b8762fad4..2731f45a0 100644 --- a/e2e/scenarios/health-checks.test.ts +++ b/e2e/scenarios/health-checks.test.ts @@ -1,13 +1,14 @@ // Cross-target: connection health checks, the feature that answers "has this -// credential expired?" (the Google 7-day dev-token case) in one declared probe. -// Entirely through the typed client: +// credential expired?" (the Google 7-day dev-token case) and "whose account is +// this?" in one declared probe. Entirely through the typed client: // // 1. register an OpenAPI integration whose `GET /me` is auth-gated, // 2. CONFIGURE a health check by picking that operation (the same flow the // user drives in the editor: list candidates, ranked GET-first, then set), -// 3. CHECK a SAVED connection and watch its status flip healthy -> expired -// when the stored key stops working, -// 4. confirm a connection with no configured check reports `unknown`. +// 3. VALIDATE a pasted key without saving it (the key-first connect flow) and +// watch the probe derive the connection identity from the live response, +// 4. CHECK a SAVED connection and watch its status flip healthy -> expired +// when the stored key stops working. // // The upstream API is a real node:http server started inside the scenario on // 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); type Client = HttpApiClient.ForApi; const TEMPLATE = AuthTemplateSlug.make("apiKey"); -const ACCOUNT_EMAIL = "alice@example.com"; +const IDENTITY = "alice@example.com"; const newSlug = (prefix: string) => IntegrationSlug.make(`${prefix}-${randomBytes(4).toString("hex")}`); -/** OpenAPI 3 spec with an auth-gated GET (`/me`, the obvious health check) plus a - * destructive POST so the candidate ranking has something to sort the GET ahead - * of. */ +/** OpenAPI 3 spec with an auth-gated identity GET (`/me`, the obvious health + * check) plus a destructive POST so the candidate ranking has something to sort + * the GET ahead of. */ const identitySpec = (baseUrl: string): string => JSON.stringify({ openapi: "3.0.3", @@ -73,6 +74,72 @@ const identitySpec = (baseUrl: string): string => }, }); +/** OpenAPI 3 spec whose `GET /me` response mirrors Vercel's `getAuthUser`: the + * account is a `oneOf` of two object variants, the obvious identity scalars + * (`email`, `id`) sit behind a large nested object, and one field (`limited`) + * exists only on the second variant. A naive walker that follows only the first + * union branch (and descends the nested object until a field cap) drops both + * `user.email` and `user.limited`; the projector must merge branches and emit + * shallow fields first. No live server needed (candidate projection is static). */ +const discriminatedUnionSpec = (baseUrl: string): string => { + // 60 nested scalars, listed before `email`, so a depth-first walk blows the + // field cap inside `profile` before it ever reaches the top-level identity. + const profileProps: Record = {}; + for (let i = 0; i < 60; i++) profileProps[`field${i}`] = { type: "string" }; + return JSON.stringify({ + openapi: "3.0.3", + info: { title: "Union Identity API", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/me": { + get: { + operationId: "getAuthUser", + summary: "The current account", + responses: { + "200": { + description: "The authenticated account", + content: { + "application/json": { + schema: { + type: "object", + properties: { + user: { + oneOf: [ + { $ref: "#/components/schemas/AccountFull" }, + { $ref: "#/components/schemas/AccountLimited" }, + ], + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + AccountFull: { + type: "object", + properties: { + profile: { type: "object", properties: profileProps }, + email: { type: "string" }, + id: { type: "string" }, + }, + }, + AccountLimited: { + type: "object", + properties: { + email: { type: "string" }, + limited: { type: "boolean" }, + }, + }, + }, + }, + }); +}; + /** A real node:http identity API on 127.0.0.1. `GET /me` returns the account * JSON only when the bearer token matches `validToken`; any other token is a * 401 (the "the dev token got revoked" case the health check classifies as @@ -89,7 +156,7 @@ const serveIdentityApi = (validToken: string) => return; } response.writeHead(200, { "content-type": "application/json" }); - response.end(JSON.stringify({ email: ACCOUNT_EMAIL, login: "alice" })); + response.end(JSON.stringify({ email: IDENTITY, login: "alice" })); return; } response.writeHead(404, { "content-type": "application/json" }); @@ -130,9 +197,9 @@ const registerIdentityIntegration = (client: Client, slug: IntegrationSlug, base }, }); -/** The stored operation name for the GET probe (openapi prefixes it by tag, e.g. - * `me.getMe`), discovered the same way the editor does: from the ranked - * candidate list. */ +/** The stored operation name for the GET identity probe (openapi prefixes it by + * tag, e.g. `me.getMe`), discovered the same way the editor does: from the + * ranked candidate list. */ const getMeOperation = (client: Client, slug: IntegrationSlug) => Effect.gen(function* () { const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } }); @@ -142,7 +209,7 @@ const getMeOperation = (client: Client, slug: IntegrationSlug) => }); scenario( - "Health checks · the editor ranks the non-destructive GET ahead of the destructive POST", + "Health checks · configuring a check, then validating a key derives the connection identity", {}, Effect.scoped( Effect.gen(function* () { @@ -152,14 +219,14 @@ scenario( const client = yield* makeClient(api, identity); const goodToken = `gk_${randomBytes(8).toString("hex")}`; const server = yield* serveIdentityApi(goodToken); - const slug = newSlug("hc-rank"); + const slug = newSlug("hc-validate"); yield* Effect.ensuring( Effect.gen(function* () { yield* registerIdentityIntegration(client, slug, server.url); // The editor offers the integration's operations, ranked so the - // non-destructive GET endpoint floats to the top. + // non-destructive GET identity endpoint floats to the top. const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug }, }); @@ -169,7 +236,7 @@ scenario( return yield* Effect.die("identity spec should expose a GET and a POST candidate"); } // Operations are stored tag-prefixed (e.g. `me.getMe`); match the suffix. - expect(get.operation.split(".").at(-1), "the GET is offered").toBe("getMe"); + expect(get.operation.split(".").at(-1), "the identity GET is offered").toBe("getMe"); expect(post.operation.split(".").at(-1), "the destructive POST is offered").toBe( "sendMessage", ); @@ -177,18 +244,37 @@ scenario( candidates[0]?.operation, "the non-destructive GET ranks ahead of the destructive POST", ).toBe(get.operation); - expect(get.destructive, "the GET probe is non-destructive").toBe(false); + expect(get.destructive, "the GET identity probe is non-destructive").toBe(false); expect(post.destructive, "the POST is flagged destructive").toBe(true); + const operation = get.operation; - // Pick it: just the operation (a pure liveness probe). + // Pick it: the operation plus the dot-path to the identity field. yield* client.integrations.healthCheckSet({ params: { slug }, - payload: { spec: { operation: get.operation } }, + payload: { spec: { operation, identityField: "email" } }, }); const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); expect(stored, "the chosen health check round-trips").toEqual({ - operation: get.operation, + operation, + identityField: "email", }); + + // Key-first connect: a pasted key is probed WITHOUT saving, and the + // probe surfaces the identity the UI fills the connection name from. + const healthy = yield* client.connections.validate({ + payload: { owner: "org", integration: slug, template: TEMPLATE, value: goodToken }, + }); + expect(healthy.status, "a live key validates as healthy").toBe("healthy"); + expect(healthy.httpStatus, "the probe saw the 200").toBe(200); + expect(healthy.identity, "the identity is derived from the response body").toBe(IDENTITY); + + // A revoked / wrong key validates as expired, with no identity. + const expired = yield* client.connections.validate({ + payload: { owner: "org", integration: slug, template: TEMPLATE, value: "wrong-key" }, + }); + expect(expired.status, "a rejected key validates as expired").toBe("expired"); + expect(expired.httpStatus, "the probe saw the 401").toBe(401); + expect(expired.identity, "no identity is surfaced for a rejected key").toBeUndefined(); }), client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), ); @@ -216,10 +302,10 @@ scenario( const operation = yield* getMeOperation(client, slug); yield* client.integrations.healthCheckSet({ params: { slug }, - payload: { spec: { operation } }, + payload: { spec: { operation, identityField: "email" } }, }); - // A connection holding the live key checks out healthy. + // A connection holding the live key checks out healthy, identity and all. yield* client.connections.create({ payload: { owner: "org", @@ -234,6 +320,7 @@ scenario( }); expect(healthy.status, "the saved connection's live key is healthy").toBe("healthy"); expect(healthy.httpStatus, "the saved probe saw the 200").toBe(200); + expect(healthy.identity, "the saved probe derives the account identity").toBe(IDENTITY); // Re-creating the same (owner, integration, name) replaces the stored // key in place: now the connection holds a key the server rejects. @@ -251,6 +338,7 @@ scenario( }); expect(expired.status, "the same connection now reads as expired").toBe("expired"); expect(expired.httpStatus, "the saved probe saw the 401").toBe(401); + expect(expired.identity, "an expired connection surfaces no identity").toBeUndefined(); }), Effect.gen(function* () { yield* client.connections @@ -263,6 +351,56 @@ scenario( ), ); +scenario( + "Health checks · the identity picker surfaces shallow fields across a discriminated union", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const slug = newSlug("hc-union"); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: discriminatedUnionSpec("https://union.example.com") }, + slug, + baseUrl: "https://union.example.com", + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] }, + }, + ], + }, + }); + + // The identity picker is fed by the GET candidate's projected response + // fields. They must include the shallow identity scalar even though it + // sits behind a 60-field nested object... + const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } }); + const get = candidates.find((candidate) => candidate.method === "get"); + if (!get) return yield* Effect.die("union spec exposed no GET candidate"); + const paths = (get.responseFields ?? []).map((field) => field.path); + expect(paths, "the shallow identity scalar is offered, not starved by nesting").toContain( + "user.email", + ); + // ...and the field that exists ONLY on the second union variant, proving + // every branch contributes (not just the first). + expect(paths, "a field unique to the second union branch is offered").toContain( + "user.limited", + ); + }), + client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), + ); + }), + ), +); + scenario( "Health checks · a connection with no configured check reports unknown, not a failure", {}, diff --git a/packages/core/api/src/handlers/connections.ts b/packages/core/api/src/handlers/connections.ts index a4a1ba681..d129285ad 100644 --- a/packages/core/api/src/handlers/connections.ts +++ b/packages/core/api/src/handlers/connections.ts @@ -44,7 +44,9 @@ const toHealthResponse = (r: HealthCheckResult) => ({ status: r.status, checkedAt: r.checkedAt, ...(r.httpStatus !== undefined ? { httpStatus: r.httpStatus } : {}), + ...(r.identity !== undefined ? { identity: r.identity } : {}), ...(r.detail !== undefined ? { detail: r.detail } : {}), + ...(r.responseSample !== undefined ? { responseSample: r.responseSample } : {}), }); export const ConnectionsHandlers = HttpApiBuilder.group(ExecutorApi, "connections", (handlers) => diff --git a/packages/core/sdk/src/health-check.ts b/packages/core/sdk/src/health-check.ts index c4beaec3c..7635922f2 100644 --- a/packages/core/sdk/src/health-check.ts +++ b/packages/core/sdk/src/health-check.ts @@ -2,16 +2,17 @@ // @executor-js/sdk health-check vocabulary — browser-safe. // // A health check is a single declared, authenticated operation a connection can -// run to answer one question: "is this credential still alive?". The plugin owns -// WHICH operation (it lives in the plugin's opaque integration config, picked by -// the user the same way auth methods are configured); core owns the shared -// vocabulary below so every surface (API, React, plugins) speaks the same shapes. +// run to answer two questions at once: "is this credential still alive?" and +// "whose account is this?". The plugin owns WHICH operation (it lives in the +// plugin's opaque integration config, picked by the user the same way auth +// methods are configured); core owns the shared vocabulary below so every +// surface (API, React, plugins) speaks the same shapes. // -// The probe runs an operation with optional pinned `args` (some liveness -// endpoints need a fixed parameter; e.g. Google's People API needs -// `resourceName=people/me`), maps the HTTP status to a `HealthStatus`, and -// reports it. Everything here is pure Effect/Schema with no server/node imports -// so it is safe to import from React. +// The probe runs an operation with optional pinned `args` (Google's People API +// needs `resourceName=people/me`; there is no zero-arg identity endpoint), maps +// the HTTP status to a `HealthStatus`, and extracts a display `identity` from a +// dot-path on the response body. Everything here is pure Effect/Schema with no +// server/node imports so it is safe to import from React. // --------------------------------------------------------------------------- import { Schema } from "effect"; @@ -28,33 +29,58 @@ export const HealthStatus = Schema.Literals(["healthy", "expired", "degraded", " export type HealthStatus = typeof HealthStatus.Type; // --------------------------------------------------------------------------- -// HealthCheckSpec — the persisted configuration: which operation to run and any -// pinned arguments. Stored inside the owning plugin's opaque integration config; -// core never parses it (the plugin reads it back in `checkHealth`). +// HealthCheckSpec — the persisted configuration: which operation to run, any +// pinned arguments, and the dot-path to the identity field. Stored inside the +// owning plugin's opaque integration config; core never parses it (the plugin +// reads it back in `checkHealth`). // --------------------------------------------------------------------------- export const HealthCheckSpec = Schema.Struct({ /** The tool / operation name to invoke (the plugin maps this to its binding). */ operation: Schema.String, - /** Pinned arguments merged into the probe call. Required for liveness + /** Pinned arguments merged into the probe call. Required for identity * endpoints that take a fixed parameter (e.g. People API `resourceName`). */ args: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + /** Dot-path into the successful response body whose value is shown as the + * connection's identity (e.g. `emailAddresses.0.value`, `user.login`). */ + identityField: Schema.optional(Schema.String), }); export type HealthCheckSpec = typeof HealthCheckSpec.Type; +// --------------------------------------------------------------------------- +// HealthCheckResponseSample — one scalar leaf from the actual response body, +// `path` (the dot-path that would resolve it) plus `value` (a truncated string +// rendering). The live preview shows these so the user can see what the chosen +// operation really returns and pick the right identity field. Sampled from the +// real response, so it works even when a spec's response schema is thin/absent. +// --------------------------------------------------------------------------- + +export const HealthCheckResponseSample = Schema.Struct({ + path: Schema.String, + value: Schema.String, +}); +export type HealthCheckResponseSample = typeof HealthCheckResponseSample.Type; + // --------------------------------------------------------------------------- // HealthCheckResult — the outcome of running a probe. `httpStatus` and `detail` -// are diagnostic. +// are diagnostic; `identity` is the extracted display value when the check +// succeeded and an `identityField` was configured (and resolved); `responseSample` +// carries a bounded set of the actual returned fields for the live preview. // --------------------------------------------------------------------------- export const HealthCheckResult = Schema.Struct({ status: HealthStatus, /** The HTTP status the probe observed, when the check ran against HTTP. */ httpStatus: Schema.optional(Schema.Number), + /** Display identity extracted from `identityField`, when present. */ + identity: Schema.optional(Schema.String), /** Epoch ms the check ran. */ checkedAt: Schema.Number, /** Human-readable diagnostic (error message, "no health check configured"). */ detail: Schema.optional(Schema.String), + /** Bounded sample of scalar fields from the response body, for the live + * preview ("show me what this operation returns"). */ + responseSample: Schema.optional(Schema.Array(HealthCheckResponseSample)), }); export type HealthCheckResult = typeof HealthCheckResult.Type; @@ -62,7 +88,7 @@ export type HealthCheckResult = typeof HealthCheckResult.Type; // HealthCheckCandidate — one operation the user can pick as the health check, // projected from the plugin's stored operations. The editor lists these ranked // (non-destructive first, then fewest required args) so the obvious "GET /me" -// style endpoint floats to the top. +// style identity endpoint floats to the top. // --------------------------------------------------------------------------- export const HealthCheckCandidateParameter = Schema.Struct({ @@ -74,6 +100,19 @@ export const HealthCheckCandidateParameter = Schema.Struct({ }); export type HealthCheckCandidateParameter = typeof HealthCheckCandidateParameter.Type; +// --------------------------------------------------------------------------- +// HealthCheckResponseField — one scalar leaf the candidate operation can return, +// projected from its response schema: `path` (the dot-path to set as +// `HealthCheckSpec.identityField`) and a `type` display label ("string", +// "number", "string[]", …). Feeds the typed identity picker. +// --------------------------------------------------------------------------- + +export const HealthCheckResponseField = Schema.Struct({ + path: Schema.String, + type: Schema.String, +}); +export type HealthCheckResponseField = typeof HealthCheckResponseField.Type; + export const HealthCheckCandidate = Schema.Struct({ /** The operation / tool name to store as `HealthCheckSpec.operation`. */ operation: Schema.String, @@ -88,12 +127,15 @@ export const HealthCheckCandidate = Schema.Struct({ summary: Schema.optional(Schema.String), /** The operation's parameters, so the editor can offer pinned-arg inputs. */ parameters: Schema.optional(Schema.Array(HealthCheckCandidateParameter)), + /** Scalar leaves from the operation's response schema, for the typed identity + * picker. Projected via `projectResponseFields`. */ + responseFields: Schema.optional(Schema.Array(HealthCheckResponseField)), }); export type HealthCheckCandidate = typeof HealthCheckCandidate.Type; // --------------------------------------------------------------------------- -// Pure helpers — shared so classification behaves identically wherever a probe -// is interpreted. +// Pure helpers — shared so classification + identity extraction behave +// identically wherever a probe is interpreted. // --------------------------------------------------------------------------- /** Map an HTTP status to a health state: 2xx healthy, 401/403 expired (the auth @@ -104,6 +146,28 @@ export const classifyHttpStatus = (status: number): HealthStatus => { return "degraded"; }; +/** 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. */ +export const extractIdentity = (data: unknown, dotpath?: string): string | undefined => { + if (dotpath == null || dotpath.length === 0) return undefined; + let current: unknown = data; + for (const segment of dotpath.split(".")) { + if (current == null) return undefined; + if (Array.isArray(current)) { + const index = Number(segment); + if (!Number.isInteger(index) || index < 0 || index >= current.length) return undefined; + current = current[index]; + continue; + } + if (typeof current !== "object") return undefined; + current = (current as Record)[segment]; + } + if (typeof current === "string") return current; + if (typeof current === "number" || typeof current === "boolean") return String(current); + return undefined; +}; + /** Stable ranking for the candidate list: non-destructive before destructive, * then fewest required args, then by method (get first), then alphabetical. * Returns a negative/zero/positive comparator value. */ @@ -120,3 +184,203 @@ export const compareHealthCheckCandidates = ( } return a.operation < b.operation ? -1 : a.operation > b.operation ? 1 : 0; }; + +// --------------------------------------------------------------------------- +// Response-field projection — two pure walkers that feed the typed identity +// picker and the live preview. `projectResponseFields` walks a (normalized) +// response SCHEMA to enumerate selectable identity paths; `extractResponseFields` +// walks an actual response BODY so the preview can show what the operation +// really returns even when the schema is thin or absent. +// --------------------------------------------------------------------------- + +const SCALAR_TYPE_LABELS: Record = { + string: "string", + number: "number", + integer: "number", + boolean: "boolean", +}; + +/** Pick the first non-null entry of an OpenAPI `type` (which may be a union like + * `["string", "null"]`), as a string or undefined. */ +const primaryType = (raw: unknown): string | undefined => { + if (Array.isArray(raw)) { + const found = raw.find((x) => typeof x === "string" && x !== "null"); + return typeof found === "string" ? found : undefined; + } + return typeof raw === "string" ? raw : undefined; +}; + +/** Flatten one schema node's composition into a single effective shape: follow + * `$ref`s and merge the properties of EVERY `allOf`/`oneOf`/`anyOf` member, so a + * discriminated union (or multi-member allOf) contributes the union of all its + * branches' fields, not just the first. Returns the merged properties, any array + * `items`, a scalar type label (when the node is a leaf), and the set of refs + * followed (so children inherit a cycle guard). */ +const flattenSchemaShape = ( + node: unknown, + defs: Record, + seenRefs: ReadonlySet, +): { + readonly props: Map; + readonly items: unknown; + readonly scalarType: string | undefined; + readonly refs: ReadonlySet; +} => { + const props = new Map(); + let items: unknown = undefined; + let scalarType: string | undefined; + const refs = new Set(seenRefs); + // Bounded work queue over composition members (refs + allOf/oneOf/anyOf) at + // this one level; the guard bounds pathological self-referential composition. + const stack: unknown[] = [node]; + let guard = 0; + while (stack.length > 0 && guard++ < 500) { + const current = stack.pop(); + if (current == null || typeof current !== "object") continue; + const s = current as Record; + + const ref = s["$ref"]; + if (typeof ref === "string") { + const name = ref.startsWith("#/$defs/") ? ref.slice("#/$defs/".length) : undefined; + if (name !== undefined && !refs.has(name)) { + refs.add(name); + const target = defs[name]; + if (target != null && typeof target === "object") stack.push(target); + } + continue; + } + + for (const key of ["allOf", "oneOf", "anyOf"] as const) { + const members = s[key]; + if (Array.isArray(members)) for (const member of members) stack.push(member); + } + + const type = primaryType(s["type"]); + if (type !== undefined && SCALAR_TYPE_LABELS[type] !== undefined) + scalarType = SCALAR_TYPE_LABELS[type]; + if (items === undefined && s["items"] !== undefined) items = s["items"]; + const ownProps = s["properties"]; + if (ownProps != null && typeof ownProps === "object") { + for (const [key, child] of Object.entries(ownProps as Record)) { + if (!props.has(key)) props.set(key, child); + } + } + } + return { props, items, scalarType, refs }; +}; + +/** + * Enumerate the scalar leaves of a response schema as selectable identity paths. + * Returns dot-paths (`a.b.0.c`) paired with a display type label. Array items use + * the literal `"0"` segment to match `extractIdentity`'s numeric-index + * convention. Resolves `#/$defs/X` refs against `defs` with a cycle guard, and + * merges ALL `allOf`/`oneOf`/`anyOf` members so discriminated-union branches each + * contribute their fields. Traverses BREADTH-FIRST so shallow scalars (`email`, + * `id`, `username`) are emitted before deep nested fields and aren't starved by + * the field cap. Bounded to depth 5 and 50 (deduped) fields. + */ +export const projectResponseFields = ( + schema: unknown, + defs: Record = {}, +): HealthCheckResponseField[] => { + const fields: HealthCheckResponseField[] = []; + const seenPaths = new Set(); + const MAX_DEPTH = 5; + const MAX_FIELDS = 50; + const MAX_FRONTIER = 2000; + + type Item = { + readonly node: unknown; + readonly path: string; + readonly depth: number; + readonly seenRefs: ReadonlySet; + }; + + let frontier: Item[] = [{ node: schema, path: "", depth: 0, seenRefs: new Set() }]; + while (frontier.length > 0 && fields.length < MAX_FIELDS) { + const nextFrontier: Item[] = []; + for (const item of frontier) { + if (fields.length >= MAX_FIELDS) break; + if (item.node == null || typeof item.node !== "object") continue; + const { props, items, scalarType, refs } = flattenSchemaShape(item.node, defs, item.seenRefs); + + // Object: queue children one level deeper (shallow fields emit first). + if (props.size > 0) { + for (const [key, child] of props) { + if (nextFrontier.length >= MAX_FRONTIER) break; + nextFrontier.push({ + node: child, + path: item.path === "" ? key : `${item.path}.${key}`, + depth: item.depth + 1, + seenRefs: refs, + }); + } + continue; + } + // Array: descend into items with the numeric-index segment. + if (items !== undefined) { + nextFrontier.push({ + node: items, + path: item.path === "" ? "0" : `${item.path}.0`, + depth: item.depth + 1, + seenRefs: refs, + }); + continue; + } + // Scalar leaf. + if (scalarType !== undefined && item.path !== "" && !seenPaths.has(item.path)) { + seenPaths.add(item.path); + fields.push({ path: item.path, type: scalarType }); + } + } + frontier = nextFrontier.filter((item) => item.depth <= MAX_DEPTH); + } + return fields; +}; + +/** + * Walk an actual JSON response body and return its scalar leaves as + * `{ path, value }` rows (value stringified + truncated). Drives the live + * preview's "show me what this returns" list. Bounded to depth 4, 25 fields, + * and ~120-char values. + */ +export const extractResponseFields = (data: unknown): HealthCheckResponseSample[] => { + const out: HealthCheckResponseSample[] = []; + const MAX_DEPTH = 4; + const MAX_FIELDS = 25; + const MAX_VALUE = 120; + + const render = (v: string): string => (v.length > MAX_VALUE ? `${v.slice(0, MAX_VALUE)}...` : v); + + const visit = (node: unknown, path: string, depth: number) => { + if (out.length >= MAX_FIELDS || node == null) return; + + if (Array.isArray(node)) { + if (depth >= MAX_DEPTH) return; + for (let i = 0; i < node.length; i++) { + if (out.length >= MAX_FIELDS) break; + visit(node[i], path === "" ? String(i) : `${path}.${i}`, depth + 1); + } + return; + } + + if (typeof node === "object") { + if (depth >= MAX_DEPTH) return; + for (const [key, child] of Object.entries(node as Record)) { + if (out.length >= MAX_FIELDS) break; + visit(child, path === "" ? key : `${path}.${key}`, depth + 1); + } + return; + } + + if ( + path !== "" && + (typeof node === "string" || typeof node === "number" || typeof node === "boolean") + ) { + out.push({ path, value: render(String(node)) }); + } + }; + + visit(data, "", 0); + return out; +}; diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 1e1f86f2f..fc5635e4d 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -107,10 +107,15 @@ export { HealthStatus, HealthCheckSpec, HealthCheckResult, + HealthCheckResponseSample, HealthCheckCandidate, HealthCheckCandidateParameter, + HealthCheckResponseField, classifyHttpStatus, + extractIdentity, compareHealthCheckCandidates, + projectResponseFields, + extractResponseFields, } from "./health-check"; // Core schema. diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 2a7abe38a..58ffa6557 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -110,6 +110,7 @@ export { HealthCheckCandidate, HealthCheckCandidateParameter, classifyHttpStatus, + extractIdentity, compareHealthCheckCandidates, } from "./health-check"; diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index 96b92c9b4..32a5f7a30 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -107,8 +107,10 @@ export default function AddOpenApiSource(props: { // Optional health check drafted while adding. Empty `hcOperation` means "not // drafted": we then leave the integration's auto-detected default untouched - // rather than persisting a blank. + // rather than persisting a blank. No live preview here (no integration/key + // exists pre-creation, and `validateConnection` requires a persisted one). const [hcOperation, setHcOperation] = useState(""); + const [hcIdentityField, setHcIdentityField] = useState(""); const [hcArgs, setHcArgs] = useState>({}); const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); @@ -230,9 +232,10 @@ export default function AddOpenApiSource(props: { const onHcOperationChange = (next: string) => { setHcOperation(next); - // Args are operation-specific; drop them so none dangle onto a freshly - // picked operation. + // Args and identity path are operation-specific; drop them so neither + // dangles onto a freshly picked operation. setHcArgs({}); + setHcIdentityField(""); }; const onHcArgChange = (name: string, value: string) => setHcArgs((prev) => ({ ...prev, [name]: value })); @@ -323,12 +326,14 @@ export default function AddOpenApiSource(props: { // place. A failure here is non-fatal: the integration is already created and // the check stays editable from its detail page, so surface it and move on. if (hcOperation.length > 0) { + const identity = hcIdentityField.trim(); const argEntries = Object.entries(hcArgs) .map(([key, value]) => [key, value.trim()] as const) .filter(([, value]) => value.length > 0); const spec: HealthCheckSpec = { operation: hcOperation, ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}), + ...(identity.length > 0 ? { identityField: identity } : {}), }; const exit = await doSetHealthCheck({ params: { slug: integration }, @@ -436,7 +441,8 @@ export default function AddOpenApiSource(props: {

Health check (optional)

One read-only call Executor runs to tell whether a connection's credential is still - alive. You can change this later from the integration page. + alive and, optionally, whose account it is. You can change this later, and run a live + preview against a key, from the integration page.

(() => { + const templates = methods + .filter((m) => m.kind === "apikey") + .map((m) => ({ template: m.template, label: m.label })); + if (templates.length === 0) return undefined; + return { owner: defaultConnectionOwnerForHost(organizationId), templates }; + }, [methods, organizationId]); + return (
- +
); } diff --git a/packages/plugins/openapi/src/sdk/backing.ts b/packages/plugins/openapi/src/sdk/backing.ts index 1987b8f16..a9feacab5 100644 --- a/packages/plugins/openapi/src/sdk/backing.ts +++ b/packages/plugins/openapi/src/sdk/backing.ts @@ -9,7 +9,11 @@ import { authToolFailure, classifyHttpStatus, compareHealthCheckCandidates, + extractIdentity, + extractResponseFields, + projectResponseFields, type HealthCheckCandidate, + type HealthCheckResponseField, type HealthCheckResult, type HealthCheckSpec, type IntegrationConfig, @@ -673,7 +677,7 @@ export const resolveOpenApiBackedAnnotations = (input: { }); // --------------------------------------------------------------------------- -// Health checks — the declared liveness probe for a connection. +// Health checks — the declared liveness/identity probe for a connection. // --------------------------------------------------------------------------- /** Resolve the invocation binding for a health-check operation. Unlike the tool @@ -787,10 +791,17 @@ export const checkHealthOpenApi = (input: { } const status = classifyHttpStatus(probe.result.status); + const identity = + status === "healthy" ? extractIdentity(probe.result.data, spec.identityField) : undefined; + // Sample the actual returned body (success body, else the error body) so the + // live preview can show what the operation returns regardless of status. + const responseSample = extractResponseFields(probe.result.data ?? probe.result.error); return { status, httpStatus: probe.result.status, + ...(identity !== undefined ? { identity } : {}), checkedAt, + ...(responseSample.length > 0 ? { responseSample } : {}), ...(status === "healthy" ? {} : { detail: extractOpenApiUpstreamMessage(probe.result.error, probe.result.status) }), @@ -799,7 +810,7 @@ export const checkHealthOpenApi = (input: { /** List the operations a user can pick as the health check, ranked * non-destructive-first then fewest-required-args so the obvious "GET /me" - * style endpoint floats to the top. Recompiles the spec once (best-effort) + * identity endpoint floats to the top. Recompiles the spec once (best-effort) * to recover human summaries the stored binding does not keep. */ export const listHealthCheckCandidatesOpenApi = (input: { readonly ctx: PluginCtx; @@ -811,6 +822,7 @@ export const listHealthCheckCandidatesOpenApi = (input: { const config = decodeOpenApiIntegrationConfig(input.integration.config); const summaries = new Map(); + const responseFieldsByTool = new Map(); if (config) { const specText = yield* loadOpenApiSpecText(input.ctx.storage, config).pipe( Effect.catch(() => Effect.succeed(null)), @@ -825,6 +837,13 @@ export const listHealthCheckCandidatesOpenApi = (input: { Option.getOrUndefined(def.operation.summary) ?? Option.getOrUndefined(def.operation.description); if (summary) summaries.set(def.toolPath, summary); + // `outputSchema` is NOT pre-normalized; `hoistedDefs` ARE, so normalize + // the schema before walking refs against them. + const fields = projectResponseFields( + normalizeOpenApiRefs(Option.getOrUndefined(def.operation.outputSchema)), + compiled.hoistedDefs, + ); + if (fields.length > 0) responseFieldsByTool.set(def.toolPath, fields); } } } @@ -839,6 +858,7 @@ export const listHealthCheckCandidatesOpenApi = (input: { ? { description: parameter.description.value } : {}), })); + const responseFields = responseFieldsByTool.get(op.toolName); return { operation: op.toolName, method, @@ -846,6 +866,7 @@ export const listHealthCheckCandidatesOpenApi = (input: { destructive: REQUIRE_APPROVAL.has(method), summary: summaries.get(op.toolName) ?? `${method.toUpperCase()} ${op.binding.pathTemplate}`, ...(parameters.length > 0 ? { parameters } : {}), + ...(responseFields && responseFields.length > 0 ? { responseFields } : {}), }; }); return [...candidates].sort(compareHealthCheckCandidates); diff --git a/packages/plugins/openapi/src/sdk/preview.ts b/packages/plugins/openapi/src/sdk/preview.ts index 5c3999ba7..b98749c1e 100644 --- a/packages/plugins/openapi/src/sdk/preview.ts +++ b/packages/plugins/openapi/src/sdk/preview.ts @@ -1,11 +1,16 @@ import { Effect, Option, Predicate } from "effect"; import { Schema } from "effect"; -import { HealthCheckCandidate, compareHealthCheckCandidates } from "@executor-js/sdk/core"; +import { + HealthCheckCandidate, + compareHealthCheckCandidates, + projectResponseFields, +} from "@executor-js/sdk/core"; import { parse, resolveSpecText, type ParsedDocument } from "./parse"; import { extract } from "./extract"; import { compileToolDefinitions } from "./definitions"; +import { normalizeOpenApiRefs } from "./backing"; import { DocResolver } from "./openapi-utils"; import { HttpMethod, ServerInfo, type ExtractedOperation, type ExtractionResult } from "./types"; @@ -21,6 +26,12 @@ const DESTRUCTIVE_METHODS = new Set(["post", "put", "patch", "delete"]); // top-ranked 1000. Beyond that, the picker stays freeform (type an exact op). const MAX_PREVIEW_CANDIDATES = 1000; +// Cap on how many carried candidates get their response schema WALKED for the +// typed identity picker. Walking is the only expensive part, so it stays small: +// the top-ranked survivors get typed identity fields; the long tail is +// metadata-only and its identity picker falls back to a freeform dot-path. +const MAX_PREVIEW_RESPONSE_FIELD_CANDIDATES = 50; + // --------------------------------------------------------------------------- // OAuth 2.0 flows — one entry per supported grant type // --------------------------------------------------------------------------- @@ -167,7 +178,7 @@ export const SpecPreview = Schema.Struct({ /** OAuth2 presets — one per (oauth2 scheme × supported flow) combination */ oauth2Presets: Schema.Array(OAuth2Preset), /** Top-ranked health-check candidates (bounded), so the add screen can offer a - * typed operation picker before the integration is registered. */ + * typed operation + identity picker before the integration is registered. */ healthCheckCandidates: Schema.Array(HealthCheckCandidate), }); export type SpecPreview = typeof SpecPreview.Type; @@ -429,24 +440,25 @@ const collectTags = (result: ExtractionResult): string[] => { /** * Project the top-ranked health-check candidates from a parsed doc + its - * extracted operations, so the add screen can offer a typed operation picker - * before registration. + * extracted operations, so the add screen can offer a typed operation/identity + * picker before registration. * * Tool paths are computed on the FULL operation set (`compileToolDefinitions` * collision resolution is stateful, so they must match the paths the operations - * get at registration). Candidates are ranked and sliced to - * `MAX_PREVIEW_CANDIDATES` (enough for the operation picker to search the whole - * spec). + * get at registration). Candidates are ranked, sliced to `MAX_PREVIEW_CANDIDATES` + * (enough for the operation picker to search the whole spec), and only the + * top `MAX_PREVIEW_RESPONSE_FIELD_CANDIDATES` get their response schema walked for + * the typed identity field; the rest stay metadata-only (freeform identity). */ const buildPreviewHealthCheckCandidates = ( - _doc: ParsedDocument, + doc: ParsedDocument, operations: readonly ExtractedOperation[], ): HealthCheckCandidate[] => { if (operations.length === 0) return []; const definitions = compileToolDefinitions(operations); - return definitions + const ranked = definitions .map((def): HealthCheckCandidate => { const op = def.operation; const method = op.method.toLowerCase(); @@ -472,6 +484,30 @@ const buildPreviewHealthCheckCandidates = ( }) .sort(compareHealthCheckCandidates) .slice(0, MAX_PREVIEW_CANDIDATES); + + // Walk response schemas only for the top survivors (the realistic health-check + // picks). `outputSchema` is NOT pre-normalized; the hoisted `$defs` ARE, so + // normalize the schema first. The rest are returned metadata-only so the + // operation picker still lists them while keeping schema walking bounded. + const hoistedDefs: Record = {}; + const rawSchemas = doc.components?.schemas; + if (rawSchemas) { + for (const [name, schema] of Object.entries(rawSchemas)) { + hoistedDefs[name] = normalizeOpenApiRefs(schema); + } + } + const operationByToolPath = new Map(definitions.map((def) => [def.toolPath, def.operation])); + + return ranked.map((candidate, index): HealthCheckCandidate => { + if (index >= MAX_PREVIEW_RESPONSE_FIELD_CANDIDATES) return candidate; + const op = operationByToolPath.get(candidate.operation); + if (!op) return candidate; + const responseFields = projectResponseFields( + normalizeOpenApiRefs(Option.getOrUndefined(op.outputSchema)), + hoistedDefs, + ); + return responseFields.length > 0 ? { ...candidate, responseFields } : candidate; + }); }; // --------------------------------------------------------------------------- diff --git a/packages/react/src/components/accounts-section.tsx b/packages/react/src/components/accounts-section.tsx index 610f2d036..45f136296 100644 --- a/packages/react/src/components/accounts-section.tsx +++ b/packages/react/src/components/accounts-section.tsx @@ -88,10 +88,14 @@ function AccountRow(props: { const status: HealthStatus = probe?.status ?? "unknown"; const indicator = HEALTH_INDICATOR_COLOR[status]; - const displayLabel = - connection.identityLabel && connection.identityLabel.length > 0 + // Prefer a probed identity (the live account), then the stored label, then the + // connection name. The probe is the whole point: it shows WHICH account this is. + const identity = + (probe?.identity && probe.identity.length > 0 ? probe.identity : null) ?? + (connection.identityLabel && connection.identityLabel.length > 0 ? connection.identityLabel - : String(connection.name); + : null); + const displayLabel = identity ?? String(connection.name); const expired = status === "expired"; @@ -112,7 +116,9 @@ function AccountRow(props: { } setProbe(exit.value); if (exit.value.status === "healthy") { - toast.success("Connection is healthy"); + toast.success( + exit.value.identity ? `Healthy — ${exit.value.identity}` : "Connection is healthy", + ); } else if (exit.value.status === "expired") { toast.error("Connection expired — reconnect to restore access"); } else if (exit.value.status === "degraded") { diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index a5456c366..89857c1c8 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -649,7 +649,7 @@ function KeyValidationStatus(props: { ); } if (!props.result) return null; - const { status, detail } = props.result; + const { status, identity, detail } = props.result; const indicator = HEALTH_INDICATOR_COLOR[status]; const tone = status === "healthy" ? "text-muted-foreground" : "text-destructive"; return ( @@ -657,6 +657,12 @@ function KeyValidationStatus(props: { {HEALTH_STATUS_LABEL[status]} + {status === "healthy" && identity ? ( + <> + {" · "} + {identity} + + ) : null} {status !== "healthy" && detail ? {detail} : null} @@ -714,7 +720,11 @@ function AddAccountModalView(props: AddAccountModalProps) { const [validating, setValidating] = useState(false); const [validationResult, setValidationResult] = useState(null); const [hcOperation, setHcOperation] = useState(""); + const [hcIdentityField, setHcIdentityField] = useState(""); const [hcArgs, setHcArgs] = useState>({}); + // Whether the display name was auto-filled from a probed identity (so a later + // probe may overwrite it, but a hand-typed name is never clobbered). + const [nameAutofilled, setNameAutofilled] = useState(false); // Explicit create-time choice (no ambient owner). Cloud defaults to Personal; // local/desktop hide the picker and save to the one local workspace. const [owner, setOwner] = useState(defaultOwner); @@ -1108,12 +1118,14 @@ function AddAccountModalView(props: AddAccountModalProps) { let inlineSpec: HealthCheckSpec | undefined; if (!hasHealthCheck) { if (hcOperation.length === 0 || hcMissingRequired) return; + const identityPath = hcIdentityField.trim(); const argEntries = Object.entries(hcArgs) .map(([key, value]) => [key, value.trim()] as const) .filter(([, value]) => value.length > 0); inlineSpec = { operation: hcOperation, ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}), + ...(identityPath.length > 0 ? { identityField: identityPath } : {}), }; } setValidating(true); @@ -1146,6 +1158,18 @@ function AddAccountModalView(props: AddAccountModalProps) { }); if (Exit.isSuccess(saved)) toast.success("Saved as this integration's health check"); } + // Derive the connection name from the probed identity, unless the user + // hand-typed one (only fill when empty or a prior auto-fill). + const probedIdentity = result.identity?.trim(); + if ( + result.status === "healthy" && + probedIdentity && + probedIdentity.length > 0 && + (label.trim().length === 0 || nameAutofilled) + ) { + setLabel(probedIdentity); + setNameAutofilled(true); + } }; const handleOAuthConnect = async () => { @@ -1421,14 +1445,22 @@ function AddAccountModalView(props: AddAccountModalProps) { ) => setLabel(e.target.value)} + onChange={(e: React.ChangeEvent) => { + setLabel(e.target.value); + // A hand-typed name takes over: a later probe won't overwrite it. + setNameAutofilled(false); + }} />

This connection will be callable as{" "} @@ -1649,6 +1681,12 @@ function AddAccountModalView(props: AddAccountModalProps) { onOperationChange={(next) => { setHcOperation(next); setHcArgs({}); + setHcIdentityField(""); + clearKeyCheck(); + }} + identityField={hcIdentityField} + onIdentityFieldChange={(path) => { + setHcIdentityField(path); clearKeyCheck(); }} args={hcArgs} diff --git a/packages/react/src/components/health-check-editor.tsx b/packages/react/src/components/health-check-editor.tsx index d5efe9deb..0372e0c35 100644 --- a/packages/react/src/components/health-check-editor.tsx +++ b/packages/react/src/components/health-check-editor.tsx @@ -3,9 +3,13 @@ import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as Exit from "effect/Exit"; import { + type AuthTemplateSlug, type HealthCheckCandidate, + type HealthCheckResult, type HealthCheckSpec, + type HealthStatus, type IntegrationSlug, + type Owner, } from "@executor-js/sdk/shared"; import { toast } from "sonner"; @@ -13,6 +17,7 @@ import { integrationHealthCheckAtom, integrationHealthCheckCandidatesAtom, setIntegrationHealthCheck, + validateConnection, } from "../api/atoms"; import { healthCheckWriteKeys } from "../api/reactivity-keys"; import { messageFromExit } from "../api/error-reporting"; @@ -20,6 +25,7 @@ import { Button } from "./button"; import { FreeformCombobox, type FreeformComboboxOption } from "./combobox"; import { Input } from "./input"; import { Label } from "./label"; +import { NativeSelect, NativeSelectOption } from "./native-select"; import { Sheet, SheetContent, @@ -31,19 +37,38 @@ import { // --------------------------------------------------------------------------- // Health-check editor: the integration-level configuration for "what single -// authenticated call tells us a connection is still alive". It is the same shape -// as configuring an auth method: pick one of the integration's operations (ranked -// so the obvious read-only endpoint floats up) and optionally pin the required -// arguments (Google's People API needs `resourceName=people/me`). +// authenticated call tells us a connection is still alive and (optionally) whose +// account it is". It is the same shape as configuring an auth method: pick one of +// the integration's operations (ranked so the obvious read-only identity endpoint +// floats up), optionally pin the required arguments (Google's People API needs +// `resourceName=people/me`), and optionally name a response field to show as the +// account identity. // -// The check is run per-connection elsewhere (the AccountRow "Check now" probe); -// this surface only declares it. +// Identity is a facet, not the point: with no identity field the check degrades +// to a pure "alive / expired" probe. The typed identity picker and the live +// preview (a real probe against a pasted test key, showing the response) exist so +// the user can SEE what the operation returns and pick the right field. +// +// The check is run per-connection elsewhere (the AccountRow "Check now" probe and +// the key-first connect validation); this surface only declares it. // // It hides itself when the owning integration exposes no candidate operations AND // none is configured (i.e. the plugin has no health-check capability), so it // costs nothing on integrations that can't support it. // --------------------------------------------------------------------------- +/** Context the edit sheet needs to run a live preview: which owner to probe as + * and the credential auth-templates a pasted test key can be validated against. + * Absent (or empty) on surfaces with no persisted integration yet (the add + * screen), where the preview is suppressed. */ +export interface HealthCheckLivePreview { + readonly owner: Owner; + readonly templates: ReadonlyArray<{ + readonly template: AuthTemplateSlug; + readonly label: string; + }>; +} + /** "GET /users/me" style label for a candidate, with a writes marker so a * mutating operation picked as a health check reads as the hazard it is. */ const candidateLabel = (candidate: HealthCheckCandidate): string => { @@ -61,10 +86,24 @@ const specSummary = ( return match ? `${match.method.toUpperCase()} ${spec.operation}` : spec.operation; }; +const STATUS_LABEL: Record = { + healthy: "Healthy", + expired: "Expired", + degraded: "Degraded", + unknown: "Unknown", +}; + +const STATUS_CLASS: Record = { + healthy: "text-emerald-600 dark:text-emerald-400", + expired: "text-destructive", + degraded: "text-amber-600 dark:text-amber-500", + unknown: "text-muted-foreground", +}; + // --------------------------------------------------------------------------- -// HealthCheckConfigFields: the presentational operation + pinned-arg form, -// shared by the edit sheet and the add-integration screen so the two stay in -// lockstep. State (and the `selected` candidate) is owned by the parent; this +// HealthCheckConfigFields: the presentational operation + identity + pinned-arg +// form, shared by the edit sheet and the add-integration screen so the two stay +// in lockstep. State (and the `selected` candidate) is owned by the parent; this // component only renders and reports edits. // --------------------------------------------------------------------------- @@ -73,12 +112,14 @@ function HealthCheckConfigFields(props: { readonly selected: HealthCheckCandidate | null; readonly operation: string; readonly onOperationChange: (operation: string) => void; + readonly identityField: string; + readonly onIdentityFieldChange: (path: string) => void; readonly args: Record; readonly onArgChange: (name: string, value: string) => void; readonly disabled?: boolean; readonly idPrefix: string; }) { - const { candidates, selected, operation, args, disabled, idPrefix } = props; + const { candidates, selected, operation, identityField, args, disabled, idPrefix } = props; const operationOptions = useMemo( () => @@ -90,6 +131,25 @@ function HealthCheckConfigFields(props: { [candidates], ); + // The identity picker is a typed combobox over the operation's response fields, + // with a leading "None" that clears the field (pure health check). It stays + // freeform so a custom dot-path the projector missed is still reachable. + const identityOptions = useMemo( + () => [ + { + value: "", + label: "None - health check only", + description: "Status only, no account identity", + }, + ...(selected?.responseFields ?? []).map((f) => ({ + value: f.path, + label: f.path, + description: f.type, + })), + ], + [selected], + ); + const requiredParams = useMemo( () => (selected?.parameters ?? []).filter((p) => p.required), [selected], @@ -123,7 +183,7 @@ function HealthCheckConfigFields(props: {

Required arguments

- Pinned into every probe. A liveness endpoint often needs a fixed value here (for + Pinned into every probe. An identity endpoint often needs a fixed value here (for example resourceName ={" "} people/me).

@@ -152,21 +212,188 @@ function HealthCheckConfigFields(props: { ))}
) : null} + +
+ + +

+ Optional. Pick a response field whose value labels the connected account ( + user.login,{" "} + emailAddresses.0.value). Leave as{" "} + None for a pure health check. Numeric segments index + arrays. +

+
); } +// --------------------------------------------------------------------------- +// Live preview: runs the drafted check against a pasted test key WITHOUT +// saving it (the same `validateConnection` probe the key-first connect uses), +// then shows the real response so the user can confirm the status and see which +// field carries the identity. Edit-sheet only: it needs a persisted integration +// and a credential template, neither of which exists on the add screen. +// --------------------------------------------------------------------------- + +function HealthCheckLivePreviewBlock(props: { + readonly integration: IntegrationSlug; + readonly preview: HealthCheckLivePreview; + readonly operation: string; + readonly identityField: string; + readonly args: Record; + readonly disabled: boolean; +}) { + const { integration, preview, operation, identityField, args, disabled } = props; + const doValidate = useAtomSet(validateConnection, { mode: "promiseExit" }); + + const [value, setValue] = useState(""); + const [template, setTemplate] = useState(preview.templates[0]?.template ?? ""); + const [running, setRunning] = useState(false); + const [result, setResult] = useState(null); + + const handlePreview = async () => { + const credential = value.trim(); + if (operation.length === 0 || credential.length === 0) return; + const slug = (template || preview.templates[0]?.template) as AuthTemplateSlug | undefined; + if (slug === undefined) return; + const identity = identityField.trim(); + const argEntries = Object.entries(args) + .map(([key, v]) => [key, v.trim()] as const) + .filter(([, v]) => v.length > 0); + const spec: HealthCheckSpec = { + operation, + ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}), + ...(identity.length > 0 ? { identityField: identity } : {}), + }; + setRunning(true); + const exit = await doValidate({ + payload: { owner: preview.owner, integration, template: slug, value: credential, spec }, + }); + setRunning(false); + if (Exit.isFailure(exit)) { + setResult(null); + toast.error(messageFromExit(exit, "Couldn't run the preview")); + return; + } + setResult(exit.value); + }; + + return ( +
+
+

Live preview

+

+ Paste a test credential to run this check now. Nothing is saved; the response below is + what the operation returns. +

+
+ + {preview.templates.length > 1 ? ( +
+ + ) => setTemplate(e.target.value)} + > + {preview.templates.map((t) => ( + + {t.label} + + ))} + +
+ ) : null} + +
+ +
+ ) => setValue(e.target.value)} + placeholder="Paste a key to probe" + disabled={disabled || running} + className="flex-1" + /> + +
+
+ + {result ? ( +
+

+ Status:{" "} + + {STATUS_LABEL[result.status]} + + {result.httpStatus !== undefined ? ( + + (HTTP {result.httpStatus}) + + ) : null} +

+ {identityField.trim().length > 0 ? ( +

+ Resolves to:{" "} + {result.identity ? ( + {result.identity} + ) : ( + no value at this field + )} +

+ ) : null} + {result.detail ?

{result.detail}

: null} + {result.responseSample && result.responseSample.length > 0 ? ( +
+

Response

+
+ {result.responseSample.map((row) => ( +
+
{row.path}
+
{row.value}
+
+ ))} +
+
+ ) : null} +
+ ) : null} +
+ ); +} + function HealthCheckEditorSheet(props: { readonly integration: IntegrationSlug; readonly spec: HealthCheckSpec | null; readonly candidates: readonly HealthCheckCandidate[]; + readonly livePreview?: HealthCheckLivePreview; readonly open: boolean; readonly onOpenChange: (open: boolean) => void; }) { - const { integration, spec, candidates, open, onOpenChange } = props; + const { integration, spec, candidates, livePreview, open, onOpenChange } = props; const doSet = useAtomSet(setIntegrationHealthCheck, { mode: "promiseExit" }); const [operation, setOperation] = useState(""); + const [identityField, setIdentityField] = useState(""); // Pinned argument values keyed by parameter name; stored as strings (the form // input value) and trimmed/dropped when empty at save. const [args, setArgs] = useState>({}); @@ -176,6 +403,7 @@ function HealthCheckEditorSheet(props: { useEffect(() => { if (!open) return; setOperation(spec?.operation ?? candidates[0]?.operation ?? ""); + setIdentityField(spec?.identityField ?? ""); setArgs( spec?.args ? Object.fromEntries( @@ -201,9 +429,10 @@ function HealthCheckEditorSheet(props: { const onOperationChange = (next: string) => { setOperation(next); - // Parameters differ per operation; drop the prior pick's pinned args so none - // dangle onto a new op. + // Parameters and the response shape differ per operation; drop the prior + // pick's pinned args and identity path so neither dangles onto a new op. setArgs({}); + setIdentityField(""); }; const onArgChange = (name: string, value: string) => @@ -212,12 +441,14 @@ function HealthCheckEditorSheet(props: { const handleSave = async () => { if (operation.length === 0) return; setSaving(true); + const identity = identityField.trim(); const argEntries = Object.entries(args) .map(([key, value]) => [key, value.trim()] as const) .filter(([, value]) => value.length > 0); const nextSpec: HealthCheckSpec = { operation, ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}), + ...(identity.length > 0 ? { identityField: identity } : {}), }; const exit = await doSet({ params: { slug: integration }, @@ -249,6 +480,8 @@ function HealthCheckEditorSheet(props: { onOpenChange(false); }; + const canPreview = livePreview !== undefined && livePreview.templates.length > 0; + return ( // Non-modal: a modal sheet's `react-remove-scroll` locks the wheel to the // sheet subtree, so the operation combobox's portaled popup can't scroll. @@ -258,7 +491,7 @@ function HealthCheckEditorSheet(props: { Health check One read-only call Executor runs to tell whether a connection's credential is still - alive. A 401 or 403 marks the connection expired. + alive and, optionally, whose account it is. A 401 or 403 marks the connection expired. @@ -268,11 +501,24 @@ function HealthCheckEditorSheet(props: { selected={selected} operation={operation} onOperationChange={onOperationChange} + identityField={identityField} + onIdentityFieldChange={setIdentityField} args={args} onArgChange={onArgChange} disabled={saving} idPrefix="health-check" /> + + {canPreview ? ( + + ) : null} @@ -296,8 +542,11 @@ function HealthCheckEditorSheet(props: { ); } -export function HealthCheckEditor(props: { readonly integration: IntegrationSlug }) { - const { integration } = props; +export function HealthCheckEditor(props: { + readonly integration: IntegrationSlug; + readonly livePreview?: HealthCheckLivePreview; +}) { + const { integration, livePreview } = props; const specResult = useAtomValue(integrationHealthCheckAtom(integration)); const candidatesResult = useAtomValue(integrationHealthCheckCandidatesAtom(integration)); const [open, setOpen] = useState(false); @@ -317,15 +566,27 @@ export function HealthCheckEditor(props: { readonly integration: IntegrationSlug

Health check

- A read-only call Executor runs to tell whether a connection's credential is still alive. + A read-only call Executor runs to tell whether a connection's credential is still alive + and, optionally, whose account it is.

{spec ? ( -

- {specSummary(spec, candidates)} -

+ <> +

+ {specSummary(spec, candidates)} +

+ {spec.identityField ? ( +

+ Identity: {spec.identityField} +

+ ) : ( +

+ Health check only (no identity). +

+ )} + ) : (

No health check configured.

)} @@ -338,6 +599,7 @@ export function HealthCheckEditor(props: { readonly integration: IntegrationSlug integration={integration} spec={spec} candidates={candidates} + livePreview={livePreview} open={open} onOpenChange={setOpen} /> @@ -346,5 +608,5 @@ export function HealthCheckEditor(props: { readonly integration: IntegrationSlug } // Re-exported so the add-integration screen can compose the same operation + -// pinned-arg form without the sheet chrome. +// identity form without the sheet/live-preview chrome. export { HealthCheckConfigFields };