376 lines
23 KiB
Markdown
376 lines
23 KiB
Markdown
# The Plainpages plugin contract
|
||
|
||
The authoritative reference for the plugin API — the product's main surface. A plugin is a
|
||
self-contained folder under `plugins/` that the host discovers at boot; there is no
|
||
registration step. The contract is **TypeScript** (`src/plugin.ts`), so the types here are the
|
||
single source of truth — this document explains them, the guarantees around them, and the rules
|
||
the host enforces.
|
||
|
||
**Design stance.** The audience is experienced developers. The API optimises for being
|
||
**powerful, predictable, and overloadable** — a plugin can take over as much of a page as it
|
||
wants. The host **fails loud at boot/discovery** rather than sandboxing at runtime: a malformed
|
||
manifest, a version mismatch, or a conflict stops startup with a clear message. Runtime
|
||
crash-isolation (one bad plugin can't take the host down) is a *non-goal* — diagnose at deploy
|
||
time, not in production.
|
||
|
||
> **Status.** This is the contract the §2 host implements. The types and pure rules
|
||
> (`checkApiVersion`, `findConflicts`, `isValidPluginId`) live in `src/plugin.ts`; **discovery**
|
||
> (`src/discovery.ts`), the **router** (`src/router.ts` — method+path match, `:name` params,
|
||
> permission gate, `RouteResult` → response), and the **per-plugin view resolver**
|
||
> (`src/view-resolver.ts` — a `view` result renders `plugins/<id>/views/`, with the core partials
|
||
> reachable via `include()`), **per-plugin static serving** (`/public/<id>/` → the plugin's
|
||
> `public/`, `routePublic` in `src/static.ts`), and the **central menu override + branding**
|
||
> (`config/menu.ts`, loaded by `src/menu-config.ts`, with branding — name, logo, default theme —
|
||
> rendered in the app shell) are wired. The §2 plugin host is feature-complete; the remaining §2
|
||
> items are a project-wide review and comment/test cleanup.
|
||
|
||
## Anatomy of a plugin
|
||
|
||
```
|
||
plugins/scheduling/ # folder name = the plugin id → mounted at /scheduling
|
||
plugin.ts # default export: the manifest (definePlugin(...))
|
||
shifts.ts # handlers, helpers — plain modules
|
||
views/ # EJS templates for this plugin's pages
|
||
shifts.ejs
|
||
public/ # static assets, served at /public/scheduling/
|
||
scheduling.css
|
||
```
|
||
|
||
**Identity comes from the folder.** The folder name *is* the plugin `id`, and the mount path is
|
||
`/<id>` — neither is written in the manifest, so they can't drift or be claimed twice. The id
|
||
must be **URL/path-safe** (`isValidPluginId`: lowercase `a–z`, digits, and dashes — dashes
|
||
anywhere; no uppercase, underscores, dots, or slashes); the host rejects a malformed folder name
|
||
at discovery. The id also namespaces the plugin's `views/`, its `/public/<id>/` assets, and (by
|
||
convention) its nav/permission tokens.
|
||
|
||
A handful of ids are **reserved** for the host's own first-party mounts — the gated `dashboard`, the
|
||
Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`),
|
||
the `admin` screens, the `oauth2` provider routes, and `public` (static). Since plugin routes resolve
|
||
first, a folder claiming one would silently shadow a built-in route, so discovery refuses it loud
|
||
(`RESERVED_PLUGIN_IDS`). (`/` is owned by the `home` field, not a route, so it needs no reservation.)
|
||
|
||
Installing a plugin is "drop the folder, restart." Removing one is "delete the folder, restart."
|
||
Nothing else references it; the operator stays in control through the central menu override
|
||
(`config/menu.ts`).
|
||
|
||
## The manifest
|
||
|
||
A plugin imports its host surface from one module — `src/plugin-api.ts`, the **stable author
|
||
barrel** (`definePlugin`, the manifest/handler types, `RequestContext`, the guards, and the
|
||
body/CSRF/list-query helpers). That barrel *is* the contract boundary; don't reach into deeper
|
||
`src/*` modules — the host may refactor those freely as long as the barrel holds.
|
||
|
||
```ts
|
||
import { definePlugin } from "../../src/plugin-api.ts";
|
||
import { listShifts, createShift } from "./shifts.ts";
|
||
|
||
export default definePlugin({
|
||
apiVersion: "1.0.0", // semver of the host contract this was built against (a literal — see Versioning)
|
||
|
||
// Nav fragment, merged into the global menu and permission-filtered per user.
|
||
// `icon` is a Lucide icon by its sprite id (src/icons.ts).
|
||
nav: [{
|
||
icon: "i-cal", id: "scheduling:root", label: "Scheduling",
|
||
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
||
}],
|
||
|
||
// Permission tokens this plugin introduces. Declared for documentation, conflict detection, and
|
||
// bootstrap seeding (the demo admin is granted every discovered plugin's tokens). Optional.
|
||
permissions: [
|
||
{ token: "scheduling:read", description: "View shifts" },
|
||
{ token: "scheduling:write", description: "Create and edit shifts" },
|
||
],
|
||
|
||
// Route handlers, mounted under the plugin's path (/scheduling). `permission` gates first.
|
||
routes: [
|
||
{ method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts },
|
||
{ method: "POST", path: "/shifts", permission: "scheduling:write", handler: createShift },
|
||
],
|
||
});
|
||
```
|
||
|
||
`definePlugin()` only types the object and returns it unchanged — a manifest may equally be a
|
||
plain typed object. It types the authored shape (`PluginManifest`); the host attaches the
|
||
folder-derived `id` to produce the loaded `Plugin`. All validation happens at discovery. Note
|
||
there is **no `id` or `basePath`** in the manifest — both come from the folder
|
||
([Anatomy](#anatomy-of-a-plugin)).
|
||
|
||
| Field | Required | Notes |
|
||
| --- | --- | --- |
|
||
| `apiVersion` | yes | Semver the plugin was built against — a **literal**, not `HOST_API_VERSION`. See [Versioning](#contract-versioning). |
|
||
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/icons.ts`); node `id`s must be globally unique. |
|
||
| `permissions` | no | Tokens this plugin introduces; declared for docs, conflict detection, and bootstrap seeding (see [Nav & permissions](#nav--permissions)). |
|
||
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
||
| `hooks` | no | See [Hooks](#hooks). |
|
||
|
||
A plugin may be routes-only, nav-only, or hooks-only — every collection field is optional.
|
||
|
||
## Routes & handlers
|
||
|
||
A route is `{ method, path, permission?, handler }`. `path` is **relative to the plugin's mount
|
||
path `/<id>`** (so `/shifts` in the `scheduling` plugin serves `/scheduling/shifts`); the host
|
||
matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`,
|
||
runs the `permission` gate (a coarse JWT-claim check — see the README), and only then calls the
|
||
handler with the [request context](#requestcontext). When the gate fails, an **anonymous** visitor
|
||
is redirected to `/login` to sign in (same as the built-in admin screens); the requested page is
|
||
preserved as `return_to`, so after signing in they land **back on the page they asked for**, not the
|
||
dashboard. A **signed-in** user who simply lacks the role gets the **403** page.
|
||
|
||
`method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`.
|
||
|
||
A handler returns a **`RouteResult`** (or a `Promise` of one); the host turns it into the HTTP
|
||
response. Returning `void` is the escape hatch — the handler wrote to `ctx.res` itself.
|
||
|
||
```ts
|
||
type RouteResult =
|
||
| { view: string; data?: Record<string, unknown>; status?: number; headers?: Record<string, string> }
|
||
| { html: string; status?: number; headers?: Record<string, string> }
|
||
| { json: unknown; status?: number; headers?: Record<string, string> } // opt-in JS enhancement
|
||
| { redirect: string; status?: number }; // 303 unless status set
|
||
```
|
||
|
||
```ts
|
||
// shifts.ts
|
||
import { parseListQuery, type RequestContext } from "../../src/plugin-api.ts";
|
||
|
||
export async function listShifts(ctx: RequestContext) {
|
||
const q = parseListQuery(ctx.url);
|
||
const rows = await fetch(`${upstream}/shifts?${ctx.url.searchParams}`).then((r) => r.json());
|
||
return { view: "shifts", data: { rows, q } }; // renders plugins/scheduling/views/shifts.ejs
|
||
}
|
||
```
|
||
|
||
- **`view`** resolves against the plugin's own `views/` (`src/view-resolver.ts`) — nested names
|
||
like `"shifts/edit"` work, and an out-of-bounds name is refused. The template may `include()`
|
||
the core building-block partials (app shell, nav tree, data table, …) and its own
|
||
partials/subfolders to render a full page — exactly as the built-in screens do. To load the
|
||
plugin's own CSS, pass its `/public/<id>/x.css` href in the shell's `styles` slot (an array of
|
||
extra stylesheet hrefs) — see the reference's `views/shifts.ejs`.
|
||
- **Finer authorization than the route `permission`** uses the guards from `src/plugin-api.ts`:
|
||
`requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect
|
||
to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx,
|
||
{namespace, object, relation})` (a live Keto check for relationship rules — the subject is the
|
||
signed-in user, anonymous ⇒ denied). Throw `new GuardError(403, …)` after a failed `can`/`check`
|
||
to render the 403 page.
|
||
- The handler **fetches its own data** from upstream and renders it; plugins hold no state
|
||
(see the README's *Stateless* section). The partials only need rows.
|
||
- `default` status: `200` for `view`/`html`/`json`, `303` for `redirect`.
|
||
|
||
### Escaping & the trust boundary
|
||
|
||
The host does not sandbox plugin output (crash-isolation is a non-goal), so a handler **owns the
|
||
safety of the data it renders**:
|
||
|
||
- **Raw HTML is raw.** An `{ html }` result and the `*.html` partial fields (`cell.html`,
|
||
`error.html`, a menu `trigger.html`) are emitted **unescaped** — that's their purpose (slot
|
||
composition). Escape any untrusted content yourself before putting it there.
|
||
- **Text is auto-escaped; URLs are not scheme-checked.** Partials escape text fields (labels,
|
||
names), so those are injection-safe. But a URL field — nav `href`, a table cell link, a menu
|
||
item, a breadcrumb, `brand.logo` — is emitted as-is inside the attribute: a `javascript:` or
|
||
`data:` URL from upstream/user data becomes live XSS. When a URL comes from data you don't
|
||
control, pass it through **`safeUrl()`** from `src/plugin-api.ts` first — it returns the URL when
|
||
it's relative or `http(s):` and collapses anything else to `"#"`:
|
||
```ts
|
||
import { safeUrl } from "../../src/plugin-api.ts";
|
||
return { view: "list", data: { rows: rows.map((r) => ({ ...r, href: safeUrl(r.href) })) } };
|
||
```
|
||
|
||
## The landing pages (`home` & `dashboard`)
|
||
|
||
The host has two replaceable landing slots, and a plugin may own either or both:
|
||
|
||
| Slot | Path | Gate | Default |
|
||
| --- | --- | --- | --- |
|
||
| `home` | `/` | **public** — anyone | An intro page with prominent sign-in / register links. |
|
||
| `dashboard` | `/dashboard` | **signed-in session** (anonymous → `/login`, with `/dashboard` as `return_to`) | The built-in mock-data People list. |
|
||
|
||
```ts
|
||
import { definePlugin } from "../../src/plugin-api.ts";
|
||
import { landing, board } from "./pages.ts";
|
||
|
||
export default definePlugin({
|
||
apiVersion: "1.0.0",
|
||
home: landing, // owns "/" — the public front page
|
||
dashboard: board, // owns "/dashboard" — the post-login app home
|
||
});
|
||
```
|
||
|
||
Each is a `RouteHandler` like any route's — it receives the [`RequestContext`](#requestcontext) and
|
||
returns a `RouteResult`, typically a `view` from the plugin's own `views/`. A `dashboard` handler
|
||
renders against the native app shell via `ctx.chrome` exactly as a route handler does; a `home`
|
||
handler is a **public** page, so `ctx.user` may be `null` (use it to show a "go to dashboard" link to
|
||
a signed-in visitor, or sign-in / register to an anonymous one). After login the user lands on
|
||
`/dashboard` (or the `return_to` they were headed to), and the global menu's **Dashboard** link
|
||
points there.
|
||
|
||
For the gated `dashboard`, the host enforces the session gate first, so `ctx.user` is non-null;
|
||
branch on `ctx.roles` *inside* to tailor the page per role. Don't gate `dashboard` itself behind a
|
||
single permission — there's no second dashboard to fall back to, so a user lacking it would land on a
|
||
403. (Both slots answer `GET` and `HEAD`.)
|
||
|
||
Only **one** plugin may own each slot: two declaring `home` (or two declaring `dashboard`) is a
|
||
boot-stopping conflict ([below](#conflict-rules)), never last-write-wins. Neither needs a `routes`
|
||
entry — the host mounts them above the `/<id>` route namespace, and `/` can't be shadowed by a plugin
|
||
route at all (route paths always carry the `/<id>` prefix).
|
||
|
||
## RequestContext
|
||
|
||
Every handler receives one argument, the `RequestContext` (`src/context.ts`), built once per
|
||
request:
|
||
|
||
```ts
|
||
interface RequestContext {
|
||
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell
|
||
log: Log; // request-scoped logger, in this request's trace (§9)
|
||
params: Record<string, string>; // path params from the route match, e.g. /shifts/:id → { id }
|
||
query: URLSearchParams; // alias of url.searchParams
|
||
req: IncomingMessage;
|
||
res: ServerResponse;
|
||
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
|
||
url: URL;
|
||
user: User | null; // { id, email, roles } from the verified session JWT, or null
|
||
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie
|
||
}
|
||
```
|
||
|
||
**`ctx.chrome`** is the page chrome the host builds per request — `{ brand, csrfToken, nav, theme,
|
||
user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same
|
||
sidebar, branding, theme switch and signed-in profile as the built-in screens); `chrome.nav` is the
|
||
global menu — your plugin's nav fragment plus the others and the admin section — already composed,
|
||
role-filtered, and current-marked for this request. **`ctx.verifyCsrf(submitted)`** guards a
|
||
state-changing form: render `chrome.csrfToken` in a hidden `_csrf` field, then on POST read your own
|
||
body and `if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, …)`. The host owns the
|
||
secret and sets the cookie; the plugin never touches it. (See the reference: `plugins/scheduling/`.)
|
||
|
||
**`ctx.log`** is a structured, request-scoped logger ([`@larvit/log`](https://www.npmjs.com/package/@larvit/log),
|
||
§9) already in this request's trace: `ctx.log.info("…", { key: "value" })` (also `warn`/`error`/`debug`,
|
||
metadata values are string/number/boolean), and **`ctx.log.fetch(url, init?)`** — a drop-in `fetch`
|
||
for upstream calls that adds a client span and propagates the trace (W3C `traceparent`) downstream.
|
||
The barrel also exports a standalone **`tracedFetch`** (same behaviour, reads the ambient request log)
|
||
to default an upstream client's `fetch` to — the reference plugin's `createUpstream` does exactly this,
|
||
so its calls are traced with no per-handler wiring. Lines are correlated by a `requestId` and carry
|
||
`service.name`; output/level/OTLP export are the host's config (it logs to console always, and to an
|
||
OpenTelemetry Collector when `OTLP_ENDPOINT` is set).
|
||
|
||
**Stability guarantee.** The fields above are the stable contract — present and non-breaking
|
||
across a major `apiVersion`. New fields may be **added** within a major version (additive, never
|
||
breaking). `req`/`res` are the raw Node objects and the full escape hatch; reading them is fine,
|
||
but prefer the typed fields so a handler keeps working as the host evolves. `user`/`roles` come
|
||
from the §4 JWT middleware and are `null`/`[]` until a session exists.
|
||
|
||
## Nav & permissions
|
||
|
||
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/nav.ts`), which
|
||
applies the central override and then **filters per user** by the roles in the session JWT — a
|
||
node shows iff it declares no `permission` or the user's roles include that token. Use arbitrary
|
||
depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a **Lucide
|
||
icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are
|
||
`ICON_NAMES` in `src/icons.ts`, and adding one means registering its lucide name there.
|
||
|
||
**A `permission` token is a coarse role.** The route/nav gate passes iff the user's JWT `roles`
|
||
include the token; those roles come from Keto at login, so an operator grants a token by writing the
|
||
Keto tuple `Role:<token>#members@user:<id>` (or to a group) — the admin **Roles** screen does this.
|
||
(The fine-grained, per-row tier is the separate Keto `Resource` namespace — see the README's *Three
|
||
tiers of "may I?"*; it is not what a route `permission` checks.)
|
||
|
||
Permission tokens are a **shared global namespace** — that's deliberate, so an operator grants
|
||
`scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace
|
||
your tokens as `<id>:<action>` to avoid accidental clashes. Declaring them in `permissions` is
|
||
optional but recommended: it documents them, feeds conflict detection, and lets the one-command
|
||
bootstrap seed them — the demo admin is granted every discovered plugin's declared tokens (§3), so
|
||
a dropped-in plugin works out of the box without editing host config.
|
||
|
||
## Contract versioning
|
||
|
||
Each manifest declares `apiVersion` — a **semver** string naming the host contract it was built
|
||
against — and the host exposes the current `HOST_API_VERSION` (e.g. `"1.0.0"`). The host bumps
|
||
**major** on a breaking manifest/handler change and **minor** on an additive one. At discovery
|
||
the host parses both with `parseSemver` (the official semver core regex — strict: no ranges,
|
||
`v` prefixes, or leading zeros) and applies provider/consumer semantics in `checkApiVersion`:
|
||
|
||
| Plugin `apiVersion` vs host | Result | Host action |
|
||
| --- | --- | --- |
|
||
| same major, same minor (patch ignored) | `ok` | load |
|
||
| same major, plugin minor **<** host minor | `warn` | load, log — additive-compatible, newer features exist |
|
||
| same major, plugin minor **>** host minor | `refuse` | **abort boot** — plugin needs a newer host |
|
||
| different major | `refuse` | **abort boot** — incompatible contract |
|
||
| missing / not a valid semver | `refuse` | **abort boot** — must be declared |
|
||
|
||
The plugin pins one exact version (no ranges — in keeping with the project's pinning rules); the
|
||
*host* supplies the caret-style compatibility. `parseSemver`/`checkApiVersion` are tight,
|
||
dependency-free functions (the `semver` package's ranges/coercion/prerelease-precedence are more
|
||
than the contract needs).
|
||
|
||
**Write a literal, never `HOST_API_VERSION`.** `apiVersion` records the version the plugin was
|
||
*built against*. Importing the host's current constant would make every plugin always equal the
|
||
host — the check could never fire, and a future breaking change would slip through silently.
|
||
|
||
## Conflict rules
|
||
|
||
Plugins are independent folders, so the host detects collisions across all discovered plugins
|
||
with `findConflicts` and resolves them **loudly — never last-write-wins**. `error` aborts boot;
|
||
`warn` logs and continues.
|
||
|
||
| Kind | Level | Rule |
|
||
| --- | --- | --- |
|
||
| `id` | error | Two plugins share an `id` (folder name). Ids must be globally unique — they namespace the mount path, views/static, and the override target. |
|
||
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
|
||
| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. |
|
||
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
|
||
| `permission` | warn | A permission token is declared by more than one plugin. Sharing is legitimate (shared role); namespace as `<id>:<action>` if unintended. |
|
||
|
||
There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its
|
||
uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns
|
||
rather than aborts; everything else is an error an author fixes before the host will start.
|
||
|
||
## Hooks
|
||
|
||
Optional, for reacting to system actions. A plugin's `hooks` may implement:
|
||
|
||
| Hook | When | May |
|
||
| --- | --- | --- |
|
||
| `onBoot()` | after discovery, before the server listens | warm caches, validate upstream config |
|
||
| `onRequest(ctx)` | before route matching | inspect, or **short-circuit** by returning a `RouteResult` |
|
||
| `onResponse(ctx, result)` | after the handler | observe/log; cannot change the response |
|
||
|
||
Hooks run in **discovery order** (plugins sorted by id). `onRequest` fires on every request that
|
||
reaches routing (static assets bypass it); the **first** hook to return a `RouteResult` wins and
|
||
short-circuits — later `onRequest` hooks and the route handler are skipped, and that result renders
|
||
against its own plugin's views. `onResponse` runs for a matched route after its handler, with the
|
||
handler's result; its return value is ignored. Hooks run with no sandbox — a throwing hook fails
|
||
loud (boot for `onBoot`, the request for the others). Keep them cheap; `onRequest` is on the hot
|
||
path (the host skips the pipeline entirely when no plugin declares a hook). This surface is
|
||
intentionally small and may grow additively within the major version.
|
||
|
||
## Local dev & test story
|
||
|
||
A plugin is a normal folder of TypeScript, so an author tests it the same way the core is tested
|
||
— everything in Docker, no host tooling. The shipped reference (`plugins/scheduling/`) is the
|
||
worked example: thin handlers bound to an injectable upstream client, unit-tested in
|
||
`shifts.test.ts` with a mocked `fetch` and a hand-built `ctx` (no host).
|
||
|
||
1. **Unit-test handlers as pure functions.** Keep a handler thin: parse `ctx`, fetch upstream,
|
||
return a `RouteResult`. Test the data-shaping in isolation (mock `fetch`/upstream) with
|
||
`node --test`, exactly like `src/dashboard.test.ts` tests the dashboard model. No host needed.
|
||
|
||
```bash
|
||
docker compose run --rm web npm test
|
||
```
|
||
|
||
2. **Run one plugin against the host.** Get the folder into the container's `/app/plugins/<id>`
|
||
— either in your clone (the dev compose bind-mounts the tree) or by bind-mounting an external
|
||
folder (README → *Where plugins live*) — and `docker compose up`; the host discovers it. For
|
||
an isolated harness, the §2 host exposes plugin injection (`createApp({ plugins: [myPlugin] })`)
|
||
so a test can mount a single manifest and assert its routes, nav, and gating without the rest
|
||
of the stack.
|
||
|
||
3. **E2E the user-facing flow.** Per AGENTS.md §6, ship a side-effect-free Playwright test in
|
||
`e2e/` for each plugin page/form so the suite stays `fullyParallel`, run against the live `web`
|
||
service with the plugin mounted. The reference's permission-gating is covered in `visual.spec.ts`;
|
||
its authenticated list/form happy-path is the §8 full-E2E item (needs cross-host login infra).
|
||
|
||
The validation an author hits is the same the host runs: bad `apiVersion` or a conflict
|
||
([above](#conflict-rules)) stops boot with a precise message naming the plugin(s) involved.
|