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. Load more
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,