createAdminHandler accepts an optional plugins array. Each plugin can
add new pages (SSR HTML inside the existing admin layout, plus inline
CSS/JS) and links on the edit screen and each list row. Plugins cannot
replace the dashboard, list, or form, intercept create/update/delete, or
add a sidebar entry.
Factory options such as relationGraphPlugin({ models, depth }) belong on the plugin author, not on createAdminHandler. The core only sees AdminPlugin.
AdminPlugin
name— unique, non-empty. Used in boot errors and internal view ids.pages—patterntokens are literals,:model, or:idonly. Typical graph URL:[':model', ':id', 'graph'].recordActions—{ label, href({ model, id, basePath }), models? }. Shown on edit and list rows (before Edit), never on create.models?: string[]on a page or action — omit means every visible model. A page whose pattern matches but whose model is not listed renders the admin NotFound page (HTTP 200,renderis not called).
Plugin patterns are matched before builtins (matchRoute receives [...pluginRoutes, ...BUILTIN_ROUTES]). That is what makes ['hello'] and [':model', 'stats'] reachable: otherwise the builtin :model list and :model/:id edit would swallow them (:id is a wildcard).
Only an identical, token-for-token pattern — [], ['_search'], ['_logout'], [':model'], [':model', 'new'], [':model', ':id'] — or
another plugin’s exact pattern throws at boot. A literal token in a :model or :id position instead shadows the matching builtin view
for that one value, silently: ['user'] takes over the entire User list
(and POST /admin/user becomes 405, since plugin pages are GET-only); ['user', 'new'] takes over User create; [':model', 'stats'] takes over
edit/delete for any model’s record whose primary key happens to be the
literal string "stats". Prefer wildcard-only additions like [':model', ':id', 'graph'], or a literal that is not a real model slug
(['hello']), to avoid this. Overlapping but non-identical plugin
patterns: the first registered plugin wins. Actions concatenate in plugins array order.
Security
Plugin pages run after authCheck. Logout POST is unchanged.
Reads must go through ctx.loadRecord / ctx.listRecords / ctx.getM2mSelectedIds. There is no ORM client on the context object — a
host that stashes one on event.locals can still be reached via ctx.event, since plugins are trusted dependencies, not sandboxed code.
loadRecord/listRecordsapply that model’sscopeandlistWhere(AND, never a spread) and striphiddenplus sensitive names (password/hash/secret/token) via the same redaction as the audit log.- That redaction (
redactForAudit) is a whitelist, not a blacklist: it copies only scalar schema fields onto the result, soctx.record,loadRecord, andlistRecordspayloads never carry relation objects, list fields, or unknown/computed columns either — not only the fields named inhiddenor matched by the sensitive-name predicate. - A
:idoutside the model’s scope renders the admin NotFound page (HTTP 200, same as a builtin missing record) —renderis not called. scopeandlistWhereare not interchangeable here.scopeis the tenant wall: it also covers the builtin detail, edit and delete paths, so a plugin page and/admin/user/1agree on what is out of bounds.listWherenarrows the list view (and, historically, plugin reads) but never protected builtin edit or delete — a plugin scoped only bylistWherestill sits in front of an unscoped edit screen.- A
scopeorlistWherethat returns{}still throws (fail-loud). - Action labels and hrefs are HTML-escaped. Plugin
html/styles/scriptsare developer-supplied (same trust asbranding.primaryColor); interpolate database fields withctx.escapeHtml.
POST (and any non-GET) to a plugin page is 405. Writes stay on the
builtin list/create/edit POST handlers, or on your own app routes linked
from a recordAction.