import { Effect, Option, Schema } from "effect"; import { defineSchema, type StorageDeps, type StorageFailure, } from "@executor/sdk"; import { AnnotationPolicy, HeaderValue, InvocationConfig, OAuth2Auth, OpenApiOAuthSession, OperationBinding, } from "./types"; // --------------------------------------------------------------------------- // Schema — three tables: // - openapi_source: one row per onboarded spec (baseUrl, headers, oauth2, ...) // - openapi_operation: one row per operation binding keyed by tool id // - openapi_oauth_session: transient session rows used during oauth onboarding // --------------------------------------------------------------------------- export const openapiSchema = defineSchema({ openapi_source: { fields: { id: { type: "string", required: true }, scope_id: { type: "string", required: true, index: true }, name: { type: "string", required: true }, spec: { type: "string", required: true }, // Origin URL the spec was fetched from. Set when `addSpec` was // invoked with an http(s) URL; null when the caller passed raw // spec text. Drives `canRefresh` on the core source row and // is the address re-fetched on `refreshSource`. source_url: { type: "string", required: false }, base_url: { type: "string", required: false }, headers: { type: "json", required: false }, oauth2: { type: "json", required: false }, annotation_policy: { type: "json", required: false }, invocation_config: { type: "json", required: true }, }, }, openapi_operation: { fields: { id: { type: "string", required: true }, scope_id: { type: "string", required: true, index: true }, source_id: { type: "string", required: true, index: true }, binding: { type: "json", required: true }, }, }, openapi_oauth_session: { fields: { id: { type: "string", required: true }, scope_id: { type: "string", required: true, index: true }, session: { type: "json", required: true }, created_at: { type: "date", required: true }, }, }, }); export type OpenapiSchema = typeof openapiSchema; // --------------------------------------------------------------------------- // In-memory shapes // --------------------------------------------------------------------------- export interface SourceConfig { readonly spec: string; /** Origin URL when the spec was fetched from http(s). Absent for * raw-text adds. Persisted so `refreshSource` can re-fetch. */ readonly sourceUrl?: string; readonly baseUrl?: string; readonly namespace?: string; readonly headers?: Record; readonly oauth2?: OAuth2Auth; readonly annotationPolicy?: AnnotationPolicy; } export interface StoredSource { readonly namespace: string; /** Executor scope id this source row lives in. Writes stamp this on * `scope_id`; reads return whichever scope's row the adapter's * fall-through filter sees first. */ readonly scope: string; readonly name: string; readonly config: SourceConfig; readonly invocationConfig: InvocationConfig; } // --------------------------------------------------------------------------- // Schema-class mirror of StoredSource for the API layer, where we need // an encodable/decodable shape for HTTP responses. // --------------------------------------------------------------------------- export class StoredSourceSchema extends Schema.Class( "OpenApiStoredSource", )({ namespace: Schema.String, name: Schema.String, config: Schema.Struct({ spec: Schema.String, sourceUrl: Schema.optional(Schema.String), baseUrl: Schema.optional(Schema.String), namespace: Schema.optional(Schema.String), headers: Schema.optional( Schema.Record({ key: Schema.String, value: HeaderValue }), ), // Exposed so the UI can render a per-user "Connections" pane for // sources onboarded with an OAuth2 preset — one OAuth2Auth per // securitySchemeName, with the secret ids that back its tokens. oauth2: Schema.optional(OAuth2Auth), }), invocationConfig: Schema.optional(InvocationConfig), }) {} export type StoredSourceSchemaType = typeof StoredSourceSchema.Type; export interface StoredOperation { readonly toolId: string; readonly sourceId: string; readonly binding: OperationBinding; } // --------------------------------------------------------------------------- // Schema encode/decode — OperationBinding has Option fields, so we must use // Schema.encode/decode rather than plain JSON to round-trip correctly. // --------------------------------------------------------------------------- const encodeBinding = Schema.encodeSync(OperationBinding); const decodeBinding = Schema.decodeUnknownSync(OperationBinding); const encodeInvocationConfig = Schema.encodeSync(InvocationConfig); const decodeInvocationConfig = Schema.decodeUnknownSync(InvocationConfig); const encodeOAuth2 = Schema.encodeSync(OAuth2Auth); const decodeOAuth2 = Schema.decodeUnknownSync(OAuth2Auth); const encodeOAuthSession = Schema.encodeSync(OpenApiOAuthSession); const decodeOAuthSession = Schema.decodeUnknownSync(OpenApiOAuthSession); const asJsonObject = (value: unknown): Record => { if (value == null) return {}; if (typeof value === "string") return JSON.parse(value) as Record; return value as Record; }; const decodeHeaders = (value: unknown): Record => { if (value == null) return {}; if (typeof value === "string") return JSON.parse(value) as Record; return value as Record; }; // --------------------------------------------------------------------------- // Store interface // --------------------------------------------------------------------------- // Every method routes through the typed adapter (`ctx.storage.adapter`) // so the typed error channel is `StorageFailure`. Schema-decode failures // inside `Effect.gen` land as defects, not typed errors, and are caught // by the HTTP edge's observability middleware. // // Every read/write that targets a single row pins BOTH the natural id // (namespace, toolId, sessionId) AND the owning `scope_id`. The store // runs behind the scoped adapter (which auto-injects `scope_id IN // (stack)`), so a bare `{id}` filter resolves to any matching row in // the stack in adapter-iteration order. For shadowed rows (same id at // multiple scopes — e.g. an org-level openapi source with a per-user // override), that's a scope-isolation bug: updates and deletes can // land on the wrong scope's row. Callers thread the resolved scope in // (typically `path.scopeId` for HTTP, `toolRow.scope_id` / // `input.scope` for invokeTool/lifecycle) so every keyed mutation // targets exactly one row. export interface OpenapiStore { readonly upsertSource: ( input: StoredSource, operations: readonly StoredOperation[], ) => Effect.Effect; readonly updateSourceMeta: ( namespace: string, scope: string, patch: { readonly name?: string; readonly baseUrl?: string; readonly headers?: Record; readonly oauth2?: OAuth2Auth; }, ) => Effect.Effect; readonly getSource: ( namespace: string, scope: string, ) => Effect.Effect; readonly listSources: () => Effect.Effect; readonly getOperationByToolId: ( toolId: string, scope: string, ) => Effect.Effect; readonly listOperationsBySource: ( sourceId: string, scope: string, ) => Effect.Effect; readonly removeSource: ( namespace: string, scope: string, ) => Effect.Effect; readonly putOAuthSession: ( sessionId: string, session: OpenApiOAuthSession, ) => Effect.Effect; readonly getOAuthSession: ( sessionId: string, ) => Effect.Effect; readonly deleteOAuthSession: (sessionId: string) => Effect.Effect; } // --------------------------------------------------------------------------- // Default store implementation // --------------------------------------------------------------------------- export const makeDefaultOpenapiStore = ({ adapter, }: StorageDeps): OpenapiStore => { const rowToSource = (row: Record): StoredSource => { const oauth2Raw = row.oauth2; const oauth2 = oauth2Raw == null ? undefined : decodeOAuth2(typeof oauth2Raw === "string" ? JSON.parse(oauth2Raw) : oauth2Raw); const headers = decodeHeaders(row.headers); const invocationConfig = decodeInvocationConfig(asJsonObject(row.invocation_config)); return { namespace: row.id as string, scope: row.scope_id as string, name: row.name as string, config: { spec: row.spec as string, sourceUrl: (row.source_url as string | null | undefined) ?? undefined, baseUrl: (row.base_url as string | null | undefined) ?? undefined, headers, oauth2, }, invocationConfig, }; }; const rowToOperation = (row: Record): StoredOperation => ({ toolId: row.id as string, sourceId: row.source_id as string, binding: decodeBinding( typeof row.binding === "string" ? JSON.parse(row.binding) : row.binding, ), }); const deleteSource = (namespace: string, scope: string) => Effect.gen(function* () { yield* adapter.deleteMany({ model: "openapi_operation", where: [ { field: "source_id", value: namespace }, { field: "scope_id", value: scope }, ], }); yield* adapter.delete({ model: "openapi_source", where: [ { field: "id", value: namespace }, { field: "scope_id", value: scope }, ], }); }); return { upsertSource: (input, operations) => Effect.gen(function* () { yield* deleteSource(input.namespace, input.scope); yield* adapter.create({ model: "openapi_source", data: { id: input.namespace, scope_id: input.scope, name: input.name, spec: input.config.spec, source_url: input.config.sourceUrl ?? undefined, base_url: input.config.baseUrl ?? undefined, headers: (input.config.headers ?? {}) as unknown as Record, oauth2: input.config.oauth2 ? (encodeOAuth2(input.config.oauth2) as unknown as Record) : undefined, invocation_config: encodeInvocationConfig( input.invocationConfig, ) as unknown as Record, }, forceAllowId: true, }); if (operations.length > 0) { yield* adapter.createMany({ model: "openapi_operation", data: operations.map((op) => ({ id: op.toolId, scope_id: input.scope, source_id: op.sourceId, binding: encodeBinding(op.binding) as unknown as Record, })), forceAllowId: true, }); } }), updateSourceMeta: (namespace, scope, patch) => Effect.gen(function* () { const existingRow = yield* adapter.findOne({ model: "openapi_source", where: [ { field: "id", value: namespace }, { field: "scope_id", value: scope }, ], }); if (!existingRow) return; const existing = rowToSource(existingRow); const nextName = patch.name?.trim() || existing.name; const nextBaseUrl = patch.baseUrl !== undefined ? patch.baseUrl : existing.config.baseUrl; const nextHeaders = patch.headers !== undefined ? patch.headers : existing.config.headers ?? {}; const nextOAuth2 = patch.oauth2 !== undefined ? patch.oauth2 : existing.config.oauth2; const nextInvocationConfig = new InvocationConfig({ baseUrl: nextBaseUrl ?? existing.invocationConfig.baseUrl, headers: nextHeaders, oauth2: nextOAuth2 ? Option.some(nextOAuth2) : Option.none(), }); yield* adapter.update({ model: "openapi_source", where: [ { field: "id", value: namespace }, { field: "scope_id", value: scope }, ], update: { name: nextName, base_url: nextBaseUrl ?? undefined, headers: nextHeaders as unknown as Record, oauth2: nextOAuth2 ? (encodeOAuth2(nextOAuth2) as unknown as Record) : undefined, invocation_config: encodeInvocationConfig( nextInvocationConfig, ) as unknown as Record, }, }); }), getSource: (namespace, scope) => adapter .findOne({ model: "openapi_source", where: [ { field: "id", value: namespace }, { field: "scope_id", value: scope }, ], }) .pipe(Effect.map((row) => (row ? rowToSource(row) : null))), listSources: () => adapter .findMany({ model: "openapi_source" }) .pipe(Effect.map((rows) => rows.map(rowToSource))), getOperationByToolId: (toolId, scope) => adapter .findOne({ model: "openapi_operation", where: [ { field: "id", value: toolId }, { field: "scope_id", value: scope }, ], }) .pipe(Effect.map((row) => (row ? rowToOperation(row) : null))), listOperationsBySource: (sourceId, scope) => adapter .findMany({ model: "openapi_operation", where: [ { field: "source_id", value: sourceId }, { field: "scope_id", value: scope }, ], }) .pipe(Effect.map((rows) => rows.map(rowToOperation))), removeSource: (namespace, scope) => deleteSource(namespace, scope), putOAuthSession: (sessionId, session) => Effect.gen(function* () { // Defensive overwrite — sessionIds are UUIDs so collisions are // negligible, but pin to the target scope so a hypothetical // collision with a session in another scope of this stack // can't wipe the wrong row. yield* adapter.delete({ model: "openapi_oauth_session", where: [ { field: "id", value: sessionId }, { field: "scope_id", value: session.tokenScope }, ], }); yield* adapter.create({ model: "openapi_oauth_session", data: { id: sessionId, // Session row lives at the same scope the completed tokens // will land in — keeps in-flight sessions visible only to // the executor that started them (user stacks can see // their own sessions but not another user's). scope_id: session.tokenScope, session: encodeOAuthSession(session) as unknown as Record, created_at: new Date(), }, forceAllowId: true, }); }), getOAuthSession: (sessionId) => adapter .findOne({ model: "openapi_oauth_session", where: [{ field: "id", value: sessionId }], }) .pipe( Effect.map((row) => { if (!row) return null; const raw = row.session; return decodeOAuthSession( typeof raw === "string" ? JSON.parse(raw) : raw, ); }), ), deleteOAuthSession: (sessionId) => adapter .delete({ model: "openapi_oauth_session", where: [{ field: "id", value: sessionId }], }) .pipe(Effect.asVoid), }; };