Skip to content

fix(admin): make the plugin and theme screens keep the panel's promises - #123

Merged
jouwdan merged 1 commit into
mainfrom
claude/mei52-plugin-theme-hygiene
Aug 16, 2026
Merged

fix(admin): make the plugin and theme screens keep the panel's promises#123
jouwdan merged 1 commit into
mainfrom
claude/mei52-plugin-theme-hygiene

Conversation

@jouwdan

@jouwdan jouwdan commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Three defects audited in MEI-52, all on the plugin and theme admin surface, so they ship as one PR. Conventions: docs/nextjs-conventions.md.

1. Re-auth stopped at the edge of themes and plugins (finding E)

The panel banner promises "It has been a while since you confirmed your password. Anything destructive will ask again" (apps/community/app/admin/layout.tsx). Nine destructive user/group/forum actions honour it with requireFreshAdmin(). Nothing in themes or plugins did — including theme reset and import, which throw away every stored override for a theme in one press while the screen itself warns "Take a copy of the export above first".

This follows the reasoning #115 established for API tokens: the fresh gate is for the press that cannot be taken back, not for every write.

What I classified as destructive, and why

Action Gate Why
resetThemeAction requireFreshAdmin() Deletes every token override and the custom CSS for that theme, and deletes the row outright when nothing else is left in it. No undo, no copy kept — the screen's own advice is to export first.
importThemeAction requireFreshAdmin() The same blast radius by a different route: the pasted document replaces the whole override set wholesale. What was there is gone, and the replacement came from outside the board.
setPluginEnabledAction, disabling requireFreshAdmin() Takes a live capability off the whole board for every member at once. It is durable across instances and survives a redeploy, and it stops the plugin's scheduled tasks, its pages and its endpoints (/plugins/<key>/… starts answering 404). The highest-blast-radius button on the screen.
setPluginEnabledAction, enabling requireAdmin() It is the undo. Gating the way back out of a disable would be friction with nothing behind it.
saveThemeAction / previewThemeAction requireAdmin() Editing a palette from a form pre-filled with the current values. Reversible by editing back; preview writes nothing at all.
setThemeEnabledAction requireAdmin() Undone by the control beside it. A theme turned off can be turned on again with nothing lost.
setDefaultThemeAction requireAdmin() Moves a pointer. Move it back.
savePluginSettingsAction requireAdmin() Same category as saving a palette — an edit to values the screen is already showing.

The gate in setPluginEnabledAction reads the direction out of the FormData before choosing which require*() to call. Reading form fields is side-effect-free, so nothing happens before authorization.

The one e2e press of a now-gated control (admin-tabs-no-js.spec.ts:721, "Reset to the theme") signs in at the top of the file and resets well inside the 15-minute REAUTH_MINUTES window, so it is unaffected.

2. Env-owned boolean plugin settings were silently overwritten (finding F)

savePluginSettingsAction skipped settings the environment owns with:

if (raw === null && type !== 'boolean') continue

— which exempted exactly the kind of field that can never be submitted. An env-owned boolean renders as a disabled checkbox captioned "…in the environment — this box is inert" (plugin-forms.tsx). A disabled checkbox posts nothing, so raw === null, so the boolean branch below stored '0' — an override written under a box that was displaying on, from the environment, at the moment of the save.

That is precisely the deferred surprise the guard's own comment forbade: the stored '0' is invisible while the variable is set, and the day the variable comes out of the environment the plugin silently flips to a value nobody chose.

The row data already knows each field's provenance — source === 'environment' is what greys the control — so the action now resolves the same detail with resolvePluginSettingDetails(definition, overrides, readPluginEnv) and skips env-owned fields whatever their type. The existing raw === null skip is kept underneath it, so nothing else about which absent fields get written changes.

3. Region and health reads did not sync the operator disable (finding F)

filterView and emitEvent in plugin-view.tsx await syncOperatorDisables() before touching the host. pluginRegion/boardRegion did not, and pluginInventory in plugin-admin.ts read pluginHost.health() without it either.

PluginHost initialises every plugin's operatorDisabled to false, so in a freshly started process the in-memory set says "everything is enabled" until something reads the settings table. Until some unrelated request happened to sync first, a plugin the operator had switched off could still render its contributions into a page, and the admin screen could report "Running on this server" for it.

All three paths now reconcile before they read. pluginRegion and boardRegion become async as a result, and their four call sites await them:

  • app/(board)/page.tsxindex.footer
  • app/(board)/member/[id]/page.tsxprofile.panel
  • app/(board)/thread/[slug]/page.tsxpostbit.badges, postbit.footer
  • src/components/shell/page-shell.tsxheader.notice

All five remain Server Components, and no client component gained a domain-package import.

Tests

New, and each one proved to fail before it passed — the fix reverted, the test watched go red, the fix restored:

Test Breaking the fix
theme-admin-actions.test.tsasks for a fresh password before throwing every override away, destroys nothing when the proof is stale reset + import back to requireAdmin()3 red
theme-admin-actions.test.tslets the reversible writes through on the panel session alone over-gating setDefaultThemeAction2 red (pins the classification in both directions)
plugin-admin-actions.test.tsasks for a fresh password before taking a plugin off the board, switches nothing off when the proof is stale, puts a plugin back, and saves settings, on the panel session alone disable back to requireAdmin()2 red
plugin-admin-actions.test.tsleaves an env-owned boolean unwritten rather than storing the inert box as "0", plus companions for the string case and for a declared-but-unset variable restoring the type !== 'boolean' exemption → 1 red
plugin-view.test.ts (new file) — region reads against a real PluginHost, where the sync is the only thing that ever applies the switch dropping await syncOperatorDisables()4 red
plugin-admin.test.tsreconciles the host before reading health, so a fresh process does not say "running" dropping the sync in pluginInventory1 red

The env-owned tests drive the real readPluginEnv through vi.stubEnv, so they exercise the resolution path production uses rather than a stand-in.

Documentation

No inline comments — the explanations went into docs/operating.md in the same commit: the reset/import gate under What you can change without a deploy, the disable gate and the fresh-process sync under Administering one, and the greyed-field rule beside the existing note on where plugin credentials live.

Validation

  • pnpm verify — exit 0 (5731 passed, 8 skipped; the single remaining lint warning is pre-existing in admin.test.ts)
  • DATA_SOURCE=fixture pnpm build — exit 0, required because this diff touches app/
  • pnpm lint, pnpm typecheck, pnpm typecheck:app and all 53 apps/community/src/server test files (701 tests) re-run against the committed tree
  • pnpm test:e2e not run — fixed ports shared with sibling worktrees. No e2e spec was changed.

Refs MEI-52.

🤖 Generated with Claude Code


Generated by Claude Code

The panel's banner says "Anything destructive will ask again", and the user,
group and forum screens honour it with `requireFreshAdmin()`. Nothing in themes
or plugins did. Theme **reset** and **import** each replace every stored
override for a theme in one press, with no undo — the import screen itself says
"Take a copy of the export above first" — so both now take the fresh gate.
Turning a plugin off takes a live capability away from every member of the board
at once, durably and on every instance, so disabling takes it too. The
reversible controls beside them keep the plain admin gate: activating a theme,
moving the default, saving a palette from the editor, saving plugin settings,
and enabling a plugin, which is the undo for the one gate that was added.

An env-owned plugin setting was silently overwritten whenever it was a boolean.
The guard that skips settings the environment owns read `raw === null && type
!== 'boolean'`, exempting exactly the kind that cannot be submitted: an
env-owned checkbox renders disabled, so it is absent from the POST, so every
save stored a `'0'` override under a box that displayed "on" from the
environment — the deferred surprise the guard existed to prevent, since removing
the variable later would flip the plugin to a stored value nobody chose. The
save now resolves each field's source the same way the screen does and skips
`source === 'environment'` whatever the field's type.

`pluginRegion`/`boardRegion` rendered plugin contributions and `pluginInventory`
read `pluginHost.health()` without first awaiting `syncOperatorDisables()`,
unlike their siblings `filterView` and `emitEvent`. A freshly started process
defaults its in-memory disabled set to "enabled", so a switched-off plugin's UI
could render and "Running on this server" could misreport until some unrelated
request happened to sync. All three paths now reconcile before they read;
`pluginRegion` and `boardRegion` become async and their four call sites await.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kP43A5shJWmAKqkBovhaF
@jouwdan
jouwdan merged commit d79c2d1 into main Aug 16, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants