docs: restructure README around the plugin-author path, add examples/README, genericize the plugin walkthrough

This commit is contained in:
2026-06-30 23:30:13 +02:00
parent fe97c3854a
commit 4adf14f386
3 changed files with 179 additions and 132 deletions
+20 -5
View File
@@ -31,6 +31,13 @@ commands and layout.
Tests stay independent and side-effect-free so the suite runs `fullyParallel` — keep it Tests stay independent and side-effect-free so the suite runs `fullyParallel` — keep it
that way as it grows (never serialise on shared state); parallelism is what keeps it that way as it grows (never serialise on shared state); parallelism is what keeps it
fast. E2E runs in Docker against the live stack — see `README.md`. fast. E2E runs in Docker against the live stack — see `README.md`.
7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the
only way to add domain features. It optimises for being **powerful, predictable, and
overloadable** (a plugin can take over as much of a page as it wants), and the host
**fails loud at boot/discovery** (bad manifest, version mismatch, or conflict stops
startup with a clear message) rather than sandboxing at runtime. Runtime crash-isolation
is a deliberate **non-goal** — diagnose at deploy time, not in production. Keep this
contract stable; see `README.md` → Building plugins.
## Deliberate architectural deviations (don't re-flag) ## Deliberate architectural deviations (don't re-flag)
@@ -72,11 +79,15 @@ docker compose -f compose.yml up --build -d # production
before Quick start — no philosophy, no rationale. Keep its commands copy-pasteable and the before Quick start — no philosophy, no rationale. Keep its commands copy-pasteable and the
example plugin as small as possible; deeper detail lives in its own section, linked. example plugin as small as possible; deeper detail lives in its own section, linked.
2. **Returning developer (rest).** A **Contents** ToC immediately after Quick start, then 2. **Returning developer (rest).** A **Contents** ToC immediately after Quick start, then
sections ordered **most-used/overview first → least-used/in-depth last**: Overview → sections ordered by **what a developer adopting Plainpages reaches for, in priority
Architecture → Building plugins → menu/blocks/interactivity → Configuration → Auth → order** — not by architectural layering. The value that sets the order: getting up and
Email → Testing → Production → Observability → the JWT-rotation runbook → the running **building plugins** comes first, then **configuring and securing** the system
Project-layout file map → Extending. Niche ops runbooks and the file-map reference stay (Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are
near the end. deliberately deferred — they're not top of mind when starting out. Concretely: Overview →
Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
Architecture → Testing → Production → Observability → the JWT-rotation runbook → the
Project-layout file map → Extending. When adding a section, place it by this value (how
early an adopter needs it), not by where it sits in the stack.
When editing: put content in the section it belongs to (don't prepend rationale above Quick When editing: put content in the section it belongs to (don't prepend rationale above Quick
start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in
@@ -100,6 +111,10 @@ one home, linking to it rather than restating (credentials, env vars, rotation s
versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept
exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g. exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
`node:24.16.0-alpine3.24`). `node:24.16.0-alpine3.24`).
- A plugin's `apiVersion` is a **hand-written literal** semver — the host version the
plugin was built against — bumped by hand on rebuild, **never** the host's
`HOST_API_VERSION` constant. Importing the constant makes every plugin always equal the
host, so `checkApiVersion` can never fire and a breaking change slips through silently.
- Run the stability reviewer agent after every implementation of something that can be like - Run the stability reviewer agent after every implementation of something that can be like
a PR. That includes any change pushed directly to master. a PR. That includes any change pushed directly to master.
Skip this if the changes are purely documentation and/or comments. Skip this if the changes are purely documentation and/or comments.
+153 -127
View File
@@ -1,32 +1,27 @@
# Plainpages # Plainpages
A self-hostable **foundation for admin and operational web UIs** — the back-office you A self-hostable **foundation for server-rendered web applications** — **public pages,
build for a webshop, a school scheduling system, a water-treatment plant, or any tool access-controlled pages, or any mix**, built from a **zero-JS design system** with a
where staff register, find, and work with data. It ships the parts that are the same **config-driven menu** and **optional authentication & authorization** baked in (any page
every time — **authentication, authorization, a config-driven menu, and a server-rendered, can be public or gated). You add everything domain-specific by **dropping in plugin
zero-JS design system** — and lets you add everything domain-specific by **dropping in folders** — the admin UI for a webshop, a public service portal, a school scheduler, a
plugin folders**. water-treatment dashboard — without rebuilding auth, the menu, and the design system every
time.
## Quick start ## Quick start
> **Requirements:** **Docker** and **Docker Compose** — and nothing else. Never run > **Requirements:** **Docker** and **Docker Compose** — and nothing else.
> `node`/`npm`/`tsc` on the host; every command goes through Compose.
**1. Clone and start the whole stack.** **1. Clone and start the whole stack.**
```bash ```bash
git clone ssh://git@gitea.larvit.se:21022/larvit/plainpages.git git clone ssh://git@gitea.larvit.se:21022/larvit/plainpages.git
cd plainpages cd plainpages
docker compose up # http://localhost:3000, live-reloads on source changes docker compose up -d # http://localhost:3000, live-reloads on source changes
``` ```
One command brings up `web` + Postgres + Ory (Kratos/Keto/Hydra), generates the JWT
signing key, seeds a demo admin, and prints a banner with the login URL — no key
generation, no hand-edited Ory config, no separate database.
**2. Sign in.** Open <http://localhost:3000> and sign in as the seeded admin — **2. Sign in.** Open <http://localhost:3000> and sign in as the seeded admin —
**`admin@plainpages.local` / `admin`** (change before production). `/` is the public **`admin@plainpages.local` / `admin`**.
landing; the gated app home is `/dashboard`.
**3. Add your first plugin.** The clone is bind-mounted into the container, so a new **3. Add your first plugin.** The clone is bind-mounted into the container, so a new
folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`: folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`:
@@ -58,8 +53,7 @@ From here, render real pages against the app shell and fetch upstream data — s
## Contents ## Contents
- [Overview](#overview) - [Overview](#overview)
- [Architecture](#architecture) - [how it compares](#how-it-compares)
- [Stateless — no application database](#stateless--no-application-database)
- [Building plugins](#building-plugins) - [Building plugins](#building-plugins)
- [anatomy](#anatomy-of-a-plugin) - [anatomy](#anatomy-of-a-plugin)
- [the manifest](#the-manifest) - [the manifest](#the-manifest)
@@ -85,6 +79,8 @@ From here, render real pages against the app shell and fetch upstream data — s
- [three tiers](#three-tiers-of-may-i) - [three tiers](#three-tiers-of-may-i)
- [OAuth2 (Hydra)](#oauth2-provider-hydra) - [OAuth2 (Hydra)](#oauth2-provider-hydra)
- [Email](#email) - [Email](#email)
- [Architecture](#architecture)
- [Stateless](#stateless)
- [Testing](#testing) - [Testing](#testing)
- [end-to-end](#end-to-end-playwright) - [end-to-end](#end-to-end-playwright)
- [the full gate](#the-full-gate-one-command) - [the full gate](#the-full-gate-one-command)
@@ -96,12 +92,17 @@ From here, render real pages against the app shell and fetch upstream data — s
## Overview ## Overview
Plainpages gives you the boring-but-hard parts of a back-office and stays out of your Plainpages gives you the boring-but-hard parts of a web app — a design system, a menu,
domain logic. The only screens it ships itself are the ones for running the system: sessions, and access control — and stays out of your domain logic. **Any page can be public
**users, groups, and permissions**. Everything else is a plugin. or gated**, so the same foundation serves a purely public site, a fully locked-down internal
tool, or the common middle: a public front with an authenticated area behind it. Its **sweet
spot** is the **back-office and operational tooling** you'd otherwise hand-roll for the tenth
time, but nothing ties it to internal-only use. The only screens it ships itself are the ones
for running the system: **users, groups, and permissions**. Everything else is a plugin.
**Who it's for.** Experienced developers building back-office, admin, and dashboard **Who it's for.** Experienced developers building server-rendered web products — back-office
products — for their own use or for a client. You know HTTP, Docker, and identity and operational tools, dashboards, portals, or public sites with a gated area — for their own
use or for a client. You know HTTP, Docker, and identity
providers, and you'd rather assemble pages from building blocks than fight a framework or providers, and you'd rather assemble pages from building blocks than fight a framework or
hand-roll auth for the tenth time. It's not a no-code tool and doesn't hide its moving hand-roll auth for the tenth time. It's not a no-code tool and doesn't hide its moving
parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--permissions)) reads as parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--permissions)) reads as
@@ -125,6 +126,15 @@ production. The shape doesn't change as it grows: every plugin is the same self-
folder, the hot path is the same I/O-free JWT check, and there's no app database to scale folder, the hot path is the same I/O-free JWT check, and there's no app database to scale
or migrate. or migrate.
**Plugins are the extension model — powerful, predictable, fail-loud.** Everything
domain-specific is a plugin, and the plugin API is the product's main surface, written for
experienced developers. It 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 deliberate **non-goal** — diagnose at deploy time, not in production. See
[Building plugins](#building-plugins).
**Low-end by design.** Plainpages deliberately targets **low-end systems, odd hardware, **Low-end by design.** Plainpages deliberately targets **low-end systems, odd hardware,
and low-bandwidth environments** — a tablet on a factory floor, an old thin client at a and low-bandwidth environments** — a tablet on a factory floor, an old thin client at a
reception desk, a remote site on a flaky link. That's *why* the baseline is boring, reception desk, a remote site on a flaky link. That's *why* the baseline is boring,
@@ -137,70 +147,34 @@ landmarks, one `<h1>` per page, lists and tables with proper headers, a skip lin
ARIA (`aria-current`/`aria-sort`) only where the platform leaves a gap (see ARIA (`aria-current`/`aria-sort`) only where the platform leaves a gap (see
[AGENTS.md](AGENTS.md)). [AGENTS.md](AGENTS.md)).
> **Status.** The full architecture this README describes is built and exercised ### How it compares
> end-to-end by the Playwright suites (see [Testing](#testing)): the Node 24 + EJS server,
> the zero-JS **design system** (app shell, nav tree, data table, filters, pagination,
> forms), the **plugin host** (discovery, router, per-plugin views + static, the
> `config/menu.ts` override + branding), the **Ory stack** (Postgres, Kratos + the
> session→JWT tokenizer, Keto, Hydra), the **auth** wiring that consumes it (themed
> sign-in / register / reset / SSO, the session→JWT hot path, the users/groups/roles admin
> screens), **Hydra's login / consent / logout handlers**, and **production & ops
> hardening** (the prod compose profile, response security headers, **structured logging +
> OTLP observability**, the [JWT key-rotation runbook](#jwt-signing-key--rotation)).
## Architecture The space around Plainpages is crowded, but it splits into families that each share **one**
of its traits and miss the rest. Here's the map — established names per family, and where
Plainpages sits relative to them:
Plainpages runs as a small set of containers, orchestrated by Docker Compose: | Family · examples | What it is | Where Plainpages differs |
| --- | --- | --- |
| **Modular app frameworks** — Odoo · Frappe · OrchardCore · ABP | extend by dropping a **module folder** in; server-rendered | Closest in *shape* to the plugin model, but each is **metadata/model-driven with its own ORM/DB** and a large framework. Plainpages keeps the folder model while staying **stateless, framework-light, and component-not-generator**. |
| **Developer portals / IDPs** — Backstage · Port · Cortex · Roadie · OpsLevel · Compass | plugin-based internal platforms with a service catalog | Closest on the **plugin** axis, but heavy **React SPAs** with a build step, built to catalog services. Plainpages is **zero-JS, few-deps, no-build** and renders general pages, not a catalog. |
| **Model-driven auto-admin** — Django Admin · AdminJS · Filament · ActiveAdmin · EasyAdmin · Sonata · sqladmin · Starlette-admin | generate a CRUD UI **from your ORM/DB models** | Plainpages is a **component library, not a generator** — there is **no app DB** to model against; handlers fetch from upstream and you assemble the page. |
| **Schema-driven content platforms** — KeystoneJS · Payload · Directus · Strapi · Wagtail | define a content schema, get an admin **+ API**; they own the data | Plainpages owns **no data** and isn't schema-first; it renders pages over services you already run, rather than being the system of record. |
| **Naked-objects / runtime UI** — Apache Causeway · OpenXava · JHipster | the UI is **auto-projected from domain objects** (the generator extreme) | The opposite stance: Plainpages hands you **building blocks to assemble**, with no domain model driving the screen. |
| **Low-code builders** — Retool · Appsmith · ToolJet · Budibase · NocoBase · ILLA | drag-and-drop GUI builders, **client-JS-heavy**, runtime state | Plainpages is **code-first and zero-JS** — server-rendered HTML versioned in your repo, no visual editor or runtime app state. |
| **Code-first internal-tool platforms** — Windmill · Lowdefy · Superblocks | turn **scripts/config into auto-generated UIs** | Closest in *spirit* (for developers, self-hosted), but script/workflow-runner-centric. Plainpages gives you **full pages you control**, not a UI inferred from a function signature. |
| **Hypermedia / zero-JS movement** — htmx · Hotwire/Turbo · Unpoly · Datastar | the **server-rendered-HTML philosophy** Plainpages is built on | These are *techniques*, not a foundation — no auth, menu, or plugins. Plainpages is what you **assemble with** the approach (and plugins may opt into htmx). *(Phoenix LiveView shares it but trades in a stateful socket.)* |
| **CSS-only admin shells** — AdminLTE · Tabler · Bootstrap themes | a **visual shell** — markup + styles only | No backend, auth, routing, or extension model. Plainpages **includes the shell** and adds the hard-every-time parts. |
| **Themed auth UI on Ory** — Kratos self-service UIs (`ory/kratos-selfservice-ui-node`, `kratos-admin-ui`) | the **login / registration screens** over Ory | The one *slice* with a direct off-the-shelf alternative: Plainpages reimplements it inside its own shell, so you could swap it out to avoid maintaining that part. |
| Container | Role | No family combines the whole set: **[drop-in plugin folders](#building-plugins)**, a **zero-JS
| -------------- | ---- | server-rendered** design system, **[optional auth](#auth-sessions--permissions)** (any page
| `web` | The Node 24 + TypeScript app: server-rendered EJS, the plugin host, the building-block partials. Stays tiny. | public or gated), **no app database**, and a **framework-light TypeScript** core with no build
| `kratos` | **Ory Kratos** — identity: login, registration, password reset, SSO, sessions. | step. Each neighbour shares one trait and trades away the rest — Plainpages is the intersection.
| `keto` | **Ory Keto** — permissions: the authorization decisions (`can user X do Y on Z?`). |
| `hydra` | **Ory Hydra** — OAuth2/OIDC provider, so other apps can log in *through* plainpages. |
| `postgres` | **Ory's** storage only (Kratos/Keto/Hydra). The `web` app never connects to it. |
The `web` app is an Ory **relying party**: it never stores passwords. At login it turns
the Kratos session into a short-lived, **locally-validated JWT** (the Kratos session
tokenizer) carrying the user's coarse roles — so every later request gates the menu and
pages by **verifying the JWT in-process, with no per-request call to Ory**. Keto answers
the rarer fine-grained checks; Hydra is used only when the app acts as an OAuth2 **login &
consent provider** for other apps. It reaches the Ory services over their **REST APIs
using Node's built-in `fetch`** — no SDK dependency. See
[Auth, sessions & permissions](#auth-sessions--permissions).
In **dev** the host-facing Ory ports are published — Kratos public `4433` (where the browser
POSTs self-service flows) and Hydra public `4444`; **prod** (`docker compose -f compose.yml
up`) keeps them internal.
So the `web` app is **stateless** and its npm footprint stays tiny — a small, pinned set
of runtime deps (today **`ejs`** for templating, **`lucide-static`** for icons, and
**`@larvit/log`** — itself zero-dependency — for structured/OTLP logging), grown only with
justification and never a framework. Auth, sessions, SSO, and OAuth2 add *services*, not
npm packages; data lives upstream.
### Stateless — no application database
Plainpages and its plugins hold **no state of their own**. The only database in the stack
is **Postgres, and it belongs to Ory** (Kratos/Keto/Hydra); the `web` app never connects
to it.
A plugin gets its data by **calling an upstream service** from its route handler — a REST
API, an ERP, a plant historian, the customer's own backend — and renders the response with
the building blocks; writes are forwarded the same way. The partials only need rows to
render and don't care where they came from.
This keeps `web` trivially scalable and crash-safe: any instance can serve any request,
because the session lives in Kratos and the data lives upstream.
## Building plugins ## Building plugins
A plugin is a self-contained folder under `plugins/` that the host discovers at boot — no A plugin is a self-contained folder under `plugins/` that the host discovers at boot — no
registration step, no central wiring. The folder name is the plugin **id** *and* its **mount registration step, no central wiring. Each plugin carries its own nav, routes, views, and CSS.
path** (`plugins/scheduling/``/scheduling`); neither is declared in the manifest, so they
can't drift or be claimed twice. Each plugin carries its own nav, routes, views, and CSS, so
installing one is "drop the folder, restart"; an operator stays in control via a central
override (see [The menu system](#the-menu-system)).
This is the **authoritative reference** for the plugin API — the product's main surface. The This is the **authoritative reference** for the plugin API — the product's main surface. The
contract is **TypeScript** (`src/plugin-host/plugin.ts`), so the types there are the single contract is **TypeScript** (`src/plugin-host/plugin.ts`), so the types there are the single
@@ -210,30 +184,31 @@ the host enforces. A complete, runnable example lives in
permission-gated list page fetching upstream data (it points `SCHEDULING_UPSTREAM` at its backend; permission-gated list page fetching upstream data (it points `SCHEDULING_UPSTREAM` at its backend;
the dev compose ships a tiny mock, `examples/shifts-upstream/`), a CSRF-guarded form forwarding the dev compose ships a tiny mock, `examples/shifts-upstream/`), a CSRF-guarded form forwarding
writes upstream, and a mix of public + role-gated nav. It is **not** pre-installed — `plugins/` writes upstream, and a mix of public + role-gated nav. It is **not** pre-installed — `plugins/`
ships empty so you mount your own; the E2E suites bind-mount this example onto ships empty so you mount your own. To run it in dev, copy it in
`/app/plugins/scheduling` to exercise it. To run it in dev, copy it in
(`cp -r examples/scheduling-plugin plugins/scheduling`, then restart) — the dev compose already (`cp -r examples/scheduling-plugin plugins/scheduling`, then restart) — the dev compose already
points `SCHEDULING_UPSTREAM` at its mock backend. Copy it to `plugins/<id>/` and adapt. points `SCHEDULING_UPSTREAM` at its mock backend. Copy it to `plugins/<id>/` and adapt.
**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.
### Anatomy of a plugin ### Anatomy of a plugin
``` ```
plugins/scheduling/ # folder name = the plugin id → mounted at /scheduling plugins/things/ # the plugin folder — its name is the id AND the mount path (→ /things)
plugin.ts # default export: the manifest (definePlugin(...)) plugin.ts # REQUIRED — the one fixed filename; default-exports the manifest (definePlugin(...))
shifts.ts # handlers, helpers — plain modules views/ # fixed name, optional — EJS the host renders for a { view } result
views/ # EJS templates for this plugin's pages things.ejs # your view files; a handler picks one with { view: "things" }
shifts.ejs public/ # fixed name, optional — static assets, served at /public/things/
public/ # static assets, served at /public/scheduling/ things.css # your asset files
scheduling.css handlers.ts # your code, any names/layout — host never looks here; plugin.ts imports it
service.ts # e.g. route handlers, upstream calls, domain helpers — design as you wish
``` ```
**Only `plugin.ts` is required.** The host looks for exactly that filename and its
default-exported manifest. `views/` and `public/` are the two fixed folder *names* it resolves
against — used only if the plugin renders views or serves assets — but the files inside are
yours to name. Everything else (handlers, upstream clients, their filenames and folder layout)
the host never sees; `plugin.ts` simply imports it. The `handlers.ts`/`service.ts` split above is
just an example — name and arrange your modules however you like, or keep a routes-only plugin to a
single `plugin.ts`.
**Identity comes from the folder.** The folder name *is* the plugin `id`, and the mount path is **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 `/<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 `az`, digits, and dashes — dashes must be **URL/path-safe** (`isValidPluginId`: lowercase `az`, digits, and dashes — dashes
@@ -260,29 +235,25 @@ body/CSRF/list-query helpers). That barrel *is* the contract boundary; don't rea
```ts ```ts
import { definePlugin } from "../../src/plugin-host/plugin-api.ts"; import { definePlugin } from "../../src/plugin-host/plugin-api.ts";
import { listShifts, createShift } from "./shifts.ts"; import { listThings, createThings } from "./handlers.ts";
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", // semver of the host contract this was built against (a literal — see Versioning) apiVersion: "1.0.0", // semver string of the host contract this plugin was built against (see Versioning)
// Nav fragment, merged into the global menu and permission-filtered per user. // Nav fragment, merged into the global menu and permission-filtered per user.
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts). // `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
nav: [{ nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }],
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 // Permission tokens this plugin introduces. Optional — see Nav & permissions.
// bootstrap seeding (the demo admin is granted every discovered plugin's tokens). Optional.
permissions: [ permissions: [
{ token: "scheduling:read", description: "View shifts" }, { token: "things:read", description: "View things" },
{ token: "scheduling:write", description: "Create and edit shifts" }, { token: "things:write", description: "Create and edit things" },
], ],
// Route handlers, mounted under the plugin's path (/scheduling). `permission` gates first. // Route handlers, mounted under the plugin's path (/things). `permission` gates first.
routes: [ routes: [
{ method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts }, { method: "GET", path: "/", permission: "things:read", handler: listThings },
{ method: "POST", path: "/shifts", permission: "scheduling:write", handler: createShift }, { method: "POST", path: "/", permission: "things:write", handler: createThings },
], ],
}); });
``` ```
@@ -295,11 +266,11 @@ there is **no `id` or `basePath`** in the manifest — both come from the folder
| Field | Required | Notes | | Field | Required | Notes |
| --- | --- | --- | | --- | --- | --- |
| `apiVersion` | yes | Semver the plugin was built against — a **literal**, not `HOST_API_VERSION`. See [Versioning](#contract-versioning). | | `apiVersion` | yes | Semver string of the host contract the plugin was built against. 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). | | `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). | | `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/ui/icons.ts`); node `id`s must be globally unique. | | `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/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)). | | `permissions` | no | Tokens this plugin introduces. See [Nav & permissions](#nav--permissions). |
| `routes` | no | See [Routes & handlers](#routes--handlers). | | `routes` | no | See [Routes & handlers](#routes--handlers). |
| `hooks` | no | See [Hooks](#hooks). | | `hooks` | no | See [Hooks](#hooks). |
@@ -308,7 +279,7 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field
### Routes & handlers ### Routes & handlers
A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's
mount path `/<id>`** (so `/shifts` in the `scheduling` plugin serves `/scheduling/shifts`); the host mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host
matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`,
runs the `permission` gate (a coarse JWT-claim check — see [Nav & permissions](#nav--permissions)), runs the `permission` gate (a coarse JWT-claim check — see [Nav & permissions](#nav--permissions)),
and only then calls the handler with the [request context](#requestcontext). When the gate fails, an and only then calls the handler with the [request context](#requestcontext). When the gate fails, an
@@ -324,26 +295,33 @@ A handler returns a **`RouteResult`** (or a `Promise` of one); the host turns it
response. Returning `void` is the escape hatch — the handler wrote to `ctx.res` itself. response. Returning `void` is the escape hatch — the handler wrote to `ctx.res` itself.
```ts ```ts
// Optional on every variant below: status (HTTP status code) and headers (extra response headers).
type ResponseMeta = { status?: number; headers?: Record<string, string> };
type RouteResult = type RouteResult =
| { view: string; data?: Record<string, unknown>; status?: number; headers?: Record<string, string> } // Render the plugin's own view (plugins/<id>/views/<name>.ejs) with `data`.
| { html: string; status?: number; headers?: Record<string, string> } | ResponseMeta & { view: string; data?: Record<string, unknown> }
| { json: unknown; status?: number; headers?: Record<string, string> } // opt-in JS enhancement // Pre-rendered HTML, sent as-is.
| { redirect: string; status?: number }; // 303 unless status set | ResponseMeta & { html: string }
// JSON body
| ResponseMeta & { json: unknown }
// Redirect to a URL (takes only status, no headers).
| { redirect: string; status?: number };
``` ```
```ts ```ts
// shifts.ts // handlers.ts
import { parseListQuery, type RequestContext } from "../../src/plugin-host/plugin-api.ts"; import { parseListQuery, type RequestContext } from "../../src/plugin-host/plugin-api.ts";
export async function listShifts(ctx: RequestContext) { export async function listThings(ctx: RequestContext) {
const q = parseListQuery(ctx.url); const q = parseListQuery(ctx.url);
const rows = await fetch(`${upstream}/shifts?${ctx.url.searchParams}`).then((r) => r.json()); const rows = await fetch(`${upstream}/things?${ctx.url.searchParams}`).then((r) => r.json());
return { view: "shifts", data: { rows, q } }; // renders plugins/scheduling/views/shifts.ejs return { view: "things", data: { rows, q } }; // renders plugins/things/views/things.ejs
} }
``` ```
- **`view`** resolves against the plugin's own `views/` (`src/plugin-host/view-resolver.ts`) — nested names - **`view`** resolves against the plugin's own `views/` (`src/plugin-host/view-resolver.ts`) — nested names
like `"shifts/edit"` work, and an out-of-bounds name is refused. The template may `include()` like `"things/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 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 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 plugin's own CSS, pass its `/public/<id>/x.css` href in the shell's `styles` slot (an array of
@@ -355,7 +333,7 @@ export async function listShifts(ctx: RequestContext) {
signed-in user, anonymous ⇒ denied). Throw `new GuardError(403, …)` after a failed `can`/`check` signed-in user, anonymous ⇒ denied). Throw `new GuardError(403, …)` after a failed `can`/`check`
to render the 403 page. to render the 403 page.
- The handler **fetches its own data** from upstream and renders it; plugins hold no state - The handler **fetches its own data** from upstream and renders it; plugins hold no state
(see [Stateless](#stateless--no-application-database)). The partials only need rows. (see [Stateless](#stateless)). The partials only need rows.
- `default` status: `200` for `view`/`html`/`json`, `303` for `redirect`. - `default` status: `200` for `view`/`html`/`json`, `303` for `redirect`.
#### Escaping & the trust boundary #### Escaping & the trust boundary
@@ -424,7 +402,7 @@ request:
interface RequestContext { interface RequestContext {
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell
log: Log; // request-scoped logger, in this request's trace log: Log; // request-scoped logger, in this request's trace
params: Record<string, string>; // path params from the route match, e.g. /shifts/:id → { id } params: Record<string, string>; // path params from the route match, e.g. /things/:id → { id }
query: URLSearchParams; // alias of url.searchParams query: URLSearchParams; // alias of url.searchParams
req: IncomingMessage; req: IncomingMessage;
res: ServerResponse; res: ServerResponse;
@@ -528,10 +506,6 @@ The plugin pins one exact version (no ranges — in keeping with the project's p
dependency-free functions (the `semver` package's ranges/coercion/prerelease-precedence are more dependency-free functions (the `semver` package's ranges/coercion/prerelease-precedence are more
than the contract needs). 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 ### Conflict rules
Plugins are independent folders, so the host detects collisions across all discovered plugins Plugins are independent folders, so the host detects collisions across all discovered plugins
@@ -967,6 +941,51 @@ is the simplest place. See Ory's
[courier message templates](https://www.ory.sh/docs/kratos/emails-sms/custom-message-templates) [courier message templates](https://www.ory.sh/docs/kratos/emails-sms/custom-message-templates)
docs for the full template-type list and the data each template receives. docs for the full template-type list and the data each template receives.
## Architecture
Plainpages runs as a small set of containers, orchestrated by Docker Compose:
| Container | Role |
| -------------- | ---- |
| `web` | The Node 24 + TypeScript app: server-rendered EJS, the plugin host, the building-block partials. Stays tiny. |
| `kratos` | **Ory Kratos** — identity: login, registration, password reset, SSO, sessions. |
| `keto` | **Ory Keto** — permissions: the authorization decisions (`can user X do Y on Z?`). |
| `hydra` | **Ory Hydra** — OAuth2/OIDC provider, so other apps can log in *through* plainpages. |
| `postgres` | **Ory's** storage (Kratos/Keto/Hydra). |
The `web` app is an Ory **relying party**: it never stores passwords. At login it turns
the Kratos session into a short-lived, **locally-validated JWT** (the Kratos session
tokenizer) carrying the user's coarse roles — so every later request gates the menu and
pages by **verifying the JWT in-process, with no per-request call to Ory**. Keto answers
the rarer fine-grained checks; Hydra is used only when the app acts as an OAuth2 **login &
consent provider** for other apps. It reaches the Ory services over their **REST APIs
using Node's built-in `fetch`** — no SDK dependency. See
[Auth, sessions & permissions](#auth-sessions--permissions).
In **dev** the host-facing Ory ports are published — Kratos public `4433` (where the browser
POSTs self-service flows) and Hydra public `4444`; **prod** (`docker compose -f compose.yml
up`) keeps them internal.
So the `web` app is **stateless** and its npm footprint stays tiny — a small, pinned set
of runtime deps (today **`ejs`** for templating, **`lucide-static`** for icons, and
**`@larvit/log`** — itself zero-dependency — for structured/OTLP logging), grown only with
justification and never a framework. Auth, sessions, SSO, and OAuth2 add *services*, not
npm packages; data lives upstream.
### Stateless
Plainpages hold **no state of its own**. The only database in the stack
is **Postgres** and is used by Ory (Kratos/Keto/Hydra); the `web` app never connects
to it.
Plugins are encouraged to save state by **calling an upstream service** from its route handler
— a REST API, an ERP, a plant historian, the customer's own backend — and renders the response with
the building blocks; writes are forwarded the same way. The partials only need rows to
render and don't care where they came from.
This keeps `web` trivially scalable and crash-safe: any instance can serve any request,
because the session lives in Kratos and the data lives upstream.
## Testing ## Testing
Type check and unit tests run off the Ory stack — units need no Postgres/Kratos/Keto, and Type check and unit tests run off the Ory stack — units need no Postgres/Kratos/Keto, and
@@ -985,7 +1004,8 @@ service — no Node/browsers on the host. There are five suites:
**Visual + design system** (`visual.spec.ts`) — Ory-free, so it stays fast. It screenshots **Visual + design system** (`visual.spec.ts`) — Ory-free, so it stays fast. It screenshots
the live pages and asserts the rendered design system — the app shell, theme switch, mobile the live pages and asserts the rendered design system — the app shell, theme switch, mobile
off-canvas layout, icon sprite, CSRF-guarded sign-out, the public landing, the 404 page, and off-canvas layout, icon sprite, CSRF-guarded sign-out, the public landing, the 404 page, and
plugin permission-gating. plugin permission-gating — the last exercised by bind-mounting the reference example
(`examples/scheduling-plugin/`) onto `/app/plugins/scheduling`.
```bash ```bash
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --build --rm e2e # run the suite docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --build --rm e2e # run the suite
@@ -1299,3 +1319,9 @@ All versions are pinned to **exact, human-readable semantic versions** (no range
digests): npm deps via `.npmrc` (`save-exact=true`) + the committed lockfile (`npm ci`), and digests): npm deps via `.npmrc` (`save-exact=true`) + the committed lockfile (`npm ci`), and
container images by tag in the `Dockerfile` / compose files (e.g. `node:24.16.0-alpine3.24`, container images by tag in the `Dockerfile` / compose files (e.g. `node:24.16.0-alpine3.24`,
pinned Ory and Postgres tags). pinned Ory and Postgres tags).
A plugin's `apiVersion` follows the same pin-it-by-hand spirit: write a **literal** semver — the
host version the plugin was built against — and bump it by hand on rebuild. Never set it from the
host's `HOST_API_VERSION` constant: that would make the plugin always equal the host, so the
compatibility check ([Contract versioning](#contract-versioning)) could never fire and a breaking
change would slip through silently.
+6
View File
@@ -0,0 +1,6 @@
# examples/
| Folder | Example of |
| --- | --- |
| [`scheduling-plugin/`](scheduling-plugin/) | The reference plugin you copy into `plugins/`: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. See its [README](scheduling-plugin/README.md) and the [plugin contract](../README.md#building-plugins). |
| [`shifts-upstream/`](shifts-upstream/) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. |