851 lines · 35.2 KB · TypeScript
RawDownload
import { Effect, Option, Schema } from "effect";import type { Layer } from "effect";import { HttpClient } from "effect/unstable/http"; import {  IntegrationAlreadyExistsError,  IntegrationDetectionResult,  IntegrationNotFoundError,  IntegrationSlug,  ToolResult,  definePlugin,  mergeAuthTemplates,  sha256Hex,  tool,  type AuthMethodDescriptor,  type Integration,  type IntegrationConfig,  type IntegrationRecord,  type PluginCtx,  type StorageFailure,} from "@executor-js/sdk/core"; import { decodeOpenApiIntegrationConfig, type OpenApiIntegrationConfig } from "./config";import { OpenApiExtractionError, OpenApiOAuthError, OpenApiParseError } from "./errors";import { parse, resolveSpecText } from "./parse";import { extract } from "./extract";import { previewSpecText, type SpecPreview } from "./preview";import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth";import { openApiPresets } from "./presets";import { makeDefaultOpenapiStore, type OpenapiStore } from "./store";import type { Authentication } from "./types";import { normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types";import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";import {  compileOpenApiSpec,  invokeOpenApiBackedTool,  openApiStoredOperationsFromCompiled,  resolveOpenApiBackedAnnotations,  resolveOpenApiBackedTools,} from "./backing"; // ---------------------------------------------------------------------------// Extension input shapes// --------------------------------------------------------------------------- export type OpenApiSpecInput = typeof OpenApiSpecInputSchema.Type; export interface OpenApiPreviewInput {  readonly spec: string;} /** Add an OpenAPI integration to the catalog. The integration is the API *  surface; connections (the credentials) are attached separately and resolve *  their value through the declared `authenticationTemplate`. */export interface OpenApiSpecConfig {  readonly spec: OpenApiSpecInput;  /** The catalog slug for the new integration (the `<integration>` segment). */  readonly slug: string;  /** Display name (defaults to the spec title). */  readonly name?: string;  /** Agent-visible description (defaults to the spec's `info.description`,   *  then the title). */  readonly description?: string;  readonly baseUrl?: string;  /** Static headers applied to every request (no secret material). */  readonly headers?: Record<string, string>;  /** Static query params applied to every request. */  readonly queryParams?: Record<string, string>;  /** Auth methods a connection's value renders through - canonical   *  placements or the request-shaped authoring dialect. */  readonly authenticationTemplate?: readonly AuthenticationInput[];} export interface OpenApiExtensionFailure {  readonly _tag: string;} /** Add / merge custom auth methods onto an existing OpenAPI integration's *  `authenticationTemplate`. Mirrors the GraphQL plugin's `configure`. */export interface OpenApiConfigureInput {  /** The auth methods to add. Each entry is appended to (or, when its `slug`   *  already exists, replaces) the integration's existing template array. A   *  custom apiKey method with no `slug` is assigned a generated `custom_<id>`   *  slug that is collision-checked against the existing template. */  readonly authenticationTemplate: readonly AuthenticationInput[];  readonly mode?: "merge" | "replace";} /** What changed in the tool catalog when a spec was updated in place. Tool *  names, not addresses - the same diff applies to every connection. */export interface UpdateSpecResult {  readonly slug: IntegrationSlug;  readonly toolCount: number;  readonly addedTools: readonly string[];  readonly removedTools: readonly string[];} export interface OpenApiUpdateSpecInput {  /** New spec source. Omit to re-fetch from the integration's stored   *  `sourceUrl`. */  readonly spec?: OpenApiSpecInput;} export interface OpenApiPluginExtension {  readonly previewSpec: (    input: string | OpenApiPreviewInput,  ) => Effect.Effect<    SpecPreview,    OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError | StorageFailure  >;  readonly addSpec: (    config: OpenApiSpecConfig,  ) => Effect.Effect<    { readonly slug: IntegrationSlug; readonly toolCount: number },    | OpenApiParseError    | OpenApiExtractionError    | OpenApiOAuthError    | IntegrationAlreadyExistsError    | StorageFailure  >;  /** Re-resolve the integration's spec (from its stored source URL, or the   *  provided input) and rebuild its tools IN PLACE - connections,   *  credentials, policies, and the curated description are untouched. */  readonly updateSpec: (    slug: string,    input?: OpenApiUpdateSpecInput,  ) => Effect.Effect<    UpdateSpecResult,    | OpenApiParseError    | OpenApiExtractionError    | OpenApiOAuthError    | IntegrationNotFoundError    | StorageFailure  >;  readonly removeSpec: (slug: string) => Effect.Effect<void, StorageFailure>;  readonly getIntegration: (slug: string) => Effect.Effect<Integration | null, StorageFailure>;  /** Read the integration's full opaque config, including its   *  `authenticationTemplate`. Returns null when the integration is absent. */  readonly getConfig: (    slug: string,  ) => Effect.Effect<OpenApiIntegrationConfig | null, StorageFailure>;  /** Add / merge custom auth methods onto the integration's   *  `authenticationTemplate`. Returns the resulting template array. */  readonly configure: (    slug: string,    input: OpenApiConfigureInput,  ) => Effect.Effect<readonly Authentication[], StorageFailure>;} // ---------------------------------------------------------------------------// Control-tool input/output schemas// --------------------------------------------------------------------------- const PreviewSpecInputSchema = Schema.Struct({  spec: Schema.String,}); const StaticPreviewServerVariableSchema = Schema.Struct({  default: Schema.String,  enum: Schema.NullOr(Schema.Array(Schema.String)),  description: Schema.NullOr(Schema.String),});const StaticPreviewServerSchema = Schema.Struct({  url: Schema.String,  description: Schema.NullOr(Schema.String),  variables: Schema.NullOr(Schema.Record(Schema.String, StaticPreviewServerVariableSchema)),});const StaticPreviewOAuthAuthorizationCodeFlowSchema = Schema.Struct({  authorizationUrl: Schema.String,  tokenUrl: Schema.String,  refreshUrl: Schema.NullOr(Schema.String),  scopes: Schema.Record(Schema.String, Schema.String),});const StaticPreviewOAuthClientCredentialsFlowSchema = Schema.Struct({  tokenUrl: Schema.String,  refreshUrl: Schema.NullOr(Schema.String),  scopes: Schema.Record(Schema.String, Schema.String),});const StaticPreviewOAuthFlowsSchema = Schema.Struct({  authorizationCode: Schema.NullOr(StaticPreviewOAuthAuthorizationCodeFlowSchema),  clientCredentials: Schema.NullOr(StaticPreviewOAuthClientCredentialsFlowSchema),});const StaticPreviewSecuritySchemeSchema = Schema.Struct({  name: Schema.String,  type: Schema.Literals(["http", "apiKey", "oauth2", "openIdConnect"]),  scheme: Schema.NullOr(Schema.String),  bearerFormat: Schema.NullOr(Schema.String),  in: Schema.NullOr(Schema.Literals(["header", "query", "cookie"])),  headerName: Schema.NullOr(Schema.String),  description: Schema.NullOr(Schema.String),  flows: Schema.NullOr(StaticPreviewOAuthFlowsSchema),  openIdConnectUrl: Schema.NullOr(Schema.String),});const StaticPreviewOAuth2PresetSchema = Schema.Struct({  label: Schema.String,  securitySchemeName: Schema.String,  flow: Schema.Literals(["authorizationCode", "clientCredentials"]),  authorizationUrl: Schema.NullOr(Schema.String),  tokenUrl: Schema.String,  refreshUrl: Schema.NullOr(Schema.String),  scopes: Schema.Record(Schema.String, Schema.String),  identityScopes: Schema.Union([    Schema.Literal("auto"),    Schema.Literal(false),    Schema.Array(Schema.String),  ]),});const StaticPreviewSpecOutputSchema = Schema.Struct({  title: Schema.NullOr(Schema.String),  version: Schema.NullOr(Schema.String),  servers: Schema.Array(StaticPreviewServerSchema),  operationCount: Schema.Number,  tags: Schema.Array(Schema.String),  securitySchemes: Schema.Array(StaticPreviewSecuritySchemeSchema),  authStrategies: Schema.Array(Schema.Struct({ schemes: Schema.Array(Schema.String) })),  headerPresets: Schema.Array(    Schema.Struct({      label: Schema.String,      headers: Schema.Record(Schema.String, Schema.NullOr(Schema.String)),      secretHeaders: Schema.Array(Schema.String),    }),  ),  oauth2Presets: Schema.Array(StaticPreviewOAuth2PresetSchema),});type StaticPreviewSpecOutput = typeof StaticPreviewSpecOutputSchema.Type; const OpenApiSpecInputSchema = Schema.Union([  Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }),  Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }),]); const AuthenticationSchema = Schema.Union([  Schema.Struct({    slug: Schema.String,    kind: Schema.Literal("oauth2"),    authorizationUrl: Schema.String,    tokenUrl: Schema.String,    scopes: Schema.Array(Schema.String),  }),  // Credential methods are authored request-shaped - the ONE apikey input  // dialect: `{ type: "apiKey", headers: { Authorization: ["Bearer ",  // variable("token")] }, queryParams: { … } }`.  ApiKeyAuthTemplate,]); const AddSourceInputSchema = Schema.Struct({  spec: OpenApiSpecInputSchema,  slug: Schema.String,  description: Schema.optional(Schema.String),  baseUrl: Schema.optional(Schema.String),  headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),  queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),  authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),}); const AddSourceOutputSchema = Schema.Struct({  slug: Schema.String,  toolCount: Schema.Number,}); const PreviewSpecInputStandardSchema = Schema.toStandardSchemaV1(  Schema.toStandardJSONSchemaV1(PreviewSpecInputSchema),);const PreviewSpecOutputStandardSchema = Schema.toStandardSchemaV1(  Schema.toStandardJSONSchemaV1(StaticPreviewSpecOutputSchema),);const AddSourceInputStandardSchema = Schema.toStandardSchemaV1(  Schema.toStandardJSONSchemaV1(AddSourceInputSchema),);const AddSourceOutputStandardSchema = Schema.toStandardSchemaV1(  Schema.toStandardJSONSchemaV1(AddSourceOutputSchema),); const openApiToolFailure = (code: string, message: string, details?: unknown) =>  ToolResult.fail({    code,    message,    ...(details === undefined ? {} : { details }),  }); const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ({  title: Option.getOrNull(preview.title),  version: Option.getOrNull(preview.version),  servers: preview.servers.map((server) => ({    url: server.url,    description: Option.getOrNull(server.description),    variables: Option.getOrNull(server.variables)      ? Object.fromEntries(          Object.entries(Option.getOrNull(server.variables) ?? {}).map(([name, variable]) => [            name,            {              default: variable.default,              enum: Option.getOrNull(variable.enum),              description: Option.getOrNull(variable.description),            },          ]),        )      : null,  })),  operationCount: preview.operationCount,  tags: preview.tags,  securitySchemes: preview.securitySchemes.map((scheme) => ({    name: scheme.name,    type: scheme.type,    scheme: Option.getOrNull(scheme.scheme),    bearerFormat: Option.getOrNull(scheme.bearerFormat),    in: Option.getOrNull(scheme.in),    headerName: Option.getOrNull(scheme.headerName),    description: Option.getOrNull(scheme.description),    flows: Option.isSome(scheme.flows)      ? {          authorizationCode: Option.isSome(scheme.flows.value.authorizationCode)            ? {                authorizationUrl: scheme.flows.value.authorizationCode.value.authorizationUrl,                tokenUrl: scheme.flows.value.authorizationCode.value.tokenUrl,                refreshUrl: Option.getOrNull(scheme.flows.value.authorizationCode.value.refreshUrl),                scopes: scheme.flows.value.authorizationCode.value.scopes,              }            : null,          clientCredentials: Option.isSome(scheme.flows.value.clientCredentials)            ? {                tokenUrl: scheme.flows.value.clientCredentials.value.tokenUrl,                refreshUrl: Option.getOrNull(scheme.flows.value.clientCredentials.value.refreshUrl),                scopes: scheme.flows.value.clientCredentials.value.scopes,              }            : null,        }      : null,    openIdConnectUrl: Option.getOrNull(scheme.openIdConnectUrl),  })),  authStrategies: preview.authStrategies,  headerPresets: preview.headerPresets,  oauth2Presets: preview.oauth2Presets.map((preset) => ({    label: preset.label,    securitySchemeName: preset.securitySchemeName,    flow: preset.flow,    authorizationUrl: Option.getOrNull(preset.authorizationUrl),    tokenUrl: preset.tokenUrl,    refreshUrl: Option.getOrNull(preset.refreshUrl),    scopes: preset.scopes,    identityScopes: preset.identityScopes,  })),}); const specInputToSourceUrl = (spec: OpenApiSpecInput): string | undefined =>  spec.kind === "url" ? spec.url : undefined; // ---------------------------------------------------------------------------// Declared auth methods - project the stored `authenticationTemplate` into the// catalog's plugin-agnostic `AuthMethodDescriptor[]`. This mirrors the client's// `authMethodsFromConfig` (in the React auth-method-config module) on the// server so the catalog field is consistent. apikey/none projection comes from// the shared model; the oauth method carries the stored endpoints + scopes.// --------------------------------------------------------------------------- export const describeOpenApiAuthMethods = (  record: IntegrationRecord,): readonly AuthMethodDescriptor[] => {  const config = decodeOpenApiIntegrationConfig(record.config);  if (!config) return [];  return (config.authenticationTemplate ?? []).map(    (template: Authentication): AuthMethodDescriptor => {      if (template.kind === "oauth2") {        return {          id: String(template.slug),          label: "OAuth2",          kind: "oauth",          template: String(template.slug),          oauth: {            authorizationUrl: template.authorizationUrl,            tokenUrl: template.tokenUrl,            scopes: template.scopes,          },        };      }      return describeApiKeyAuthMethod(template);    },  );}; export const describeOpenApiIntegrationDisplay = (  record: IntegrationRecord,): { readonly url?: string } => {  const config = decodeOpenApiIntegrationConfig(record.config);  return { url: config?.baseUrl ?? config?.sourceUrl };}; // ---------------------------------------------------------------------------// Plugin factory// --------------------------------------------------------------------------- export interface OpenApiPluginOptions {  readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;} export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {  const resolveSpecForInput = (    spec: OpenApiSpecInput,    httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,  ): Effect.Effect<    {      readonly specText: string;    },    OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError  > =>    Effect.gen(function* () {      if (spec.kind === "url") {        const specText = yield* resolveSpecText(spec.url).pipe(Effect.provide(httpClientLayer));        return { specText };      }      return { specText: spec.value };    });   return {    id: "openapi" as const,    packageName: "@executor-js/plugin-openapi",    integrationPresets: openApiPresets.map((preset) => ({      id: preset.id,      name: preset.name,      summary: preset.summary,      ...(preset.url ? { url: preset.url } : {}),      ...(preset.icon ? { icon: preset.icon } : {}),      ...(preset.featured ? { featured: preset.featured } : {}),    })),    storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),     extension: (ctx: PluginCtx<OpenapiStore>) => {      const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;       const addSpec = (config: OpenApiSpecConfig) =>        Effect.gen(function* () {          // Resolve URL → text and parse BEFORE opening a transaction. Holding          // `BEGIN` across a network fetch is the Hyperdrive deadlock path.          const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer);          const compiled = yield* compileOpenApiSpec(resolved.specText);           // Defaults the add page derives from its preview, applied here so          // headless callers (MCP, API) get the same integration the UI's          // add flow would produce - see e2e/scenarios/connect-handoff.test.ts:          //   - effectiveBaseUrl: the spec's first server, used to anchor the          //     derived auth template's absolute URLs. It is NOT stored as the          //     connection baseUrl - the request host is resolved per call from          //     the operation's extracted `servers`.          //   - authenticationTemplate: the spec's declared security schemes          //     (else the Add-connection modal is a dead "No authentication"          //     end with nowhere to paste a credential)          // An explicit input always wins; for auth, an explicit EMPTY array          // means "no auth methods" and suppresses the derivation.          const explicitBaseUrl = config.baseUrl;          const needsDerivedBaseUrl = explicitBaseUrl == null;          const needsDerivedAuth = config.authenticationTemplate == null;          const preview =            needsDerivedBaseUrl || needsDerivedAuth              ? yield* previewSpecText(resolved.specText)              : undefined;          const derivedBaseUrl =            needsDerivedBaseUrl && preview ? firstBaseUrlForPreview(preview) : undefined;          const effectiveBaseUrl = explicitBaseUrl ?? (derivedBaseUrl || undefined);          const derivedAuthenticationTemplate =            needsDerivedAuth && preview              ? deriveAuthenticationTemplateFromPreview(preview, effectiveBaseUrl)              : undefined;           const slug = IntegrationSlug.make(config.slug);           // Block re-adding an existing slug. The core `integrations.register`          // primitive upserts (so boot re-registration is idempotent), but an          // explicit add must NOT silently clobber an existing integration's          // tools, connections, and policies. To add more auth, update the          // existing integration instead.          const existing = yield* ctx.core.integrations.get(slug);          if (existing) {            return yield* new IntegrationAlreadyExistsError({ slug });          }           const specHash = yield* sha256Hex(resolved.specText);           const integrationConfig: OpenApiIntegrationConfig = {            specHash,            ...(specInputToSourceUrl(config.spec) !== undefined              ? { sourceUrl: specInputToSourceUrl(config.spec) }              : {}),            // baseUrl is an optional override only. The host is otherwise            // resolved per call from the operation's `servers` (extracted from            // the spec), so we never bake a derived base URL into the config.            ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),            ...(config.headers ? { headers: config.headers } : {}),            ...(config.queryParams ? { queryParams: config.queryParams } : {}),            // Prefer the caller's explicit template; otherwise derive from the            // spec's declared security schemes.            ...(config.authenticationTemplate              ? {                  authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate),                }              : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0                ? { authenticationTemplate: derivedAuthenticationTemplate }                : {}),          };           // The spec blob is written OUTSIDE the transaction: it's

Lines 1–500 of 851.