feat(plugins): auto-generate admin settings UI from settingsSchema (#341)#1893
Conversation
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 detectedLatest commit: 3e4b3ef The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
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 |
Scope checkThis 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. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
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:
-
Sandbox-bypass build-time entries lose
settingsSchema. The PR addssettingsSchematoadaptSandboxEntry,generatePluginsModule,generateSandboxedPluginsModule, andloadMarketplacePluginsBypassed, but the siblingloadBypassedPluginspath (used whensandbox: falseloads build-timesandboxed: []entries in-process) still only passesadminPages,adminWidgets,portableTextBlocks, andfieldWidgets. Those plugins will reporthasSettings: falseand the settings endpoint will return an empty schema. -
Runtime-installed marketplace plugins are not resolvable.
getPluginSettingsSchemaonly looks atconfiguredPluginsand build-timesandboxedPluginEntries. Plugins installed from the marketplace at runtime are loaded into the sandbox from R2 and only exist inmarketplaceManifestCache; 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, |
There was a problem hiding this comment.
[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.
| settingsSchema: bundle.manifest.admin?.settingsSchema, | |
| }) | |
| ), | |
| + settingsSchema: entry.settingsSchema, | |
| portableTextBlocks: entry.portableTextBlocks, | |
| fieldWidgets: entry.fieldWidgets, | |
| }); |
There was a problem hiding this comment.
Fixed in 8f779b4 — loadBypassedPlugins now passes settingsSchema: entry.settingsSchema to adaptSandboxEntry, same as the marketplace-bypass path.
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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).
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
|
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.
|
Good catch, fixed in 8e68932. Emptying a field now sends |
There was a problem hiding this comment.
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:
- Sandbox-bypass build-time entries now carry
settingsSchema:loadBypassedPluginspassessettingsSchema: entry.settingsSchematoadaptSandboxEntry. - Runtime-installed marketplace/registry plugins are now resolvable:
handlePluginListaccepts aruntimeSettingsSchemaLookup,EmDashRuntime.getRuntimePluginSettingsSchemareads frommarketplaceManifestCache, and the settings route falls back to it when the plugin isn't inconfiguredPlugins/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, |
There was a problem hiding this comment.
[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.
| 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 () => { |
There was a problem hiding this comment.
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).
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.
There was a problem hiding this comment.
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:
- Sandbox-bypass build-time entries now carry
settingsSchemavialoadBypassedPlugins→adaptSandboxEntry. - Runtime-installed marketplace/registry plugins are now resolvable:
handlePluginListaccepts aruntimeSettingsSchemaLookup,EmDashRuntime.getRuntimePluginSettingsSchemareads frommarketplaceManifestCache, 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.tsxpasses the raw{ value, label }[]array to<Select items={field.options}>, but KumoSelect.itemsexpects aRecord<string, string>(orstring[]). Every comparable select in the admin (ContentEditor.tsx,Widgets.tsx,BylineFieldEditor.tsx, etc.) builds aRecord<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()andtype: "error"instead ofuseKumoToastManager()+variant. - Re-export the new
fetchPluginSettings,updatePluginSettings, andSettingFieldfrompackages/admin/src/lib/api/index.tsto 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:313Kumo
Select.itemsexpects aRecord<string, string>(orstring[]), butfield.optionsis 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.tsxandWidgets.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:10The rest of the admin accesses the toast manager through
Toast.useToastManager()(PluginManager.tsx,MenuEditor.tsx,SectionEditor.tsx, etc.) and usestype: "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"/ novariantin thetoastManager.addcalls. -
[suggestion]
packages/admin/src/lib/api/index.ts:103-108The 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";
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.
|
@emdashbot all three addressed in 82b75ee: [needs fixing] Select items shape: Toast convention: switched to Barrel re-export: Component suite 7/7 green. |
There was a problem hiding this comment.
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:
PluginSettings.tsxnow builds aRecord<string, string>forSelect.itemsbefore passing it in.- Toast usage now matches the rest of the admin:
Toast.useToastManager()withtype: "error". - The new
fetchPluginSettings,updatePluginSettings, andSettingFieldare re-exported frompackages/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).
| 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) }; |
There was a problem hiding this comment.
[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.
| 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";There was a problem hiding this comment.
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.
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.
There was a problem hiding this comment.
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:
PluginSettings.tsxnow builds aRecord<string, string>forSelect.itemsbefore passing it in.- Toast usage now matches the rest of the admin:
Toast.useToastManager()withtype: "error". - The new
fetchPluginSettings,updatePluginSettings, andSettingFieldare re-exported frompackages/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.
…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>
What does this PR do?
Plugins can declare
admin.settingsSchemaindefinePlugin(), 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 foradmin.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:
GET/PUT /_emdash/api/admin/plugins/[id]/settings(both gated onplugins:manage). GET returns the schema plus current values with defaults applied; PUT validates each value against its field type (string,numberwithmin/max,boolean,selectagainstoptions,url,email,secret) and rejects keys not in the schema.nullclears a stored value.secretsSetflag. 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.plugin:{id}:settings:{key}in the options table), so plugins read them with the documentedctx.kv.get<T>("settings:<key>")— no new storage concept./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 asettingsSchemaand a separate pages icon foradmin.pages.settingsSchemais threaded through the sandboxed-plugin descriptor and virtual modules, so standard-format bundles whose manifest carriesadmin.settingsSchemaget the same form — bundle manifests already serialize it.Verified live against the Cloudflare demo (workerd + D1): the forms plugin's
turnstileSiteKey/turnstileSecretKeyschema renders, saves, survives reload with the secret masked, and the values land underplugin:emdash-forms:settings:*in the options table.Closes #341
Type of change
(Filed as a bug fix per the issue: the documented
settingsSchemabehavior didn't exist. Happy to reframe as a feature + Discussion if maintainers prefer.)Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.AI-generated code disclosure
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.