The plugin API
@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, every payload) is generated into Plugin hooks.
Writing a plugin
A plugin is a module that calls definePlugin and is registered in
community.plugins.ts — the installed list lives in its own file, beside
community.config.ts, so the operator CLI can read it without importing the
themes' component trees.
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 },
},
})Installing it is pnpm add, a line in community.plugins.ts, and a redeploy.
What a plugin can declare
| 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. |
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. |
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. |
onInstall / onEnable / onDisable / onUninstall |
Lifecycle callbacks — declared and typed, not yet dispatched by the host. See the inventory below. |
What a plugin cannot do
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 is a plugin able to grant itself anything. The one, narrow exception — putting a member in a group the operator pre-approved, for a limited time — is below, and the design of its refusals is what keeps 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, and no amount of isolation makes that recoverable |
See an Actor |
Payloads carry { userId, isGuest }. An Actor carries resolved group membership, which invites a plugin to make its own permission decision from group ids |
| Open a database connection | A plugin does not import @meith/db and 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 |
Filters and events
| 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 |
Ordering
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. Nothing depends on registration
order or on how community.config.ts happens to list its plugins.
Failure isolation
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().
Three limits worth stating
A guarantee with an unstated edge is worse than a smaller honest one.
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. That is not a timeout.
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 node that throws during React's
own render cannot be contained from the server — catching that needs an error
boundary, and error boundaries are client components.
So: build your markup in the function. Do not return a component that does work.
UI regions
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 have to be resolved 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, listed in Plugin hooks. The list is short on purpose — every region is a commitment every theme has to render or silently drop.
Namespacing
A plugin's key namespaces everything it registers, and the host builds the names, so a plugin cannot collide with another or reach a core one.
| Thing | Name it gets |
|---|---|
| Setting | plugin.<key>.<setting> |
| Task | plugin.<key>.<task> |
| Admin page | /admin/plugins/<key>/<path> |
One name in that namespace is the host's: plugin.<key>._enabled is the
operator's kill switch. A plugin cannot declare it — setting names cannot start
with an underscore — which is what makes the collision 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 produces an ambiguous setting key, and
a slash in a page path escapes the admin prefix.
Timed group grants
context.grants — 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, a reward a member can gift to another — 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 checkbox refuses these too, so the refusal is heard when the operator sets it up, 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, another
plugin's.
grantrefuses it,revokeleaves it alone. - An empty
reason. The reason is stored on the row; it is the audit trail.
A grant is always an additive secondary membership: primary_group_id and
display_group_id are never touched, so nothing a plugin does changes how a
member is displayed or what happens when the grant ends — they fall back to
exactly what they were.
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.
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 tick
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.
Where the board runs on fixture data there is no membership table; every call rejects with a clear error rather than pretending.
A database of its own
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.
The namespace is enforced where it can be
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 shape 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. There is no per-plugin database role today; if that changes, the
contract here does not.
Looking up a member
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
email, no state, no groups — for the same reason payloads carry a ViewerRef
and not an Actor.
HTTP routes
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, which means 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 Origin header 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.routewith 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 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 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 — visible on the plugin's health screen.
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 honesty in mind.
One honest limit: route paths are exact matches — put ids in the query string, not the path.
Board pages
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 the board rather than an iframe of somewhere else:
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 to the plugin page afterwards. A page on a
disabled plugin is a 404, exactly like a route.
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.
Notifications
context.notify — on every runtime context — sends a member a notification
through the board's own system: the bell, and an e-mail if the member wants
one. 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 },
],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 whether it e-mails them.emailByDefaultsets the starting position; 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, 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.- Sending is member-to-member scale, not broadcast: there is deliberately no fan-out primitive. A plugin that wants to tell everyone something has the announcement system's front door like anybody else.
Settings
A setting declares a type when its default cannot say enough: 'secret'
and 'select' are strings with extra rules, 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_URL and 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. definePlugin refuses 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; a blank submit keeps what is
stored, because the form can never show the current value to re-submit. A
secret's value reaches the plugin's runtime context and nowhere else.
required reports, it does not block. An unset required setting is a
named problem on the plugin's screen — set it here or with THE_VARIABLE —
rather than a save that refuses everything else. A board mid-setup can still
be configured a field at a time.
A select whose 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.
Migrations
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.
Versioning
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.
What is wired, and what is not
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 column cannot drift from the code.
25 of the 95 hooks are wired — the shell filters, the view models of every reading surface, and the three posting events. The generated reference's wired column is the authoritative list.
A hook that is declared but not wired is not broken; it is a call site that has not been written. Registering a handler for one is legal, does nothing, and the reference marks it so you find out before you ship.
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.
The lifecycle callbacks do not run yet. onInstall, onEnable,
onDisable and onUninstall are part of the declared shape and validated like
everything else, but no host code dispatches them today. Write them if the
shape of your plugin wants them — just do not put anything there that must run
for the plugin to be correct.
The descriptors execute
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 by the same tick as everything
else. Admin pages are mounted 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 — are live on every context;
on a fixture-mode board they reject with a clear error instead of
pretending.
What that leaves, 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 (PluginAdminPageContext.query), which is what a post-redirect-get notice needs and nothing more. A page renders under an already-authenticated panel route; there is no per-page permission to declare, because a plugin does not get to make that decision. The acting half lives on routes: a route withaccess: 'admin'is the form target for an admin page's buttons, checked and logged by the host as described under HTTP routes. - 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 not offered. The
panel's switch writes a row that every instance reconciles against on its next
request, so it survives a redeploy — the plugin somebody switched off at 2am is
exactly the one that must stay off. Removing a plugin is
pnpm remove, a line out ofcommunity.plugins.ts, and a redeploy; a button that dropped the rows and left the code running would produce a state neither installing nor removing does.
The generated reference is a gate
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, because a hook is added in the feature that needs it and documented, if at all, afterwards.
If the check fails, run pnpm plugin:docs and commit the result.