1570 lines · 58.2 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,  ConfiguredHeaderBinding,  OAuth2Auth,  OAuth2SourceConfig,  OpenApiOAuthSession,  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 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, OpenApiHeaderInput>;  readonly oauth2?: OpenApiOAuthInput;} export interface OpenApiUpdateSourceInput {  readonly name?: string;  readonly baseUrl?: string;  readonly headers?: Record<string, OpenApiHeaderInput>;  /** Refresh the source's stored OAuth2 metadata after a successful   *  re-authenticate. */  readonly oauth2?: OpenApiOAuthInput;} // ---------------------------------------------------------------------------// 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[];  /** Stable logical Connection id this source should resolve at invoke time.   *  Physical ownership still comes from `tokenScope`; the same id can   *  have separate rows at user/org scopes. Defaults to the legacy   *  source-derived id for compatibility. */  readonly connectionId?: string;  /**   * Source (namespace) the resulting Connection will back. Used as the   * compatibility default for the stable Connection *name* so repeat   * sign-ins refresh a single row per scope instead of spawning a   * fresh UUID every click:   *   *   clientCredentials → `openapi-oauth2-app-${sourceId}`   *   authorizationCode → `openapi-oauth2-user-${sourceId}`   *   * The resulting Connection is written at the innermost executor   * scope so per-user credentials (secrets shadowed at user scope via   * `ctx.secrets.get`'s scope-stacked resolution) and per-user consent   * (authorizationCode) both produce per-user rows. Because   * `findInnermostConnectionRow` resolves by id across the caller's   * stack, the single `source.oauth2.connectionId` string on a shared   * org source still lets every user reach their own physical row.   */  readonly sourceId: string;  /** Executor scope that will own the resulting Connection (and its   *  backing token secrets). Defaults to `ctx.scopes[0].id`. Callers   *  can override to write at a different stack scope (e.g. an admin   *  writing an org-wide shared connection). */  readonly tokenScope?: string;} const defaultOAuthConnectionId = (  flow: "authorizationCode" | "clientCredentials",  sourceId: string,): string =>  flow === "clientCredentials"    ? `openapi-oauth2-app-${sourceId}`    : `openapi-oauth2-user-${sourceId}`; 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 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>;  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}`),  );}; 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;    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,        oauth2: base.config.oauth2 ?? fallback.config.oauth2,      },      headersSource: hasBaseHeaders ? base : fallback,

Lines 1–500 of 1570.