@meith/plugin-kit is the contract between the board and a plugin.
This document is the policy: what a plugin is, what it may and may not do, and what the guarantees actually cover. The reference — every hook and every payload — is generated into Plugin hooks.
A plugin is a module that calls definePlugin. community.plugins.ts — the
installed list, in its own file beside community.config.ts so the operator
CLI can read it without importing the themes' component trees — is
generated from board.plugins.json, and that manifest is the
installation path for any plugin that fits it:
export const greeter = definePlugin({
key: "greeter",
name: "Greeter",
version: "0.1.0",
hooks: {
// A filter: what it returns replaces the value.
"view.footer": (footer) => ({
...footer,
links: [...footer.links, { label: "Rules", href: "/rules" }],
}),
// An event: its return value is discarded.
"post.created": { handler: (post) => report(post.postId), priority: 200 },
},
})Fitting the manifest means the package's entry point exports the
finished plugin under two fixed names, built with no arguments — its own
configuration resolved from settings rather than a
constructor, the way plugins/dues's plans moved there:
// index.ts — @meith/plugin-greeter
export { greeter as plugin } from './definition'
export { greeterMessages as messages } from './messages'Installing one in this checkout is then pnpm add, community plugin:add <package>, and a rebuild and redeploy. This repository carries
two boards — apps/community, the in-repo dev target, and boards/stock,
the workspace docker/Dockerfile builds the official image from (see
docs/architecture.md, "The board-config seam") — and their two
board.plugins.json files are required to stay identical
(tests/boards-stock.test.ts is the drift guard pnpm verify runs), so the
package has to land as a dependency of both, and plugin:add writes both
manifests:
pnpm add @meith/plugin-greeter --filter @meith/web
pnpm add @meith/plugin-greeter --filter @meith/board-stock
community plugin:add @meith/plugin-greeterplugin:add infers the manifest key from a @scope/plugin-<key> package
name (pass --key when it does not fit that shape, or --disabled to
install it switched off) and writes both board.plugins.json files:
{ "plugins": [{ "key": "greeter", "package": "@meith/plugin-greeter", "enabled": true }] }then runs pnpm board:gen for you, which writes the import and the list
entry into both community.plugins.ts files. community plugin:remove <key>
is the reverse, on both boards. If the generator refuses one board's manifest
— the package is not yet a dependency there, most often, or the two manifests
already disagree before the command ran — neither board.plugins.json file
is touched: plugin:add/plugin:remove roll every board back together, so a
failed attempt never leaves one manifest edited and the other not. Neither
command takes plugin configuration — the manifest has no field for it, on
purpose, and plugin:add refuses an attempt to pass any: a plugin that needs
arguments is not manifest-installable until its configuration moves into its
own settings, the way plugins/dues's did.
The escape hatch is still real code, and it is honest about being one.
A plugin that cannot yet fit the manifest — it takes constructor
configuration, or you are still writing it — is registered by hand, exactly
as before: a line in community.plugins.ts. Because that file is generated,
a hand-written entry cannot live there directly; it goes in
community.demo.plugins.ts instead (this repository's own demo and test
boards keep their plugins there already), spread into the generated list
through showcasePlugins(), which the generator preserves as a fixed
extension point. Nothing about community.plugins.ts being generated
changes what runs — it changes how the manifest-installable, common case
gets there without hand-editing TypeScript.
pnpm board:gen:check, wired into pnpm verify, fails when either board's
manifest and its community.plugins.ts disagree — run pnpm board:gen and
commit the result — and tests/boards-stock.test.ts fails when the two
board.plugins.json files disagree with each other. Each board.plugins.json
refuses: a duplicate key; a key definePlugin would refuse; a key that is
legal but whose camelCase identifier collides with another entry's, or is
not itself a valid identifier (a repeated or trailing hyphen, most often —
foo--bar and foo- are both legal plugin keys and both make
community.plugins.ts un-generatable without this check); a non-boolean
enabled; a package that is not a valid npm package name; and a package
its own board does not depend on, naming the fix — pnpm add <package> --filter @meith/web for apps/community, pnpm add <package> --filter @meith/board-stock for boards/stock — against whichever board actually
lacks it. apps/cli/src/board-eject.ts renders the same shape of
community.plugins.ts for an ejected board and carries its own copy of the
key/identifier/enabled/package-name checks (not the dependency check — an
ejected build has no such list to check against); the two toIdentifier
implementations are pinned to agree by a test in board-eject.test.ts
rather than shared, for the reason plugin-manifest.ts's shelling out to
the generator is: a plain script and a workspace TypeScript package cannot
share a module without one of them changing what it is.
Tip
examples/hello-plugin
is the worked example to copy — the smallest plugin that does something
visible with each extension point: a footer-link filter, a region
contribution, a setting, a migration, a task and an admin page, each with
a comment explaining its shape. It ships as reference code, not installed;
examples/README.md
walks through registering it or your copy of it.
| Field | What it is |
|---|---|
hooks |
Handlers for named hooks. Filters change a value; events observe. |
settings |
Settings the admin panel renders, stored under plugin.<key>.<name>. |
migrations |
Forward-only SQL, applied in ascending id order and recorded per plugin. |
tasks |
Scheduled work, registered as plugin.<key>.<id> and run by the same tick as core's tasks. |
adminPages |
Pages mounted under /admin/plugins/<key>/. |
routes |
HTTP endpoints mounted under /api/plugins/<key>/, dispatched by the host. |
pages |
Member-facing pages mounted under /plugins/<key>/, rendered inside the board's shell. |
navigation |
Board navigation entries the operator then owns — see below. |
notifications |
Notification kinds this plugin may send, each a line on the member's preferences screen. |
allowedRedirectHosts |
The only hosts an absolute redirect from this plugin's routes may point at. |
contributions |
Markup in named UI regions. |
dependsOn |
Other plugin keys whose migrations must run first. |
onInstall / onEnable / onDisable / onUninstall |
Lifecycle callbacks — see below. |
Note
Everything but the callbacks is declarative. A plugin does not call
registerHook at import time — it exports an object and the host reads
it. Registration by side effect would make the installed set depend on
module evaluation order, which differs between the dev server, a bundled
build and the worker — the direct cause of the "works locally, missing in
production" class of plugin bug.
These are not discouraged; there is no API for them.
| It cannot | Why |
|---|---|
| Decide authorization | No hook filters authorization.can(), and none ever will. A plugin able to change that answer can grant itself anything. The one narrow exception — putting a member in a group the operator pre-approved, for a limited time — is timed group grants, and its refusals are what keep it from being this row. |
| Reach inside the visibility filter | No hook sits in the query path. A plugin that could rewrite a where clause could publish a private forum. |
See an Actor |
Payloads carry { userId, isGuest }. An Actor carries resolved group membership, which would invite a plugin to make its own permission decisions. |
| Open a database connection | A plugin never holds a connection. Its own tables are reachable through context.data — host-run, parameterised, under a database-side timeout — and its migrations can only create objects under its own prefix. |
| Patch core | There is no monkey-patching seam and no way to replace a domain command. |
| Fill a theme slot | A theme owns its slots. Plugins contribute to regions — see below. |
| What it gets | What happens to the return value | |
|---|---|---|
| Filter | A value | It is used. Filters chain: each plugin receives what the previous one returned. |
| Event | A notification | Discarded. |
Tip
Anything that only wants to know — logging, a webhook, a counter — should be an event. An event handler cannot corrupt the thing it is watching, even when it is wrong.
Handlers run in (priority, plugin key) order. Lower priority runs first; the default is 100, so a plugin can insert on either side of an unopinionated one without negative numbers. Both halves are declared and total, so two plugins compose the same way on every request, on every instance, in every deployment.
Every handler runs inside the host's try/catch:
| What happens | Result |
|---|---|
| A filter throws | The value is left as it was, and the chain continues with the next plugin |
A filter returns undefined |
Treated the same way — that is the shape of a handler that forgot to return |
| An event throws | Recorded and forgotten |
Nothing a plugin does propagates to the page. That makes plugin failures
survivable, not invisible: every failure is counted, logged with the
plugin key and the hook, and reported by host.health().
Two limits and one guarantee are worth stating plainly, because a promise with an unstated edge is worse than a smaller honest one:
Auto-disable is durable. Every failure is counted in a plugin_health
row, and the fifth switches the plugin off with the hook and the message
that did it. The row is the answer, not this process's tally: it survives a
restart, it is shared by every web instance and the worker, and each of them
reconciles against it. A plugin that started failing at 2am is off when the
platform recycles the instance at 3am, and off on the instance that never
saw it fail.
Nothing re-enables it on its own. An operator clears the record — Clear
failures and re-enable on /admin/plugins, which deletes the row and
takes effect on the next request across the board. Deliberately manual: a
plugin that fails five times and is switched back on by a timer fails five
more times, and the board has learned nothing.
Note
A count that reaches the threshold is the board's count, not one instance's, so a plugin failing twice on each of three instances is switched off — which is the point of moving it out of memory.
Timing is measured, never enforced. Each call is timed, and slow ones
are logged and counted. There is no timeout, because JavaScript cannot abort
a handler: a Promise.race that "times out" returns control while the
handler keeps running, keeps its connection, and resolves later.
UI contributions are isolated when they are built, not while they
render. The host calls your render function inside a try/catch, so a
throw there drops your contribution and the region renders without it. A
component that throws later, inside React's own render, cannot be contained
from the server. So: build your markup in the function; do not return a
component that does work.
Regions are not theme slots, and the distinction is deliberate. If a plugin could fill a slot, an installed plugin would decide what a post looks like — and two plugins filling the same slot would need resolving somehow.
A region is the other arrangement: an explicit "plugins may add something here" point that a theme renders. The theme keeps control of where plugin output appears; the plugin keeps control of what it is; several plugins compose by concatenation, in the usual deterministic order.
There are six: header.notice, index.footer, postbit.badges,
postbit.footer, profile.panel and admin.dashboard — described in
Plugin hooks. The list is short on purpose, because
every region is a commitment every theme has to render or deliberately
drop.
Seven filters reach the render pipeline, and they divide on when they run — which decides what a plugin can change and what it costs.
| Filter | When it runs | What it shapes |
|---|---|---|
markdown.parse.text |
Write | The source handed to the parser |
markdown.render.html |
Write | The HTML the renderer constructed |
markdown.directives |
Write | The :::name and :name[…] vocabulary |
smilies.list |
Write | The smilie set substituted at render |
post.body.html |
Read | One post's body, in the thread it is read in |
signature.html |
Read | A member's signature, wherever it appears |
word-filter.patterns |
Read | The render-time word filter's rules |
Write means the filter runs where a body becomes HTML — a new thread or reply, an edit, a private message, a saved signature, the composer's preview — and its output is what the board stores. That is why those four carry no viewer: a stored render is shared by everybody who reads the post, so a set of smilies or a rewrite that depended on who was looking would be whichever reader happened to write the row first.
Read means the filter runs once per body per page view. Nothing is stored, so a change takes effect immediately and disappearing when the plugin is removed costs nothing.
Two things follow that are worth knowing before you write one.
The source is never touched. markdown.parse.text changes what the
parser is handed; the message column still holds exactly what the member
typed, which is what quoting, editing and the next re-render start from. A
plugin cannot rewrite somebody's post.
Installing or removing a formatting plugin re-renders the board. The
board records a rendering signature — the keys and versions of the
installed plugins that register any of the four write-time filters. When it
changes, the content revision is bumped, and posts.render_backfill walks
the board re-rendering every post through the new pipeline. That is what
makes a formatting plugin apply to the ten years of posts that were there
before it, and what makes removing one take its markup back out. On a large
board the sweep takes a while and reports its backlog in /admin/system;
nothing looks broken while it runs, because a row the sweep has not reached
is rendered in memory when somebody reads it.
Warning
What markdown.render.html, post.body.html and signature.html return
is trusted output: it is inserted as markup and nothing escapes it
afterwards. post.body.html runs after the board's word filter, so a
plugin's own additions are not filtered either. This is the same trust an
operator extends by installing the plugin at all — but it is the one
place where a mistake becomes markup on every page.
Four callbacks, each with one moment it runs and its own answer to "what if it
throws". All four are handed the same runtime context a task gets — resolved
settings, a logger, and grants, data, users and notify.
| Callback | When | If it throws |
|---|---|---|
onInstall |
The first community upgrade on a board that has never recorded this plugin, after its migrations |
The upgrade stops |
onEnable |
An operator switches the plugin on in the panel | The switch stands; counted as a plugin failure |
onDisable |
An operator switches it off | The switch stands; counted as a plugin failure |
onUninstall |
community plugin:purge <key>, before anything is dropped |
Nothing is dropped |
None of them runs inside the host's try/catch. That isolation exists to keep a page rendering, and none of these is on a page.
onInstall runs once per board, not once per deploy. The board records a
plugin:<key> version row; no row means it has never seen the plugin. It runs
after that plugin's migrations, so its tables exist, and before the version row
is written, so a throw leaves the board able to try again. A throw stops the
upgrade — a plugin that could not finish installing is not one the board should
start serving.
onEnable and onDisable run on the operator's switch only. They do not
run on the host's own switch after repeated failures: a plugin that has just
failed five times is not one to hand more work to. They run after the switch
is written, so the callback sees the state it is being told about, and the
switch stands whatever they do — a callback that throws is the plugin's fault,
so it is counted and shown in the plugin's health row rather than reported to
the operator as their action having failed.
onUninstall needs community plugin:purge, and that is not a workaround.
Removing a plugin is pnpm remove, taking it out of community.plugins.ts
(community plugin:remove <key> for a manifest entry, by hand for the escape
hatch) and a redeploy — and at the moment the board would call onUninstall,
the function is no longer in the build. There is no point in time where the
host holds both "this plugin is gone" and "this plugin's code". So the
operator says when:
community plugin:purge dues # says what it would do
community plugin:purge dues --yes # runs onUninstall, then drops the dataIt runs onUninstall first and drops nothing if that throws, then takes away
the plugin's plugin_<key>_* tables, its settings, its migration records, its
navigation items, its version row and its health row. Then you remove the code.
Purging a plugin that is not in the build is refused, with that explanation:
there would be no onUninstall left to run.
Tip
Write these if the shape of your plugin wants them, but keep onInstall
idempotent anyway. It runs once per board, and a board restored from a
backup taken before the install is a board that will run it again.
A plugin with a member-facing page usually wants a link to it. navigation
is how it asks:
navigation: [
{ key: 'plans', label: 'Supporters', path: '', audience: 'members' },
{ key: 'manage', label: 'Your membership', path: 'manage', audience: 'members', under: 'plans' },
]Each entry names one of the plugin's own pages by path, so a
navigation item cannot point somewhere the plugin did not build. The host
writes it into the board's navigation table under plugin.<key>.<item> the
first time the board's menu is built after the plugin appears — no admin
visit required — and from that moment the operator owns it: they rename
it, reorder it, nest
it under another item, restrict it to groups, or switch it off on
/admin/content/navigation, exactly as they would a link they added
themselves. Redeploying does not undo any of that — only the address is
refreshed from the code, because that is the half the plugin knows better.
The rest follows from it being a real row:
labelis a starting point, not a fixed string. It is what the item is called until somebody renames it. GivelabelKeytoo and the board translates it, until an operator types their own label — at which point theirs wins in every language, which is what they asked for.audienceis the default scope (all,guests,members,staff), and the operator can narrow it further to specific groups. It is presentation, not permission: the page re-checks whoever arrives.underis the default nesting. Name another of the plugin's own items and this one is created as its sub-menu entry. The menu is one level deep, so the item named must itself be top-level. Likeaudienceit only seeds the row: the operator re-nests or flattens it afterwards, and a redeploy leaves their arrangement alone.- The item disappears with the plugin. Switch the plugin off and the
link stops rendering; take the plugin out of the build and the row goes
at the next
community upgrade. An operator's ordering is not lost in between.
Appending to view.header instead would put a link where no operator could
reach it — unnameable, unmovable, and impossible to switch off without
switching off the plugin.
A plugin's key namespaces everything it registers, and the host builds the names, so a plugin cannot collide with another plugin or reach a core name:
| Thing | Name it gets |
|---|---|
| Setting | plugin.<key>.<setting> |
| Task | plugin.<key>.<task> |
| Admin page | /admin/plugins/<key>/<path> |
| Message | <key>.<message>, by convention rather than by construction |
One name in that namespace belongs to the host: plugin.<key>._enabled is
the operator's kill switch. A plugin cannot declare it — setting names
cannot start with an underscore — so the collision is impossible rather
than unlikely.
definePlugin refuses a key, setting name, task id or page path that would
not namespace cleanly: a dot in a plugin key would produce an ambiguous
setting key, and a slash in a page path would escape the admin prefix.
A plugin that shows text to a member ships a message catalog and is registered
with it in community.config.ts:
plugins: [{ key: 'dues', plugin: dues, messages: duesMessages }]where duesMessages is { [locale]: { [key]: pattern } }. Plugin catalogs are
merged after the board's and after any theme's, so a plugin can reword either —
which is a feature when you mean it and a collision when you do not. Namespace
your keys with your plugin key, the way settings and tasks are namespaced, and
name a board key only when overriding it is the point.
A page context also carries locale, the language tag the board resolved for
this reader. A plugin renders arbitrary UI rather than filling a slot, so unlike
a theme it formats its own dates and numbers — new Intl.NumberFormat(context.locale)
rather than toLocaleString(), which the no-fixed-locale-format guard refuses.
Nothing about a plugin's own text is required to be translatable; a plugin that
ships only en works, and its messages fall back to English for every reader.
Languages covers the message syntax, the plural
categories, and how a translator adds a language.
context.grants — available on every runtime context — is the only write a
plugin gets against the board's own data: it can put a member in a usergroup
until a date. That is the whole API, deliberately. A usergroup already
carries forum permissions, a badge and a name colour, so time-limited
membership of one is a complete building block — a paid pass, a trial, a
course cohort, an event's temporary access — and the host does not know or
care which of these a plugin is building.
await context.grants.grant({ userId, groupKey: 'supporters', until, reason: 'order 42 paid' })
await context.grants.extend({ userId, groupKey: 'supporters', until })
await context.grants.revoke({ userId, groupKey: 'supporters', reason: 'refunded' })
const held = await context.grants.list(userId)What keeps this from being "a plugin deciding authorization" is the list of things the host refuses, checked on every call:
- A group the operator has not marked "may be granted by plugins" on its admin screen. The opt-in is per group and off by default.
- A system or staff group, or any group whose permission set carries administrative or moderation power. The admin checkbox refuses these too, so the refusal is heard at setup time, not when the first grant fails.
- A grant with no expiry, an expiry in the past, or one more than two years out. Every grant lapses on its own.
- A membership someone else granted — an administrator's, or another
plugin's.
grantrefuses it andrevokeleaves it alone. - An empty
reason. The reason is stored on the row; it is the audit trail.
A grant is an additive secondary membership by default: the member's primary and display groups are left alone, so nothing the plugin does changes how they are shown, and when the grant ends everything falls back to exactly what it was.
Expiry is true at the read, not enforced by a sweep. Actor assembly
skips a lapsed row, so access ends at the boundary even if no task ever runs
again — uninstalling the plugin, stopping the tick, or the plugin's own bugs
cannot leave anyone holding access they no longer have. A groups.expire
task deletes lapsed rows afterwards and bumps the permission version so
derived caches follow. Re-granting and extending only ever move an expiry
forward: a stale or replayed call cannot shorten what a member already
holds.
primary: true on a grant asks for more than access — it asks for the group
to become the member's primary one, which is usually what a paid
membership means to the member buying it:
await context.grants.grant({ userId, groupKey: 'supporters', until, reason, primary: true })The board does the swap, not the plugin, and it is reversible by construction:
- The group the member was primary in becomes an ordinary secondary
membership with no expiry, and the granted row remembers it in
previous_primary_group_id. - A second promotion on top of a first still remembers the group behind both, never a group that is itself only held until a date — so a member cannot end up primary in a group they have stopped paying for.
- On
revoke, and whengroups.expirecollects the lapsed row, the remembered group is made primary again and the secondary row it left behind is removed. - Actor assembly does the same fallback at the read: a promoted primary whose grant has lapsed confers nothing, and permissions are assembled from the remembered group instead.
All the refusals above still apply, and one more: a staff member's primary group is never displaced. Where the buyer is already primary in a staff group, or a group carrying administrative or moderation power, the promotion is silently skipped — the grant still lands as a secondary membership, so they get everything the group carries, but their standing is left alone. This is not an error and nothing is reported; a plugin has no business branching on whether the buyer is a moderator.
grant takes a plain userId, and nothing ties it to whoever is acting.
Who may cause a grant for whom — a member for themselves, one member for
another, an automated rule for anyone — is the plugin's own policy, decided
in its own code with its own records.
On a fixture-mode board there is no membership table, and every call rejects with a clear error rather than pretending.
context.data — on every runtime context — reads and writes the tables this
plugin's migrations created. Three methods, parameterised only:
await context.data.query('insert into plugin_example_entry (user_id, note) values ($1, $2)', [userId, note])
const row = await context.data.one('select * from plugin_example_entry where user_id = $1', [userId])
await context.data.tx(async (tx) => {
// everything in here commits together or not at all
})Three properties are the contract:
- Values travel as
$1,$2, … and are bound by the driver. There is no string-building helper, on purpose — the ordinary path is the safe one. - Every call runs under a database-side
statement_timeout— short in a page render, longer in a task. This is the one timeout in the plugin API that actually holds, because Postgres can abort a query where JavaScript cannot abort a handler. txis a real transaction. A throw rolls the whole body back; a nestedtxjoins the outer one rather than opening a second.
definePlugin refuses a migration whose statements create, alter, drop or
fill anything not named plugin_<key>_* (hyphens in the key become
underscores) — and refuses a foreign key that reaches outside that
namespace, because a plugin table referencing a core one couples the
plugin's schema to the board's and breaks the moment either migrates. Copy
ids into plain columns instead; the reconcile-and-sweep pattern handles rows
whose subject has since gone.
Stated honestly: this is a rail, not a sandbox. Plugin code runs in the
host's process, and context.data does not rewrite queries — a plugin can
select from a core table, the way any code in the process can. The
migration rule guards the part that would corrupt a board (a plugin
altering somebody else's schema); the rest is the same trust you extended
when you installed the code.
context.users resolves a member to the pair a plugin is allowed to see —
{ userId, username } — by name or by id:
const recipient = await context.users.byUsername(input) // null if unknownIt exists because a plugin's own records point at members and its UI asks
for them by name — "award this to @name" needs an id before anything can be
stored. Deleted accounts do not resolve. Nothing richer is exposed — no
e-mail, no state, no groups — for the same reason payloads carry a
ViewerRef and not an Actor.
A plugin declares endpoints the way it declares everything else — as data —
and the host mounts them under /api/plugins/<key>/<path>:
routes: [
{ path: 'hook/stripe', method: 'POST', access: 'anonymous', rawBody: true, handler },
{ path: 'checkout', method: 'POST', access: 'member', handler },
],
allowedRedirectHosts: ['checkout.stripe.com'],A handler receives a PluginRequest — viewer, method, path, query, headers,
a parsed or raw body, the board's URL — plus the same runtime context as
every other surface, and answers with an envelope:
{ kind: 'json' | 'text' | 'redirect', … }. A route declaring
rawBody: true gets the exact request bytes, which is what webhook
signature verification needs.
The host owns every decision a plugin must not:
accessis enforced before the handler runs.'member'answers 401 to a guest;'admin'answers 403 to anyone without a live control-panel session — the same check the panel's own screens make, including its re-authentication window. The handler never sees a refused request.- Admin routes mount under the panel, not the board. An
access: 'admin'route answers at/admin/api/plugins/<key>/<path>and is a 404 on the board mount, and the reverse. The panel's session token is a cookie scoped to the/adminpath precisely so it never rides an ordinary board request, so an admin endpoint must live where that cookie travels. An admin page's form posts there;pluginAdminRoutePathbuilds the URL. - A member or admin POST must come from the board's own origin. The
Originheader is checked against the request's host; a cross-site form post is a 403. - An admin POST lands in the panel's action log as
plugin.route, with the plugin key and path, next to every other administrative act. Admin GETs are reads and stay out of the log. cookieandauthorizationnever reach the handler, and the response envelope has no header or cookie field at all. That single restriction is what stops a plugin route from becoming a second authentication system.- Redirects are allow-listed. A relative path always passes; an
absolute URL must be https — plain http only to a loopback address, for a
test double — and its host must be declared in
allowedRedirectHosts, so a compromised setting cannot turn a board route into an open redirect. - Bodies are capped — 64 KiB by default,
maxBodyBytesup to 1 MiB. - Every response is
cache-control: no-store. - A disabled plugin's routes 404 — operator-disabled and auto-disabled alike. An off plugin has no endpoints, not broken ones.
- Failures count. A route runs under the same accounting as a hook: timed, logged against the plugin, and auto-disabling after repeated failures.
- A route can declare its own rate limit —
rateLimit: { limit, windowSeconds }— and the host enforces it before the handler runs: a spent window answers 429 with aretry-afterheader. The count is per caller (signed-in user id, else the client address) and per instance, in process memory — abuse-pressure relief, not accounting. A board that scales out multiplies the budget by its instance count; declare limits with that in mind. Counting is sliding-window, weighing the previous window's usage against how much of the current window has elapsed, so a caller who spends a window's full budget cannot double it by timing a second burst just after the window rolls over.
One honest limit: route paths are exact matches — put ids in the query string, not the path.
Note
A form POST cannot 303 off the board. The board's CSP pins
form-action to 'self', and browsers hold a form submission's whole
redirect chain to it — so a member-form route answering a redirect to a
payment provider is blocked by the browser, not by the host. The pattern
that works, without weakening the policy: 303 to one of your own pages
with the target in the query, validate it there against your
allowedRedirectHosts, and render a meta refresh plus a fallback link.
An ordinary navigation is outside form-action's remit. plugins/dues
ships this as its go page.
adminPages are the operator-facing half, mounted at
/admin/plugins/<key>/<path>. render gets a PluginAdminPageContext —
the runtime context plus the panel URL's query string — and returns markup,
which the panel frames so that the cards a page brings still read as
raised.
A plugin that declares any pages becomes a place in the panel rather than a row in a list:
- A tab bar across the top of every one of its screens, the plugin's
own settings screen included (labelled
Settings, the first tab). A plugin with one page gets no tab bar, because a single tab is not a choice. - Its own section in the panel's rail, headed with the plugin's name
and listing its pages, whenever the operator is anywhere under
/admin/plugins/<key>. - Links on its row of
/admin/plugins, so the screens are reachable before anyone opens the plugin at all.
Declaring a page is the whole of it — there is nothing to register with the nav and no ordering to configure. Pages appear in the order the plugin declares them, and a page on a disabled plugin appears nowhere.
title is a label, so keep it short. It becomes the tab, the rail
entry and the page heading, and the plugin's name is already above all
three — 'Plans', not 'Dues — plans'.
pages are the member-facing half, mounted at /plugins/<key>/<path> and
rendered inside the board's shell with the page's declared title, so a
plugin's screen looks like part of the board:
pages: [
{ path: '', title: 'Membership', access: 'member', render },
{ path: 'return', title: 'Confirming', access: 'anonymous', render },
]render gets a PluginPageContext — the runtime context plus the viewer,
the path, the query and the board URL — and returns markup. The same
containment as admin pages applies: a throw is logged and the page renders a
plain failure notice in the shell, not a 500. access: 'member' sends a
guest to the sign-in page and back afterwards. A page on a disabled plugin
is a 404, exactly like a route.
As with contributions: build your markup in the render function rather than returning a component that does work — the host's try/catch is around the call, and a component that throws later inside React's own render cannot be contained from the server.
context.notify — on every runtime context — sends a member a notification
through the board's own system: the bell, an e-mail if the member wants one,
and a pushed notification if the board offers push and the
member asked for it. A plugin first declares its kinds as data:
notifications: [
{ key: 'gift_received', title: 'Somebody gifts you a membership',
description: 'A member bought a membership in your name.' },
{ key: 'renewal_trouble', title: 'A membership payment fails',
description: 'Your renewal did not go through; access holds while Stripe retries.',
emailByDefault: false, pushByDefault: true },
],and then sends against a declared kind:
await context.notify.send({
userId: recipient,
kind: 'gift_received',
subject: 'alice bought you a 90-day pass',
body: 'It starts the moment the payment confirmed.',
href: '/plugins/dues/manage',
dedupeKey: `order:${order.id}`,
})The host owns the decisions a plugin must not:
- Every kind is namespaced —
plugin.<plugin>.<kind>— and lands as its own line on the member's notification preferences screen, where the member decides which channels it reaches them by.emailByDefaultandpushByDefaultset the starting positions — push starts off unless a plugin asks otherwise — and the member's choice wins from then on. - An undeclared kind refuses at send. Declaring kinds is what makes them legible to members; a plugin cannot invent one on the fly.
- The words travel as data. The subject (up to 200 characters) and body (up to 2,000) are rendered by the board on the bell and in the e-mail — the same template and the same unsubscribe machinery as every core notification.
hrefstays on the board. A notification links to a board path, never off-site — the plugin's own pages are the place for anything external.dedupeKeycoalesces repeats exactly as core kinds do: raising the same key again bumps a counter instead of stacking rows.- There is deliberately no fan-out primitive. Sending is member-to-member scale, not broadcast; a plugin that wants to tell everyone something has the announcement system's front door like anybody else.
A setting declares a type when its default cannot say enough: 'secret'
and 'select' are strings with extra rules, 'number' and 'boolean' are
usually inferred from the default's own JavaScript type, and env names an
environment variable that overrides whatever the panel stores.
settings: [
{ key: 'secret_key', label: 'API secret', type: 'secret',
env: 'MYPLUGIN_SECRET_KEY', required: true, default: '' },
{ key: 'mode', label: 'Mode', type: 'select', default: 'off',
options: [{ value: 'off', label: 'Off' }, { value: 'live', label: 'Live' }] },
]- Resolution is environment, then board, then default — the same rule
as
APP_URLand the mail settings. When the variable is set, the panel's box goes inert and says which variable owns it, so nobody edits a field that cannot take effect. - A secret is write-only.
definePluginrefuses one with a shipped default (a working fallback credential is a credential in the repository). The panel shows that a value is set, never the value, and a blank submit keeps what is stored — the form can never show the current value to re-submit. A secret's value reaches the plugin's runtime context and nowhere else. requiredreports, it does not block. An unset required setting is a named problem on the plugin's screen rather than a save that refuses everything else, so a board mid-setup can still be configured a field at a time.- A
select's option values are matched case-insensitively and trimmed, so a strayDUES_CURRENCY=EURor a trailing space from copy-pasting an environment value still finds the option'eur'declares — the resolved value is always the option's own declared casing, never the raw input. Declare option values lowercase. - A
selectwhose stored value is no longer among its options — an older version of the plugin declared more — resolves to the default instead of handing the plugin a value it never declared. - A
numberhas no minimum, maximum or step to declare. An unparseable stored or environment value resolves as if unset, falling through to the next source in the same order as everything else; a plugin that needs a bounded number clamps it itself when readingcontext.settings— seeplugins/dues's grace-period setting for the pattern. - A setting has no
descriptionArgs— unlike a plugin's own top-level description, a setting'sdescription/descriptionKeyis translated with no interpolation. A bound worth stating (a grace period's allowed range, a number's units) belongs in the catalog text itself, kept in sync by hand with whatever constant actually enforces it.
Forward-only, like core's, and for the same reason: a down migration that drops a column is a data-loss button on a live board.
Ids look like 0001_add_table and are applied in sort order.
Important
definePlugin refuses a migration list that is not written in ascending
order, because the failure would otherwise be silent: a fresh board
applies everything, an upgraded board skips the id that sorts before the
last one applied, and the two boards end up with different schemas and no
error anywhere.
definePlugin requires semver. The version is the plugin's own — it is
what the admin panel shows and what its migration history is recorded
against.
apiVersion declares which plugin-kit major the plugin was written
against. The same policy as the theme API
applies: a minor adds hooks, payload fields and regions; a major may remove
or rename one, and only after a deprecation cycle.
An honest inventory, because the alternative is a document describing a
system that does not run. It is derived rather than remembered:
scripts/hook-callsites.mjs computes it by scanning the tree, so the
generated reference's wired column cannot drift from the code.
All 102 hooks are wired. Every entry in the registry has a call site in the board, and the generated reference's wired column — computed from the tree, not maintained by hand — says so. If that column ever reads anything else, believe the column: it is derived and this sentence is not.
A hook that is declared but not wired would not be broken, only unfinished: registering a handler for one is legal and does nothing. The reference marks which is which so you find out before you ship, rather than after.
plugins/reference must handle every wired hook, enforced by its own
test. That is the ratchet: wiring a new call site into the board fails the
reference plugin's test until a handler is added there, so a hook cannot
join the running product without something proving it fires. The same
plugin declares a route of every shape, a board page, a secret setting with
an environment override and a select — and its tests drive each one, so
none of those surfaces can silently rot either.
Everything declared runs today, the four lifecycle callbacks included — see
the lifecycle for when each fires and what a throw costs.
Migrations are applied by community upgrade in
dependency order, one transaction each. Settings are stored at
plugin.<key>.<name> and edited in the control panel, with environment
overrides resolved as described above. Tasks are registered as
plugin.<key>.<id> and run on the same tick as everything else. Admin
pages mount at /admin/plugins/<key>/<path>, routes at
/api/plugins/<key>/<path> (admin routes at
/admin/api/plugins/<key>/<path>), board pages at /plugins/<key>/<path>.
The runtime capabilities — grants, data, users, notify — are live on
every context; on a fixture-mode board they reject with a clear error
instead of pretending.
A few consequences, stated plainly:
- A page cannot reach anything a task cannot. Both are handed the
runtime context — resolved settings and a logger — and neither gets the
Actor, the request, or a database handle. An admin page additionally sees the panel URL's query string, which is what a post-redirect-get notice needs and nothing more. There is no per-page permission to declare, because a plugin does not get to make that decision; the acting half lives on routes, whereaccess: 'admin'is checked and logged by the host. - A task's failure is not swallowed. Hooks are isolated because the alternative is a plugin taking down a page render. A task has no page to take down, and the scheduler already records failures and notifies administrators — catching there would turn every failure into a successful run of nothing.
- There is no plugin-run button for migrations, and there will not be. A schema change belongs to the deploy that shipped the code expecting it. The panel reports which migrations have and have not been applied, which is the part an operator cannot otherwise find out.
- Disabling is durable and immediate; uninstalling is a command, not a
button. Both switches — the panel's and the host's own, after repeated
failures — write a row that every instance reconciles against on its next
request, so both survive a redeploy: the plugin somebody switched off at
2am is exactly the one that must stay off. Removing a plugin is still
pnpm remove, a line out ofcommunity.plugins.ts, and a redeploy, withcommunity plugin:purgebefore it when its data should go too. There is no button, because a button that dropped the rows while the code kept running would produce a state neither installing nor removing does.
Plugin hooks is written by
scripts/plugin-hook-docs.mjs from the registry. pnpm verify and CI run
pnpm plugin:docs:check, which fails when the file and the code disagree.
Hook documentation goes stale faster than most — a hook is added in the
feature that needs it and documented, if at all, afterwards — which is why
this one is a gate rather than a habit.
If the check fails, run pnpm plugin:docs and commit the result.