From 98f93bbd9903036dfb089071a53dcb849a948992 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:33:54 +0200 Subject: [PATCH 1/7] feat(plugins): auto-generate admin settings UI from settingsSchema Plugins that declare `admin.settingsSchema` in definePlugin() now get an auto-generated settings form in the admin, reachable via a gear icon on the plugin card. Values persist to the plugin's KV store (`settings:` prefix); secrets are write-only and never echoed back. Both GET and PUT require `plugins:manage`. Fixes #341 --- .changeset/plugin-settings-schema-ui.md | 6 + .../your-first-native-plugin.mdx | 2 + .../admin/src/components/PluginManager.tsx | 15 +- .../admin/src/components/PluginSettings.tsx | 360 ++++++++++++++++++ packages/admin/src/lib/api/plugins.ts | 60 +++ packages/admin/src/router.tsx | 16 + .../tests/components/PluginManager.test.tsx | 41 +- .../tests/components/PluginSettings.test.tsx | 208 ++++++++++ packages/core/src/api/errors.ts | 2 + packages/core/src/api/handlers/index.ts | 6 + .../core/src/api/handlers/plugin-settings.ts | 210 ++++++++++ packages/core/src/api/handlers/plugins.ts | 6 + packages/core/src/astro/integration/routes.ts | 5 + .../core/src/astro/integration/runtime.ts | 3 + .../src/astro/integration/virtual-modules.ts | 2 + .../routes/api/admin/plugins/[id]/settings.ts | 84 ++++ packages/core/src/emdash-runtime.ts | 5 + .../core/src/plugins/adapt-sandbox-entry.ts | 3 + .../unit/api/plugin-settings-handlers.test.ts | 181 +++++++++ .../unit/api/plugin-settings-route.test.ts | 139 +++++++ 20 files changed, 1344 insertions(+), 10 deletions(-) create mode 100644 .changeset/plugin-settings-schema-ui.md create mode 100644 packages/admin/src/components/PluginSettings.tsx create mode 100644 packages/admin/tests/components/PluginSettings.test.tsx create mode 100644 packages/core/src/api/handlers/plugin-settings.ts create mode 100644 packages/core/src/astro/routes/api/admin/plugins/[id]/settings.ts create mode 100644 packages/core/tests/unit/api/plugin-settings-handlers.test.ts create mode 100644 packages/core/tests/unit/api/plugin-settings-route.test.ts diff --git a/.changeset/plugin-settings-schema-ui.md b/.changeset/plugin-settings-schema-ui.md new file mode 100644 index 0000000000..e6fc287818 --- /dev/null +++ b/.changeset/plugin-settings-schema-ui.md @@ -0,0 +1,6 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +--- + +Adds the auto-generated admin settings form for plugins that declare `admin.settingsSchema`. A gear icon on the plugin's card in Plugins opens a form generated from the schema (string, number, boolean, select, secret, url, and email fields), persisted to the plugin's KV store under `settings:` keys. Secret fields are write-only: the admin shows whether a value is set but never returns it. Editing plugin settings requires the `plugins:manage` permission. diff --git a/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx b/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx index e5d42847fa..f557e7528d 100644 --- a/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx +++ b/docs/src/content/docs/plugins/creating-native-plugins/your-first-native-plugin.mdx @@ -217,6 +217,8 @@ admin: { Field types: `string`, `number`, `boolean`, `select`, `secret`, `url`, `email`. Each accepts `label`, `description`, `default`, plus type-specific extras like `min`/`max`/`options`. Settings are persisted to the same per-plugin KV store sandboxed plugins use — read them with `ctx.kv.get("settings:")` from anywhere. +The generated form appears behind the gear icon on the plugin's card in **Plugins** (admins only — editing plugin settings requires the `plugins:manage` permission). Secret fields are write-only: the admin never sees the stored value, only whether one is set. + For richer settings UI than `settingsSchema` provides, ship custom React pages — see [React admin pages and widgets](/plugins/creating-native-plugins/react-admin/). ## Complete example — audit log plugin diff --git a/packages/admin/src/components/PluginManager.tsx b/packages/admin/src/components/PluginManager.tsx index b83450911f..49025f7e78 100644 --- a/packages/admin/src/components/PluginManager.tsx +++ b/packages/admin/src/components/PluginManager.tsx @@ -430,14 +430,25 @@ function PluginCard({ )} + {plugin.hasSettings && plugin.enabled && ( + } + /> + )} + {plugin.hasAdminPages && plugin.enabled && ( } + icon={} /> )} diff --git a/packages/admin/src/components/PluginSettings.tsx b/packages/admin/src/components/PluginSettings.tsx new file mode 100644 index 0000000000..d48fe195a4 --- /dev/null +++ b/packages/admin/src/components/PluginSettings.tsx @@ -0,0 +1,360 @@ +/** + * Plugin Settings page + * + * Auto-generates a settings form from a plugin's `admin.settingsSchema`. + * Values are stored server-side under the same KV keys the plugin reads + * via `ctx.kv.get("settings:{key}")`. Secret fields are write-only: the + * server never returns their values, only whether one is set. + */ + +import { Button, Input, InputArea, Select, Switch, useKumoToastManager } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import { FloppyDisk } from "@phosphor-icons/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import * as React from "react"; + +import { + fetchPlugin, + fetchPluginSettings, + updatePluginSettings, + type SettingField, +} from "../lib/api/plugins.js"; +import { ArrowPrev } from "./ArrowIcons.js"; +import { EditorHeader } from "./EditorHeader"; +import { RouterLinkButton } from "./RouterLinkButton.js"; + +export interface PluginSettingsProps { + pluginId: string; +} + +export function PluginSettings({ pluginId }: PluginSettingsProps) { + const { t } = useLingui(); + const queryClient = useQueryClient(); + const toastManager = useKumoToastManager(); + + const { data: plugin } = useQuery({ + queryKey: ["plugins", pluginId], + queryFn: () => fetchPlugin(pluginId), + }); + + const { + data: settings, + isLoading, + error, + } = useQuery({ + queryKey: ["plugin-settings", pluginId], + queryFn: () => fetchPluginSettings(pluginId), + }); + + const [values, setValues] = React.useState>({}); + // Secret fields are write-only: track typed input separately and only + // send keys the user actually changed. + const [secretInputs, setSecretInputs] = React.useState>({}); + const [clearedSecrets, setClearedSecrets] = React.useState>(new Set()); + + React.useEffect(() => { + if (settings) { + setValues(settings.values); + setSecretInputs({}); + setClearedSecrets(new Set()); + } + }, [settings]); + + const saveMutation = useMutation({ + mutationFn: (updates: Record) => updatePluginSettings(pluginId, updates), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["plugin-settings", pluginId] }); + toastManager.add({ + title: t`Settings saved successfully`, + variant: "success", + timeout: 3000, + }); + }, + onError: (err) => { + toastManager.add({ + title: t`Failed to save settings`, + description: err instanceof Error ? err.message : t`An error occurred`, + variant: "error", + timeout: 5000, + }); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!settings) return; + + const updates: Record = {}; + for (const [key, field] of Object.entries(settings.schema)) { + if (field.type === "secret") { + if (clearedSecrets.has(key)) { + updates[key] = null; + } else if (secretInputs[key]) { + updates[key] = secretInputs[key]; + } + continue; + } + const value = values[key]; + // null means "not set" — don't write it back, so schema + // defaults keep applying. + if (value !== null && value !== undefined) { + updates[key] = value; + } + } + saveMutation.mutate(updates); + }; + + const setValue = (key: string, value: unknown) => { + setValues((prev) => ({ ...prev, [key]: value })); + }; + + const pluginName = plugin?.name ?? pluginId; + const backLink = ( + } + /> + ); + + if (isLoading) { + return ( +
+
+ {backLink} +

{t`${pluginName} Settings`}

+
+
+

{t`Loading settings...`}

+
+
+ ); + } + + if (error || !settings) { + return ( +
+
+ {backLink} +

{t`${pluginName} Settings`}

+
+
+

+ {error instanceof Error ? error.message : t`Failed to load plugin settings`} +

+
+
+ ); + } + + const fields = Object.entries(settings.schema); + + return ( +
+ } + > + {saveMutation.isPending ? t`Saving...` : t`Save Settings`} + + } + > +

{t`${pluginName} Settings`}

+
+ +
+
+
+ {fields.length === 0 && ( +

{t`This plugin has no configurable settings.`}

+ )} + {fields.map(([key, field]) => ( + setValue(key, value)} + onSecretChange={(text) => { + setSecretInputs((prev) => ({ ...prev, [key]: text })); + setClearedSecrets((prev) => { + if (!prev.has(key)) return prev; + const next = new Set(prev); + next.delete(key); + return next; + }); + }} + onSecretClear={() => { + setSecretInputs((prev) => ({ ...prev, [key]: "" })); + setClearedSecrets((prev) => new Set(prev).add(key)); + }} + /> + ))} +
+
+ + {fields.length > 0 && ( + + )} +
+
+ ); +} + +interface SettingFieldInputProps { + fieldKey: string; + field: SettingField; + value: unknown; + secretSet: boolean; + secretInput: string; + secretCleared: boolean; + onChange: (value: unknown) => void; + onSecretChange: (text: string) => void; + onSecretClear: () => void; +} + +function SettingFieldInput({ + fieldKey, + field, + value, + secretSet, + secretInput, + secretCleared, + onChange, + onSecretChange, + onSecretClear, +}: SettingFieldInputProps) { + const { t } = useLingui(); + + // Schema labels/descriptions are plugin-authored strings (like admin + // page labels) — rendered as-is, not localized. + switch (field.type) { + case "string": + return field.multiline ? ( + onChange(e.target.value)} + /> + ) : ( + onChange(e.target.value)} + /> + ); + + case "url": + case "email": + return ( + onChange(e.target.value)} + /> + ); + + case "number": + return ( + { + const parsed = Number.parseFloat(e.target.value); + onChange(Number.isNaN(parsed) ? null : parsed); + }} + /> + ); + + case "boolean": + return ( +
+
+

{field.label}

+ {field.description &&

{field.description}

} +
+ onChange(checked)} + aria-label={field.label} + /> +
+ ); + + case "select": + return ( + + ); + + case "secret": + return ( +
+ onSecretChange(e.target.value)} + /> + {secretSet && !secretCleared && ( + + )} +
+ ); + + default: { + const _exhaustive: never = field; + return null; + } + } +} + +export default PluginSettings; diff --git a/packages/admin/src/lib/api/plugins.ts b/packages/admin/src/lib/api/plugins.ts index 4eaab9f284..360ec618c4 100644 --- a/packages/admin/src/lib/api/plugins.ts +++ b/packages/admin/src/lib/api/plugins.ts @@ -18,6 +18,8 @@ export interface PluginInfo { hasAdminPages: boolean; hasDashboardWidgets: boolean; hasHooks: boolean; + /** True when the plugin declares `admin.settingsSchema` (auto-generated settings form) */ + hasSettings: boolean; installedAt?: string; activatedAt?: string; deactivatedAt?: string; @@ -65,6 +67,64 @@ export async function fetchPlugin(pluginId: string): Promise { return result.item; } +// ── Plugin settings (auto-generated from settingsSchema) ───────── + +interface BaseSettingField { + label: string; + description?: string; +} + +export type SettingField = + | (BaseSettingField & { type: "string"; default?: string; multiline?: boolean }) + | (BaseSettingField & { type: "number"; default?: number; min?: number; max?: number }) + | (BaseSettingField & { type: "boolean"; default?: boolean }) + | (BaseSettingField & { + type: "select"; + options: Array<{ value: string; label: string }>; + default?: string; + }) + | (BaseSettingField & { type: "secret" }) + | (BaseSettingField & { type: "url"; default?: string; placeholder?: string }) + | (BaseSettingField & { type: "email"; default?: string; placeholder?: string }); + +export interface PluginSettingsResponse { + schema: Record; + /** Current values; secret fields are never included (see secretsSet) */ + values: Record; + /** For secret fields: whether a value is currently stored */ + secretsSet: Record; +} + +/** + * Fetch a plugin's settings schema and current values + */ +export async function fetchPluginSettings(pluginId: string): Promise { + const response = await apiFetch(`${API_BASE}/admin/plugins/${pluginId}/settings`); + return parseApiResponse( + response, + i18n._(msg`Failed to fetch plugin settings`), + ); +} + +/** + * Update a plugin's settings. Only keys present in `values` are written; + * `null` clears a stored value (reverting to the schema default). + */ +export async function updatePluginSettings( + pluginId: string, + values: Record, +): Promise { + const response = await apiFetch(`${API_BASE}/admin/plugins/${pluginId}/settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ values }), + }); + return parseApiResponse( + response, + i18n._(msg`Failed to update plugin settings`), + ); +} + /** * Enable a plugin */ diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index d0006f7f2c..04f27681ac 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -42,6 +42,7 @@ import { MediaLibrary } from "./components/MediaLibrary"; import { MenuEditor } from "./components/MenuEditor"; import { MenuList } from "./components/MenuList"; import { PluginManager } from "./components/PluginManager"; +import { PluginSettings } from "./components/PluginSettings"; import { Redirects } from "./components/Redirects"; import { RegistryBrowse } from "./components/RegistryBrowse"; import { RegistryPluginDetail } from "./components/RegistryPluginDetail"; @@ -1899,6 +1900,20 @@ function ContentTypesEditPage() { ); } +// Auto-generated plugin settings route (from admin.settingsSchema). +// Lives under /plugins-manager so it can never shadow a plugin's own +// admin pages (which own the /plugins/$pluginId/* namespace). +const pluginSettingsRoute = createRoute({ + getParentRoute: () => adminLayoutRoute, + path: "/plugins-manager/$pluginId/settings", + component: PluginSettingsPage, +}); + +function PluginSettingsPage() { + const { pluginId } = useParams({ from: "/_admin/plugins-manager/$pluginId/settings" }); + return ; +} + // Plugin page route const pluginRoute = createRoute({ getParentRoute: () => adminLayoutRoute, @@ -1943,6 +1958,7 @@ const adminRoutes = adminLayoutRoute.addChildren([ menuListRoute, menuEditorRoute, pluginManagerRoute, + pluginSettingsRoute, marketplaceDetailRoute, marketplaceBrowseRoute, themeMarketplaceBrowseRoute, diff --git a/packages/admin/tests/components/PluginManager.test.tsx b/packages/admin/tests/components/PluginManager.test.tsx index 0a206164bf..6b7c570bf9 100644 --- a/packages/admin/tests/components/PluginManager.test.tsx +++ b/packages/admin/tests/components/PluginManager.test.tsx @@ -82,6 +82,7 @@ function makePlugin(overrides: Partial = {}): PluginInfo { hasAdminPages: false, hasDashboardWidgets: false, hasHooks: true, + hasSettings: false, ...overrides, }; } @@ -172,32 +173,56 @@ describe("PluginManager", () => { await expect.element(disableToggle).toBeInTheDocument(); }); - it("settings link shown only for enabled plugins with admin pages", async () => { + it("pages link shown only for enabled plugins with admin pages", async () => { const screen = await render( , ); await expect.element(screen.getByText("Audit Log")).toBeInTheDocument(); - const settingsLinks = screen.getByRole("link", { name: "Settings" }).all(); - expect(settingsLinks.length).toBe(1); + const pagesLinks = screen.getByRole("link", { name: "Plugin pages" }).all(); + expect(pagesLinks.length).toBe(1); }); - it("settings link points to the plugin root, not a /settings sub-path", async () => { + it("pages link points to the plugin root, not a /settings sub-path", async () => { const screen = await render( , ); await expect.element(screen.getByText("Audit Log")).toBeInTheDocument(); - const settingsLink = screen.getByRole("link", { name: "Settings" }); - await expect.element(settingsLink).toBeInTheDocument(); - const anchor = settingsLink.element() as HTMLAnchorElement; - // Plugins are not required to expose a `/settings` sub-page; the gear + const pagesLink = screen.getByRole("link", { name: "Plugin pages" }); + await expect.element(pagesLink).toBeInTheDocument(); + const anchor = pagesLink.element() as HTMLAnchorElement; + // Plugins are not required to expose a `/settings` sub-page; the pages // icon should land on the plugin's primary admin page. expect(anchor.getAttribute("href")).toMatch(/^\/plugins\/audit-log\/?$/); }); + it("settings gear shown only for enabled plugins with a settings schema", async () => { + mockFetchPlugins.mockResolvedValue([ + makePlugin({ id: "with-settings", name: "With Settings", hasSettings: true }), + makePlugin({ id: "no-settings", name: "No Settings", hasSettings: false }), + makePlugin({ + id: "disabled-settings", + name: "Disabled Settings", + hasSettings: true, + enabled: false, + status: "inactive", + }), + ]); + const screen = await render( + + + , + ); + await expect.element(screen.getByText("With Settings")).toBeInTheDocument(); + const settingsLinks = screen.getByRole("link", { name: "Settings" }).all(); + expect(settingsLinks.length).toBe(1); + const anchor = settingsLinks[0]!.element() as HTMLAnchorElement; + expect(anchor.getAttribute("href")).toBe("/plugins-manager/with-settings/settings"); + }); + it("expand/collapse shows plugin details", async () => { const screen = await render( diff --git a/packages/admin/tests/components/PluginSettings.test.tsx b/packages/admin/tests/components/PluginSettings.test.tsx new file mode 100644 index 0000000000..8c24556c55 --- /dev/null +++ b/packages/admin/tests/components/PluginSettings.test.tsx @@ -0,0 +1,208 @@ +/** + * PluginSettings: auto-generated settings form from a plugin's + * settingsSchema (#341). Covers field rendering per type, the + * write-only secret contract, and the save payload. + */ + +import { Toasty } from "@cloudflare/kumo"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import * as React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import type { PluginInfo, PluginSettingsResponse } from "../../src/lib/api/plugins"; +import { render } from "../utils/render.tsx"; + +vi.mock("@tanstack/react-router", async () => { + const actual = await vi.importActual("@tanstack/react-router"); + return { + ...actual, + Link: ({ children, to, ...props }: any) => ( + + {children} + + ), + useNavigate: () => vi.fn(), + }; +}); + +const mockFetchPlugin = vi.fn<() => Promise>(); +const mockFetchPluginSettings = vi.fn<() => Promise>(); +const mockUpdatePluginSettings = + vi.fn<(pluginId: string, values: Record) => Promise>(); + +vi.mock("../../src/lib/api/plugins", async () => { + const actual = await vi.importActual("../../src/lib/api/plugins"); + return { + ...actual, + fetchPlugin: (...args: unknown[]) => mockFetchPlugin(...(args as [])), + fetchPluginSettings: (...args: unknown[]) => mockFetchPluginSettings(...(args as [])), + updatePluginSettings: (...args: unknown[]) => + mockUpdatePluginSettings(...(args as [string, Record])), + }; +}); + +const { PluginSettings } = await import("../../src/components/PluginSettings"); + +const SETTINGS: PluginSettingsResponse = { + schema: { + siteKey: { type: "string", label: "Turnstile Site Key", description: "Public key" }, + retries: { type: "number", label: "Retries", min: 0, max: 10 }, + enabled: { type: "boolean", label: "Enable checks" }, + mode: { + type: "select", + label: "Mode", + options: [ + { value: "fast", label: "Fast" }, + { value: "safe", label: "Safe" }, + ], + default: "safe", + }, + secretKey: { type: "secret", label: "Turnstile Secret Key" }, + }, + values: { siteKey: "0xAAA", retries: 3, enabled: true, mode: "safe" }, + secretsSet: { secretKey: true }, +}; + +function Wrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return ( + + {children} + + ); +} + +describe("PluginSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchPlugin.mockResolvedValue({ + id: "emdash-forms", + name: "Forms", + version: "1.0.0", + enabled: true, + status: "active", + capabilities: [], + hasAdminPages: true, + hasDashboardWidgets: false, + hasHooks: false, + hasSettings: true, + }); + mockFetchPluginSettings.mockResolvedValue(SETTINGS); + mockUpdatePluginSettings.mockResolvedValue(SETTINGS); + }); + + it("renders a field for every schema entry with current values", async () => { + const screen = await render( + + + , + ); + + const siteKey = screen.getByLabelText("Turnstile Site Key"); + await expect.element(siteKey).toBeInTheDocument(); + expect((siteKey.element() as HTMLInputElement).value).toBe("0xAAA"); + + const retries = screen.getByLabelText("Retries"); + await expect.element(retries).toBeInTheDocument(); + expect((retries.element() as HTMLInputElement).value).toBe("3"); + + await expect.element(screen.getByRole("switch", { name: "Enable checks" })).toBeInTheDocument(); + await expect.element(screen.getByText("Mode")).toBeInTheDocument(); + await expect.element(screen.getByLabelText("Turnstile Secret Key")).toBeInTheDocument(); + }); + + it("masks stored secrets: empty input with a 'currently set' hint", async () => { + const screen = await render( + + + , + ); + + const secret = screen.getByLabelText("Turnstile Secret Key"); + await expect.element(secret).toBeInTheDocument(); + const input = secret.element() as HTMLInputElement; + expect(input.value).toBe(""); + expect(input.type).toBe("password"); + expect(input.placeholder).toContain("Currently set"); + await expect.element(screen.getByText("Clear stored value")).toBeInTheDocument(); + }); + + it("saves edited values but omits untouched secrets", async () => { + const screen = await render( + + + , + ); + + const siteKey = screen.getByLabelText("Turnstile Site Key"); + await expect.element(siteKey).toBeInTheDocument(); + await siteKey.fill("0xBBB"); + + await screen.getByRole("button", { name: "Save Settings" }).first().click(); + + await vi.waitFor(() => { + expect(mockUpdatePluginSettings).toHaveBeenCalledTimes(1); + }); + const [pluginId, values] = mockUpdatePluginSettings.mock.calls[0]!; + expect(pluginId).toBe("emdash-forms"); + expect(values).toEqual({ + siteKey: "0xBBB", + retries: 3, + enabled: true, + mode: "safe", + }); + // Untouched secret is not sent — the stored value stays as-is. + expect("secretKey" in values).toBe(false); + }); + + it("sends null for a cleared secret", async () => { + const screen = await render( + + + , + ); + + await expect.element(screen.getByLabelText("Turnstile Secret Key")).toBeInTheDocument(); + await screen.getByText("Clear stored value").click(); + await screen.getByRole("button", { name: "Save Settings" }).first().click(); + + await vi.waitFor(() => { + expect(mockUpdatePluginSettings).toHaveBeenCalledTimes(1); + }); + const [, values] = mockUpdatePluginSettings.mock.calls[0]!; + expect(values.secretKey).toBeNull(); + }); + + it("sends a typed secret value", async () => { + const screen = await render( + + + , + ); + + const secret = screen.getByLabelText("Turnstile Secret Key"); + await expect.element(secret).toBeInTheDocument(); + await secret.fill("new-secret"); + await screen.getByRole("button", { name: "Save Settings" }).first().click(); + + await vi.waitFor(() => { + expect(mockUpdatePluginSettings).toHaveBeenCalledTimes(1); + }); + const [, values] = mockUpdatePluginSettings.mock.calls[0]!; + expect(values.secretKey).toBe("new-secret"); + }); + + it("shows an empty state for a plugin without settings", async () => { + mockFetchPluginSettings.mockResolvedValue({ schema: {}, values: {}, secretsSet: {} }); + const screen = await render( + + + , + ); + await expect + .element(screen.getByText("This plugin has no configurable settings.")) + .toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/api/errors.ts b/packages/core/src/api/errors.ts index 43e802c541..4a9e27738d 100644 --- a/packages/core/src/api/errors.ts +++ b/packages/core/src/api/errors.ts @@ -189,6 +189,8 @@ export const ErrorCode = { PLUGIN_ENABLE_ERROR: "PLUGIN_ENABLE_ERROR", PLUGIN_DISABLE_ERROR: "PLUGIN_DISABLE_ERROR", PLUGIN_ID_CONFLICT: "PLUGIN_ID_CONFLICT", + PLUGIN_SETTINGS_READ_ERROR: "PLUGIN_SETTINGS_READ_ERROR", + PLUGIN_SETTINGS_UPDATE_ERROR: "PLUGIN_SETTINGS_UPDATE_ERROR", MARKETPLACE_NOT_CONFIGURED: "MARKETPLACE_NOT_CONFIGURED", MARKETPLACE_UNAVAILABLE: "MARKETPLACE_UNAVAILABLE", MARKETPLACE_ERROR: "MARKETPLACE_ERROR", diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts index 181ec2cb68..33ccb64b27 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -109,6 +109,12 @@ export { type PluginListResponse, type PluginResponse, } from "./plugins.js"; +export { + handlePluginSettingsGet, + handlePluginSettingsUpdate, + getPluginSettingsSchema, + type PluginSettingsResponse, +} from "./plugin-settings.js"; // Menu handlers export { diff --git a/packages/core/src/api/handlers/plugin-settings.ts b/packages/core/src/api/handlers/plugin-settings.ts new file mode 100644 index 0000000000..972f83b203 --- /dev/null +++ b/packages/core/src/api/handlers/plugin-settings.ts @@ -0,0 +1,210 @@ +/** + * Plugin settings handlers + * + * Auto-generated settings UI backend for plugins that declare + * `admin.settingsSchema`. Values are stored in the options table under + * `plugin:{pluginId}:settings:{key}` — the same keys the plugin itself + * reads via `ctx.kv.get("settings:{key}")`. + */ + +import type { Kysely } from "kysely"; + +import { OptionsRepository } from "../../database/repositories/options.js"; +import type { Database } from "../../database/types.js"; +import type { SandboxedPluginEntry } from "../../emdash-runtime.js"; +import type { ResolvedPlugin, SettingField } from "../../plugins/types.js"; +import { ErrorCode } from "../errors.js"; +import type { ApiResult } from "../types.js"; + +export interface PluginSettingsResponse { + /** The plugin's declared settings schema, keyed by setting name */ + schema: Record; + /** + * Current values keyed by setting name. Secret fields are never + * included here — check `secretsSet` instead. + */ + values: Record; + /** For secret fields: whether a value is currently stored */ + secretsSet: Record; +} + +function settingsKey(pluginId: string, key: string): string { + return `plugin:${pluginId}:settings:${key}`; +} + +/** + * Resolve a plugin's settings schema from either the configured + * (in-process) plugin list or the statically-sandboxed entries. + * Returns null when the plugin doesn't exist, undefined-equivalent + * empty object when it declares no schema. + */ +export function getPluginSettingsSchema( + configuredPlugins: ResolvedPlugin[], + sandboxedPluginEntries: SandboxedPluginEntry[], + pluginId: string, +): Record | null { + const plugin = configuredPlugins.find((p) => p.id === pluginId); + if (plugin) return plugin.admin.settingsSchema ?? {}; + + const sandboxed = sandboxedPluginEntries.find((e) => e.id === pluginId); + if (sandboxed) return sandboxed.settingsSchema ?? {}; + + return null; +} + +/** + * Validate a single value against its schema field. + * Returns an error message, or null when valid. + */ +function validateValue(key: string, field: SettingField, value: unknown): string | null { + switch (field.type) { + case "string": + case "secret": + case "url": + case "email": + if (typeof value !== "string") return `Setting "${key}" must be a string`; + if (field.type === "url" && value !== "" && !URL.canParse(value)) { + return `Setting "${key}" must be a valid URL`; + } + if (field.type === "email" && value !== "" && !value.includes("@")) { + return `Setting "${key}" must be a valid email address`; + } + return null; + case "number": { + if (typeof value !== "number" || Number.isNaN(value)) { + return `Setting "${key}" must be a number`; + } + if (field.min !== undefined && value < field.min) { + return `Setting "${key}" must be at least ${field.min}`; + } + if (field.max !== undefined && value > field.max) { + return `Setting "${key}" must be at most ${field.max}`; + } + return null; + } + case "boolean": + return typeof value === "boolean" ? null : `Setting "${key}" must be a boolean`; + case "select": + if (typeof value !== "string" || !field.options.some((o) => o.value === value)) { + return `Setting "${key}" must be one of the defined options`; + } + return null; + default: { + const _exhaustive: never = field; + return `Setting "${key}" has an unknown field type`; + } + } +} + +async function buildSettingsResponse( + optionsRepo: OptionsRepository, + pluginId: string, + schema: Record, +): Promise { + const keys = Object.keys(schema); + const stored = await optionsRepo.getMany(keys.map((key) => settingsKey(pluginId, key))); + + const values: Record = {}; + const secretsSet: Record = {}; + + for (const key of keys) { + const field = schema[key]; + if (!field) continue; + const storedValue = stored.get(settingsKey(pluginId, key)); + + if (field.type === "secret") { + secretsSet[key] = typeof storedValue === "string" && storedValue.length > 0; + continue; + } + + if (storedValue !== undefined && storedValue !== null) { + values[key] = storedValue; + } else if ("default" in field && field.default !== undefined) { + values[key] = field.default; + } else { + values[key] = null; + } + } + + return { schema, values, secretsSet }; +} + +/** + * Get a plugin's settings (schema + current values, secrets masked) + */ +export async function handlePluginSettingsGet( + db: Kysely, + pluginId: string, + schema: Record, +): Promise> { + try { + const optionsRepo = new OptionsRepository(db); + return { success: true, data: await buildSettingsResponse(optionsRepo, pluginId, schema) }; + } catch { + return { + success: false, + error: { + code: ErrorCode.PLUGIN_SETTINGS_READ_ERROR, + message: "Failed to read plugin settings", + }, + }; + } +} + +/** + * Update a plugin's settings. + * + * Only keys present in `updates` are written. A `null` value deletes the + * stored value (reverting to the schema default). Secret fields are + * write-only: they accept a new string value or `null` to clear, and the + * response never echoes them back. + */ +export async function handlePluginSettingsUpdate( + db: Kysely, + pluginId: string, + schema: Record, + updates: Record, +): Promise> { + try { + // Validate everything before writing anything. + for (const [key, value] of Object.entries(updates)) { + const field = schema[key]; + if (!field) { + return { + success: false, + error: { + code: ErrorCode.VALIDATION_ERROR, + message: `Unknown setting "${key}" for plugin "${pluginId}"`, + }, + }; + } + if (value === null) continue; + const error = validateValue(key, field, value); + if (error) { + return { + success: false, + error: { code: ErrorCode.VALIDATION_ERROR, message: error }, + }; + } + } + + const optionsRepo = new OptionsRepository(db); + for (const [key, value] of Object.entries(updates)) { + if (value === null) { + await optionsRepo.delete(settingsKey(pluginId, key)); + } else { + await optionsRepo.set(settingsKey(pluginId, key), value); + } + } + + return { success: true, data: await buildSettingsResponse(optionsRepo, pluginId, schema) }; + } catch { + return { + success: false, + error: { + code: ErrorCode.PLUGIN_SETTINGS_UPDATE_ERROR, + message: "Failed to update plugin settings", + }, + }; + } +} diff --git a/packages/core/src/api/handlers/plugins.ts b/packages/core/src/api/handlers/plugins.ts index 24e1736b5a..9fa0c05f8b 100644 --- a/packages/core/src/api/handlers/plugins.ts +++ b/packages/core/src/api/handlers/plugins.ts @@ -29,6 +29,8 @@ export interface PluginInfo { hasAdminPages: boolean; hasDashboardWidgets: boolean; hasHooks: boolean; + /** True when the plugin declares `admin.settingsSchema` (auto-generated settings form) */ + hasSettings: boolean; installedAt?: string; activatedAt?: string; deactivatedAt?: string; @@ -78,6 +80,7 @@ function buildPluginInfo( hasAdminPages: (plugin.admin.pages?.length ?? 0) > 0, hasDashboardWidgets: (plugin.admin.widgets?.length ?? 0) > 0, hasHooks: Object.keys(plugin.hooks ?? {}).length > 0, + hasSettings: Object.keys(plugin.admin.settingsSchema ?? {}).length > 0, installedAt: state?.installedAt?.toISOString(), activatedAt: state?.activatedAt?.toISOString() ?? undefined, deactivatedAt: state?.deactivatedAt?.toISOString() ?? undefined, @@ -110,6 +113,7 @@ function buildSandboxedPluginInfo( hasAdminPages: (entry.adminPages?.length ?? 0) > 0, hasDashboardWidgets: (entry.adminWidgets?.length ?? 0) > 0, hasHooks: false, + hasSettings: Object.keys(entry.settingsSchema ?? {}).length > 0, installedAt: state?.installedAt?.toISOString(), activatedAt: state?.activatedAt?.toISOString() ?? undefined, deactivatedAt: state?.deactivatedAt?.toISOString() ?? undefined, @@ -166,6 +170,7 @@ export async function handlePluginList( hasAdminPages: false, hasDashboardWidgets: false, hasHooks: false, + hasSettings: false, installedAt: state.installedAt?.toISOString(), activatedAt: state.activatedAt?.toISOString() ?? undefined, deactivatedAt: state.deactivatedAt?.toISOString() ?? undefined, @@ -266,6 +271,7 @@ function buildStateOnlyPluginInfo( hasAdminPages: false, hasDashboardWidgets: false, hasHooks: false, + hasSettings: false, installedAt: state.installedAt?.toISOString(), activatedAt: state.activatedAt?.toISOString() ?? undefined, deactivatedAt: state.deactivatedAt?.toISOString() ?? undefined, diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 7b818cadba..b6df92673e 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -399,6 +399,11 @@ export function injectCoreRoutes( entrypoint: resolveRoute("api/admin/plugins/[id]/disable.ts"), }); + injectRoute({ + pattern: "/_emdash/api/admin/plugins/[id]/settings", + entrypoint: resolveRoute("api/admin/plugins/[id]/settings.ts"), + }); + // Marketplace plugin routes injectRoute({ pattern: "/_emdash/api/admin/plugins/marketplace", diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 58dc34e616..e82df3176b 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -15,6 +15,7 @@ import type { FieldWidgetConfig, PortableTextBlockConfig, ResolvedPlugin, + SettingField, } from "../../plugins/types.js"; import type { ExperimentalConfig } from "../../registry/types.js"; import type { StorageDescriptor } from "../storage/types.js"; @@ -103,6 +104,8 @@ export interface PluginDescriptor> { adminPages?: PluginAdminPage[]; /** Dashboard widgets */ adminWidgets?: PluginDashboardWidget[]; + /** Settings schema for the auto-generated admin settings form */ + settingsSchema?: Record; /** * Portable Text block types this plugin contributes to the editor. * Declarative (Block Kit) — surfaced in the admin slash menu and consumed diff --git a/packages/core/src/astro/integration/virtual-modules.ts b/packages/core/src/astro/integration/virtual-modules.ts index 755c928629..1f43c2dd6e 100644 --- a/packages/core/src/astro/integration/virtual-modules.ts +++ b/packages/core/src/astro/integration/virtual-modules.ts @@ -271,6 +271,7 @@ export function generatePluginsModule(descriptors: PluginDescriptor[]): string { storage: descriptor.storage, adminPages: descriptor.adminPages, adminWidgets: descriptor.adminWidgets, + settingsSchema: descriptor.settingsSchema, portableTextBlocks: descriptor.portableTextBlocks, fieldWidgets: descriptor.fieldWidgets, })})`, @@ -642,6 +643,7 @@ export const sandboxedPlugins = []; storage: ${JSON.stringify(descriptor.storage ?? {})}, adminPages: ${JSON.stringify(descriptor.adminPages ?? [])}, adminWidgets: ${JSON.stringify(descriptor.adminWidgets ?? [])}, + settingsSchema: ${JSON.stringify(descriptor.settingsSchema)}, portableTextBlocks: ${JSON.stringify(descriptor.portableTextBlocks ?? [])}, fieldWidgets: ${JSON.stringify(descriptor.fieldWidgets ?? [])}, adminEntry: ${JSON.stringify(descriptor.adminEntry)}, diff --git a/packages/core/src/astro/routes/api/admin/plugins/[id]/settings.ts b/packages/core/src/astro/routes/api/admin/plugins/[id]/settings.ts new file mode 100644 index 0000000000..f236af1268 --- /dev/null +++ b/packages/core/src/astro/routes/api/admin/plugins/[id]/settings.ts @@ -0,0 +1,84 @@ +/** + * Plugin settings endpoint (auto-generated settings UI) + * + * GET /_emdash/api/admin/plugins/:id/settings - Get schema + current values (secrets masked) + * PUT /_emdash/api/admin/plugins/:id/settings - Update values + */ + +import type { APIRoute } from "astro"; +import { z } from "zod"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError } from "#api/error.js"; +import { + getPluginSettingsSchema, + handlePluginSettingsGet, + handlePluginSettingsUpdate, +} from "#api/handlers/plugin-settings.js"; +import { unwrapResult } from "#api/index.js"; +import { isParseError, parseBody } from "#api/parse.js"; + +export const prerender = false; + +const updateBody = z.object({ + values: z.record(z.string(), z.unknown()), +}); + +export const GET: APIRoute = async ({ params, locals }) => { + const { emdash, user } = locals; + const { id } = params; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + const denied = requirePerm(user, "plugins:manage"); + if (denied) return denied; + + if (!id) { + return apiError("INVALID_REQUEST", "Plugin ID required", 400); + } + + const schema = getPluginSettingsSchema( + emdash.configuredPlugins, + emdash.sandboxedPluginEntries, + id, + ); + if (schema === null) { + return apiError("NOT_FOUND", `Plugin not found: ${id}`, 404); + } + + const result = await handlePluginSettingsGet(emdash.db, id, schema); + return unwrapResult(result); +}; + +export const PUT: APIRoute = async ({ params, request, locals }) => { + const { emdash, user } = locals; + const { id } = params; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + const denied = requirePerm(user, "plugins:manage"); + if (denied) return denied; + + if (!id) { + return apiError("INVALID_REQUEST", "Plugin ID required", 400); + } + + const schema = getPluginSettingsSchema( + emdash.configuredPlugins, + emdash.sandboxedPluginEntries, + id, + ); + if (schema === null) { + return apiError("NOT_FOUND", `Plugin not found: ${id}`, 404); + } + + const body = await parseBody(request, updateBody); + if (isParseError(body)) return body; + + const result = await handlePluginSettingsUpdate(emdash.db, id, schema, body.values); + return unwrapResult(result); +}; diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index c5279699e0..cef698efe3 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -53,6 +53,7 @@ import type { PageFragmentContribution, PortableTextBlockConfig, FieldWidgetConfig, + SettingField, } from "./plugins/types.js"; import type { FieldType } from "./schema/types.js"; import { hashString } from "./utils/hash.js"; @@ -224,6 +225,8 @@ export interface SandboxedPluginEntry { adminPages?: Array<{ path: string; label?: string; icon?: string }>; /** Dashboard widgets */ adminWidgets?: Array<{ id: string; title?: string; size?: string }>; + /** Settings schema for the auto-generated admin settings form */ + settingsSchema?: Record; /** Portable Text block types contributed to the editor (declarative Block Kit) */ portableTextBlocks?: PortableTextBlockConfig[]; /** Field widget types contributed for schema-field editing UIs */ @@ -1029,6 +1032,7 @@ export class EmDashRuntime { size: w.size === "full" || w.size === "half" || w.size === "third" ? w.size : undefined, })), + settingsSchema: bundle.manifest.admin?.settingsSchema, }); newPlugins.push(adapted); this.allPipelinePlugins.push(adapted); @@ -2103,6 +2107,7 @@ export class EmDashRuntime { size: w.size === "full" || w.size === "half" || w.size === "third" ? w.size : undefined, })), + settingsSchema: bundle.manifest.admin?.settingsSchema, }); resolved.push(adapted); console.log( diff --git a/packages/core/src/plugins/adapt-sandbox-entry.ts b/packages/core/src/plugins/adapt-sandbox-entry.ts index 760b4e42c0..92332d3c40 100644 --- a/packages/core/src/plugins/adapt-sandbox-entry.ts +++ b/packages/core/src/plugins/adapt-sandbox-entry.ts @@ -290,6 +290,9 @@ export function adaptSandboxEntry( if (descriptor.adminWidgets) { admin.widgets = descriptor.adminWidgets; } + if (descriptor.settingsSchema) { + admin.settingsSchema = descriptor.settingsSchema; + } if (descriptor.portableTextBlocks) { admin.portableTextBlocks = descriptor.portableTextBlocks; } diff --git a/packages/core/tests/unit/api/plugin-settings-handlers.test.ts b/packages/core/tests/unit/api/plugin-settings-handlers.test.ts new file mode 100644 index 0000000000..b4389f37ae --- /dev/null +++ b/packages/core/tests/unit/api/plugin-settings-handlers.test.ts @@ -0,0 +1,181 @@ +/** + * Plugin settings handlers: auto-generated settings UI backend (#341). + * + * Values must land under `plugin:{id}:settings:{key}` in the options + * table — the same keys plugins read via `ctx.kv.get("settings:{key}")` — + * and secret fields must never be echoed back to the client. + */ + +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + getPluginSettingsSchema, + handlePluginSettingsGet, + handlePluginSettingsUpdate, +} from "../../../src/api/handlers/plugin-settings.js"; +import { OptionsRepository } from "../../../src/database/repositories/options.js"; +import type { Database } from "../../../src/database/types.js"; +import type { SandboxedPluginEntry } from "../../../src/emdash-runtime.js"; +import type { ResolvedPlugin, SettingField } from "../../../src/plugins/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +const PLUGIN_ID = "test-plugin"; + +const SCHEMA: Record = { + apiUrl: { type: "url", label: "API URL", placeholder: "https://example.com" }, + retries: { type: "number", label: "Retries", min: 0, max: 10, default: 3 }, + enabled: { type: "boolean", label: "Enabled", default: true }, + mode: { + type: "select", + label: "Mode", + options: [ + { value: "fast", label: "Fast" }, + { value: "safe", label: "Safe" }, + ], + default: "safe", + }, + apiKey: { type: "secret", label: "API Key" }, + note: { type: "string", label: "Note", multiline: true }, +}; + +function makePlugin(overrides: Partial = {}): ResolvedPlugin { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- minimal test fixture + return { + id: PLUGIN_ID, + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storage: {}, + admin: { settingsSchema: SCHEMA }, + hooks: {}, + routes: {}, + ...overrides, + } as ResolvedPlugin; +} + +describe("getPluginSettingsSchema", () => { + it("resolves the schema from a configured plugin", () => { + expect(getPluginSettingsSchema([makePlugin()], [], PLUGIN_ID)).toBe(SCHEMA); + }); + + it("resolves the schema from a sandboxed entry", () => { + const entry: SandboxedPluginEntry = { + id: "sandboxed", + version: "1.0.0", + options: {}, + code: "", + capabilities: [], + allowedHosts: [], + storage: {}, + settingsSchema: SCHEMA, + }; + expect(getPluginSettingsSchema([], [entry], "sandboxed")).toBe(SCHEMA); + }); + + it("returns an empty schema for a plugin without settingsSchema", () => { + expect(getPluginSettingsSchema([makePlugin({ admin: {} })], [], PLUGIN_ID)).toEqual({}); + }); + + it("returns null for an unknown plugin", () => { + expect(getPluginSettingsSchema([], [], "nope")).toBeNull(); + }); +}); + +describe("plugin settings handlers", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("GET returns schema defaults when nothing is stored", async () => { + const result = await handlePluginSettingsGet(db, PLUGIN_ID, SCHEMA); + expect(result.success).toBe(true); + if (!result.success) return; + + expect(result.data.values).toEqual({ + apiUrl: null, + retries: 3, + enabled: true, + mode: "safe", + note: null, + }); + expect(result.data.secretsSet).toEqual({ apiKey: false }); + // Secret values never appear in `values`. + expect("apiKey" in result.data.values).toBe(false); + }); + + it("PUT stores values under the plugin's settings: KV keys", async () => { + const result = await handlePluginSettingsUpdate(db, PLUGIN_ID, SCHEMA, { + apiUrl: "https://api.example.com", + retries: 5, + enabled: false, + mode: "fast", + apiKey: "s3cret", + }); + expect(result.success).toBe(true); + if (!result.success) return; + + // Response reflects the new values, secrets masked but flagged. + expect(result.data.values.apiUrl).toBe("https://api.example.com"); + expect(result.data.values.retries).toBe(5); + expect(result.data.values.enabled).toBe(false); + expect(result.data.values.mode).toBe("fast"); + expect(result.data.secretsSet).toEqual({ apiKey: true }); + expect("apiKey" in result.data.values).toBe(false); + + // Stored exactly where `ctx.kv.get("settings:{key}")` reads. + const options = new OptionsRepository(db); + expect(await options.get(`plugin:${PLUGIN_ID}:settings:apiKey`)).toBe("s3cret"); + expect(await options.get(`plugin:${PLUGIN_ID}:settings:retries`)).toBe(5); + }); + + it("PUT with null clears a stored value (reverting to the default)", async () => { + await handlePluginSettingsUpdate(db, PLUGIN_ID, SCHEMA, { retries: 7, apiKey: "x" }); + + const result = await handlePluginSettingsUpdate(db, PLUGIN_ID, SCHEMA, { + retries: null, + apiKey: null, + }); + expect(result.success).toBe(true); + if (!result.success) return; + + expect(result.data.values.retries).toBe(3); + expect(result.data.secretsSet).toEqual({ apiKey: false }); + + const options = new OptionsRepository(db); + expect(await options.get(`plugin:${PLUGIN_ID}:settings:retries`)).toBeNull(); + }); + + it("PUT rejects unknown keys and writes nothing", async () => { + const result = await handlePluginSettingsUpdate(db, PLUGIN_ID, SCHEMA, { + retries: 5, + bogus: "nope", + }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("VALIDATION_ERROR"); + + // Validation happens before any write — the valid key wasn't stored either. + const options = new OptionsRepository(db); + expect(await options.get(`plugin:${PLUGIN_ID}:settings:retries`)).toBeNull(); + }); + + it.each([ + ["number out of range", { retries: 99 }], + ["wrong type for number", { retries: "five" }], + ["wrong type for boolean", { enabled: "yes" }], + ["select value not in options", { mode: "turbo" }], + ["invalid url", { apiUrl: "not a url" }], + ])("PUT rejects %s", async (_label, updates) => { + const result = await handlePluginSettingsUpdate(db, PLUGIN_ID, SCHEMA, updates); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("VALIDATION_ERROR"); + }); +}); diff --git a/packages/core/tests/unit/api/plugin-settings-route.test.ts b/packages/core/tests/unit/api/plugin-settings-route.test.ts new file mode 100644 index 0000000000..134b0daaca --- /dev/null +++ b/packages/core/tests/unit/api/plugin-settings-route.test.ts @@ -0,0 +1,139 @@ +/** + * Plugin settings route (#341): registration, authorization, and the + * secret-masking contract at the HTTP boundary. + * + * Reading settings can reveal plugin configuration and updating can + * change plugin behaviour, so both methods require `plugins:manage` + * (admin), not just `plugins:read` (editor). + */ + +import { Role } from "@emdash-cms/auth"; +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { injectCoreRoutes } from "../../../src/astro/integration/routes.js"; +import { + GET as settingsGet, + PUT as settingsPut, +} from "../../../src/astro/routes/api/admin/plugins/[id]/settings.js"; +import type { Database } from "../../../src/database/types.js"; +import type { ResolvedPlugin, SettingField } from "../../../src/plugins/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +const SCHEMA: Record = { + siteKey: { type: "string", label: "Site Key" }, + secretKey: { type: "secret", label: "Secret Key" }, +}; + +// eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- minimal test fixture +const PLUGIN = { + id: "emdash-forms", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storage: {}, + admin: { settingsSchema: SCHEMA }, + hooks: {}, + routes: {}, +} as ResolvedPlugin; + +describe("plugin settings route registration", () => { + it("registers /_emdash/api/admin/plugins/[id]/settings", () => { + const injectRoute = vi.fn(); + injectCoreRoutes(injectRoute); + const patterns = injectRoute.mock.calls.map((call) => (call[0] as { pattern: string }).pattern); + expect(patterns).toContain("/_emdash/api/admin/plugins/[id]/settings"); + }); +}); + +describe("plugin settings route", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + const makeLocals = (user: { id: string; role: number } | null) => ({ + emdash: { + db, + configuredPlugins: [PLUGIN], + sandboxedPluginEntries: [], + }, + user, + }); + + const makeContext = (user: { id: string; role: number } | null, request?: Request) => + ({ + params: { id: "emdash-forms" }, + request: + request ?? new Request("http://localhost/_emdash/api/admin/plugins/emdash-forms/settings"), + locals: makeLocals(user), + }) as unknown as Parameters[0]; + + it("GET returns 401 for anonymous users", async () => { + const response = await settingsGet(makeContext(null)); + expect(response.status).toBe(401); + }); + + it("GET returns 403 for editors (plugins:manage is admin-only)", async () => { + const response = await settingsGet(makeContext({ id: "u1", role: Role.EDITOR })); + expect(response.status).toBe(403); + }); + + it("GET returns 404 for an unknown plugin", async () => { + const ctx = makeContext({ id: "u1", role: Role.ADMIN }); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- test fixture + (ctx as { params: { id: string } }).params = { id: "nope" }; + const response = await settingsGet(ctx); + expect(response.status).toBe(404); + }); + + it("GET returns schema and masked secrets for admins", async () => { + const response = await settingsGet(makeContext({ id: "u1", role: Role.ADMIN })); + expect(response.status).toBe(200); + const { data: body } = (await response.json()) as { + data: { + schema: Record; + values: Record; + secretsSet: Record; + }; + }; + expect(Object.keys(body.schema)).toEqual(["siteKey", "secretKey"]); + expect(body.secretsSet).toEqual({ secretKey: false }); + expect("secretKey" in body.values).toBe(false); + }); + + it("PUT requires plugins:manage and persists values", async () => { + const put = (user: { id: string; role: number } | null) => + settingsPut( + makeContext( + user, + new Request("http://localhost/_emdash/api/admin/plugins/emdash-forms/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ values: { siteKey: "abc", secretKey: "shh" } }), + }), + ), + ); + + expect((await put(null)).status).toBe(401); + expect((await put({ id: "u1", role: Role.EDITOR })).status).toBe(403); + + const response = await put({ id: "u1", role: Role.ADMIN }); + expect(response.status).toBe(200); + const { data: body } = (await response.json()) as { + data: { + values: Record; + secretsSet: Record; + }; + }; + expect(body.values.siteKey).toBe("abc"); + expect(body.secretsSet).toEqual({ secretKey: true }); + // The secret round-trips into storage but never back to the client. + expect("secretKey" in body.values).toBe(false); + }); +}); From 8f779b420ae72002c080e09ce2502605dbf8ed57 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:41:47 +0200 Subject: [PATCH 2/7] fix: resolve settingsSchema for bypassed and runtime-installed plugins Review follow-up: loadBypassedPlugins now forwards settingsSchema to adaptSandboxEntry, and marketplace/registry plugins loaded at runtime resolve their schema from the cached manifest via a new getRuntimePluginSettingsSchema accessor (settings endpoint fallback + hasSettings in the plugin list). --- packages/core/src/api/handlers/plugins.ts | 8 +++++++- .../routes/api/admin/plugins/[id]/settings.ts | 16 ++++++---------- .../astro/routes/api/admin/plugins/index.ts | 1 + packages/core/src/emdash-runtime.ts | 14 ++++++++++++++ .../tests/integration/api/plugins.test.ts | 19 +++++++++++++++++++ .../unit/api/plugin-settings-route.test.ts | 1 + 6 files changed, 48 insertions(+), 11 deletions(-) diff --git a/packages/core/src/api/handlers/plugins.ts b/packages/core/src/api/handlers/plugins.ts index 9fa0c05f8b..22a7663d2c 100644 --- a/packages/core/src/api/handlers/plugins.ts +++ b/packages/core/src/api/handlers/plugins.ts @@ -129,6 +129,12 @@ export async function handlePluginList( configuredPlugins: ResolvedPlugin[], sandboxedPluginEntries: SandboxedPluginEntry[], marketplaceUrl?: string, + /** + * Settings-schema lookup for runtime-installed (marketplace/registry) + * plugins, which aren't in either build-time list. Typically + * `EmDashRuntime.getRuntimePluginSettingsSchema`. + */ + runtimeSettingsSchemaLookup?: (pluginId: string) => Record | null, ): Promise> { try { const stateRepo = new PluginStateRepository(db); @@ -170,7 +176,7 @@ export async function handlePluginList( hasAdminPages: false, hasDashboardWidgets: false, hasHooks: false, - hasSettings: false, + hasSettings: Object.keys(runtimeSettingsSchemaLookup?.(state.pluginId) ?? {}).length > 0, installedAt: state.installedAt?.toISOString(), activatedAt: state.activatedAt?.toISOString() ?? undefined, deactivatedAt: state.deactivatedAt?.toISOString() ?? undefined, diff --git a/packages/core/src/astro/routes/api/admin/plugins/[id]/settings.ts b/packages/core/src/astro/routes/api/admin/plugins/[id]/settings.ts index f236af1268..65f4330c9b 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/[id]/settings.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/[id]/settings.ts @@ -39,11 +39,9 @@ export const GET: APIRoute = async ({ params, locals }) => { return apiError("INVALID_REQUEST", "Plugin ID required", 400); } - const schema = getPluginSettingsSchema( - emdash.configuredPlugins, - emdash.sandboxedPluginEntries, - id, - ); + const schema = + getPluginSettingsSchema(emdash.configuredPlugins, emdash.sandboxedPluginEntries, id) ?? + emdash.getRuntimePluginSettingsSchema(id); if (schema === null) { return apiError("NOT_FOUND", `Plugin not found: ${id}`, 404); } @@ -67,11 +65,9 @@ export const PUT: APIRoute = async ({ params, request, locals }) => { return apiError("INVALID_REQUEST", "Plugin ID required", 400); } - const schema = getPluginSettingsSchema( - emdash.configuredPlugins, - emdash.sandboxedPluginEntries, - id, - ); + const schema = + getPluginSettingsSchema(emdash.configuredPlugins, emdash.sandboxedPluginEntries, id) ?? + emdash.getRuntimePluginSettingsSchema(id); if (schema === null) { return apiError("NOT_FOUND", `Plugin not found: ${id}`, 404); } diff --git a/packages/core/src/astro/routes/api/admin/plugins/index.ts b/packages/core/src/astro/routes/api/admin/plugins/index.ts index a4a4d90ed1..582e3301b9 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/index.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/index.ts @@ -27,6 +27,7 @@ export const GET: APIRoute = async ({ locals }) => { emdash.configuredPlugins, emdash.sandboxedPluginEntries, emdash.config.marketplace, + (pluginId) => emdash.getRuntimePluginSettingsSchema(pluginId), ); return unwrapResult(result); diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index cef698efe3..157e384b38 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -472,6 +472,7 @@ const marketplaceManifestCache = new Map< admin?: { pages?: PluginAdminPage[]; widgets?: PluginDashboardWidget[]; + settingsSchema?: Record; }; } >(); @@ -1809,6 +1810,7 @@ export class EmDashRuntime { storage: entry.storage as never, adminPages, adminWidgets, + settingsSchema: entry.settingsSchema, portableTextBlocks: entry.portableTextBlocks, fieldWidgets: entry.fieldWidgets, }); @@ -3319,6 +3321,18 @@ export class EmDashRuntime { return null; } + /** + * Resolve the settings schema for a runtime-installed (marketplace or + * registry) plugin from its cached manifest. Returns `{}` for a known + * plugin without a schema and `null` for unknown plugins, matching the + * contract of `getPluginSettingsSchema` for build-time plugins. + */ + getRuntimePluginSettingsSchema(pluginId: string): Record | null { + const meta = marketplaceManifestCache.get(pluginId); + if (!meta) return null; + return meta.admin?.settingsSchema ?? {}; + } + async handlePluginApiRoute(pluginId: string, _method: string, path: string, request: Request) { if (!this.isPluginEnabled(pluginId)) { return { diff --git a/packages/core/tests/integration/api/plugins.test.ts b/packages/core/tests/integration/api/plugins.test.ts index 8cb8bfb97c..320d622f86 100644 --- a/packages/core/tests/integration/api/plugins.test.ts +++ b/packages/core/tests/integration/api/plugins.test.ts @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { handlePluginList } from "../../../src/api/handlers/plugins.js"; import type { Database } from "../../../src/database/types.js"; import type { SandboxedPluginEntry } from "../../../src/emdash-runtime.js"; +import { PluginStateRepository } from "../../../src/plugins/state.js"; import type { ResolvedPlugin } from "../../../src/plugins/types.js"; import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; @@ -74,4 +75,22 @@ describe("plugin admin handlers: sandboxed plugins", () => { expect(shared[0]?.version).toBe("1.0.0"); expect(shared[0]?.sandboxed).toBeUndefined(); }); + + it("derives hasSettings for runtime-installed plugins from the schema lookup", async () => { + const stateRepo = new PluginStateRepository(db); + await stateRepo.upsert("mp-with-settings", "1.0.0", "active", { source: "marketplace" }); + await stateRepo.upsert("mp-without-settings", "1.0.0", "active", { source: "marketplace" }); + + const result = await handlePluginList(db, [], [], undefined, (pluginId) => + pluginId === "mp-with-settings" ? { apiKey: { type: "secret", label: "API Key" } } : null, + ); + + expect(result.success).toBe(true); + if (!result.success) return; + + const withSettings = result.data.items.find((p) => p.id === "mp-with-settings"); + const withoutSettings = result.data.items.find((p) => p.id === "mp-without-settings"); + expect(withSettings?.hasSettings).toBe(true); + expect(withoutSettings?.hasSettings).toBe(false); + }); }); diff --git a/packages/core/tests/unit/api/plugin-settings-route.test.ts b/packages/core/tests/unit/api/plugin-settings-route.test.ts index 134b0daaca..5f8fdf5ea1 100644 --- a/packages/core/tests/unit/api/plugin-settings-route.test.ts +++ b/packages/core/tests/unit/api/plugin-settings-route.test.ts @@ -62,6 +62,7 @@ describe("plugin settings route", () => { db, configuredPlugins: [PLUGIN], sandboxedPluginEntries: [], + getRuntimePluginSettingsSchema: () => null, }, user, }); From 8e689329d5896ce5be0757cee210a710edf874d3 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:58:09 +0200 Subject: [PATCH 3/7] fix(admin): cleared settings fields revert to schema default Review follow-up: emptying a non-secret field now sends null so the server deletes the stored value and the schema default applies again. Previously the empty string was persisted and shadowed the default. --- .../admin/src/components/PluginSettings.tsx | 12 +++++----- .../tests/components/PluginSettings.test.tsx | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/admin/src/components/PluginSettings.tsx b/packages/admin/src/components/PluginSettings.tsx index d48fe195a4..5d688e65a6 100644 --- a/packages/admin/src/components/PluginSettings.tsx +++ b/packages/admin/src/components/PluginSettings.tsx @@ -95,11 +95,13 @@ export function PluginSettings({ pluginId }: PluginSettingsProps) { continue; } const value = values[key]; - // null means "not set" — don't write it back, so schema - // defaults keep applying. - if (value !== null && value !== undefined) { - updates[key] = value; - } + // An emptied field reverts to the schema default: send null so + // the server deletes the stored value. Sending "" would persist + // an empty string and shadow the default; skipping the key would + // leave the old stored value in place. Never-set fields also send + // null — deleting a missing key is a no-op. + const cleared = value === null || value === undefined || value === ""; + updates[key] = cleared ? null : value; } saveMutation.mutate(updates); }; diff --git a/packages/admin/tests/components/PluginSettings.test.tsx b/packages/admin/tests/components/PluginSettings.test.tsx index 8c24556c55..9625b13786 100644 --- a/packages/admin/tests/components/PluginSettings.test.tsx +++ b/packages/admin/tests/components/PluginSettings.test.tsx @@ -157,6 +157,28 @@ describe("PluginSettings", () => { expect("secretKey" in values).toBe(false); }); + it("sends null (not empty string) for a cleared field so it reverts to the default", async () => { + const screen = await render( + + + , + ); + + const siteKey = screen.getByLabelText("Turnstile Site Key"); + await expect.element(siteKey).toBeInTheDocument(); + await siteKey.fill(""); + + await screen.getByRole("button", { name: "Save Settings" }).first().click(); + + await vi.waitFor(() => { + expect(mockUpdatePluginSettings).toHaveBeenCalledTimes(1); + }); + const [, values] = mockUpdatePluginSettings.mock.calls[0]!; + // Storing "" would shadow the schema default forever; null deletes + // the stored value server-side so the default applies again. + expect(values.siteKey).toBeNull(); + }); + it("sends null for a cleared secret", async () => { const screen = await render( From e2c7e0d4d720da01b940913ba3d94cf4b8717607 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:58:46 +0200 Subject: [PATCH 4/7] test(plugins): cover runtime-installed plugin settings schema fallback The settings route tests mocked getRuntimePluginSettingsSchema to always return null, so the runtime-installed (marketplace) plugin fallback added to fix the prior review gap was never exercised. Add a GET case where the plugin is absent from configuredPlugins/sandboxedPluginEntries and the runtime lookup supplies the schema, asserting 200 with the schema keys and masked secrets. --- .../unit/api/plugin-settings-route.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/core/tests/unit/api/plugin-settings-route.test.ts b/packages/core/tests/unit/api/plugin-settings-route.test.ts index 5f8fdf5ea1..758ccc9280 100644 --- a/packages/core/tests/unit/api/plugin-settings-route.test.ts +++ b/packages/core/tests/unit/api/plugin-settings-route.test.ts @@ -108,6 +108,34 @@ describe("plugin settings route", () => { expect("secretKey" in body.values).toBe(false); }); + it("GET resolves a runtime-installed (marketplace) plugin via the runtime schema fallback", async () => { + // The plugin is not in configuredPlugins/sandboxedPluginEntries, so the + // static lookup misses and the route must fall back to + // getRuntimePluginSettingsSchema — the fix for runtime-installed plugins. + const runtimeLocals = { + emdash: { + db, + configuredPlugins: [], + sandboxedPluginEntries: [], + getRuntimePluginSettingsSchema: (id: string) => (id === "emdash-forms" ? SCHEMA : null), + }, + user: { id: "u1", role: Role.ADMIN }, + }; + const ctx = { + params: { id: "emdash-forms" }, + request: new Request("http://localhost/_emdash/api/admin/plugins/emdash-forms/settings"), + locals: runtimeLocals, + } as unknown as Parameters[0]; + + const response = await settingsGet(ctx); + expect(response.status).toBe(200); + const { data: body } = (await response.json()) as { + data: { schema: Record; secretsSet: Record }; + }; + expect(Object.keys(body.schema)).toEqual(["siteKey", "secretKey"]); + expect(body.secretsSet).toEqual({ secretKey: false }); + }); + it("PUT requires plugins:manage and persists values", async () => { const put = (user: { id: string; role: number } | null) => settingsPut( From 82b75ee6e08209179454186b8172f5967636daf8 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:53:02 +0200 Subject: [PATCH 5/7] fix(admin): correct PluginSettings select items shape and toast usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addressing emdashbot review: Kumo Select.items expects a value->label Record (or string[]), not {value,label}[] — building the record first so the select trigger renders the chosen label. Also align with admin conventions: use Toast.useToastManager() with type:"error" instead of useKumoToastManager() + variant, and re-export the plugin-settings client functions and SettingField from the api barrel. --- .../admin/src/components/PluginSettings.tsx | 43 +++++++++++-------- packages/admin/src/lib/api/index.ts | 3 ++ 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/packages/admin/src/components/PluginSettings.tsx b/packages/admin/src/components/PluginSettings.tsx index 5d688e65a6..c2adb7c189 100644 --- a/packages/admin/src/components/PluginSettings.tsx +++ b/packages/admin/src/components/PluginSettings.tsx @@ -7,7 +7,7 @@ * server never returns their values, only whether one is set. */ -import { Button, Input, InputArea, Select, Switch, useKumoToastManager } from "@cloudflare/kumo"; +import { Button, Input, InputArea, Select, Switch, Toast } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { FloppyDisk } from "@phosphor-icons/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -30,7 +30,7 @@ export interface PluginSettingsProps { export function PluginSettings({ pluginId }: PluginSettingsProps) { const { t } = useLingui(); const queryClient = useQueryClient(); - const toastManager = useKumoToastManager(); + const toastManager = Toast.useToastManager(); const { data: plugin } = useQuery({ queryKey: ["plugins", pluginId], @@ -66,7 +66,6 @@ export function PluginSettings({ pluginId }: PluginSettingsProps) { void queryClient.invalidateQueries({ queryKey: ["plugin-settings", pluginId] }); toastManager.add({ title: t`Settings saved successfully`, - variant: "success", timeout: 3000, }); }, @@ -74,7 +73,7 @@ export function PluginSettings({ pluginId }: PluginSettingsProps) { toastManager.add({ title: t`Failed to save settings`, description: err instanceof Error ? err.message : t`An error occurred`, - variant: "error", + type: "error", timeout: 5000, }); }, @@ -304,21 +303,27 @@ function SettingFieldInput({ ); - case "select": - return ( - - ); + case "select": { + // Kumo Select.items is a value->label record, not {value,label}[]. + const items: Record = {}; + for (const option of field.options) { + items[option.value] = option.label; + } + return ( + + ); + } case "secret": return ( diff --git a/packages/admin/src/lib/api/index.ts b/packages/admin/src/lib/api/index.ts index f4eb905825..4a65d7289d 100644 --- a/packages/admin/src/lib/api/index.ts +++ b/packages/admin/src/lib/api/index.ts @@ -101,8 +101,11 @@ export { // Plugins export { type PluginInfo, + type SettingField, fetchPlugins, fetchPlugin, + fetchPluginSettings, + updatePluginSettings, enablePlugin, disablePlugin, } from "./plugins.js"; From 553cc4655d7a849873646d8663e97b4b4d3ebb03 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Mon, 20 Jul 2026 07:56:26 +0000 Subject: [PATCH 6/7] style: format --- .../admin/src/components/PluginSettings.tsx | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/admin/src/components/PluginSettings.tsx b/packages/admin/src/components/PluginSettings.tsx index c2adb7c189..5abda32d1c 100644 --- a/packages/admin/src/components/PluginSettings.tsx +++ b/packages/admin/src/components/PluginSettings.tsx @@ -303,27 +303,27 @@ function SettingFieldInput({ ); - case "select": { - // Kumo Select.items is a value->label record, not {value,label}[]. - const items: Record = {}; - for (const option of field.options) { - items[option.value] = option.label; + case "select": { + // Kumo Select.items is a value->label record, not {value,label}[]. + const items: Record = {}; + for (const option of field.options) { + items[option.value] = option.label; + } + return ( + + ); } - return ( - - ); - } case "secret": return ( From 3e4b3ef3fed846dc11629a7ead49511ae7e34c3e Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:30:37 +0200 Subject: [PATCH 7/7] fix(plugins): wrap settings update writes in a transaction The update handler validated every field up front but then ran the delete/set writes in a bare loop, so a failure partway through left the options table half-updated. Wrap the write loop + read-back in withTransaction (real transaction on SQLite/Postgres; degrades to a direct run on D1, which is single-writer so per-statement atomicity still holds), using a transaction-scoped OptionsRepository. --- .../core/src/api/handlers/plugin-settings.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/core/src/api/handlers/plugin-settings.ts b/packages/core/src/api/handlers/plugin-settings.ts index 972f83b203..118e1b490f 100644 --- a/packages/core/src/api/handlers/plugin-settings.ts +++ b/packages/core/src/api/handlers/plugin-settings.ts @@ -10,6 +10,7 @@ import type { Kysely } from "kysely"; import { OptionsRepository } from "../../database/repositories/options.js"; +import { withTransaction } from "../../database/transaction.js"; import type { Database } from "../../database/types.js"; import type { SandboxedPluginEntry } from "../../emdash-runtime.js"; import type { ResolvedPlugin, SettingField } from "../../plugins/types.js"; @@ -188,16 +189,23 @@ export async function handlePluginSettingsUpdate( } } - const optionsRepo = new OptionsRepository(db); - for (const [key, value] of Object.entries(updates)) { - if (value === null) { - await optionsRepo.delete(settingsKey(pluginId, key)); - } else { - await optionsRepo.set(settingsKey(pluginId, key), value); + // Wrap the writes + read-back in a transaction so a partial failure + // can't leave some settings updated and others not. On D1 + // withTransaction degrades to running the callback directly — D1 is + // single-writer, so per-statement atomicity still holds. + const data = await withTransaction(db, async (trx) => { + const txRepo = new OptionsRepository(trx); + for (const [key, value] of Object.entries(updates)) { + if (value === null) { + await txRepo.delete(settingsKey(pluginId, key)); + } else { + await txRepo.set(settingsKey(pluginId, key), value); + } } - } + return buildSettingsResponse(txRepo, pluginId, schema); + }); - return { success: true, data: await buildSettingsResponse(optionsRepo, pluginId, schema) }; + return { success: true, data }; } catch { return { success: false,