-
Edit OpenAPI Source
-
Source: {props.sourceId}
- {/* TODO: show spec info, auth config, operation list */}
-
+
+
+
Edit OpenAPI Source
+
+ Update the base URL and authentication headers for this source.
+
+
+
+
+
+
+
+
+
+ {headers.map((h, i) => (
+ updateHeader(i, update)}
+ onSelectSecret={(secretId) => updateHeader(i, { secretId })}
+ onRemove={() => { setHeaders((prev) => prev.filter((_, j) => j !== i)); setDirty(true); }}
+ existingSecrets={secretList}
+ />
+ ))}
+
+
+
+ {error && (
+
+ )}
+
+
+
+
+
);
}
+
+// ---------------------------------------------------------------------------
+// Main component
+// ---------------------------------------------------------------------------
+
+export default function EditOpenApiSource(props: {
+ sourceId: string;
+ onSave: () => void;
+}) {
+ const scopeId = useScope();
+ const sourceResult = useAtomValue(openApiSourceAtom(scopeId, props.sourceId));
+
+ if (!Result.isSuccess(sourceResult) || !sourceResult.value) {
+ return (
+
+
+
Edit OpenAPI Source
+
Loading configuration…
+
+
+ );
+ }
+
+ return
;
+}
diff --git a/packages/plugins/openapi/src/react/atoms.ts b/packages/plugins/openapi/src/react/atoms.ts
index a8bc3d2768..a39c090dc9 100644
--- a/packages/plugins/openapi/src/react/atoms.ts
+++ b/packages/plugins/openapi/src/react/atoms.ts
@@ -1,5 +1,16 @@
+import type { ScopeId } from "@executor/sdk";
import { OpenApiClient } from "./client";
+// ---------------------------------------------------------------------------
+// Query atoms
+// ---------------------------------------------------------------------------
+
+export const openApiSourceAtom = (scopeId: ScopeId, namespace: string) =>
+ OpenApiClient.query("openapi", "getSource", {
+ path: { scopeId, namespace },
+ timeToLive: "15 seconds",
+ });
+
// ---------------------------------------------------------------------------
// Mutation atoms
// ---------------------------------------------------------------------------
@@ -10,3 +21,5 @@ export const previewOpenApiSpec = OpenApiClient.mutation(
);
export const addOpenApiSpec = OpenApiClient.mutation("openapi", "addSpec");
+
+export const updateOpenApiSource = OpenApiClient.mutation("openapi", "updateSource");
diff --git a/packages/plugins/openapi/src/sdk/config-file-store.ts b/packages/plugins/openapi/src/sdk/config-file-store.ts
new file mode 100644
index 0000000000..e8a21eb805
--- /dev/null
+++ b/packages/plugins/openapi/src/sdk/config-file-store.ts
@@ -0,0 +1,69 @@
+/**
+ * Config-file wrapper for OpenApiOperationStore.
+ *
+ * Decorates an underlying store so that `putSource` and `removeSource` also
+ * write to executor.jsonc.
+ */
+
+import { Effect } from "effect";
+import { FileSystem } from "@effect/platform";
+import type { Layer } from "effect";
+
+import {
+ addSourceToConfig,
+ removeSourceFromConfig,
+ SECRET_REF_PREFIX,
+} from "@executor/config";
+import type { SourceConfig as ConfigFileSourceConfig, ConfigHeaderValue } from "@executor/config";
+
+import type { OpenApiOperationStore, StoredSource } from "./operation-store";
+
+type PluginHeaderValue = string | { readonly secretId: string; readonly prefix?: string };
+
+const translateSecretHeaders = (
+ headers: Readonly
> | undefined,
+): Record | undefined => {
+ if (!headers) return undefined;
+ const result: Record = {};
+ for (const [key, value] of Object.entries(headers)) {
+ if (typeof value === "string") {
+ result[key] = value;
+ continue;
+ }
+ const ref = `${SECRET_REF_PREFIX}${value.secretId}`;
+ result[key] = value.prefix ? { value: ref, prefix: value.prefix } : ref;
+ }
+ return result;
+};
+
+const toSourceConfig = (source: StoredSource): ConfigFileSourceConfig => ({
+ kind: "openapi",
+ spec: source.config.spec,
+ baseUrl: source.config.baseUrl,
+ namespace: source.namespace,
+ headers: translateSecretHeaders(source.config.headers),
+});
+
+export const withConfigFile = (
+ inner: OpenApiOperationStore,
+ configPath: string,
+ fsLayer: Layer.Layer,
+): OpenApiOperationStore => ({
+ ...inner,
+ putSource: (source) =>
+ Effect.gen(function* () {
+ yield* inner.putSource(source);
+ yield* addSourceToConfig(configPath, toSourceConfig(source)).pipe(
+ Effect.provide(fsLayer),
+ Effect.catchAll(() => Effect.void),
+ );
+ }),
+ removeSource: (namespace) =>
+ Effect.gen(function* () {
+ yield* inner.removeSource(namespace);
+ yield* removeSourceFromConfig(configPath, namespace).pipe(
+ Effect.provide(fsLayer),
+ Effect.catchAll(() => Effect.void),
+ );
+ }),
+});
diff --git a/packages/plugins/openapi/src/sdk/index.ts b/packages/plugins/openapi/src/sdk/index.ts
index 235578841b..3c49ea5f51 100644
--- a/packages/plugins/openapi/src/sdk/index.ts
+++ b/packages/plugins/openapi/src/sdk/index.ts
@@ -16,6 +16,7 @@ export {
makeKvOperationStore,
makeInMemoryOperationStore,
} from "./kv-operation-store";
+export { withConfigFile } from "./config-file-store";
export {
previewSpec,
SecurityScheme,
diff --git a/packages/plugins/openapi/src/sdk/kv-operation-store.ts b/packages/plugins/openapi/src/sdk/kv-operation-store.ts
index b2fc7c94c2..a70851711d 100644
--- a/packages/plugins/openapi/src/sdk/kv-operation-store.ts
+++ b/packages/plugins/openapi/src/sdk/kv-operation-store.ts
@@ -8,7 +8,8 @@ import { Effect, Schema } from "effect";
import { scopeKv, makeInMemoryScopedKv, type Kv, type ToolId, type ScopedKv } from "@executor/sdk";
import type { OpenApiOperationStore, StoredOperation, StoredSource } from "./operation-store";
-import { OperationBinding, InvocationConfig, HeaderValue } from "./types";
+import { OperationBinding, InvocationConfig } from "./types";
+import { StoredSourceSchema } from "./stored-source";
// ---------------------------------------------------------------------------
// Stored schemas
@@ -23,18 +24,6 @@ class StoredEntry extends Schema.Class("StoredEntry")({
const encodeEntry = Schema.encodeSync(Schema.parseJson(StoredEntry));
const decodeEntry = Schema.decodeUnknownSync(Schema.parseJson(StoredEntry));
-const StoredSourceSchema = Schema.Struct({
- namespace: Schema.String,
- name: Schema.String,
- config: Schema.Struct({
- spec: Schema.String,
- baseUrl: Schema.optional(Schema.String),
- namespace: Schema.optional(Schema.String),
- headers: Schema.optional(
- Schema.Record({ key: Schema.String, value: HeaderValue }),
- ),
- }),
-});
const encodeSource = Schema.encodeSync(Schema.parseJson(StoredSourceSchema));
const decodeSource = Schema.decodeUnknownSync(Schema.parseJson(StoredSourceSchema));
@@ -112,6 +101,21 @@ const makeStore = (
const entries = yield* sources.list();
return entries.map((e) => decodeSource(e.value) as StoredSource);
}),
+
+ getSource: (namespace) =>
+ Effect.gen(function* () {
+ const raw = yield* sources.get(namespace);
+ if (!raw) return null;
+ return decodeSource(raw) as StoredSource;
+ }),
+
+ getSourceConfig: (namespace) =>
+ Effect.gen(function* () {
+ const raw = yield* sources.get(namespace);
+ if (!raw) return null;
+ const source = decodeSource(raw) as StoredSource;
+ return source.config;
+ }),
});
};
diff --git a/packages/plugins/openapi/src/sdk/operation-store.ts b/packages/plugins/openapi/src/sdk/operation-store.ts
index 4f59344fbf..5ad919865f 100644
--- a/packages/plugins/openapi/src/sdk/operation-store.ts
+++ b/packages/plugins/openapi/src/sdk/operation-store.ts
@@ -45,4 +45,12 @@ export interface OpenApiOperationStore {
readonly removeSource: (namespace: string) => Effect.Effect;
readonly listSources: () => Effect.Effect;
+
+ readonly getSource: (
+ namespace: string,
+ ) => Effect.Effect;
+
+ readonly getSourceConfig: (
+ namespace: string,
+ ) => Effect.Effect;
}
diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts
index a3cf98d78e..bca8fa14ec 100644
--- a/packages/plugins/openapi/src/sdk/plugin.ts
+++ b/packages/plugins/openapi/src/sdk/plugin.ts
@@ -19,7 +19,7 @@ import { extract } from "./extract";
import { compileToolDefinitions, type ToolDefinition } from "./definitions";
import { makeOpenApiInvoker } from "./invoke";
import { resolveBaseUrl } from "./openapi-utils";
-import type { OpenApiOperationStore } from "./operation-store";
+import type { OpenApiOperationStore, StoredSource } from "./operation-store";
import { makeInMemoryOperationStore } from "./kv-operation-store";
import { previewSpec, SpecPreview } from "./preview";
import {
@@ -48,6 +48,11 @@ export interface OpenApiSpecConfig {
// Plugin extension
// ---------------------------------------------------------------------------
+export interface OpenApiUpdateSourceInput {
+ readonly baseUrl?: string;
+ readonly headers?: Record;
+}
+
export interface OpenApiPluginExtension {
/** Preview a spec without registering — returns metadata, auth strategies, header presets */
readonly previewSpec: (specText: string) => Effect.Effect;
@@ -59,6 +64,17 @@ export interface OpenApiPluginExtension {
/** Remove all tools from a previously added spec by namespace */
readonly removeSpec: (namespace: string) => Effect.Effect;
+
+ /** Fetch the full stored source by namespace (or null if missing) */
+ readonly getSource: (
+ namespace: string,
+ ) => Effect.Effect;
+
+ /** Update config (baseUrl, headers) for an existing OpenAPI source */
+ readonly updateSource: (
+ namespace: string,
+ input: OpenApiUpdateSourceInput,
+ ) => Effect.Effect;
}
// ---------------------------------------------------------------------------
@@ -190,6 +206,7 @@ export const openApiPlugin = (options?: {
runtime: false,
canRemove: true,
canRefresh: false,
+ canEdit: true,
}),
),
),
@@ -343,6 +360,48 @@ export const openApiPlugin = (options?: {
}
yield* operationStore.removeSource(namespace);
}),
+
+ getSource: (namespace: string) =>
+ operationStore.getSource(namespace),
+
+ updateSource: (namespace: string, input: OpenApiUpdateSourceInput) =>
+ Effect.gen(function* () {
+ const existingSource = yield* operationStore.getSourceConfig(namespace);
+ if (!existingSource) return;
+
+ const updatedConfig = {
+ ...existingSource,
+ ...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}),
+ ...(input.headers !== undefined ? { headers: input.headers as Record } : {}),
+ };
+
+ const newInvocationConfig = new InvocationConfig({
+ baseUrl: updatedConfig.baseUrl ?? resolveBaseUrl([]),
+ headers: (updatedConfig.headers ?? {}) as Record,
+ });
+
+ const toolIds = yield* operationStore.listByNamespace(namespace);
+ for (const toolId of toolIds) {
+ const entry = yield* operationStore.get(toolId);
+ if (entry) {
+ yield* operationStore.put([{
+ toolId,
+ namespace,
+ binding: entry.binding,
+ config: newInvocationConfig,
+ }]);
+ }
+ }
+
+ const sources = yield* operationStore.listSources();
+ const existingMeta = sources.find((s) => s.namespace === namespace);
+
+ yield* operationStore.putSource({
+ namespace,
+ name: existingMeta?.name ?? namespace,
+ config: updatedConfig,
+ });
+ }),
},
close: () => runtimeTools.close(),
diff --git a/packages/plugins/openapi/src/sdk/stored-source.ts b/packages/plugins/openapi/src/sdk/stored-source.ts
new file mode 100644
index 0000000000..2e232b53c0
--- /dev/null
+++ b/packages/plugins/openapi/src/sdk/stored-source.ts
@@ -0,0 +1,25 @@
+import { Schema } from "effect";
+
+import { HeaderValue } from "./types";
+
+// ---------------------------------------------------------------------------
+// Stored source — the shape persisted by the operation store and exposed
+// via the getSource HTTP endpoint.
+// ---------------------------------------------------------------------------
+
+export class StoredSourceSchema extends Schema.Class(
+ "OpenApiStoredSource",
+)({
+ namespace: Schema.String,
+ name: Schema.String,
+ config: Schema.Struct({
+ spec: Schema.String,
+ baseUrl: Schema.optional(Schema.String),
+ namespace: Schema.optional(Schema.String),
+ headers: Schema.optional(
+ Schema.Record({ key: Schema.String, value: HeaderValue }),
+ ),
+ }),
+}) {}
+
+export type StoredSourceSchemaType = typeof StoredSourceSchema.Type;
diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx
index 1b89875c30..a3c3ea7c35 100644
--- a/packages/react/src/api/atoms.tsx
+++ b/packages/react/src/api/atoms.tsx
@@ -76,3 +76,4 @@ export const removeSource = ExecutorApiClient.mutation("sources", "remove");
export const refreshSource = ExecutorApiClient.mutation("sources", "refresh");
export const detectSource = ExecutorApiClient.mutation("sources", "detect");
+
diff --git a/packages/react/src/pages/source-detail.tsx b/packages/react/src/pages/source-detail.tsx
index a5f6c579ab..dbd0e52cd3 100644
--- a/packages/react/src/pages/source-detail.tsx
+++ b/packages/react/src/pages/source-detail.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react";
+import { Suspense, useEffect, useMemo, useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react";
import { sourceToolsAtom, sourcesAtom, sourceAtom, removeSource, refreshSource } from "../api/atoms";
@@ -6,9 +6,13 @@ import { ToolTree } from "../components/tool-tree";
import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail";
import type { ToolSummary } from "../components/tool-tree";
import { useScope } from "../hooks/use-scope";
+import type { SourcePlugin } from "../plugins/source-plugin";
-export function SourceDetailPage(props: { namespace: string }) {
- const { namespace } = props;
+export function SourceDetailPage(props: {
+ namespace: string;
+ sourcePlugins?: readonly SourcePlugin[];
+}) {
+ const { namespace, sourcePlugins } = props;
const scopeId = useScope();
const source = useAtomValue(sourceAtom(namespace, scopeId));
const tools = useAtomValue(sourceToolsAtom(namespace, scopeId));
@@ -30,10 +34,18 @@ export function SourceDetailPage(props: { namespace: string }) {
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
const [refreshing, setRefreshing] = useState(false);
+ const [editing, setEditing] = useState(false);
const sourceData = Result.isSuccess(source) ? source.value : null;
const canRefresh = sourceData ? (sourceData.canRefresh ?? true) : false;
const canRemove = sourceData ? (sourceData.canRemove ?? true) : false;
+ const canEdit = sourceData ? (sourceData.canEdit ?? false) : false;
+
+ // Find the plugin edit component based on source kind
+ const editPlugin = useMemo(() => {
+ if (!sourceData || !sourcePlugins) return null;
+ return sourcePlugins.find((p) => p.key === sourceData.kind) ?? null;
+ }, [sourceData, sourcePlugins]);
const sourceTools: ToolSummary[] = useMemo(() => {
if (!Result.isSuccess(tools)) return [];
@@ -77,6 +89,12 @@ export function SourceDetailPage(props: { namespace: string }) {
}
};
+ const handleEditSave = () => {
+ setEditing(false);
+ refreshSources();
+ refreshTools();
+ };
+
return (
{/* Header bar */}
@@ -93,7 +111,7 @@ export function SourceDetailPage(props: { namespace: string }) {
{sourceData?.kind ?? "source"}
- {Result.isSuccess(tools) && (
+ {Result.isSuccess(tools) && !editing && (
{sourceTools.length} {sourceTools.length === 1 ? "tool" : "tools"}
@@ -101,18 +119,38 @@ export function SourceDetailPage(props: { namespace: string }) {
- {canRefresh && (
+ {canEdit && editPlugin && !editing && (
+
+ )}
+
+ {editing && (
+
+ )}
+
+ {canRefresh && !editing && (
)}
- {canRemove && (confirmDelete ? (
+ {canRemove && !editing && (confirmDelete ? (
Confirm?
@@ -131,7 +169,7 @@ export function SourceDetailPage(props: { namespace: string }) {
disabled={deleting}
className="inline-flex items-center rounded-md border border-destructive/30 bg-destructive/10 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/20 disabled:opacity-50"
>
- {deleting ? "Deleting…" : "Delete"}
+ {deleting ? "Deleting..." : "Delete"}
) : (
@@ -146,41 +184,52 @@ export function SourceDetailPage(props: { namespace: string }) {
- {/* Content — split pane */}
- {Result.match(tools, {
- onInitial: () => (
- Loading...
+ ),
+ onFailure: () => (
+
+ {/* Left: tool tree */}
+
+
- ) : (
- 0} />
- )}
+
+
+ {/* Right: tool detail */}
+
+ {selectedTool ? (
+
+ ) : (
+ 0} />
+ )}
+
-
- ),
- })}
+ ),
+ })
+ )}
);
}
diff --git a/packages/react/src/plugins/secret-header-auth.tsx b/packages/react/src/plugins/secret-header-auth.tsx
index 5afd588906..a9aca48ced 100644
--- a/packages/react/src/plugins/secret-header-auth.tsx
+++ b/packages/react/src/plugins/secret-header-auth.tsx
@@ -209,6 +209,59 @@ function HeaderValuePreview(props: {
);
}
+// ---------------------------------------------------------------------------
+// Header state helpers — shared by edit forms
+// ---------------------------------------------------------------------------
+
+export type HeaderState = {
+ name: string;
+ secretId: string | null;
+ prefix?: string;
+ presetKey?: string;
+ fromPreset?: boolean;
+};
+
+export function matchPresetKey(name: string, prefix?: string): string {
+ const preset =
+ defaultHeaderAuthPresets.find((p) => p.name === name && p.prefix === prefix)
+ ?? defaultHeaderAuthPresets.find((p) => p.name === name && p.prefix === undefined);
+ return preset?.key ?? "custom";
+}
+
+export function headerValueToState(
+ name: string,
+ value: { secretId: string; prefix?: string } | string,
+): HeaderState {
+ if (typeof value === "string") {
+ return { name, secretId: null, presetKey: matchPresetKey(name, undefined) };
+ }
+ return {
+ name,
+ secretId: value.secretId,
+ prefix: value.prefix,
+ presetKey: matchPresetKey(name, value.prefix),
+ };
+}
+
+export function headersFromState(
+ entries: readonly HeaderState[],
+): Record