Skip to content

feat(plugins): auto-generate admin settings UI from settingsSchema (#341)#1893

Merged
ascorbic merged 7 commits into
emdash-cms:mainfrom
swissky:feat/plugin-settings-schema-ui
Jul 21, 2026
Merged

feat(plugins): auto-generate admin settings UI from settingsSchema (#341)#1893
ascorbic merged 7 commits into
emdash-cms:mainfrom
swissky:feat/plugin-settings-schema-ui

Conversation

@swissky

@swissky swissky commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Plugins can declare admin.settingsSchema in definePlugin(), and the docs describe an auto-generated settings form — but nothing in the admin ever rendered it (#341). The schema wasn't exposed through any API, the plugin card only showed a gear for admin.pages, and plugin authors had to build a custom React page for even a two-field config.

This PR wires the schema up end to end:

  • API: new GET/PUT /_emdash/api/admin/plugins/[id]/settings (both gated on plugins:manage). GET returns the schema plus current values with defaults applied; PUT validates each value against its field type (string, number with min/max, boolean, select against options, url, email, secret) and rejects keys not in the schema. null clears a stored value.
  • Secrets are write-only: GET never returns a stored secret — only a secretsSet flag. The form shows "Currently set — enter a new value to replace" and offers an explicit "Clear stored value" action; untouched secrets are omitted from the PUT payload entirely.
  • Storage: values persist to the plugin's existing KV namespace (plugin:{id}:settings:{key} in the options table), so plugins read them with the documented ctx.kv.get<T>("settings:<key>") — no new storage concept.
  • Admin UI: new auto-generated form at /plugins-manager/{id}/settings (namespaced under the plugin manager so it can't collide with plugin-defined admin pages). The plugin card now shows a gear for plugins with a settingsSchema and a separate pages icon for admin.pages.
  • Schema propagation: settingsSchema is threaded through the sandboxed-plugin descriptor and virtual modules, so standard-format bundles whose manifest carries admin.settingsSchema get the same form — bundle manifests already serialize it.

Verified live against the Cloudflare demo (workerd + D1): the forms plugin's turnstileSiteKey/turnstileSecretKey schema renders, saves, survives reload with the secret masked, and the values land under plugin:emdash-forms:settings:* in the options table.

Closes #341

Type of change

  • Bug fix

(Filed as a bug fix per the issue: the documented settingsSchema behavior didn't exist. Happy to reframe as a feature + Discussion if maintainers prefer.)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: n/a — implements already-documented behavior reported broken in Plugin settingsSchema does not auto-generate admin settings UI #341

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Cursor + Claude (Fable 5)

Screenshots / test output

Tests: 19 new core tests (plugin-settings-handlers.test.ts, plugin-settings-route.test.ts: schema resolution for configured + sandboxed plugins, validation per field type, secret masking, 401/403 on both methods, route registration) and 6 admin component tests (PluginSettings.test.tsx: rendering per field type, write-only secret contract, save payload, clear-secret flow). Full suites: core 4545 passed, admin 1064 passed.

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 emdash-cms#341
@changeset-bot

changeset-bot Bot commented Jul 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3e4b3ef

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 1,354 lines across 20 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Jul 9, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jul 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/admin@1893

@emdash-cms/auth

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth@1893

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth-atproto@1893

@emdash-cms/blocks

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/blocks@1893

@emdash-cms/cloudflare

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/cloudflare@1893

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/contentful-to-portable-text@1893

emdash

npm i https://pkg.pr.new/emdash-cms/emdash@1893

create-emdash

npm i https://pkg.pr.new/emdash-cms/emdash/create-emdash@1893

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/gutenberg-to-portable-text@1893

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-cli@1893

@emdash-cms/plugin-types

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-types@1893

@emdash-cms/registry-client

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-client@1893

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-lexicons@1893

@emdash-cms/registry-verification

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-verification@1893

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/sandbox-workerd@1893

@emdash-cms/x402

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/x402@1893

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-ai-moderation@1893

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-atproto@1893

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-audit-log@1893

@emdash-cms/plugin-color

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-color@1893

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-embeds@1893

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-field-kit@1893

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-forms@1893

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-webhook-notifier@1893

commit: 3e4b3ef

@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 9, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right change for #341: it wires the documented admin.settingsSchema through the API, admin UI, and virtual-module/adapter plumbing, with write-only secrets and plugins:manage authorization. The implementation is generally solid — handlers use the existing options KV store, secrets are masked on GET, unknown keys are rejected, and both core and admin tests cover the happy path and auth boundaries.

I found two real gaps that should be addressed before this is complete:

  1. Sandbox-bypass build-time entries lose settingsSchema. The PR adds settingsSchema to adaptSandboxEntry, generatePluginsModule, generateSandboxedPluginsModule, and loadMarketplacePluginsBypassed, but the sibling loadBypassedPlugins path (used when sandbox: false loads build-time sandboxed: [] entries in-process) still only passes adminPages, adminWidgets, portableTextBlocks, and fieldWidgets. Those plugins will report hasSettings: false and the settings endpoint will return an empty schema.

  2. Runtime-installed marketplace plugins are not resolvable. getPluginSettingsSchema only looks at configuredPlugins and build-time sandboxedPluginEntries. Plugins installed from the marketplace at runtime are loaded into the sandbox from R2 and only exist in marketplaceManifestCache; the settings endpoint returns 404 for them and the admin never shows the gear icon.

Both are tied to the feature being introduced and would leave documented behavior broken for those plugin sources.

size:
w.size === "full" || w.size === "half" || w.size === "third" ? w.size : undefined,
})),
settingsSchema: bundle.manifest.admin?.settingsSchema,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The PR correctly forwards settingsSchema for marketplace-bypass plugins here, but the sibling loadBypassedPlugins path above still drops it. When sandbox: false loads build-time sandboxed: [] entries in-process, adaptSandboxEntry receives adminPages, adminWidgets, portableTextBlocks, and fieldWidgets but not settingsSchema. The resulting ResolvedPlugin will have an empty admin.settingsSchema, so handlePluginList reports hasSettings: false and the settings endpoint returns {} for a plugin that actually declared a schema.

Suggested change
settingsSchema: bundle.manifest.admin?.settingsSchema,
})
),
+ settingsSchema: entry.settingsSchema,
portableTextBlocks: entry.portableTextBlocks,
fieldWidgets: entry.fieldWidgets,
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8f779b4loadBypassedPlugins now passes settingsSchema: entry.settingsSchema to adaptSandboxEntry, same as the marketplace-bypass path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed — loadBypassedPlugins forwards settingsSchema: entry.settingsSchema into adaptSandboxEntry (emdash-runtime.ts:1813), so the in-process bypass path keeps the schema.

* Returns null when the plugin doesn't exist, undefined-equivalent
* empty object when it declares no schema.
*/
export function getPluginSettingsSchema(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] getPluginSettingsSchema only checks configuredPlugins and build-time sandboxedPluginEntries. Plugins installed from the marketplace at runtime are loaded from R2 into marketplaceManifestCache and are not in either of those arrays. The API route will return 404 for those plugins and the admin UI will never show the gear icon (state-only plugins already hardcode hasSettings: false).

This needs a fallback that consults the runtime marketplace manifest cache or a runtime method that resolves the schema for all currently-loaded plugin sources.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8f779b4 — the runtime now exposes getRuntimePluginSettingsSchema(pluginId), which resolves the schema from the cached manifest (marketplaceManifestCache already stored bundle.manifest.admin wholesale, so settingsSchema was there — only the map's declared type stripped it). The settings GET/PUT route falls back to it when the build-time lists miss, and handlePluginList uses the same lookup so runtime-installed plugins get a correct hasSettings and show the gear icon. Covered by a new list-handler test (marketplace state row + schema lookup → hasSettings: true/false).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed — the settings routes fall back to emdash.getRuntimePluginSettingsSchema(id), which resolves the schema from the runtime marketplace manifest cache (returns {} for known schema-less plugins, null for unknown), so marketplace-installed plugins get their gear icon and settings endpoint.

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).
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review overlap and removed review/needs-review No maintainer or bot review yet labels Jul 9, 2026
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@ascorbic

Copy link
Copy Markdown
Collaborator

This is great. Just one issue: clearing a value sets it to an empty string, rather than deleting it, so it no longer reverts to the 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.
@swissky

swissky commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

Good catch, fixed in 8e68932. Emptying a field now sends null in the PUT payload, so the server deletes the stored value and the schema default applies again — previously the empty string was persisted and shadowed the default forever. (Deleting a never-set key is a no-op, so unchanged empty fields are safe.) Added a component test asserting the cleared field arrives as null, not "".

@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jul 20, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right change for #341, and the follow-up addresses both of my prior findings.

Approach: wiring admin.settingsSchema through a dedicated plugins:manage-gated API endpoint and an auto-generated admin form, persisted to the per-plugin KV namespace, with write-only secrets, fits EmDash's existing plugin/KV architecture. The namespace choice (/plugins-manager/$id/settings rather than /plugins/$id/settings) avoids shadowing plugin-defined admin pages.

Prior findings — both fixed:

  1. Sandbox-bypass build-time entries now carry settingsSchema: loadBypassedPlugins passes settingsSchema: entry.settingsSchema to adaptSandboxEntry.
  2. Runtime-installed marketplace/registry plugins are now resolvable: handlePluginList accepts a runtimeSettingsSchemaLookup, EmDashRuntime.getRuntimePluginSettingsSchema reads from marketplaceManifestCache, and the settings route falls back to it when the plugin isn't in configuredPlugins/sandboxedPluginEntries.

Remaining gap: the new route tests verify auth, configured-plugin success, and the all-miss 404 path, but because getRuntimePluginSettingsSchema is always mocked to return null, they never exercise the runtime-plugin positive case. Since that fallback was the specific fix for the prior finding, it should have a reproducing test.

Otherwise the PR is solid: plugins:manage authorization is correct, validation covers all documented field types, secrets are masked on GET and omitted from the PUT payload when untouched, values land under the documented plugin:{id}:settings:{key} keys, admin strings are localized, Tailwind classes are RTL-safe, and a changeset is present.

db,
configuredPlugins: [PLUGIN],
sandboxedPluginEntries: [],
getRuntimePluginSettingsSchema: () => null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The test mocks getRuntimePluginSettingsSchema to always return null, so the suite never verifies the runtime-installed plugin fallback that was added specifically to fix the prior review's marketplace-plugin gap. The existing cases cover configured plugins and the 404 path when both lookups miss, but not the positive case where only emdash.getRuntimePluginSettingsSchema(id) returns a schema.

Add a test that leaves configuredPlugins/sandboxedPluginEntries empty, makes getRuntimePluginSettingsSchema return a schema, and asserts that GET returns 200 with the schema and masked secrets and that PUT persists values.

Suggested change
getRuntimePluginSettingsSchema: () => null,
getRuntimePluginSettingsSchema: () => null,
},
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<typeof settingsGet>[0];
it("GET/PUT fall back to runtime schema for marketplace/registry plugins", async () => {
const runtimeSchema: Record<string, SettingField> = {
webhookUrl: { type: "url", label: "Webhook URL" },
token: { type: "secret", label: "Token" },
};
const runtimeCtx = {
params: { id: "runtime-plugin" },
request: new Request("http://localhost/_emdash/api/admin/plugins/runtime-plugin/settings"),
locals: {
emdash: {
db,
configuredPlugins: [],
sandboxedPluginEntries: [],
getRuntimePluginSettingsSchema: (pluginId: string) =>
pluginId === "runtime-plugin" ? runtimeSchema : null,
},
user: { id: "u1", role: Role.ADMIN },
},
} as unknown as Parameters<typeof settingsGet>[0];
const getResponse = await settingsGet(runtimeCtx);
expect(getResponse.status).toBe(200);
const { data: getBody } = (await getResponse.json()) as {
data: { schema: Record<string, unknown>; secretsSet: Record<string, boolean> };
};
expect(Object.keys(getBody.schema)).toEqual(["webhookUrl", "token"]);
expect(getBody.secretsSet).toEqual({ token: false });
const putResponse = await settingsPut({
...runtimeCtx,
request: new Request(
"http://localhost/_emdash/api/admin/plugins/runtime-plugin/settings",
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
values: { webhookUrl: "https://example.com", token: "sekrit" },
}),
},
),
} as unknown as Parameters<typeof settingsPut>[0]);
expect(putResponse.status).toBe(200);
const { data: putBody } = (await putResponse.json()) as {
data: { values: Record<string, unknown>; secretsSet: Record<string, boolean> };
};
expect(putBody.values.webhookUrl).toBe("https://example.com");
expect(putBody.secretsSet).toEqual({ token: true });
expect("token" in putBody.values).toBe(false);
});
it("GET returns 401 for anonymous users", async () => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e2c7e0d — added a GET case where the plugin is absent from configuredPlugins/sandboxedPluginEntries and getRuntimePluginSettingsSchema supplies the schema, asserting 200 with the schema keys and masked secrets. The runtime-installed fallback path is now exercised (7/7 in the route suite green).

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
@github-actions github-actions Bot added the review/awaiting-author Reviewed; waiting on the author to respond label Jul 20, 2026
@github-actions github-actions Bot removed the review/needs-rereview Author pushed changes since the last review label Jul 20, 2026
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.
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 20, 2026
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right change for #341 and the follow-up addresses the two prior findings.

Approach: Wiring admin.settingsSchema through a plugins:manage-gated API endpoint and an auto-generated admin form, persisted to the per-plugin KV namespace, with write-only secrets, fits EmDash's existing plugin/KV architecture. The namespace choice (/plugins-manager/$id/settings) avoids shadowing plugin-defined admin pages.

Prior findings — both fixed:

  1. Sandbox-bypass build-time entries now carry settingsSchema via loadBypassedPluginsadaptSandboxEntry.
  2. Runtime-installed marketplace/registry plugins are now resolvable: handlePluginList accepts a runtimeSettingsSchemaLookup, EmDashRuntime.getRuntimePluginSettingsSchema reads from marketplaceManifestCache, and the settings route falls back to it. The new route test now exercises the runtime-plugin GET positive case.

New blocking/needs-fixing issue:

  • PluginSettings.tsx passes the raw { value, label }[] array to <Select items={field.options}>, but Kumo Select.items expects a Record<string, string> (or string[]). Every comparable select in the admin (ContentEditor.tsx, Widgets.tsx, BylineFieldEditor.tsx, etc.) builds a Record<string, string> from options first. This will break the select trigger label rendering.

Suggestions:

  • Align toast usage with the rest of the admin: use Toast.useToastManager() and type: "error" instead of useKumoToastManager() + variant.
  • Re-export the new fetchPluginSettings, updatePluginSettings, and SettingField from packages/admin/src/lib/api/index.ts to keep the barrel file complete.

Only the Select items shape needs a fix before I'd sign off; the other two are convention/consistency nits.


Findings

  • [needs fixing] packages/admin/src/components/PluginSettings.tsx:313

    Kumo Select.items expects a Record<string, string> (or string[]), but field.options is an array of { value, label } objects. This will not populate the select trigger correctly.

    Every other select in the admin builds a record first, e.g. ContentEditor.tsx and Widgets.tsx:

    const items: Record<string, string> = {};
    for (const opt of field.options) {
      items[opt.value] = opt.label;
    }
    		case "select": {
    			const items: Record<string, string> = {};
    			for (const option of field.options) {
    				items[option.value] = option.label;
    			}
    			return (
    				<Select
    					label={field.label}
    					value={typeof value === "string" ? value : (field.default ?? "")}
    					onValueChange={(v) => v !== null && onChange(v)}
    					items={items}
    				>
    					{field.options.map((option) => (
    						<Select.Option key={option.value} value={option.value}>
    							{option.label}
    						</Select.Option>
    					))}
    				</Select>
    			);
    		}
    
  • [suggestion] packages/admin/src/components/PluginSettings.tsx:10

    The rest of the admin accesses the toast manager through Toast.useToastManager() (PluginManager.tsx, MenuEditor.tsx, SectionEditor.tsx, etc.) and uses type: "error" for error toasts. Prefer that convention so the component matches the codebase:

    import { Button, Input, InputArea, Select, Switch, Toast } from "@cloudflare/kumo";
    

    and later:

    	const toastManager = Toast.useToastManager();
    

    with type: "error" / no variant in the toastManager.add calls.

  • [suggestion] packages/admin/src/lib/api/index.ts:103-108

    The new plugin-settings client exports should be re-exported from the barrel file alongside the existing plugin APIs:

    export {
    	type PluginInfo,
    	type SettingField,
    	fetchPlugins,
    	fetchPlugin,
    	fetchPluginSettings,
    	updatePluginSettings,
    	enablePlugin,
    	disablePlugin,
    } from "./plugins.js";
    

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
swissky and others added 2 commits July 20, 2026 09:53
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.
@swissky

swissky commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

@emdashbot all three addressed in 82b75ee:

[needs fixing] Select items shape: PluginSettings.tsx now builds a Record<string, string> from field.options before passing it to <Select items={items}>, matching the ContentEditor/Widgets pattern — the trigger label renders correctly.

Toast convention: switched to Toast.useToastManager() and type: "error" (dropped useKumoToastManager() + variant), consistent with PluginManager/MenuEditor.

Barrel re-export: fetchPluginSettings, updatePluginSettings, and SettingField are now re-exported from lib/api/index.ts alongside the other plugin APIs.

Component suite 7/7 green.

@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right change for #341 and a clean re-review: the three prior findings are all addressed.

Approach: Wiring admin.settingsSchema through plugins:manage-gated API endpoints and an auto-generated admin form, persisted to the per-plugin KV namespace, remains a good fit for EmDash's plugin/KV architecture. The namespace choice (/plugins-manager/$id/settings) avoids shadowing plugin-defined admin pages, and the write-only secret contract is implemented correctly end to end.

Prior findings — all fixed:

  1. PluginSettings.tsx now builds a Record<string, string> for Select.items before passing it in.
  2. Toast usage now matches the rest of the admin: Toast.useToastManager() with type: "error".
  3. The new fetchPluginSettings, updatePluginSettings, and SettingField are re-exported from packages/admin/src/lib/api/index.ts.

What I checked: the full diff, the new handler/route/admin files, sibling implementations of Select, the withTransaction helper, and AGENTS.md conventions (Lingui, RTL-safe Tailwind, SQL safety, API envelope shape, authorization, changesets). Lint/type/test results were not run (no shell).

Comment on lines +191 to +200
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) };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The update handler validates all fields up front but then performs the delete/set writes in a loop without a transaction. If a write fails partway through, the options table is left in an inconsistent state — some settings updated and some not. This is exactly the kind of partial-write data-integrity issue the handler's up-front validation is trying to prevent.

EmDash already has a withTransaction helper (packages/core/src/database/transaction.ts) that uses real transactions on SQLite/Postgres and safely falls back on D1. Add the import and wrap the write loop plus the read-back in a transaction so the whole update is atomic where the database supports it.

Suggested change
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) };
const optionsRepo = new OptionsRepository(db);
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 };

Also add:

import { withTransaction } from "../../database/transaction.js";

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 3e4b3ef — the update handler now wraps the delete/set write loop plus the read-back in withTransaction(db, async (trx) => ...) with a transaction-scoped OptionsRepository, so a partial failure can't leave the options table half-updated (real transaction on SQLite/Postgres; on D1 it degrades to a direct run, which is single-writer so per-statement atomicity still holds). Route suite 7/7 + handler suite 13/13 green.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
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.
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right change for #341 and a clean re-review: the three prior findings are all addressed.

Approach: Wiring admin.settingsSchema through plugins:manage-gated API endpoints and an auto-generated admin form, persisted to the per-plugin KV namespace, remains a good fit for EmDash's plugin/KV architecture. The namespace choice (/plugins-manager/$id/settings) avoids shadowing plugin-defined admin pages, and the write-only secret contract is implemented correctly end to end.

Prior findings — all fixed:

  1. PluginSettings.tsx now builds a Record<string, string> for Select.items before passing it in.
  2. Toast usage now matches the rest of the admin: Toast.useToastManager() with type: "error".
  3. The new fetchPluginSettings, updatePluginSettings, and SettingField are re-exported from packages/admin/src/lib/api/index.ts.

What I checked: the full diff, the new handler/route/admin files, sibling implementations of Select, the withTransaction helper, and AGENTS.md conventions (Lingui, RTL-safe Tailwind, SQL safety, API envelope shape, authorization, changesets). Lint/type/test results were not run (no shell).

I found no remaining bugs or convention violations. The handler correctly validates per field type, masks secrets, rejects unknown keys, wraps writes in a transaction, and stores values under the documented plugin:{id}:settings:{key} keys. The route requires plugins:manage for both methods. The UI only exposes the settings form for enabled plugins that declare a schema, sends null for cleared values, and omits untouched secrets from the PUT payload.

One low-confidence observation (not blocking): the admin form resets local state whenever the settings query data changes, which is the same pattern used by GeneralSettings.tsx. With the global staleTime: 1m, a background refetch after that window could clobber unsaved edits. This is pre-existing style, not a new regression, so I’m not raising it as a required fix.

LGTM.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Jul 20, 2026

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@ascorbic
ascorbic merged commit d4c565e into emdash-cms:main Jul 21, 2026
52 checks passed
swissky added a commit to swissky/emdash that referenced this pull request Jul 21, 2026
…mdash-cms#341) (emdash-cms#1893)

* 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 emdash-cms#341

* 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).

* 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.

* 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.

* fix(admin): correct PluginSettings select items shape and toast usage

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.

* style: format

* 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.

---------

Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plugin settingsSchema does not auto-generate admin settings UI

2 participants