471 lines · 20.7 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// @executor-js/sdk health-check vocabulary (browser-safe).//// A health check is a single declared, authenticated operation a connection can// 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` (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"; // ---------------------------------------------------------------------------// Status: the four states a connection can be in. `expired` is the one this// whole feature exists for (Google's 7-day dev-token revocation): the credential// authenticated fine yesterday and now returns 401/403. `degraded` covers any// other non-2xx (upstream 5xx, a 404 on a mis-picked operation); `unknown` is// "never checked / no health check configured".// --------------------------------------------------------------------------- export const HealthStatus = Schema.Literals(["healthy", "expired", "degraded", "unknown"]);export type HealthStatus = typeof HealthStatus.Type; // ---------------------------------------------------------------------------// 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 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; `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; // ---------------------------------------------------------------------------// 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 identity endpoint floats to the top.// --------------------------------------------------------------------------- export const HealthCheckCandidateParameter = Schema.Struct({  name: Schema.String,  /** Where the parameter is carried (e.g. "query", "path", "header"). */  location: Schema.String,  required: Schema.Boolean,  description: Schema.optional(Schema.String),});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,  /** HTTP method, lower-cased ("get", "post", …), for display + ranking. */  method: Schema.String,  /** How many parameters are required to call it (ranking key: fewer is better). */  requiredArgCount: Schema.Number,  /** True for mutating methods (post/put/patch/delete), ranked last and shown   *  with a warning, since a health check should be safe to run repeatedly. */  destructive: Schema.Boolean,  /** Operation summary / description for display, when known. */  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 + identity extraction behave// identically wherever a probe is interpreted.// --------------------------------------------------------------------------- /** Map an HTTP status to a health state: 2xx healthy, 401/403 expired (the auth *  wall), everything else degraded. */export const classifyHttpStatus = (status: number): HealthStatus => {  if (status >= 200 && status < 300) return "healthy";  if (status === 401 || status === 403) return "expired";  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<string, unknown>)[segment];  }  if (typeof current === "string") return current;  if (typeof current === "number" || typeof current === "boolean") return String(current);  return undefined;}; /** The best identity tier among a candidate's response fields, or -1 when its *  schema exposes nothing account-naming. Feeds the identity-aware ranking: *  a call that returns an email is a better probe than a generic list. * *  Only SINGULAR paths count: an identity field under an array segment *  (`aliases.0.creator.email`) names people in a collection, not the caller, *  and must not out-rank the actual whoami call (`user.email`). */export const candidateIdentityTier = (candidate: HealthCheckCandidate): number => {  let best = -1;  for (const field of candidate.responseFields ?? []) {    if (field.path.split(".").some((segment) => /^\d+$/.test(segment))) continue;    const tier = identityPathTier(field.path);    if (tier === -1) continue;    if (best === -1 || tier < best) best = tier;  }  return best;}; /** Sort candidates identity-first: non-destructive before destructive, then *  calls whose response carries an identity field (email beats login beats *  name), then the generic order (fewest required args, GET first, *  alphabetical). Tiers are computed once per candidate, not per comparison: *  the field walk is linear in response fields and the comparator runs *  O(n log n) times on Graph-sized specs. */export const sortHealthCheckCandidatesByIdentity = (  candidates: readonly HealthCheckCandidate[],): HealthCheckCandidate[] =>  candidates    .map((candidate) => ({ candidate, tier: candidateIdentityTier(candidate) }))    .sort((a, b) => {      if (a.candidate.destructive !== b.candidate.destructive)        return a.candidate.destructive ? 1 : -1;      if (a.tier !== b.tier) {        if (a.tier === -1) return 1;        if (b.tier === -1) return -1;        return a.tier - b.tier;      }      return compareHealthCheckCandidates(a.candidate, b.candidate);    })    .map(({ candidate }) => candidate); /** 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. */export const compareHealthCheckCandidates = (  a: HealthCheckCandidate,  b: HealthCheckCandidate,): number => {  if (a.destructive !== b.destructive) return a.destructive ? 1 : -1;  if (a.requiredArgCount !== b.requiredArgCount) return a.requiredArgCount - b.requiredArgCount;  if (a.method !== b.method) {    if (a.method === "get") return -1;    if (b.method === "get") return 1;    return a.method < b.method ? -1 : 1;  }  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> = {  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<string, unknown>,  seenRefs: ReadonlySet<string>,): {  readonly props: Map<string, unknown>;  readonly items: unknown;  readonly scalarType: string | undefined;  readonly refs: ReadonlySet<string>;} => {  const props = new Map<string, unknown>();  let items: unknown = undefined;  let scalarType: string | undefined;  const refs = new Set<string>(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<string, unknown>;     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<string, unknown>)) {        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<string, unknown> = {},): HealthCheckResponseField[] => {  const fields: HealthCheckResponseField[] = [];  const seenPaths = new Set<string>();  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<string>;  };   let frontier: Item[] = [{ node: schema, path: "", depth: 0, seenRefs: new Set<string>() }];  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<string, unknown>)) {        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;}; // ---------------------------------------------------------------------------// Identity auto-pick: choose the response field that most likely names the// account, so the connect flow can default the identity instead of asking.// Ranked by how account-naming the leaf key is (email > login/username >// name/displayName > id), shallower paths first within a tier. Returns// undefined when nothing plausible exists (pure liveness check).// --------------------------------------------------------------------------- const IDENTITY_KEY_TIERS: readonly (readonly string[])[] = [  ["email", "emailaddress", "mail", "userprincipalname"],  ["login", "username", "handle", "slug"],  ["displayname", "name", "fullname"],  ["id", "userid", "accountid"],]; /** How account-naming a dot-path's leaf key is: tier index (0 = email, best), *  or -1 when it doesn't look like an identity at all. Shared by the sample *  picker below and the candidate ranking in the connect flow. */export const identityPathTier = (path: string): number => {  const segments = path.split(".");  const named = segments.filter((segment) => !/^\d+$/.test(segment));  const leaf = (named[named.length - 1] ?? "").toLowerCase();  return IDENTITY_KEY_TIERS.findIndex((keys) => keys.includes(leaf));}; /** Order sample rows identity-first: rows whose leaf key names the account *  (email > login > name > id) come before the rest, which keep response *  order. Every surface that renders a probe response uses this, so the *  interesting fields always lead. Stable within tiers. */export const rankResponseSample = (  sample: readonly HealthCheckResponseSample[],): HealthCheckResponseSample[] =>  sample    .map((row, index) => ({ row, index, tier: identityPathTier(row.path) }))    .sort((a, b) => {      if (a.tier !== b.tier) {        if (a.tier === -1) return 1;        if (b.tier === -1) return -1;        return a.tier - b.tier;      }      return a.index - b.index;    })    .map(({ row }) => row);