1081 lines · 38.5 KB · TypeScript
RawDownload
import { Effect, Option, Schema } from "effect";import type { Layer } from "effect";import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { OpenApiGroup } from "../api/group";import { OpenApiExtensionService, OpenApiHandlers } from "../api/handlers"; import {  ConnectionId,  ScopeId,  SecretId,  SourceDetectionResult,  definePlugin,  resolveSecretBackedMap,  type PluginCtx,  type StorageFailure,  type ToolAnnotations,  type ToolRow,} from "@executor-js/sdk/core"; import {  headersToConfigValues,  type ConfigFileSink,  type OpenApiSourceConfig,} from "@executor-js/config"; import { OpenApiExtractionError, OpenApiOAuthError, OpenApiParseError } from "./errors";import { parse, resolveSpecText } from "./parse";import { extract } from "./extract";import { compileToolDefinitions, type ToolDefinition } from "./definitions";import { annotationsForOperation, invokeWithLayer, resolveHeaders } from "./invoke";import { resolveBaseUrl } from "./openapi-utils";import { previewSpec, SpecPreview } from "./preview";import {  makeDefaultOpenapiStore,  openapiSchema,  type OpenapiStore,  type SourceConfig,  type StoredOperation,  type StoredSource,} from "./store";import {  HeaderValue as HeaderValueSchema,  ConfiguredHeaderBinding,  OAuth2Auth,  OAuth2SourceConfig,  OpenApiSourceBindingInput,  type OpenApiSourceBindingRef,  type OpenApiSourceBindingValue,  OperationBinding,  type ConfiguredHeaderValue as ConfiguredHeaderValueValue,  type HeaderValue as HeaderValueValue,} from "./types"; // ---------------------------------------------------------------------------// Plugin config// --------------------------------------------------------------------------- export type HeaderValue = HeaderValueValue;export type ConfiguredHeaderValue = ConfiguredHeaderValueValue;export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue;export type OpenApiOAuthInput = OAuth2Auth | OAuth2SourceConfig; export interface OpenApiSpecFetchCredentials {  readonly headers?: Record<string, HeaderValue>;  readonly queryParams?: Record<string, HeaderValue>;} export interface OpenApiPreviewInput {  readonly spec: string;  readonly specFetchCredentials?: OpenApiSpecFetchCredentials;} export interface OpenApiSpecConfig {  readonly spec: string;  readonly specFetchCredentials?: OpenApiSpecFetchCredentials;  /**   * Executor scope id that owns this source row. Must be one of the   * executor's configured scopes. Typical shape: an admin adds the   * source at the outermost (organization) scope so it's visible to   * every inner (per-user) scope via fall-through reads.   */  readonly scope: string;  readonly name?: string;  readonly baseUrl?: string;  readonly namespace?: string;  readonly headers?: Record<string, OpenApiHeaderInput>;  readonly queryParams?: Record<string, HeaderValue>;  readonly oauth2?: OpenApiOAuthInput;} export interface OpenApiUpdateSourceInput {  readonly name?: string;  readonly baseUrl?: string;  readonly headers?: Record<string, OpenApiHeaderInput>;  readonly queryParams?: Record<string, HeaderValue>;  /** Refresh the source's stored OAuth2 metadata after a successful   *  re-authenticate. */  readonly oauth2?: OpenApiOAuthInput;} /** * Errors any OpenAPI extension method may surface. The first three are * plugin-domain tagged errors that flow directly to clients (4xx, each * carrying its own `HttpApiSchema` status). `StorageFailure` covers * raw backend failures (`StorageError`) plus `UniqueViolationError`; * the HTTP edge (`@executor-js/api`'s `withCapture`) translates * `StorageError` to the opaque `InternalError({ traceId })` at Layer * composition. `UniqueViolationError` passes through — plugins can * `Effect.catchTag` it if they want a friendlier user-facing error. */export type OpenApiExtensionFailure =  | OpenApiParseError  | OpenApiExtractionError  | OpenApiOAuthError  | StorageFailure; export interface OpenApiPluginExtension {  readonly previewSpec: (    input: string | OpenApiPreviewInput,  ) => Effect.Effect<    SpecPreview,    OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError | StorageFailure  >;  readonly addSpec: (    config: OpenApiSpecConfig,  ) => Effect.Effect<    { readonly sourceId: string; readonly toolCount: number },    OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError | StorageFailure  >;  readonly removeSpec: (namespace: string, scope: string) => Effect.Effect<void, StorageFailure>;  readonly getSource: (    namespace: string,    scope: string,  ) => Effect.Effect<StoredSource | null, StorageFailure>;  readonly updateSource: (    namespace: string,    scope: string,    input: OpenApiUpdateSourceInput,  ) => Effect.Effect<void, StorageFailure>;  readonly listSourceBindings: (    sourceId: string,    sourceScope: string,  ) => Effect.Effect<readonly OpenApiSourceBindingRef[], StorageFailure>;  readonly setSourceBinding: (    input: OpenApiSourceBindingInput,  ) => Effect.Effect<OpenApiSourceBindingRef, StorageFailure>;  readonly removeSourceBinding: (    sourceId: string,    sourceScope: string,    slot: string,    scope: string,  ) => Effect.Effect<void, StorageFailure>;} // ---------------------------------------------------------------------------// Control-tool input/output schemas// --------------------------------------------------------------------------- const PreviewSpecInputSchema = Schema.Struct({  spec: Schema.String,  specFetchCredentials: Schema.optional(    Schema.Struct({      headers: Schema.optional(Schema.Record(Schema.String, HeaderValueSchema)),      queryParams: Schema.optional(Schema.Record(Schema.String, HeaderValueSchema)),    }),  ),});type PreviewSpecInput = typeof PreviewSpecInputSchema.Type; const AddSourceInputSchema = Schema.Struct({  spec: Schema.String,  baseUrl: Schema.optional(Schema.String),  namespace: Schema.optional(Schema.String),  headers: Schema.optional(Schema.Record(Schema.String, HeaderValueSchema)),  queryParams: Schema.optional(Schema.Record(Schema.String, HeaderValueSchema)),  specFetchCredentials: Schema.optional(    Schema.Struct({      headers: Schema.optional(Schema.Record(Schema.String, HeaderValueSchema)),      queryParams: Schema.optional(Schema.Record(Schema.String, HeaderValueSchema)),    }),  ),});type AddSourceInput = typeof AddSourceInputSchema.Type; // ---------------------------------------------------------------------------// Helpers// --------------------------------------------------------------------------- /** Rewrite OpenAPI `#/components/schemas/X` refs to standard `#/$defs/X`. */const normalizeOpenApiRefs = (node: unknown): unknown => {  if (node == null || typeof node !== "object") return node;  if (Array.isArray(node)) {    let changed = false;    const out = node.map((item) => {      const n = normalizeOpenApiRefs(item);      if (n !== item) changed = true;      return n;    });    return changed ? out : node;  }   const obj = node as Record<string, unknown>;   if (typeof obj.$ref === "string") {    const match = obj.$ref.match(/^#\/components\/schemas\/(.+)$/);    if (match) return { ...obj, $ref: `#/$defs/${match[1]}` };    return obj;  }   let changed = false;  const result: Record<string, unknown> = {};  for (const [k, v] of Object.entries(obj)) {    const n = normalizeOpenApiRefs(v);    if (n !== v) changed = true;    result[k] = n;  }  return changed ? result : obj;}; const toBinding = (def: ToolDefinition): OperationBinding =>  new OperationBinding({    method: def.operation.method,    pathTemplate: def.operation.pathTemplate,    parameters: [...def.operation.parameters],    requestBody: def.operation.requestBody,  }); const descriptionFor = (def: ToolDefinition): string => {  const op = def.operation;  return Option.getOrElse(op.description, () =>    Option.getOrElse(op.summary, () => `${op.method.toUpperCase()} ${op.pathTemplate}`),  );}; const headerSlotFromName = (name: string): string =>  `header:${    name      .trim()      .toLowerCase()      .replace(/[^a-z0-9]+/g, "-")      .replace(/^-+|-+$/g, "") || "default"  }`; const oauthClientIdSlot = (securitySchemeName: string): string =>  `oauth2:${    securitySchemeName      .trim()      .toLowerCase()      .replace(/[^a-z0-9]+/g, "-")      .replace(/^-+|-+$/g, "") || "default"  }:client-id`; const oauthClientSecretSlot = (securitySchemeName: string): string =>  `oauth2:${    securitySchemeName      .trim()      .toLowerCase()      .replace(/[^a-z0-9]+/g, "-")      .replace(/^-+|-+$/g, "") || "default"  }:client-secret`; const oauthConnectionSlot = (securitySchemeName: string): string =>  `oauth2:${    securitySchemeName      .trim()      .toLowerCase()      .replace(/[^a-z0-9]+/g, "-")      .replace(/^-+|-+$/g, "") || "default"  }:connection`; const canonicalizeHeaders = (  headers: Record<string, OpenApiHeaderInput> | undefined,): {  readonly headers: Record<string, ConfiguredHeaderValue>;  readonly bindings: ReadonlyArray<{    readonly slot: string;    readonly value: OpenApiSourceBindingValue;  }>;} => {  const nextHeaders: Record<string, ConfiguredHeaderValue> = {};  const bindings: Array<{ slot: string; value: OpenApiSourceBindingValue }> = [];  for (const [name, value] of Object.entries(headers ?? {})) {    if (typeof value === "string") {      nextHeaders[name] = value;      continue;    }    if ("kind" in value) {      nextHeaders[name] = value;      continue;    }    const slot = headerSlotFromName(name);    nextHeaders[name] = new ConfiguredHeaderBinding({      kind: "binding",      slot,      prefix: value.prefix,    });    bindings.push({      slot,      value: {        kind: "secret",        secretId: SecretId.make(value.secretId),      },    });  }  return { headers: nextHeaders, bindings };}; const canonicalizeOAuth2 = (  oauth2: OpenApiOAuthInput | undefined,): {  readonly oauth2?: OAuth2SourceConfig;  readonly bindings: ReadonlyArray<{    readonly slot: string;    readonly value: OpenApiSourceBindingValue;  }>;} => {  if (!oauth2) return { bindings: [] };  if ("connectionSlot" in oauth2) {    return { oauth2, bindings: [] };  }  const bindings: Array<{ slot: string; value: OpenApiSourceBindingValue }> = [    {      slot: oauthClientIdSlot(oauth2.securitySchemeName),      value: {        kind: "secret",        secretId: SecretId.make(oauth2.clientIdSecretId),      },    },  ];  if (oauth2.clientSecretSecretId) {    bindings.push({      slot: oauthClientSecretSlot(oauth2.securitySchemeName),      value: {        kind: "secret",        secretId: SecretId.make(oauth2.clientSecretSecretId),      },    });  }  if (oauth2.connectionId) {    bindings.push({      slot: oauthConnectionSlot(oauth2.securitySchemeName),      value: {        kind: "connection",        connectionId: ConnectionId.make(oauth2.connectionId),      },    });  }  return {    oauth2: new OAuth2SourceConfig({      kind: "oauth2",      securitySchemeName: oauth2.securitySchemeName,      flow: oauth2.flow,      tokenUrl: oauth2.tokenUrl,      authorizationUrl: oauth2.authorizationUrl,      clientIdSlot: oauthClientIdSlot(oauth2.securitySchemeName),      clientSecretSlot: oauth2.clientSecretSecretId        ? oauthClientSecretSlot(oauth2.securitySchemeName)        : null,      connectionSlot: oauthConnectionSlot(oauth2.securitySchemeName),      scopes: [...oauth2.scopes],    }),    bindings,  };}; interface EffectiveSourceConfig {  readonly config: SourceConfig;  readonly headersSource: StoredSource;  readonly oauth2Source: StoredSource;} const resolveEffectiveSourceConfig = (  ctx: PluginCtx<OpenapiStore>,  base: StoredSource,): Effect.Effect<EffectiveSourceConfig, StorageFailure> =>  Effect.gen(function* () {    const rank = new Map(ctx.scopes.map((scope, index) => [scope.id as string, index] as const));    const baseRank = rank.get(base.scope) ?? Infinity;    let fallback: StoredSource | null = null;    for (let index = baseRank + 1; index < ctx.scopes.length; index++) {      const scope = ctx.scopes[index];      if (!scope) continue;      fallback = yield* ctx.storage.getSource(base.namespace, scope.id as string);      if (fallback) break;    }     if (!fallback) {      return {        config: base.config,        headersSource: base,        oauth2Source: base,      };    }     const hasBaseHeaders = Object.keys(base.config.headers ?? {}).length > 0;    const hasBaseQueryParams = Object.keys(base.config.queryParams ?? {}).length > 0;    return {      config: {        ...base.config,        sourceUrl: base.config.sourceUrl ?? fallback.config.sourceUrl,        baseUrl: base.config.baseUrl || fallback.config.baseUrl,        namespace: base.config.namespace ?? fallback.config.namespace,        headers: hasBaseHeaders ? base.config.headers : fallback.config.headers,        queryParams: hasBaseQueryParams ? base.config.queryParams : fallback.config.queryParams,        specFetchCredentials:          base.config.specFetchCredentials ?? fallback.config.specFetchCredentials,        oauth2: base.config.oauth2 ?? fallback.config.oauth2,      },      headersSource: hasBaseHeaders ? base : fallback,      oauth2Source: base.config.oauth2 ? base : fallback,    };  }); const resolveConfiguredHeaders = (  ctx: PluginCtx<OpenapiStore>,  params: {    readonly sourceId: string;    readonly sourceScope: string;    readonly headers: Record<string, ConfiguredHeaderValue>;    readonly legacyHeaders?: Record<string, HeaderValue>;  },): Effect.Effect<Record<string, string>, OpenApiOAuthError | StorageFailure> =>  Effect.gen(function* () {    const resolved: Record<string, string> = {};    for (const [name, value] of Object.entries(params.headers)) {      if (typeof value === "string") {        resolved[name] = value;        continue;      }      const binding = yield* ctx.storage.resolveSourceBinding(        params.sourceId,        params.sourceScope,        value.slot,      );      if (binding?.value.kind === "secret") {        const secret = yield* ctx.secrets.get(binding.value.secretId as string).pipe(          Effect.mapError((err) =>            "_tag" in err && err._tag === "SecretOwnedByConnectionError"              ? new OpenApiOAuthError({                  message: `Secret not found for header "${name}"`,                })              : err,          ),        );        if (secret === null) {          return yield* new OpenApiOAuthError({            message: `Missing secret "${binding.value.secretId}" for header "${name}"`,          });        }        resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret;        continue;      }      if (binding?.value.kind === "text") {        resolved[name] = value.prefix ? `${value.prefix}${binding.value.text}` : binding.value.text;        continue;      }      const legacy = params.legacyHeaders?.[name];      if (legacy) {        const fallback = yield* resolveHeaders({ [name]: legacy }, ctx.secrets).pipe(          Effect.map((headers) => headers[name]!),          Effect.mapError((err) =>            err instanceof OpenApiOAuthError              ? err              : new OpenApiOAuthError({ message: err.message }),          ),        );        resolved[name] = fallback;        continue;      }      return yield* new OpenApiOAuthError({        message: `Missing binding for header "${name}"`,      });    }    return resolved;  }); const resolveHeaderValues = (  ctx: PluginCtx<OpenapiStore>,  values: Record<string, HeaderValue> | undefined,): Effect.Effect<Record<string, string>, OpenApiOAuthError | StorageFailure> =>  resolveSecretBackedMap({    values,    getSecret: ctx.secrets.get,    onMissing: (name) =>      new OpenApiOAuthError({        message: `Secret not found for "${name}"`,      }),    onError: (err, name) =>      "_tag" in err && err._tag === "SecretOwnedByConnectionError"        ? new OpenApiOAuthError({            message: `Secret not found for "${name}"`,          })        : err,  }).pipe(    Effect.mapError((err) =>      "_tag" in err && err._tag === "SecretOwnedByConnectionError"        ? new OpenApiOAuthError({ message: "Secret resolution failed" })        : err,    ),

Lines 1–500 of 1081.