Plainpages
A self-hostable foundation for server-rendered web applications — public pages, access-controlled pages, or any mix, built from a zero-JS design system with a config-driven menu and optional authentication & authorization baked in (any page can be public or gated). You add everything domain-specific by dropping in plugin folders — the admin UI for a webshop, a public service portal, a school scheduler, a water-treatment dashboard — without rebuilding auth, the menu, and the design system every time.
True home: https://gitea.larvit.se/larvit/plainpages — development, issues, and PRs live there. github.com/larvit/plainpages is a read-only mirror, force-synced on every merge to
main.
Quick start
Requirements: Docker and Docker Compose — and nothing else.
1. Clone and start the whole stack.
git clone ssh://git@gitea.larvit.se:21022/larvit/plainpages.git
cd plainpages
docker compose up -d # http://localhost:3000, live-reloads on source changes
2. Sign in. Open http://localhost:3000 and sign in as the seeded admin —
admin@plainpages.local / admin.
3. Enable user & group admin (optional). The core ships no admin GUI — the Users / Groups
/ OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at /admin/*:
cp -r examples/plugins/admin plugins/admin
docker compose up -d
The bootstrap grants the seeded admin every permission the installed plugins declare, so the
Admin section now shows in the menu. Use up -d, not restart web — the seed runs in the
one-shot bootstrap service, and only up re-runs it. See
examples/plugins/admin/.
4. 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:
import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({
apiVersion: "0.1.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
],
});
docker compose restart web
Visit http://localhost:3000/hello — the page is mounted at /hello (the folder name
is the plugin id and the mount path) and "Hello" is in the menu. That's the whole loop:
drop a folder in plugins/, restart, it's live. A plugin that declares permissions needs
docker compose up -d instead, so the seed re-runs and grants them (as in step 3).
From here, render real pages against the app shell and fetch upstream data — see
Building plugins and the runnable reference in
examples/plugins/scheduling/.
Contents
- Overview
- Users, groups & permissions
- Building plugins
- The menu system
- Building blocks
- Interactivity: zero-JS spine
- Languages (i18n)
- Configuration
- Auth, sessions & access
- Architecture
- Testing
- CI/CD
- Production & deployment
- Upgrading
- Observability
- JWT signing key & rotation
- Project layout
- Extending the core
Overview
Plainpages gives you the boring-but-hard parts of a web app — a design system, a menu, sessions, and access control — and stays out of your domain logic. Any page can be public or gated, so the same foundation serves a public site, a locked-down internal tool, or a public front with an authenticated area behind it.
- Included in the core: themed sign-in / register / reset (Kratos-backed), the design system + app shell, the config-driven menu, sessions, and access control. No domain screens.
- Opt-in admin plugin: the users, groups, and OAuth2-clients screens ship as
examples/plugins/admin/— an ordinary plugin, reaching Ory through the privilegedctx.systemsurface. - You add: everything else domain-specific, as plugins — built from the same building blocks the admin plugin uses.
Priorities: simplicity, few dependencies, strict TypeScript, no build step, Docker-only,
environment-agnostic (no NODE_ENV — every behaviour is an explicit config toggle). Identity,
sessions, SSO, OAuth2 and permission checks are delegated to Ory sidecar services. The shape
doesn't change as it grows: every plugin is the same self-contained folder, the hot path is the
same I/O-free JWT check, and there is no app database.
Plugins are the extension model. The plugin API is the product's main surface: powerful, predictable, and overloadable — a plugin can take over as much of a page as it wants. The host fails loud at boot/discovery (bad manifest, version mismatch, conflict) rather than sandboxing at runtime; crash-isolation is a deliberate non-goal. See Building plugins.
Zero JavaScript, so pages stay fast on low-end hardware and flaky links. Where a modern CSS feature removes the need for JS (theme switching, popovers, disclosure) we use it — the trade we avoid is shipping a client-side runtime, not using the platform. Markup is semantic and accessible (see AGENTS.md).
Users, groups & permissions
Authorization here is two hops: a user — directly, or through a group — is granted a
permission, and that permission's name is exactly the string a plugin gates on. Grants are
Keto relation tuples: Permission:<name>#granted@user:<id>, or @Group:<name>#members.
| Entity | Lives in | Answers | Example |
|---|---|---|---|
| User | Kratos | who you are | user:0198f2c1-… |
| Group | Keto | who — a reusable set | Group:support |
| Permission | Keto | what you may do | Permission:scheduling:read |
| Resource | Keto | which specific row | Resource:shift-4471 |
Keto's whole model is one primitive — namespace:object#relation@subject — so those four
namespaces are ours, declared in ory/keto/namespaces.keto.ts; Keto resolves them, transitively
through nested groups. The app stores none of it — it is stateless.
There is no
Role. A route gates on a single operation, so it gates on a permission. For a bundle ("IT Support staff"), make a group and grant it several — groups nest.Ory calls a user an "identity" (its own docs use the terms interchangeably). Plainpages says user everywhere; you meet Ory's spelling only in the Kratos API and the
Identitytype insrc/auth/kratos-admin.tsthat mirrors it.
Naming a permission
Every permission name is <resource>:<action> — scheduling:read, users:write,
oauth2-clients:read. Both halves are lowercase letters, digits, dashes and underscores; discovery
refuses a plugin that breaks the rule, so it holds for every installed plugin.
<resource>names the thing acted on, not the plugin that owns it — names are one global namespace, so an operator grantsscheduling:readonce and every plugin referencing it is gated consistently. Pick one no other plugin would claim:oauth2-clients, notclients.<action>names the operation.read/writecover most screens; use a specific verb when the operation really is distinct (invoices:approve).
A bare word like admin says who someone is, not what they may do — that is a role, and roles
are groups here. Split by resource and action, then bundle with a group:
Group:it-support ──> Permission:users:read, Permission:users:write, Permission:groups:read, …
Declaring a permission stays optional, so two plugins may deliberately share a name.
A
:writeis not a small grant. The split contains the read half —users:readalone is a safe helpdesk grant — butgroups:writelets someone add themselves to a group holding every permission, andusers:writelets them mint a recovery code for any account. Treat both as full administrative access.
A worked example
Alice works support and leads scheduling; Bob works support; Carol administers the system.
people groups permissions
────── ────── ───────────
alice ──┬─────────> Group:support ────┐
│ ├──> Group:staff ──> Permission:scheduling:read
bob ────┘ │
│
alice ────────────> Group:sched-leads ┴──> Permission:scheduling:write
carol ────────────> Group:it-support ─┬──> Permission:users:read
└──> Permission:users:write
At login the host asks Keto which permissions the user holds, walking those arrows transitively,
and bakes the answer into the session JWT (see Login and the session
JWT) — alice gets scheduling:read + scheduling:write, bob
scheduling:read, carol users:read + users:write. Permissions do not nest and there is no
superuser: carol's Users grant buys nothing on Groups or /scheduling.
Against the reference plugins' actual routes:
| Request | Gate | alice | bob | carol | anonymous |
|---|---|---|---|---|---|
GET /scheduling |
public: true |
✅ | ✅ | ✅ | ✅ |
GET /scheduling/shifts |
scheduling:read |
✅ | ✅ | 403 | → /login |
GET /scheduling/shifts/new |
scheduling:write |
✅ | 403 | 403 | → /login |
POST /scheduling/shifts |
scheduling:write |
✅ | 403 | 403 | → /login |
GET /admin/users |
users:read |
403 | 403 | ✅ | → /login |
POST /admin/users |
users:write |
403 | 403 | ✅ | → /login |
GET /admin/groups |
groups:read |
403 | 403 | 403 | → /login |
Bob reaches the shifts list with no direct grant — support → staff → scheduling:read, two hops
resolved by Keto at his login. An anonymous visitor gets a redirect carrying return_to, not a
403; a signed-in user who merely lacks the permission gets the 403 page, since there is nothing to
sign in as that would help. The menu is filtered by the same permissions, so nobody is shown a
door they cannot open.
Granting a permission
Write the tuple. The admin plugin's Users and Groups screens do exactly this — each offers the declared permissions as a checkbox list — or use Keto's write API directly:
# everyone in sched-leads may write shifts
curl -X PUT http://keto:4467/admin/relation-tuples -H 'content-type: application/json' -d '{
"namespace": "Permission", "object": "scheduling:write", "relation": "granted",
"subject_set": { "namespace": "Group", "object": "sched-leads", "relation": "members" }
}'
A permission's name is authored in plugin code; only its grants live in Keto. A plugin
declares the permissions it gates on (permissions: in the manifest) and the host collects them
into one catalog — ctx.declaredPermissions — which is the fixed list the admin screens offer.
Nothing in a GUI invents a name, and a tuple naming something no installed plugin declares gates
nothing.
A change takes effect on the user's next login or JWT re-mint (~10 min) — see Instant revoke when you need it sooner.
Fine-grained, per-row access
The Resource namespace covers what a coarse permission cannot express: this row, shared with
this person. A Resource carries Keto permits (view, edit, delete, nesting as owner ⊇
editor ⊇ viewer) and never appears in the JWT.
A per-row grant never widens a coarse gate — the route's permission is checked before the
handler runs. Gate the route on something they hold, then narrow inside the handler:
{ method: "POST", path: "/shifts/:id", permission: READ, handler: editShift }
async function editShift(ctx) {
if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id, relation: "editors" })))
throw new GuardError(403, "not an editor of this shift");
…
}
Reserve this tier for relationship rules (sharing, delegation, inheritance). Ownership and tenant rules belong in the upstream service that holds the row — see Three tiers of "may I?".
Building plugins
A plugin is a self-contained folder under plugins/ that the host discovers at boot — no
registration step, no central wiring. Each plugin carries its own nav, routes, views, and CSS.
The contract is TypeScript (src/plugin-host/plugin.ts); the types there are the source of
truth and the sections below explain them and the rules the host enforces. A runnable example lives
in examples/plugins/scheduling/ — a public overview page, a
permission-gated list page over an upstream service, a CSRF-guarded form, and a mix of public +
gated nav. plugins/ ships empty, so copy it in to run it
(cp -r examples/plugins/scheduling plugins/scheduling, then restart); the dev compose already
points SCHEDULING_UPSTREAM at its mock backend (examples/shifts-upstream/).
Anatomy of a plugin
plugins/things/ # the plugin folder — its name is the id AND the mount path (→ /things)
plugin.ts # REQUIRED — the one fixed filename; default-exports the manifest (definePlugin(...))
views/ # fixed name, optional — EJS the host renders for a { view } result
things.ejs # your view files; a handler picks one with { view: "things" }
public/ # fixed name, optional — static assets, served at /public/things/
things.css # your asset files
i18n/ # fixed name, optional — this plugin's own catalogs (see Languages)
en-US.ts # the baseline; sv-SE.ts et al are written against its type
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
package.json # optional — only if you depend on npm packages (see Plugin dependencies)
node_modules/ # yours, installed from your own lockfile
Only plugin.ts is required. views/, public/ and i18n/ are fixed folder names the host
resolves against, but the files inside are yours to name. Everything else — handlers, upstream
clients, their layout — the host never sees; plugin.ts simply imports it.
Identity comes from the folder. The folder name is the plugin id and the mount path is
/<id>; neither is in the manifest, so they can't drift or be claimed twice. The id must be
URL/path-safe (isValidPluginId: lowercase a–z, digits, dashes) and also namespaces the plugin's
views/, its /public/<id>/ assets, and by convention its nav/permission names.
RESERVED_PLUGIN_IDS are refused at discovery — the gated dashboard, the Kratos auth flows
(auth, login, logout, recovery, registration, settings, verification), the oauth2
provider routes, and public — since plugin routes resolve first and a folder claiming one would
silently shadow a built-in. admin is not reserved: the admin screens are themselves a plugin.
Installing a plugin is "drop the folder, restart"; removing one is "delete the folder, restart".
The manifest
A plugin imports its host surface from one module — @plainpages/plugin-api (definePlugin, the
manifest/handler types, RequestContext, the guards, and the body/CSRF/list-query helpers). The host
publishes it as a package, so it resolves from any depth and from a plugin folder that has a
package.json of its own (Plugin dependencies). That barrel is the
contract boundary — never a relative ../../src/... path; the host refactors everything behind it
freely.
import { definePlugin } from "@plainpages/plugin-api";
import { listThings, createThings } from "./handlers.ts";
export default definePlugin({
apiVersion: "0.1.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.
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }],
// Permissions this plugin gates on. Optional — see Nav & permission gates.
permissions: [
{ description: "View things", name: "things:read" },
{ description: "Create and edit things", name: "things:write" },
],
// Route handlers, mounted under the plugin's path (/things). `permission` gates first.
routes: [
{ method: "GET", path: "/", permission: "things:read", handler: listThings },
{ method: "POST", path: "/", permission: "things:write", handler: createThings },
],
});
definePlugin() only types the object (PluginManifest) and returns it unchanged — a manifest may
equally be a plain typed object. All validation happens at discovery, and the host attaches the
folder-derived id to produce the loaded Plugin.
| Field | Required | Notes |
|---|---|---|
apiVersion |
yes | Semver string of the host contract the plugin was built against. See Versioning. |
home |
no | A RouteHandler that owns the public landing /. At most one plugin may declare it. See The landing pages. |
dashboard |
no | A RouteHandler that owns the gated app home /dashboard. At most one plugin may declare it. See The landing pages. |
nav |
no | NavNode[] fragment (same shape composeNav consumes). icon is a Lucide sprite id (src/ui/icons.ts); node ids must be globally unique. A label that names a catalog key is translated; anything else renders as written. |
permissions |
no | Permissions this plugin gates on. See Nav & permission gates. |
routes |
no | See Routes & handlers. |
hooks |
no | See Hooks. |
storage |
no | true ⇒ the host provisions a Postgres database and login role for this plugin and hands the credentials to onBoot. See Plugin storage. |
A plugin may be routes-only, nav-only, or hooks-only — every collection field is optional.
Routes & handlers
A route is { method, path, permission?, public?, handler }. path is relative to the plugin's
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, runs the
permission gate (a coarse JWT-claim check), then calls the handler with
the request context. A failed gate redirects an anonymous visitor to /login
with the page as return_to; a signed-in user lacking the permission gets the 403 page.
public: true means no gate at all (see Public pages).
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.
// Optional on every variant below: status (HTTP status code) and headers (extra response headers).
type ResponseMeta = { status?: number; headers?: Record<string, string> };
type RouteResult =
// Render the plugin's own view (plugins/<id>/views/<name>.ejs) with `data`.
| ResponseMeta & { view: string; data?: Record<string, unknown> }
// Pre-rendered HTML, sent as-is.
| ResponseMeta & { html: string }
// JSON body
| ResponseMeta & { json: unknown }
// Redirect to a URL (takes only status, no headers).
| { redirect: string; status?: number };
// handlers.ts
import { parseListQuery, type RequestContext } from "@plainpages/plugin-api";
export async function listThings(ctx: RequestContext) {
const q = parseListQuery(ctx.url);
const rows = await fetch(`${upstream}/things?${ctx.url.searchParams}`).then((r) => r.json());
return { view: "things", data: { rows, q } }; // renders plugins/things/views/things.ejs
}
viewresolves against the plugin's ownviews/(src/plugin-host/view-resolver.ts) — nested names like"things/edit"work, out-of-bounds names are refused. The template mayinclude()the core building-block partials and its own. To load the plugin's own CSS, pass its/public/<id>/x.csshref in the shell'sstylesslot — see the reference'sviews/shifts.ejs.- Finer authorization than the route
permissionuses the guards from@plainpages/plugin-api:requireSession(ctx),can(ctx, permission)(coarse JWT-claim check, zero I/O), andcheck(keto, ctx, {namespace, object, relation})(a live Keto check; anonymous ⇒ denied). Thrownew GuardError(403, …)after a failedcan/checkto render the 403 page. - The handler fetches its own data — from upstream, or from the plugin's own storage; the host holds none of it (see Stateless core).
- Default status:
200forview/html/json,303forredirect.
Escaping & the trust boundary
The host does not sandbox plugin output, so a handler owns the safety of the data it renders:
- Raw HTML is raw. An
{ html }result and the*.htmlpartial fields (cell.html,error.html, a menutrigger.html) are emitted unescaped — that's their purpose. Escape untrusted content before putting it there. - Text is auto-escaped; URLs are not scheme-checked. A URL field — nav
href, a table cell link, a menu item, a breadcrumb,brand.logo— is emitted as-is inside the attribute, so ajavascript:ordata:URL from upstream data becomes live XSS. Pass any URL you don't control throughsafeUrl()from@plainpages/plugin-api; it collapses anything but relative/http(s):to"#":import { safeUrl } from "@plainpages/plugin-api"; 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. |
import { definePlugin } from "@plainpages/plugin-api";
import { landing, board } from "./pages.ts";
export default definePlugin({
apiVersion: "0.1.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 and
returns a RouteResult, typically a view from the plugin's own views/, rendered against the
native app shell via ctx.chrome. On home (public) ctx.user may be null; on dashboard the
host enforces the session gate first, so it is non-null — branch on ctx.permissions inside
rather than gating dashboard on a permission, since there is no second dashboard to fall back to.
Both slots answer GET and HEAD.
Only one plugin may own each slot — two claiming either is a boot-stopping
conflict. Neither needs a routes entry; the host mounts them above the /<id>
route namespace.
RequestContext
Every handler receives one argument, the RequestContext (src/http/context.ts), built once per
request:
interface RequestContext {
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell
user: User | null; // { id, email, permissions } from the verified session JWT, or null
log: Log; // request-scoped logger, in this request's trace
params: Record<string, string>; // path params from the route match, e.g. /things/:id → { id }
t: Translate; // t(key, vars) in this request's language (see Languages); an unknown key renders as itself
locale: string; // the locale being served, e.g. "sv-SE"
locales: string[]; // every installed locale, sorted
localeHref(href): string; // carry an explicitly chosen locale onto a link this page renders
query: URLSearchParams; // alias of url.searchParams
req: IncomingMessage;
res: ServerResponse;
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
declaredPermissions: readonly PermissionDecl[]; // every permission the installed plugins declare, deduped + sorted — what *exists*, vs `permissions` = what this user *holds*
system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them
url: URL;
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie
}
ctx.chrome — { brand, csrfToken, nav, signInHref, theme, user }. Hand each field to the
matching partials/shell local and a view result renders the native app shell, exactly as
examples/plugins/scheduling/views/overview.ejs does; a field you omit falls back to its shell
default rather than erroring. chrome.nav is the whole global menu — every installed plugin's
fragment, already composed, permission-filtered and current-marked for this request.
chrome.signInHref carries the current page as return_to. The same shell renders every page,
so the menu looks identical signed in or out; menu: false drops the sidebar for a focused layout.
ctx.verifyCsrf(submitted) guards a state-changing form: render chrome.csrfToken in a hidden
_csrf field, then on POST if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, …).
The host owns the secret and sets the cookie. It is opt-in per handler — a route that never
calls it has no CSRF guard at all.
ctx.t translates in the request's language; the same block (t, locale, locales,
localeHref, dir) is merged into every view's data — see Languages.
ctx.log is a request-scoped @larvit/log logger
already in this request's trace: ctx.log.info("…", { key: "value" }), and
ctx.log.fetch(url, init?) — a drop-in fetch that adds a client span and propagates
traceparent downstream. The barrel also exports a standalone tracedFetch (reads the ambient
request log) to default an upstream client's fetch to, as the reference plugin's createUpstream
does. Output, level and OTLP export are the host's config.
Stability guarantee. These fields are present and non-breaking across a major apiVersion; new
ones may be added within it. req/res are the raw Node escape hatch — prefer the typed fields.
System capabilities (the ctx.system surface)
Most plugins fetch their own data from an upstream service they configure. A system plugin — one
that administers Plainpages' own identity stack — needs the host's Ory admin clients and the
instant-revoke hook instead. The host exposes those on ctx.system, and re-exports the client
types + their error classes from @plainpages/plugin-api:
interface SystemCapabilities { // every field optional — present only when the host wired it
hydra?: HydraAdmin; // OAuth2 client admin (register/list/delete Hydra clients)
keto?: KetoClient; // relationship read/write (groups, permissions)
kratosAdmin?: KratosAdmin; // identity admin (create/edit/deactivate/delete users)
revoke?: (sub: string) => void; // instant-revoke a subject's live tokens (needs the denylist)
}
ctx.system is undefined unless the host wired at least one of these. A system plugin treats
every field as optional and degrades when absent — the host never fails a request over it. The
admin plugin (examples/plugins/admin/) is the reference consumer:
Users uses kratosAdmin, Groups and the permission pickers keto, OAuth2 clients hydra, and a
deactivate/delete or user permission-change calls revoke so the change lands before the JWT TTL;
a missing capability renders a themed 503.
This is a privileged surface — the keys to identity and authorization — meant for first-party system plugins you author or vendor. An ordinary domain plugin ignores it.
Nav & permission gates
A plugin's nav fragment is merged into the global menu by composeNav (src/ui/nav.ts), which
applies the central override and then filters per user by the permissions in the session JWT: a
node shows iff it is public, declares no permission, or the user holds that name. A node's icon
is a Lucide icon by sprite id (e.g. i-cal → lucide calendar); the available ids are
ICON_NAMES in src/ui/icons.ts, and adding one means registering its lucide name there.
Gating a section header. A permission on the header takes the whole subtree with it. When the
children need different permissions, leave the header ungated and gate each child — composeNav
drops a header whose children all filtered out. That only works while the header carries no
href: give it one and it survives as an ungated leaf, visible to everyone.
Public pages & menu items
A route or nav node marked public: true is reachable by anyone and shows in everyone's menu.
That is the same as omitting permission, but stated outright so public is a deliberate choice
rather than a forgotten gate. The two are mutually exclusive — declaring both is refused at boot.
A public page still renders in the native shell; for an anonymous visitor ctx.user is null, the
shell shows a Sign in link in place of the profile block, the gated Dashboard link is hidden,
and ctx.permissions is empty (branch with can(ctx, …)). The reference plugin's /scheduling
Overview is public while its shifts list stays behind scheduling:read.
Declaring the permissions you gate on is optional but recommended: it documents them, feeds conflict detection, and lets the bootstrap seed them onto the demo admin, so a dropped-in plugin works without editing host config.
Contract versioning
Each manifest declares apiVersion — a semver string naming the Plainpages release it was
built against. The host's HOST_API_VERSION is its release version, so a plugin author reads one
version off the image they run and writes it down — there is no separate contract number. Both
release paths refuse a tag whose major.minor disagrees with the constant, so the two cannot drift.
Patch releases are invisible here — checkApiVersion ignores the patch digit, which is what lets
dependency updates ship continuously without touching any plugin. At discovery the host parses both
versions with parseSemver (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 |
major 0, plugin minor < host minor |
refuse |
abort boot — pre-1.0 the minor is the breaking slot |
| same major, plugin minor < host minor | warn |
load, log — built against an older release; check that release's notes |
| 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, per the project's pinning rules); the host supplies the compatibility. One digit carries the whole release, so a minor means either the plugin contract changed or a dependency moved far enough to warrant one.
Conflict rules
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). |
permission |
warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific <resource> if unintended. |
Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also
rejects per-manifest shape errors: a non-array nav/routes/permissions, a non-function
home/dashboard, a permission name that isn't <resource>:<action>, or a
route/nav node setting both public and permission.
Hooks
Optional, for reacting to system actions. A plugin's hooks may implement:
| Hook | When | May |
|---|---|---|
onBoot(host) |
after discovery, before the server listens | warm caches, validate upstream config, open a storage connection |
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 |
onBoot's host is a BootContext, carrying storage for a plugin that declared it. A hook
written without a parameter stays valid.
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 short-circuits
— later hooks and the route handler are skipped, and that result renders against its own plugin's
views. onResponse runs after a matched route's handler; its return value is ignored. Hooks are not
sandboxed — a throwing hook fails loud (boot for onBoot, the request for the others). Keep them
cheap: onRequest is on the hot path, though the host skips the pipeline entirely when no plugin
declares a hook.
Where plugins live (and how to mount them)
The host scans /app/plugins/ inside the web container, so "installing a plugin" means
getting its folder there.
1. In your clone (the default dev loop). Create plugins/<id>/; docker compose up
bind-mounts the whole tree (compose.override.yml: .:/app), so a restart picks it up.
2. A plugin kept in its own repo, or added to a prebuilt image. Bind-mount the plugin
folder onto /app/plugins/<id> with a small compose override. A plugin folder is code, not data —
mount it read-only:
# compose.plugins.yml — mount external plugin folders into the host
services:
web:
volumes:
- ../my-plugin:/app/plugins/my-plugin:ro # host path : /app/plugins/<id>
# Dev: list the files explicitly (a third file disables the implicit override merge)
docker compose -f compose.yml -f compose.override.yml -f compose.plugins.yml up
# Prod (image already built, no source mount):
docker compose -f compose.yml -f compose.plugins.yml up -d
A named volume works the same way (target /app/plugins/<id>). For a baked production image,
keep the plugin in the build context and it is COPY'd in at build time.
A plugin kept in its own repo mounts whole, package.json and all — see below.
Plugin dependencies
A plugin may depend on npm packages. It owns them completely: its package.json, its lockfile and
its node_modules live in the plugin folder, and nothing about them reaches the host's — installing
a plugin is still just getting its folder to /app/plugins/<id>.
Write the manifest yourself — "type": "module" is required, and the host refuses a plugin without
it, because that file (not the host's) is what tells Node how to parse everything beside it:
{ "name": "things", "version": "0.0.0", "type": "module" }
Add a plugins/things/.npmrc too. The root one does not reach a --prefix, so without it npm writes
ranges rather than the exact pins this project keeps everywhere:
save-exact=true
Then install into the folder:
# The uid keeps the files it writes yours rather than root's.
docker compose run --rm --no-deps --user "$(id -u):$(id -g)" web npm install --prefix plugins/things ms
A plugin in its own repo runs its own npm ci instead and mounts the result — node_modules
included, since the plugin folder is the repo. A baked image needs no extra step: the plugin's
node_modules is part of the build context and is COPY'd in with the rest of the folder.
- Never ship a copy of
@plainpages/plugin-api. The host publishes it into/node_modules, above every plugin, and a plugin resolves it from there — nothing to declare, just import it. A copy inside your plugin's ownnode_moduleswould shadow it with a second instance of the host's contract, turning a sign-in redirect into a 500, so discovery refuses one there at boot. - The host never upgrades or dedupes your dependencies. Two plugins depending on the same package each get their own copy at their own version, so neither can break the other by upgrading — and keeping yours current, and audited, is yours to own. Renovate here watches every manifest in this repo, the example plugins included — a plugin in its own repo needs its own.
- Depend on packages that ship JavaScript. Node refuses to strip types under
node_modules, so a dependency whose entry is.tsfails at import withERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.
npm run typecheck covers plugins/, so a dependency shipping no types of its own needs its
@types/… in your plugin's devDependencies. Typechecking a plugin repo standalone still needs the
barrel's types on disk: typecheck it mounted under the host tree, or vendor a type stub outside
node_modules and point tsconfig paths at it — a stub inside is the shadowing copy discovery
refuses, and it would travel with the folder you mount.
Plugin storage
A plugin that needs to keep data sets storage: true. The host then provisions a Postgres
database and login role of its own — both named plugin_<id> — and hands the credentials to
onBoot:
import postgres from "postgres"; // your dependency, not the host's
import { definePlugin } from "@plainpages/plugin-api";
let sql: ReturnType<typeof postgres>;
export default definePlugin({
apiVersion: "0.1.0",
storage: true,
hooks: {
onBoot: async (boot) => {
if (!boot.storage) throw new Error("things: storage was not provisioned");
sql = postgres(boot.storage.url);
// Every web instance runs onBoot, and concurrent CREATE TABLE IF NOT EXISTS is an error in
// Postgres — the lock is released when the transaction ends.
await sql.begin(async (tx) => {
await tx`SELECT pg_advisory_xact_lock(hashtext('things:schema'))`;
await tx`CREATE TABLE IF NOT EXISTS things (id uuid PRIMARY KEY, name text NOT NULL)`;
});
},
},
});
boot.storage is a StorageCredentials — database, host, password, port, user, and url,
the same values pre-assembled as a DSN, which most clients take directly. It is typed optional, so
the guard above is expected of every storage plugin rather than a sign something is wrong.
Credentials, not a client. The host has no opinion on how you reach Postgres: depend on
postgres, pg, a query builder or an ORM (Plugin dependencies). No driver
is part of the contract, so upgrading yours is yours alone to time. The flip side is that pool sizing
is yours too — keep yours under PLUGIN_DB_CONNECTION_LIMIT (default 10), the per-role ceiling the
host sets so one plugin cannot exhaust the Postgres this stack shares with Ory.
The schema is yours, migrations included. The host creates the database empty, never reads or
writes inside it, and ships no migration machinery — evolving your tables compatibly (expand, then
contract, so a rolled-back version still runs) is yours to own. Create your tables in onBoot: it
runs before the server listens, so a failure aborts boot instead of surfacing later as a broken page.
What the host does guarantee:
- One database and one role per plugin, with
CONNECTrevoked fromPUBLIC. This bounds accidents — a wrong database name, a mistyped DSN, a stray query — and it is not a security boundary: plugins share thewebprocess, so a plugin that goes looking can reach another's credentials. Install plugins you trust (Security model). - Provisioning is idempotent and runs every boot, so a plugin dropped in later is picked up by
the next
docker compose up -d— the same rule as permission seeding. Each boot re-applies the role's password, connection limit, andNOCREATEDB/NOCREATEROLE. - Your data is never dropped. Removing a plugin folder leaves its database untouched; deleting it
is a deliberate act by an operator. Each boot logs any
plugin_*database no installed plugin claims, so what you left behind stays findable — read that list before dropping anything, since a second Plainpages stack sharing this server will have its databases named there too.
Passwords are derived, never stored — each is HMAC-SHA256(PLUGIN_DB_SECRET, <plugin id>), so
bootstrap and web compute the same value independently and nothing has to be written down.
Rotate every plugin's password by changing PLUGIN_DB_SECRET and running docker compose up -d,
which re-applies each role's password and leaves the data alone — restart every web instance as
part of it, since one still holding the old secret can open no new connections. Treat the secret as
you would a database password: whoever holds it holds every plugin database. Under
REQUIRE_SECURE_SECRETS a missing, empty or throwaway secret is refused — in bootstrap before it
creates any role, so no database is ever given a password derivable from a constant in this repo.
Only bootstrap holds provisioning credentials. It alone gets PLUGIN_DB_ADMIN_URL, an account
with CREATEDB and CREATEROLE (superuser works but is more than it needs; the dev stack simply
reuses Ory's). Keep using the same account: Postgres gives a CREATEROLE account admin rights only
over the roles it created itself, so if you swap it for a fresh one, grant that one ADMIN OPTION on
the existing plugin_* roles first, or the next boot cannot re-apply their passwords. web gets PLUGIN_DB_URL, which names the server and must carry no credentials —
supply one with a username or password and boot fails, rather than leaving a privileged password in
the process that runs plugin code. Set both, plus PLUGIN_DB_SECRET
(Configuration); the dev stack sets them for you.
Storage stays off until PLUGIN_DB_URL is set, and a plugin declaring it while that is unset
aborts boot naming itself — rather than serving pages without its data. One naming limit: a
storage plugin's folder may be at most 56 characters, so plugin_<id> fits Postgres' 63-byte
identifier.
Local dev & test story
A plugin is a normal folder of TypeScript, tested the same way the core is — everything in Docker.
examples/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.
-
Unit-test handlers as pure functions with
node --test— no host needed.docker compose run --rm web npm test -
Run one plugin against the host. Get the folder into
/app/plugins/<id>anddocker compose up. For an isolated harness,createApp({ plugins: [myPlugin] })mounts a single manifest so a test can assert its routes, nav and gating without the rest of the stack. -
E2E the user-facing flow. Per AGENTS.md §6, ship a side-effect-free Playwright test in
e2e-tests/for each plugin page/form, run against the livewebservice with the plugin mounted.
The menu system
The menu is driven entirely by config and assembled from two sources:
-
Plugin fragments — each plugin contributes its own
nav(above). -
A central override —
config/menu.ts(loaded bysrc/ui/menu-config.ts, validated at boot) — where the operator reorders, renames, groups, or hides items (by nodeid), and sets branding (app name, logo, default theme). The override always wins, applied before the per-user filter. A clean clone needs noconfig/menu.ts; defaults apply.config/is an empty drop-in mount point (likeplugins/): it ships empty, and you supplyconfig/menu.tsby copying the template (examples/config/menu.ts) in or bind-mounting your own dir onto/app/config(a commented example sits incompose.override.yml). The file imports its typed builder from#menu-config(the subpath import mapped tosrc/ui/menu-config.ts), so it resolves wherever it's mounted (keep the mountedconfig/a plain dir — nopackage.jsonof its own):import { defineMenu } from "#menu-config"; export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } });
Every nav item may carry a permission; the rendered tree is filtered per user from the session
JWT (no per-request authz call), so the menu only shows what that person can reach. An item may
instead be public: true to show it to everyone — mutually exclusive with permission.
Branding (name, logo, default theme) renders in the app shell.
One menu, one shell, everywhere. A single menu (src/ui/chrome.ts buildPluginChrome) renders
in the same app shell on every page — dashboard, plugin pages, and the login / registration /
recovery / front pages — so it looks identical signed in or out and just shows fewer items to an
anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a
chrome-free layout opts out with the shell's menu: false.
Building blocks
Plainpages is a component library, not a page generator — reusable EJS partials + TS helpers, fully styled and zero-JS:
- Partials: app shell, nav tree, filter bar, data table (sort / select / row actions), pagination, form fields, badges, menus, auth cards.
- Helpers:
composeNav(menu from config),parseListQuery(?q=…&status=…&sort=…&page=…→ filter/sort/pagination),paginate(page math), and the auth guards (src/auth/guards.ts):requireSession,can(permission)(coarse JWT-claim check, zero I/O),check(relation, object)(the one live Keto call).
Interactivity: zero-JS spine
The core and all building blocks work with zero JavaScript — theme switching and filtering are
pure CSS + GET forms, and menus are the platform's own popover
API: a <button popovertarget> opens
the panel, the browser dismisses it on an outside click or Esc, CSS anchor positioning places it.
On a browser too old for popovers the trigger is inert and each panel falls back to flowing inline
underneath it — cramped, but nothing is unreachable. List state
(?q=…&status=…&sort=…&page=…) lives in the URL, so a view is bookmarkable and shareable; the
URL is the only state the UI keeps.
Plugins that genuinely need it — live dashboards, bulk actions, client-side validation — may opt into progressive enhancement (htmx, Alpine, vanilla JS) on top of working server-rendered HTML. The baseline never depends on it.
Languages (i18n)
Every string the host renders comes from a catalog: one TypeScript module per locale, named
for the locale it holds. The core ships en-US and sv-SE; a deployment adds a language by
dropping another file next to them.
src/i18n/locales/en-US.ts the baseline — every other locale is checked against it
src/i18n/locales/sv-SE.ts
locales/ drop-in mount root: your own catalogs, ships empty (like plugins/ and config/)
locales/plugins/<id>/sv-SE.ts the same, for a plugin's words — so adding a language never forks a plugin
plugins/<id>/i18n/en-US.ts a plugin's own words, looked up before the host's
plugins/<id>/i18n/sv-SE.ts
locales/ is the operator's, mounted like plugins/ and config/ — a file there for a new tag
adds a language, one for a tag the image already ships replaces that catalog wholesale (held
to the same parity check, so a partial replacement fails the boot). locales/plugins/<id>/<tag>.ts
does the same for an installed plugin's words, checked against that plugin's en-US, so
translating a vendored plugin never means forking its folder:
# compose.override.yml
services:
web:
volumes:
- ./locales:/app/locales:ro
Which language a request gets: ?locale=sv-SE wins, else Accept-Language, else en-US.
Matching is exact on a full tag — ?locale=sv-FI with only sv-SE installed matches nothing and
falls through to Accept-Language (and from there to en-US), rather than being served a
neighbouring region — but a lone language (sv, as browsers send) resolves to the first regional
catalog for it. There is no locale cookie: the URL is the only place a choice is stored, so a
link is shareable and a page is what its address says it is. When the URL asked for a language, the
host carries ?locale= onto every link it renders and ctx.localeHref(href) does the same for a
plugin's. The picker lists every installed locale and renders on every page. After a POST the
current URL may answer no GET (POST /admin/users/:id/recovery has no GET sibling), so the host
points the picker at this path when it answers GET, else the page the form was submitted from, else
/ — switching language there leaves that POST's own result behind.
Writing a catalog. en-US.ts exports the object and its type; every other locale is written
against that type, so a missing or misspelled key is a type error before the app ever boots. For a
language of your own: copy src/i18n/locales/en-US.ts into locales/<tag>.ts, type it
CoreMessages (from @plainpages/plugin-api), and translate. The as PluralMessage cast below is required —
without it the inferred type pins the plural forms to English's two, and a locale that selects more
(Polish, Arabic) becomes unwritable:
// plugins/shop/i18n/en-US.ts
import type { PluralMessage } from "@plainpages/plugin-api";
const messages = {
"shop.title": "Shop",
"shop.greeting": "Hello, {{name}}!",
"shop.orders": { one: "{{count}} order", other: "{{count}} orders" } as PluralMessage,
};
export type ShopMessages = typeof messages;
export default messages;
// plugins/shop/i18n/sv-SE.ts
import type { ShopMessages } from "./en-US.ts";
const messages: ShopMessages = { "shop.title": "Butik", /* … */ };
export default messages;
At boot every catalog is checked against its set's en-US; a missing key, an unknown key, or a
plural message that doesn't cover the categories its locale selects (Intl.PluralRules) stops
startup with the full list. A plugin may translate fewer locales than the host (its strings then
render in en-US), never one the host doesn't have.
Using it. ctx.t(key, vars) in a handler; in a view t(...) is already there, along with
locale, locales, localeHref() and dir — merged into every render, at any include depth:
// handler
return { data: { title: ctx.t("shop.title"), lead: ctx.t("shop.greeting", { name }) }, view: "shop" };
// a pure view model built outside a request (its unit test) defaults to the plugin's own English:
import { englishTranslator, type Translate } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts";
const EN: Translate = englishTranslator(enUS); // your catalog, then the host's
<!-- view -->
<h1><%= t("shop.title") %></h1>
<p><%= t("shop.orders", { count: orders.length }) %></p>
<a href="<%= localeHref("/shop/new") %>"><%= t("shop.new") %></a>
Rules worth knowing:
- An unknown key renders as itself. That is what lets a nav label be either a catalog key or
plain text —
label: "shop.title"is translated,label: "Shop"is not, and neither breaks. Same forconfig/menu.tsbranding and itsrenameoverrides. t()returns raw text; the view escapes it. Use<%= %>as for any other value. A message that deliberately carries markup uses<%- %>— and its{{vars}}must then be escaped at the call site (pagination.ejsis the worked example).- Dates and numbers are
Intl's job, not the catalog's:new Intl.DateTimeFormat(ctx.locale). - The core building blocks carry the locale for you — every href they render goes through
localeHref, and their GET forms carry it as a hidden field, since a GET submit replaces the whole query string.ctx.localeHrefis for hrefs and form actions your own markup emits (a POST replaces the URL just as a GET submit does), andlocaleParam(a view local) for your own GET forms. Responses carryVary: Accept-Language. - Reuse the core words. Generic UI verbs live in the core catalog —
common.add/cancel/delete/ edit/new/remove/save,filter.*,pagination.*,table.*— and a plugin's lookup falls through to them. Keep your catalog for your domain words. - Reserved view locals:
t,locale,locales,localeHref,localeParam,localeSwitch,dir. They are merged after yourdata, so a colliding key of yours is ignored rather than breaking the shell.localeis likewise reserved inparseListQuery, never returned as a filter.
Kratos writes the auth flow's own text (field labels, validation errors), tagging each string
with a stable numeric id; a kratos.<id> key replaces it and anything unmapped renders Kratos'
English. Field labels are keyed on the input name instead (auth.field.password), because Kratos'
trait-label id is generic — the same id says "Email" on login and "First name" on registration.
Operator- and developer-facing text (boot errors, logs) stays English.
Configuration
Read from the environment once at boot (src/config.ts) and validated there — a bad URL, an
out-of-range PORT, a non-boolean toggle, or a missing/throwaway enforced secret fails loud before
the server starts. A clean clone needs none of these; every value defaults to the dev stack.
The app is environment-agnostic: no NODE_ENV, every behaviour its own explicit toggle.
compose.yml (base) sets the hardened ones; compose.override.yml (dev, auto-merged by
docker compose up) turns them back off for live editing.
| Var | Default | Notes |
|---|---|---|
APP_URL |
unset (dev: http://localhost:3000) |
the canonical public URL — the single source for the host this deployment lives on; set ⇒ off-host visitors are redirected here, unset ⇒ no redirect (see Canonical host) |
PORT |
3000 |
web listen port |
CACHE_TEMPLATES |
false |
cache compiled EJS templates (true in prod) |
SECURE_COOKIES |
false |
mark our session/CSRF cookies Secure (true in prod https; off in dev http) |
REQUIRE_SECURE_SECRETS |
false |
when true, CSRF_SECRET — and PLUGIN_DB_SECRET once storage is configured — must be supplied and differ from the dev throwaway |
LOG_LEVEL |
info |
min severity logged: error/warn/info/verbose/debug/silly/none |
LOG_FORMAT |
text |
log line format: text (human-readable, dev) or json (structured, prod) |
SERVICE_NAME |
plainpages |
OTLP service.name on every log + span — brand it as your own deployment |
OTLP_ENDPOINT |
unset | OpenTelemetry Collector HTTP base URI; set ⇒ export logs + traces (unset ⇒ console only) |
OTLP_PROTOCOL |
http/json |
OTLP wire format: http/json or http/protobuf |
KRATOS_PUBLIC_URL / KRATOS_ADMIN_URL |
http://kratos:4433 / :4434 |
identity (self-service / admin) |
KETO_READ_URL / KETO_WRITE_URL |
http://keto:4466 / :4467 |
authorization check / write |
HYDRA_ADMIN_URL |
http://hydra:4445 |
OAuth2 provider admin API (login/consent handshake) |
JWKS_URL |
file://…/tokenizer/jwks.json |
the Kratos tokenizer signing key; verifies the session JWT |
JWT_ISSUER / JWT_AUDIENCE |
unset | optional: when set, the session JWT's iss / aud must match (the dev tokenizer sets neither) |
JWT_CLOCK_SKEW_SEC |
60 |
exp/nbf leeway (s) for Kratos↔web clock drift (the auth E2E sets 0) |
ORY_TIMEOUT_SEC |
5 |
per-call timeout for outbound Kratos/Keto/Hydra (and http JWKS) fetches, so a hung Ory can't park a request |
REVOCATION_DENYLIST |
false |
when true, enable the optional instant permission/session revoke denylist |
REVOCATION_TTL_SEC |
900 |
how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew |
CSRF_SECRET |
dev throwaway | signs our double-submit CSRF token; enforced by REQUIRE_SECURE_SECRETS |
PLUGIN_DB_URL |
unset (dev: postgres://postgres:5432) |
credential-free Postgres base URL for plugin storage; unset ⇒ storage off, and a plugin declaring it aborts boot |
PLUGIN_DB_ADMIN_URL |
unset (dev: the bundled superuser) | the DSN that provisions each plugin's database and role — read by the one-shot bootstrap service only, never by web |
PLUGIN_DB_SECRET |
dev throwaway | derives each plugin's database password; REQUIRE_SECURE_SECRETS enforces it in web once PLUGIN_DB_URL is set, and in bootstrap whenever a plugin declares storage |
PLUGIN_DB_CONNECTION_LIMIT |
10 |
per-role Postgres connection ceiling, so one plugin's pools cannot exhaust the server Ory shares; read by bootstrap when provisioning |
Canonical host (one public URL)
A site is often reachable at several URLs that resolve to the same place — localhost vs
127.0.0.1, an apex vs www.. That matters because cookies are host-scoped: the themed login
form POSTs to Kratos, and Kratos' CSRF cookie is set on the host the browser is on. Reach the app on
one host but let the form post from another and that cookie is lost — Kratos rejects the flow and
bounces to its error page.
APP_URL is the single source of truth for the public host. Set it and the web app redirects
any off-host GET/HEAD visitor to it (308, path + query preserved) before a flow starts, so the
browser, the themed forms, and the cross-origin Kratos POST share one cookie host. Static assets
under /public/ are served on any host, so health checks don't bounce. Everything else derives from
the same value: the first-run banner, and — via compose — Kratos' browser-facing URLs
(compose.override.yml maps ${APP_URL} onto every ui_url, return URL and
allowed_return_urls). A genuine Kratos flow error renders the themed /error page.
The redirect is an explicit opt-in: unset ⇒ no redirect, so a deploy that forgets APP_URL
never bounces real users to a stale default. A clean clone still works with zero config — the dev
override sets APP_URL=http://localhost:3000, and 127.0.0.1 is canonicalised onto it.
Behind a reverse proxy: the proxy must pass the public
Hostthrough (or rewrite Kratos'base_url/ui_urls to match what the browser sees). If it rewritesHostto an internal upstream name whileAPP_URLis the public domain, the canonical redirect will loop — preserveHost.Dev caveat (custom host). Only if you point
APP_URLat a non-default host (e.g. a LAN IP to test from a tablet) must you also point the dev-published Kratos port at that host: setKRATOS_PUBLIC_BROWSER_URL=http://<that-host>:4433/(it sharesAPP_URL's host but keeps the Ory port, so it can't beAPP_URLverbatim). In production Ory is fronted same-origin, so this doesn't arise.
What you must supply (the only manual prep)
A clean clone needs none of the above. What can't be auto-generated is production-only:
-
Production secrets — every value below ships as a committed dev throwaway and must be replaced before a deploy faces the internet. Only the first is enforced:
REQUIRE_SECURE_SECRETS=truerefuses to boot on a missing or throwawayCSRF_SECRETand nothing else — the rest fail silently, so treat this as a checklist.Secret Where Protects CSRF_SECRETweb env signs our double-submit CSRF token JWT signing key mount a real jwks.jsonor set…_JWKS_URLmints/verifies the session JWT — see rotation SECRETS_COOKIEkratos env signs Kratos' session + anti-CSRF cookies SECRETS_CIPHERkratos env (32 chars) encrypts credentials at rest SECRETS_SYSTEMhydra env encrypts OAuth2 tokens + consent at rest POSTGRES_USER/POSTGRES_PASSWORDcompose env the Ory databases (default ory/ory)ADMIN_EMAIL/ADMIN_PASSWORDbootstrap env the seeded first admin login (default admin@plainpages.local/admin)CSRF_SECRET, the Postgres pair and the admin pair are interpolated from the host environment. The three Ory secrets are not:compose.ymlpasses onlyDSNtokratos/hydra, so add them to those services'environment:(or anenv_file:) or they silently stay on the throwaways. -
SSO provider client id/secret — optional; password login works without them (see Social sign-in (SSO)).
Everything else is generated or seeded on first boot — Ory migrations, the dev signing key, the demo admin identity and its Keto permissions, the Keto OPL model.
Social sign-in (SSO)
Off by default — a clean clone is password-only. Kratos activates a provider purely from the
environment (no code, no rebuild): set SELFSERVICE_METHODS_OIDC_ENABLED=true and
SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS to a JSON array of providers, each carrying its
client_id/client_secret and referencing the committed claims mapper
ory/kratos/oidc/claims.jsonnet. The themed sign-in/register pages derive one button per provider
from the live flow's oidc nodes, so no creds ⇒ no button, and the SSO section disappears entirely
when none are configured. Open-source Kratos has no native SAML — front it with an OIDC bridge
(Ory Polis) and register that bridge as a generic OIDC provider.
Auth, sessions & access
Identity comes from Kratos; the hot path stays I/O-free by carrying coarse authorization in a locally-validated JWT, and Keto is reserved for the rare fine-grained, must-be-fresh check.
Login and the session JWT
The themed sign-in / register / reset / SSO screens drive Kratos self-service flows. On success,
rather than keeping the opaque Kratos cookie and calling whoami on every request, the app
exchanges the session for a signed JWT once via the Kratos session tokenizer (whoami with a
tokenize_as template) and stores it as the session cookie.
── AT LOGIN / REFRESH (the only time Ory is on the path) ──────────
Kratos verifies credentials
└─► app reads the user's permissions from Keto (direct + transitive via groups)
└─► app writes them as a derived projection on the identity (admin API)
└─► whoami(tokenize_as: "plainpages") ─► signed JWT
claims: { sub, email, permissions:[…from Keto], exp ≈ 10m }
└─► stored as the session cookie
── EVERY REQUEST (hot path — pure CPU, no I/O) ───────────────────
Browser ─cookie(JWT)─► web : verify signature (cached JWKS)
read claims.permissions
filter menu · gate routes
Keto is the single source of truth for permissions, and the admin screens write them only
there. But the tokenizer's claims mapper can read only the identity, not call Keto — so at login
the app reads the permissions from Keto and refreshes a derived projection: a read-only copy on
the identity's metadata_public, which the template maps into the JWT permissions claim. It must
be metadata_public, not metadata_admin — the session Kratos hands the tokenizer carries only
public metadata, and the user can already read these permissions in their own JWT. The projection is
a per-login cache, authoritative nowhere; a stale one self-heals on the next login.
Login resolves both direct grants and group membership, so the JWT permissions match what the
admin Effective access view shows. Cost: a handful of Keto reads + one identity refresh per
login, never per request. JWKS is cached, so signature verification hits the network only on key
rotation.
Two trade-offs — both deliberate
- Permission changes lag by up to one TTL (~10m). Gating reads the JWT, not Keto, so a change takes effect when the token is next minted. For instant revoke, turn on the optional revocation denylist.
- Ory is on the critical path for sign-in. If Kratos is down no one can log in; if it stays down past the TTL, existing sessions can't refresh and the UI goes dark. Run Ory with the availability you would give any auth provider.
Instant revoke: the optional denylist
Off by default; turn it on with REVOCATION_DENYLIST=true (src/auth/denylist.ts). When enabled,
an admin deactivating or deleting a user, or granting/revoking a permission to a user,
records that subject as revoked-now; the hot path then rejects every token for it minted before
the revoke and forces a re-mint — which re-reads permissions from Keto, or clears a dead session. A
fresh re-login passes, so a downgrade lands immediately without locking the account.
It is an in-memory, auto-evicting map — host-owned state would break the stateless
core — and the check is pure CPU, keeping Keto off the hot path. Entries self-evict after REVOCATION_TTL_SEC
(default 900s ≥ the 10m token TTL + skew). Two bounds: it is instant only on the single instance
that handled the revoke (elsewhere the guarantee falls back to the token TTL — back it with a shared
store for hard multi-instance revoke), and a group membership change is transitive across many
users, so it is left to lag.
Three tiers of "may I?"
Where each kind of rule belongs:
coarse (menu / route / feature) → JWT claim · in-process, zero I/O
fine + attribute (owner / tenant / …) → upstream service that owns the row
fine + relationship (shared / inherited)→ Keto, live check at the action
- Coarse gates the menu and routes — read straight from the JWT.
- Attribute-based row rules (ownership, tenant, status) live in the upstream service that holds the data: it is the source of truth and the check is free.
- Relationship-based rules (sharing, delegation, inherited access, or authz that must mean the same thing across several services) go to Keto. Don't pay its tuple-sync cost for rules a service can already answer from its own data.
OAuth2 provider (Hydra)
Only relevant when other apps authenticate through plainpages: the app implements Hydra's login & consent steps against the user's Kratos session, and Hydra issues the tokens those apps use. Nothing in the menu or first-party pages needs Hydra.
/oauth2/login(src/auth/oauth-login.ts) — resolve the challenge against the Kratos session and accept, or bounce an unauthenticated user to the themed login and return here once signed in./oauth2/consent(src/auth/oauth-consent.ts) — a first-party client (Hydrametadata.first_party: true), or one Hydra already skipped, is auto-granted its scopes; any other gets a themed consent screen whose CSRF-guarded Allow/Deny accepts or rejects. id_token claims come from the Kratos identity./oauth2/logout— accept the RP-initiatedlogout_challengeand resume to Hydra's post-logout redirect. The first-partyPOST /logoutstill owns ending the Kratos session and our JWT cookie.
Clients are registered from the admin plugin's OAuth2 clients screen (/admin/clients): Hydra
shows the generated client_secret once, on the confirmation page. Confidential vs public
(PKCE) and the first-party auto-consent flag are set at registration; writes go only to Hydra.
Security model
The private container network is the only thing guarding the Ory APIs. Kratos admin (4434),
Hydra admin (4445) and Keto write (4467) authenticate no one — reaching them is full identity
and authorization control. Keto read (4466) cannot write but discloses the entire
authorization graph, so treat it the same. compose.yml publishes none of the six Ory ports
(guarded by src/compose.test.ts); dev publishes only the two a browser must reach.
The JWT is signed, not encrypted. Claims are base64: a signed-in user can read their own sub,
email and permissions. HttpOnly keeps page JavaScript out of the cookie, not the user.
The JWT's ~10m TTL is not the session lifetime. The browser also holds Kratos'
plainpages_session cookie (30 days, sliding), and that is what silently re-mints a lapsed JWT —
so a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our two cookies
obey SECURE_COOKIES; the Kratos one takes its flags from Kratos' own config.
Offboarding is not instant by default — a revoked permission or deactivated identity lands within one token TTL, unless the denylist is on.
A plugin, and every package it depends on, runs with the host's full privileges — in the process
holding the JWT signing key and ctx.system's Ory admin clients, on the network that reaches the
unauthenticated Ory ports. Install only what you trust, and let the plugin's own lockfile
(Plugin dependencies) pin the tree you audited.
Hardening a real deploy is REQUIRE_SECURE_SECRETS=true, SECURE_COOKIES=true, and replacing
every committed dev secret (what you must supply).
REQUIRE_SECURE_SECRETS guards only CSRF_SECRET; nothing fails loud if you ship Ory's, Postgres'
or the demo admin's throwaways.
The only emails are the recovery and verification codes from Kratos' self-service flows, and
Kratos renders and sends them — web never touches SMTP. Dev catches them in mailpit
(http://localhost:8025); prod points Kratos at a real server via COURIER_SMTP_CONNECTION_URI
(courier.smtp in ory/kratos/kratos.yml).
Customizing the email content is a built-in Kratos feature — no code here. Set
courier.template_override_path to a mounted directory and drop Go templates in it, keyed by type:
<override-path>/recovery_code/valid/email.subject.gotmpl
<override-path>/recovery_code/valid/email.body.gotmpl (+ email.body.plaintext.gotmpl)
<override-path>/verification_code/valid/email.subject.gotmpl
<override-path>/verification_code/valid/email.body.gotmpl
The ory/kratos/ tree is already mounted into the Kratos container, so an override dir there
is the simplest place. See Ory's
courier message templates
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 | Does |
|---|---|
web |
The Node 24 + TypeScript app: server-rendered EJS, the plugin host, the building-block partials. |
kratos |
Ory Kratos — identity: login, registration, password reset, SSO, sessions. |
keto |
Ory Keto — 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 carrying the user's coarse permissions, so
every later request gates the menu and pages in-process, with no call to Ory. Keto answers the
rarer fine-grained checks; Hydra only when the app acts as an OAuth2 login & consent provider. All
of it over their REST APIs using Node's built-in fetch — no SDK dependency.
In dev the host-facing Ory ports are published — Kratos public 4433 and Hydra public 4444;
prod keeps them internal.
Runtime deps stay tiny and pinned: ejs, lucide-static, @larvit/log, and
postgres — the last one has no sub-dependencies of its own and is used in a single module, to
provision plugin storage at boot. Auth, sessions, SSO and OAuth2 add services,
not npm packages.
Stateless core
The host holds no state of its own: it owns no schema and keeps nothing between requests. The stack's Postgres backs Ory, and gives every plugin that asks for one a database of its own (Plugin storage) — which the host provisions but never reads or writes.
So a plugin gets its data one of two ways: by calling an upstream service from its route handler
— a REST API, an ERP, a plant historian, the customer's own backend — or from its own database.
Either keeps web trivially scalable and crash-safe: any instance can serve any request, because the
session lives in Kratos and the data lives outside the process.
Testing
Type check and unit tests run off the Ory stack — --no-deps keeps web from dragging up its
depends_on services:
docker compose run --rm --no-deps web npm run typecheck # strict tsc --noEmit
docker compose run --rm --no-deps web npm test # node --test (units)
End-to-end (Playwright)
E2E runs in the official Playwright image (browsers preinstalled) against the live web service —
no Node/browsers on the host. Five suites, each on its own stack:
| Suite | Compose overlay | Covers |
|---|---|---|
visual.spec.ts + language.spec.ts |
e2e-tests/compose.visual.yml |
Ory-free: the rendered design system (app shell, theme switch, mobile off-canvas, icon sprite, CSRF-guarded sign-out, landing, 404), plugin permission-gating, and language switching |
auth-refresh.spec.ts |
e2e-tests/compose.auth.yml |
The real Ory stack with the session→JWT TTL cut to 8s: a lapsed JWT is silently re-minted from the live Kratos session, and once revoked the stale cookie is cleared |
oauth-login.spec.ts |
e2e-tests/compose.oauth.yml |
Another app logging in through us — register a client, start an authorization flow, /oauth2/login accept, /oauth2/consent Allow → authorization code |
full-flow.spec.ts |
e2e-tests/compose.full.yml |
The browser UI: password + mocked-SSO login, menu filtering by permission, admin users/groups/permissions CRUD, OAuth2-clients register → one-time secret → delete, a gated plugin page, logout |
devstack-login.spec.ts |
compose.override.yml + e2e-tests/compose.devstack.yml |
The plain docker compose up topology on the host network: sign-in works both from the banner's localhost:3000 and from 127.0.0.1:3000 via the canonical-host redirect |
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --build --rm e2e
docker compose -f compose.yml -f e2e-tests/compose.visual.yml down -v
Swap the overlay for another suite; devstack also needs -f compose.override.yml, host networking
and the host ports 3000/4433 free. --build rebuilds the runner so spec edits are picked up (the
image bakes in e2e-tests/). full-flow fronts web + Kratos on one origin through
e2e-tests/proxy.ts, since the themed form posts straight to Kratos and cookies are host-scoped.
Screenshots + an HTML report land in e2e-tests/artifacts/ (git-ignored). --user is what keeps
them yours to delete — the runner writes into your checkout. On rootless Docker drop that flag:
container root is already you there, and a mapped uid cannot write. Keep new tests
side-effect-free so the suite stays fullyParallel.
Anything the browser logs fails the test. Specs import test from
e2e-tests/console-guard.ts, which watches every page a test opens — a console error or warning, or
an uncaught exception, fails that test; a page that provokes one on purpose allows it explicitly
with allowConsole(/…/). The Ory-free suites run in Chromium, Firefox and WebKit; the
Ory-backed ones share one backend and stay on Chromium.
The full gate (one command)
ci.sh is the whole gate in one reproducible command — typecheck → unit tests →
each E2E suite against its own fresh stack, with a guaranteed down -v after each (even on
failure) and a non-zero exit on the first failure. Run it locally before a release, or wire
it into your CI service:
bash ci.sh
Each E2E suite owns a clean stack — never point two suites at one backend (auth-refresh revokes the admin's sessions; full-flow writes users/groups/permissions to Keto), which is why the gate runs them serially, one stack up/down per suite.
CI/CD
Gitea Actions (.gitea/workflows/) runs the pipeline; the test job runs
ci.sh — the exact gate you run locally:
| Workflow | Trigger | Does |
|---|---|---|
ci.yml |
push, any branch except main |
the full gate (bash ci.sh, a no-op on a docs-only branch), then build + push the app image |
release.yml |
push of a vX.Y.Z tag, or manual |
check the tag against HOST_API_VERSION, re-tag that commit's image as X.Y.Z, X.Y, latest (plus X once major ≥ 1), sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger |
mirror.yml |
push to main or any tag, or manual |
force-push main + tags (pruning deleted ones) to the GitHub mirror |
registry-cleanup.yml |
nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head |
renovate.yml |
nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut a release tag for what merged |
main is not re-tested on push — its commits are meant to arrive already green from a
gated branch, so the status check to gate a merge on is CI / full-gate (push).
Merge gate on main (Gitea branch protection + repo merge settings, set via the API —
no repo files involved): direct pushes are blocked, changes land via PR only, the
CI / full-gate (push) status must be green (admins included), and the only merge style is
fast-forward-only — history stays linear and main's head is the exact commit hash of
the merged branch, which is why the branch's push-triggered status carries over.
Container images — after a green gate, ci.yml builds the app image and pushes it as
gitea.larvit.se/larvit/plainpages:<full commit hash>. Because merges are fast-forward-only, the
image for any main commit already exists from that commit's branch gate — build once, promote by
re-tagging. The build runs inside the required gate, so a missing/expired token or a registry outage
blocks all merges until restored; use a non-expiring token or track its expiry. Hash tags
accumulate one image per gated push, so nightly registry-cleanup.yml prunes them
(registry-cleanup/cleanup.ts defines what survives). Don't add a
pattern-based org cleanup rule for this package — its age/count heuristics can't see branch heads or
release tags and would delete images the workflow protects.
Releases — pushing a semver git tag (git tag v1.2.3 && git push origin v1.2.3) runs
release.yml, which pulls that commit's hash image and re-tags it 1.2.3, 1.2, latest and —
once the major reaches 1 — 1; nothing is rebuilt, so the released image is byte-identical to the
gated one. While the major is 0 the bare-major tag is skipped, because a 0.x minor is a contract
break and a moving :0 would carry one. It fails loud if no
hash image exists — release tags must point at a commit that went through the gate. The same four
tags sync to Docker Hub, releases only.
The contract check guards the tag before anything is published, refusing one
whose major.minor disagrees with HOST_API_VERSION and naming the value to set.
The Docker Hub overview is published by a separate publish-overview job from
release-tooling/dockerhub-overview.md.tmpl, with
{{VERSION}} rendered to the release, so the Plainpages tag it tells adopters to pull cannot go
stale. Its sidecar pins are Renovate-managed and gated against this repo's own compose files, so the
quick start stays a topology CI has actually run.
It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub
outage leaves the promotion green and the images untouched; and the page has its own door — run the
workflow manually with an overview_version input to republish it without cutting a release. That
input goes through the same contract check as a tag: a non-semver value, or one whose major.minor
disagrees with the tree being published, is refused. It uses
the same DOCKERHUB_TOKEN the image push uses, which is why that token needs the delete scope.
GitHub mirror — github.com/larvit/plainpages is
read-only; after every merge mirror.yml force-pushes main and all tags, overwriting any drift.
Tags go with --prune, so deleting one here deletes it there on the next run, and a tag created on
GitHub is swept away — cut releases on Gitea, never on the mirror. Branches other than main match
no refspec and are left alone.
Dependency updates — renovate.yml runs Renovate nightly
(self-hosted, this repo only) against renovate.json, bumping npm deps, Docker base
images, Gitea action versions, and the image tags pinned inside workflow run: steps (a custom regex
manager, so nothing pinned drifts unmanaged). Version-locked sets move together in one PR — the Ory
images, and the Playwright runner + its browser image — and every bump keeps the exact semver pin
exact. Each PR runs the normal gate on its renovate/* branch and automerges once
CI / full-gate (push) is green; only a red gate needs a human.
Auto-release on dependency updates — a second job in renovate.yml (auto-release) cuts one
vX.Y.Z tag per run covering the renovate-bot commits merged to main since the last tag, and
skips when the tip isn't a Renovate commit, nothing new merged, or nothing that merged carried a
trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a
Release-Bump: <updateType> trailer onto the updates that reach a running Plainpages — the rules in
renovate.json name them — and
release-tooling/next-version.ts turns the highest one into the next
version; pre-1.0 it never auto-crosses into 1.0.0. Because the contract version is the release
version, an update big enough to reach a minor stops the job rather than tagging: bump
HOST_API_VERSION in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency major, since
nextVersion shifts it down to a 0.x minor. updateType rates the dependency's own jump,
so the trailer is an allowlist: an update outside those rules carries none and rides the next patch
release instead of escalating it. It is tag-only: the tag hands off to
release.yml, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by
the built-in Actions token wouldn't trigger it). HOST_API_VERSION is never touched here.
One-time CI setup
| Actions var / secret | Value |
|---|---|
DOCKER_REGISTRY_USER (var) + DOCKER_REGISTRY_TOKEN (secret) |
A Gitea account with package write in the larvit org, and its access token with read:package + write:package. Reused by registry-cleanup.yml. |
DOCKERHUB_USER (var) + DOCKERHUB_TOKEN (secret) |
The public larvit/plainpages Docker Hub repo, and a read/write/delete token scoped to that repository (an org access token, or one on a dedicated account — an account-wide PAT reaches every repo under it, and delete is destructive). Delete is what publishing the overview needs; pushing images alone would not. |
MIRROR_GITHUB_TOKEN (secret) |
A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its main must not block force-pushes and must carry no tag protection, which would reject the prune. |
RENOVATE_TOKEN (secret) |
The shared renovate@larvit.se bot's Gitea PAT, with write access to this repo. |
RENOVATE_GITHUB_TOKEN (secret) |
A scopeless (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. |
Gitea rejects secret names prefixed GITEA_/GITHUB_. Each job fails loud until its secret exists.
The container package is org-owned, so it lists under larvit/-/packages — link it to the repo's
Packages tab once: POST /api/v1/packages/larvit/container/plainpages/-/link/plainpages.
The runner — register one
act_runner in host mode labelled docker-host
(labels: ["docker-host:host"]) on a machine with Docker Engine + Compose, git, and Node +
github.com access. Runs must never overlap — the e2e stacks use fixed compose project names, the
devstack suite uses host networking, and the workflows share the Docker daemon's registry login — so
keep exactly one runner at capacity 1, with host ports 3000/4433 free.
Production & deployment
docker compose -f compose.yml up --build -d # base config only, no source mount
compose.yml is the full prod stack — web + Postgres + the three Ory services (with migrations and
the one-shot bootstrap) — and mounts no source. Secrets come from the environment; the base sets
REQUIRE_SECURE_SECRETS=true, so a missing or dev-throwaway CSRF_SECRET fails the boot rather than
running insecure. Before going live, supply the production secrets and any SSO credentials — the
only manual prep (What you must supply).
Back up the pgdata volume. Once a plugin declares storage, Postgres holds
business data that exists nowhere else, alongside Ory's identities — the stack stops being
reproducible from the image and config alone. Snapshot the volume, or pg_dump each database on a
schedule, and rehearse the restore.
Every response carries security headers (src/http/security-headers.ts): a strict
Content-Security-Policy (the core is zero-JS — script-src 'self', no inline scripts),
X-Content-Type-Options: nosniff, X-Frame-Options: DENY + frame-ancestors 'none',
Referrer-Policy, and HSTS when SECURE_COOKIES=true. The CSP allows same-origin assets only,
so a branding logo must live under /public/ or be a data: URI; a plugin route can override any
header per-response via RouteResult.headers.
A deep link reached while signed out — or after the session JWT lapses mid-task — bounces to the
themed sign-in and returns to the page that was requested (return_to, validated host-relative
by localPath in src/http/safe-url.ts, so a crafted value can't turn login completion into an open
redirect). If Ory is unreachable on the sign-in path itself the user gets an honest 503, distinct
from the catch-all 500. The server drains in-flight requests on SIGTERM/SIGINT.
The first-boot bootstrap is idempotent and runs on every up: it generates the JWT signing key
if absent, creates the demo admin in Kratos, and grants it every discovered plugin's declared
permission names in Keto (plus any ADMIN_PERMISSIONS), so a dropped-in plugin resolves out of the
box. The web app waits for Kratos + Keto healthy and the bootstrap to finish before starting.
Change the demo admin before production.
Upgrading
Re-copy your drop-in plugins. Anything under plugins/ is your copy — the host never updates
it. A plugin copied from examples/ is still the old one after you pull, and the host may have
tightened a manifest rule since. Discovery fails loud at boot rather than running a plugin it can't
honour, naming the plugin and the rule:
rm -rf plugins/admin && cp -r examples/plugins/admin plugins/admin
docker compose up -d --build
Do the same for any other folder you copied out of examples/. A plugin you wrote yourself needs the
manifest change the error names. A host contract change big enough to move
HOST_API_VERSION shows up earlier and more precisely — discovery refuses the
plugin by version before any rule gets a chance to trip.
Two paths in the checkout are load-bearing and must stay clear of root-owned leftovers:
node_modules/ must not exist (deps live at /node_modules, and anything at /app/node_modules
silently shadows them), and e2e-tests/artifacts/ must be writable by you or every E2E suite fails
EACCES. Clearing the latter needs what sudo would do, which a dev box may lack:
docker run --rm -v "$PWD/e2e-tests:/x" node:24.19.0-alpine3.24 rm -rf /x/artifacts
git checkout -- e2e-tests/artifacts/.gitkeep # only if a pull already deleted it
Observability
Logging is structured and OTLP-native, on
@larvit/log. One app logger tags every line with
service.name; each request is cloned into a short-lived trace span, made ambient for the whole
handler (an AsyncLocalStorage), so logs and traces correlate. Three explicit toggles:
LOG_LEVEL, LOG_FORMAT (text dev / json prod) and SERVICE_NAME.
Every request emits one access line (method, path — the query is dropped, it can carry tokens —
status, ms, requestId); login/logout, admin writes and missing-permission/CSRF rejections log
at info/warn, the catch-all 500 and Ory-unreachable re-mint at error/warn. An inbound W3C
traceparent is adopted, continuing a trace started upstream.
Distributed tracing covers every outbound call. Because the request logger is ambient, all
outbound HTTP — the Ory clients and the JWKS fetch — runs through tracedFetch, becoming a client
span under the request and carrying traceparent downstream. A plugin gets the same from
ctx.log.fetch(url), or by defaulting an upstream client to the exported tracedFetch.
OTLP export (off by default). Point OTLP_ENDPOINT at an OpenTelemetry Collector's HTTP base URI
and logs and spans export there; OTLP_PROTOCOL selects http/json or http/protobuf. Export
is fire-and-forget — it never blocks or fails a served request, and nothing exports when the endpoint
is unset. A collector outage is survivable but noisy: each request's failed export writes a line to
stderr, so run a local collector you trust.
JWT signing key & rotation
The session tokenizer signs each session→JWT with an ES256 key at
ory/kratos/tokenizer/jwks.json. The committed one is a dev throwaway — never run it in
production. Mint a fresh key with the bundled generator:
docker compose run --rm -T --no-deps web node src/auth/gen-jwks.ts > ory/kratos/tokenizer/jwks.json
Install in production. Two endpoints must read the same key material:
- Kratos (signer) — mount the file over
…/tokenizer/jwks.json, or setSESSION_WHOAMI_TOKENIZER_TEMPLATES_PLAINPAGES_JWKS_URL=base64://<the JWKS JSON, base64>. - web (verifier) —
JWKS_URL(defaultfile://…/tokenizer/jwks.json). Afile://set is re-read live (5-min TTL, plus an immediate reload on an unknownkid); abase64://set is immutable and rotates only on a web redeploy. For rotation, usefile://on the web side so it picks up new keys without a restart.
Rotation is zero-downtime because Kratos signs with the first key in the set and stamps its
kid in each JWT header, while web selects the verify key by that kid — so a set can hold the new
key and the old one at once, and tokens minted before and after the swap both verify.
Scheduled rotation
The token TTL is 10 min (kratos.yml → whoami.tokenizer.…ttl); the wait window below is one
TTL + clock skew, rounded up to ~12 min. Run from the repo root.
- Prepend a fresh key (new key first, old key kept) — write via a temp file so the
shell's
>can't truncate the input before it's read:docker compose run --rm -T --no-deps web sh -c \ 'node src/auth/gen-jwks.ts --prepend ory/kratos/tokenizer/jwks.json' > /tmp/jwks.json \ && mv /tmp/jwks.json ory/kratos/tokenizer/jwks.json - Restart Kratos so it signs with the new first key:
docker compose restart kratos. web needs no restart — it hot-reloads the file. - Verify new logins mint the new
kid— decode theplainpages_jwtcookie's JWT header, or watch web's logs for ajwks reload on kid missdebug line. - Wait ~12 min, then prune the superseded key:
No Kratos restart needed — it already signs with that key; this only drops an unused verify key.
docker compose run --rm -T --no-deps web sh -c \ 'node src/auth/gen-jwks.ts --prune ory/kratos/tokenizer/jwks.json' > /tmp/jwks.json \ && mv /tmp/jwks.json ory/kratos/tokenizer/jwks.json
Rollback (before the prune): the old key is still in the set, so revert step 1's file and
restart kratos.
Emergency rotation (key compromise)
Skip the overlap — every token signed with the leaked key must die now. Replace the set with a
single fresh key (no --prepend):
docker compose run --rm -T --no-deps web node src/auth/gen-jwks.ts > ory/kratos/tokenizer/jwks.json
docker compose restart kratos
Every existing JWT now fails signature verification → its bearer falls back to anonymous and must re-authenticate (the re-mint only covers expired tokens, not bad signatures, so a forged/leaked-key token can't be silently refreshed). The instant-revoke denylist is unnecessary here — the signature itself is already invalid.
Project layout
src/ The app — strict tsc, no build step. *.test.ts sit beside their module.
server.ts Entry point; config.ts env loader; logger.ts structured log + trace span + tracedFetch
*.test.ts Topology guards with no source counterpart (compose/kratos/keto/hydra/postgres/ci-gate)
http/ Request pipeline: app.ts (createApp), builtin-routes, context, body, cookie,
static, safe-url (safeUrl + localPath), security-headers
auth/ Identity + the session-JWT hot path: jwt · jwt-middleware · jwks · gen-jwks (CLI) ·
login · guards · csrf · denylist · flow-view · oauth-login · oauth-consent ·
routes · bootstrap, and the Ory REST clients (kratos-public/admin, keto, hydra,
fetch-timeout)
i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime ·
english · view-locals · locales/ (the core en-US + sv-SE catalogs)
plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts
(ctx.system) · discovery · router · hooks · view-resolver · storage (the rules) ·
storage-provisioning (the DDL; bootstrap-only, holds the driver)
ui/ chrome (the one global menu) · shell-context · dashboard · nav (composeNav) ·
menu-config (`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules
views/ Core EJS in the one app shell: home, index, auth, oauth-consent, error, 403/404/500/503,
and partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card,
alert, menu/popover, theme switch, language picker, icon sprite). Domain screens live
in plugins.
public/ Static assets served at /public/
config/ Drop-in mount: the menu override + branding (config/menu.ts). Ships empty.
locales/ Drop-in mount: extra or replacement catalogs. Ships empty. See Languages.
plugins/ Drop-in mount: plugin folders, scanned at /app/plugins. Ships empty.
ory/ Ory config — kratos/ (identity schema, oidc claims mapper, tokenizer + dev JWKS),
keto/ (namespaces.keto.ts OPL), hydra/, postgres/init/
examples/ Copy-in reference mirroring the mount dirs: plugins/scheduling/, plugins/admin/,
config/menu.ts, and shifts-upstream/ (the dev mock backend)
e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,auth,oauth,full,devstack}.yml;
proxy.ts (same-origin gateway) and mock-oidc.ts back full-flow
release-tooling/ Everything the release runs: next-version (the bump math), contract-version
(the HOST_API_VERSION↔tag gate), dockerhub-overview (+ its .md.tmpl)
registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts)
ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack
.gitea/workflows/ Gitea Actions — see CI/CD
Extending the core
-
New page in a plugin: add a route + handler to the plugin manifest and a template in its
views/. -
Static asset: drop it in the plugin's
public/; served at/public/<plugin>/<path>. -
New dependency in a plugin: the plugin owns it — see Plugin dependencies.
-
New dependency in the core: deps live in the image, so update the manifest + lockfile and rebuild —
--package-lock-onlywrites nothing into the checkout,--userkeeps the two files yours. Keep deps minimal — prefer the Node standard library, and an Ory REST call over an SDK.docker compose run --rm --no-deps --user "$(id -u):$(id -g)" web npm install --package-lock-only <pkg> docker compose build
All versions are pinned to exact, human-readable semantic versions (no ranges, no digests): npm
deps via .npmrc (save-exact=true) + the committed lockfile (npm ci), and container images by
tag in the Dockerfile / compose files.
A plugin's apiVersion follows the same spirit: a literal semver, bumped by hand on rebuild.
Never set it from the host's HOST_API_VERSION constant — the plugin would then always equal the
host, so the compatibility check could never fire.