1153 lines · 42.7 KB · TypeScript
RawDownload
import { randomUUID } from "node:crypto"; import { Effect, Option, Schema } from "effect";import { FetchHttpClient, HttpClient } from "@effect/platform";import type { Layer } from "effect"; import {  OAuth2Error,  buildAuthorizationUrl,  createPkceCodeVerifier,  exchangeAuthorizationCode,  exchangeClientCredentials,  refreshAccessToken,  type OAuth2TokenResponse,} from "@executor/plugin-oauth2"; import {  ConnectionId,  ConnectionRefreshError,  CreateConnectionInput,  ScopeId,  SecretId,  SourceDetectionResult,  TokenMaterial,  definePlugin,  type ConnectionProvider,  type ConnectionRefreshInput,  type ConnectionRefreshResult,  type PluginCtx,  type StorageFailure,  type ToolAnnotations,  type ToolRow,} from "@executor/sdk"; import {  headersToConfigValues,  type ConfigFileSink,  type OpenApiSourceConfig,} from "@executor/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,  InvocationConfig,  OAuth2Auth,  OpenApiOAuthSession,  OperationBinding,  type HeaderValue as HeaderValueValue,} from "./types"; // ---------------------------------------------------------------------------// Plugin config// --------------------------------------------------------------------------- export type HeaderValue = HeaderValueValue; export interface OpenApiSpecConfig {  readonly spec: string;  /**   * 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, HeaderValue>;  readonly oauth2?: OAuth2Auth;} export interface OpenApiUpdateSourceInput {  readonly name?: string;  readonly baseUrl?: string;  readonly headers?: Record<string, HeaderValue>;  /** Rewrite the source's OAuth2Auth — typically after a successful   *  re-authenticate, to point at a freshly minted connection. */  readonly oauth2?: OAuth2Auth;} // ---------------------------------------------------------------------------// OAuth2 onboarding inputs / outputs — callers pre-decide identity knobs// (display name, scheme name, scopes, target scope) and the SDK mints a// Connection when the flow completes. The caller receives an OAuth2Auth// carrying just the resulting connection id.// --------------------------------------------------------------------------- interface StartOAuthIdentity {  readonly displayName: string;  readonly securitySchemeName: string;  readonly clientIdSecretId: string;  readonly scopes: readonly string[];  /** Executor scope that will own the resulting Connection (and its   *  backing token secrets). Defaults to `ctx.scopes[0].id`. */  readonly tokenScope?: string;} export interface StartAuthorizationCodeOAuthInput extends StartOAuthIdentity {  readonly flow: "authorizationCode";  readonly authorizationUrl: string;  readonly tokenUrl: string;  readonly redirectUrl: string;  readonly clientSecretSecretId?: string | null;} /** * RFC 6749 §4.4 has no user-interactive step. `startOAuth` exchanges * tokens inline, creates the Connection, and returns a completed * `OAuth2Auth` pointing at it. No `authorizationUrl`, no session row, * no `completeOAuth`. */export interface StartClientCredentialsOAuthInput extends StartOAuthIdentity {  readonly flow: "clientCredentials";  readonly tokenUrl: string;  /** RFC 6749 §2.3.1 — client_credentials is unusable without the secret. */  readonly clientSecretSecretId: string;} export type OpenApiStartOAuthInput =  | StartAuthorizationCodeOAuthInput  | StartClientCredentialsOAuthInput; export interface StartAuthorizationCodeOAuthResponse {  readonly flow: "authorizationCode";  readonly sessionId: string;  readonly authorizationUrl: string;  readonly scopes: readonly string[];} export interface StartClientCredentialsOAuthResponse {  readonly flow: "clientCredentials";  /** Completed auth ready to attach to the source's `OAuth2Auth`. */  readonly auth: OAuth2Auth;  readonly scopes: readonly string[];} export type OpenApiStartOAuthResponse =  | StartAuthorizationCodeOAuthResponse  | StartClientCredentialsOAuthResponse; export interface OpenApiCompleteOAuthInput {  readonly state: string;  readonly code?: string;  readonly error?: string;} /** * 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/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: (    specText: string,  ) => Effect.Effect<SpecPreview, OpenApiParseError | OpenApiExtractionError>;  readonly addSpec: (    config: OpenApiSpecConfig,  ) => Effect.Effect<    { readonly sourceId: string; readonly toolCount: number },    OpenApiParseError | OpenApiExtractionError | 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 startOAuth: (    input: OpenApiStartOAuthInput,  ) => Effect.Effect<OpenApiStartOAuthResponse, OpenApiOAuthError>;  readonly completeOAuth: (    input: OpenApiCompleteOAuthInput,  ) => Effect.Effect<OAuth2Auth, OpenApiOAuthError>;} // ---------------------------------------------------------------------------// Control-tool input/output schemas// --------------------------------------------------------------------------- const PreviewSpecInputSchema = Schema.Struct({  spec: Schema.String,});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({ key: Schema.String, value: 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}`),  );}; // ---------------------------------------------------------------------------// Connection `provider_state` shape for openapi-oauth2.//// Every field needed to re-hit the token endpoint on refresh lives here,// so the SDK's `ctx.connections.accessToken(id)` can drive both grant// types without the plugin keeping its own refresh state. The flow// literal chooses between `refresh_token` (authorizationCode) and// re-`exchange client_credentials` at refresh time. NONE of this is// sensitive — client credentials themselves still live in secrets and// are resolved via `ctx.secrets.get` inside the refresh handler.// --------------------------------------------------------------------------- const OPENAPI_OAUTH2_PROVIDER_KEY = "openapi:oauth2" as const; const OAuth2ProviderState = Schema.Struct({  flow: Schema.Literal("authorizationCode", "clientCredentials"),  tokenUrl: Schema.String,  clientIdSecretId: Schema.String,  clientSecretSecretId: Schema.NullOr(Schema.String),  scopes: Schema.Array(Schema.String),});type OAuth2ProviderState = typeof OAuth2ProviderState.Type; const encodeProviderState = Schema.encodeSync(OAuth2ProviderState);const decodeProviderState = Schema.decodeUnknownSync(OAuth2ProviderState); const toProviderStateRecord = (  state: OAuth2ProviderState,): Record<string, unknown> =>  encodeProviderState(state) as unknown as Record<string, unknown>; // ---------------------------------------------------------------------------// Plugin factory// --------------------------------------------------------------------------- export interface OpenApiPluginOptions {  readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;  /** If provided, source add/remove is mirrored to executor.jsonc   *  (best-effort — file errors are logged, not raised). */  readonly configFile?: ConfigFileSink;} const toOpenApiSourceConfig = (  namespace: string,  config: OpenApiSpecConfig,): OpenApiSourceConfig => ({  kind: "openapi",  spec: config.spec,  baseUrl: config.baseUrl,  namespace,  headers: headersToConfigValues(config.headers),}); const isHttpUrl = (s: string): boolean =>  s.startsWith("http://") || s.startsWith("https://"); export const openApiPlugin = definePlugin(  (options?: OpenApiPluginOptions) => {    const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer;     type RebuildInput = {      readonly specText: string;      readonly scope: string;      readonly sourceUrl?: string;      readonly name?: string;      readonly baseUrl?: string;      readonly namespace?: string;      readonly headers?: Record<string, HeaderValue>;      readonly oauth2?: OAuth2Auth;    };     // ctx comes from the plugin runtime — the same instance is passed to    // `extension(ctx)` and to every lifecycle hook (`refreshSource`, etc.),    // so helpers parameterised on ctx can be called from either surface.    const rebuildSource = (      ctx: PluginCtx<OpenapiStore>,      input: RebuildInput,    ) =>      Effect.gen(function* () {        const doc = yield* parse(input.specText);        const result = yield* extract(doc);         const namespace =          input.namespace ??          Option.getOrElse(result.title, () => "api")            .toLowerCase()            .replace(/[^a-z0-9]+/g, "_");         const hoistedDefs: Record<string, unknown> = {};        if (doc.components?.schemas) {          for (const [k, v] of Object.entries(doc.components.schemas)) {            hoistedDefs[k] = normalizeOpenApiRefs(v);          }        }         const baseUrl = input.baseUrl ?? resolveBaseUrl(result.servers);        const oauth2 = input.oauth2 ?? undefined;        const invocationConfig = new InvocationConfig({          baseUrl,          headers: input.headers ?? {},          oauth2: oauth2 ? Option.some(oauth2) : Option.none(),        });         const definitions = compileToolDefinitions(result.operations);        const sourceName =          input.name ?? Option.getOrElse(result.title, () => namespace);         const sourceConfig: SourceConfig = {          spec: input.specText,          sourceUrl: input.sourceUrl,          baseUrl: input.baseUrl,          namespace: input.namespace,          headers: input.headers,          oauth2,        };         const storedSource: StoredSource = {          namespace,          scope: input.scope,          name: sourceName,          config: sourceConfig,          invocationConfig,        };         const storedOps: StoredOperation[] = definitions.map((def) => ({          toolId: `${namespace}.${def.toolPath}`,          sourceId: namespace,          binding: toBinding(def),        }));         yield* ctx.transaction(          Effect.gen(function* () {            yield* ctx.storage.upsertSource(storedSource, storedOps);             yield* ctx.core.sources.register({              id: namespace,              scope: input.scope,              kind: "openapi",              name: sourceName,              url: baseUrl || undefined,              canRemove: true,              // `canRefresh` reflects whether we still know the              // origin URL — sources added from raw spec text have              // nothing to re-fetch, so refresh stays disabled.              canRefresh: input.sourceUrl != null,              canEdit: true,              tools: definitions.map((def) => ({                name: def.toolPath,                description: descriptionFor(def),                inputSchema: normalizeOpenApiRefs(                  Option.getOrUndefined(def.operation.inputSchema),                ),                outputSchema: normalizeOpenApiRefs(                  Option.getOrUndefined(def.operation.outputSchema),                ),              })),            });             if (Object.keys(hoistedDefs).length > 0) {              yield* ctx.core.definitions.register({                sourceId: namespace,                scope: input.scope,                definitions: hoistedDefs,              });            }          }),        );         return { sourceId: namespace, toolCount: definitions.length };      });     // No-op for missing sources and for sources added from raw spec    // text (no URL to re-fetch from). UIs gate the action via    // `canRefresh` on the source row; reaching here without a URL    // means the caller bypassed that gate, so we stay quiet rather    // than surface a 500 through the unwhitelisted error channel.    const refreshSourceInternal = (      ctx: PluginCtx<OpenapiStore>,      sourceId: string,      scope: string,    ) =>      Effect.gen(function* () {        const existing = yield* ctx.storage.getSource(sourceId, scope);        if (!existing) return;        const sourceUrl = existing.config.sourceUrl;        if (!sourceUrl) return;        const specText = yield* resolveSpecText(sourceUrl).pipe(          Effect.provide(httpClientLayer),        );        yield* rebuildSource(ctx, {          specText,          scope,          sourceUrl,          name: existing.name,          baseUrl: existing.config.baseUrl,          namespace: existing.namespace,          headers: existing.config.headers,          oauth2: existing.config.oauth2,        });      });     return {      id: "openapi" as const,      schema: openapiSchema,      storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),       extension: (ctx) => {        const addSpecInternal = (config: OpenApiSpecConfig) =>          Effect.gen(function* () {            // Resolve URL → text and parse BEFORE opening a transaction.            // Holding `BEGIN` on the pool=1 Postgres connection across a            // network fetch is the Hyperdrive deadlock path in production.            const specText = yield* resolveSpecText(config.spec).pipe(              Effect.provide(httpClientLayer),            );            return yield* rebuildSource(ctx, {              specText,

Lines 1–500 of 1153.