39 KiB
Plainpages
A self-hostable foundation for admin and operational web UIs — the kind of back-office you build for a webshop, a scheduling system for schools, a water treatment plant, or any tool where staff register, find, and work with data.
Plainpages gives you the parts that are the same every time — authentication, authorization, a config-driven menu, and a server-rendered, zero-JS design system — and lets you add everything domain-specific by dropping in plugin folders. The only screens it ships itself are the ones for running the system: users, groups, and permissions. Everything else is a plugin.
Priorities (unchanged from day one): simplicity, few dependencies, strict
TypeScript, no build step, Docker-only, environment-agnostic (no NODE_ENV —
every behaviour is an explicit config toggle). Heavy lifting that isn't simple to do
well — identity, sessions, SSO, OAuth2, permission checks — is delegated to Ory
sidecar services rather than reinvented.
"Simple" here is about the whole architecture staying simple — not just at the start, but after you've dropped in 240 plugins and run it hard in production. 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's no app database to scale or migrate.
Who this is for
Experienced developers building back-office, admin, and dashboard products — 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 hand-roll auth for the tenth time. Plainpages hands you the boring-but-hard parts (auth, authz, menu, design system, plugin host) and stays out of your domain logic. It's not a no-code tool and doesn't hide its moving parts: if "Ory is down ⇒ no logins" (see Auth) reads as obvious rather than a surprise, you're the audience.
Project goals
Plainpages deliberately targets low-end systems, odd hardware, 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, standards-compliant
HTML + CSS with zero JavaScript: it loads fast, degrades gracefully, and works on
whatever browser is already there. Where a modern CSS feature removes the need for
JavaScript (theme switching, popovers, disclosure) we use it — the trade we avoid is
shipping a client-side runtime, not using the platform. That standards-first stance also
makes semantic, accessible markup a priority: real landmarks, one <h1> per page,
lists and tables with proper headers, a skip link, and ARIA (aria-current/aria-sort)
only where the platform leaves a gap (see AGENTS.md).
Status. This README describes the target architecture. Built today (see
todo.md): the Node 24 + EJS server, the zero-JS design system (app shell, nav tree, data table, filters, pagination, forms — extracted fromhtml-css-foundation/), the plugin host (discovery, router, per-plugin views + static, theconfig/menu.tsoverride + branding), and the Ory stack wiring — Postgres, Kratos (+ session→JWT tokenizer) and Keto (authorization, OPL namespaces) and Hydra (OAuth2 provider: issuer + login/consent URLs). The auth wiring that consumes these — and Hydra's login/consent handlers — are the roadmap; sections marked (planned) are not built yet.
The MVP — "clone, one command, hack on a plugin" (planned)
The bar for a first usable release: clone, run one command, get a working register/login, and start building your own plugin — no manual key generation, no hand-edited Ory config, no separate database. That command brings up the whole stack (web + Ory + Postgres), generates signing keys, seeds an admin on first boot, and drops you at a login screen; from there you copy the example plugin folder and write your own page. SSO and the OAuth2-provider role (Hydra) come after — not required to start.
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 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.
So the web app is stateless and its npm footprint stays tiny — a small,
pinned set of runtime deps (today ejs for templating and lucide-static
for icons), grown only with justification and never a framework. Auth, sessions,
SSO, and OAuth2 add services, not npm packages; data lives upstream (see
Stateless — no application database).
What's included vs. what you add
- Included: sign-in / register / reset (themed, Kratos-backed), and the admin screens for users, groups, permissions (users via Kratos, the relationship graph via Keto).
- You add: everything domain-specific, as plugins — a list page, a form, a scheduler, a register, a dashboard. Plugins get the same building blocks the built-in screens use.
Requirements
- Docker
- Docker Compose
That's it. Do not install or run Node/npm on the host — use the commands below.
Development
docker compose up # http://localhost:3000, live reload via `node --watch`
docker compose up brings up the full stack — web + Postgres + Kratos/Keto/Hydra —
merging compose.override.yml, which mounts the source and restarts the server on
change. A one-shot bootstrap service then seeds first-boot state with zero manual
prep — it generates the JWT signing key if absent, creates a demo admin
(admin@plainpages.local / admin) in Kratos, and grants it the admin role plus every
discovered plugin's declared permission tokens in Keto, so permission checks (and any dropped-in
plugin) resolve out of the box; it is idempotent, so every up re-runs it
safely. It finishes by printing a banner with the login URL and seeded credentials.
Change the demo admin before production. The web app waits for Kratos + Keto
to be healthy and the bootstrap to finish before starting (each Ory service has a
readiness healthcheck). Dev publishes the host-facing Ory ports —
Kratos public 4433 (the browser POSTs self-service flows there) and Hydra public
4444; prod (docker compose -f compose.yml up) keeps them internal. Kratos
recovery/verification emails are caught by mailpit in dev — read the codes at
http://localhost:8025. To work on your own plugin, see
Where plugins live.
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: there is no NODE_ENV. Behaviour that used to flip
on "production" is now its own explicit toggle, so a deployment turns on exactly what it
wants. compose.yml (base) sets the hardened toggles; compose.override.yml (dev,
auto-merged by docker compose up) turns them back off for live editing.
| Var | Default | Notes |
|---|---|---|
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, the two secrets must be supplied and differ from the dev throwaways |
KRATOS_PUBLIC_URL / KRATOS_ADMIN_URL |
http://kratos:4433 / :4434 |
identity (self-service / admin) |
KETO_READ_URL / KETO_WRITE_URL |
http://keto:4466 / :4467 |
permission check / write |
JWKS_URL |
file://…/tokenizer/jwks.json |
the Kratos tokenizer signing key; verifies the session JWT (§4) |
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) |
COOKIE_SECRET / CSRF_SECRET |
dev throwaways | enforced by REQUIRE_SECURE_SECRETS |
What you must supply (the only manual prep)
A clean clone needs none of the above — docker compose up brings up the whole
stack with dev-throwaway secrets, an auto-generated signing key, and a seeded admin
(see Development). Exactly two things can't be auto-generated, and
both are production-only — neither blocks a clean clone:
- Production secrets — replace the committed dev throwaways:
COOKIE_SECRETandCSRF_SECRET(env), plus the JWT signing key (mount a realjwks.jsonor set…_JWKS_URL— see JWT signing key & rotation). SetREQUIRE_SECURE_SECRETS=trueand the app refuses to boot until the two secrets are supplied and differ from the throwaways. - SSO provider client id/secret — optional; password login works without them. Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (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 roles, the Keto OPL model — so there is nothing else to hand-configure.
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 (google,
microsoft, …), 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
provider ⇒ no button, and the whole SSO section disappears when none are configured — no
code change to add or remove one. Open-source Kratos has no native SAML — front it
with an OIDC bridge (Ory Polis) and register that bridge as a generic OIDC provider the
same way.
JWT signing key & rotation
The session tokenizer (§3) signs each session→JWT with an ES256 key at
ory/kratos/tokenizer/jwks.json. The committed one is a dev throwaway (like the
cookie/cipher secrets in kratos.yml) — a clean clone works; never run it in
production. (Re)generate with the bundled generator:
docker compose run --rm -T --no-deps web node src/gen-jwks.ts > ory/kratos/tokenizer/jwks.json
Production: mount a real key over that path, or set
SESSION_WHOAMI_TOKENIZER_TEMPLATES_PLAINPAGES_JWKS_URL=base64://<the JWKS JSON, base64>.
Rotation (zero downtime): Kratos signs with the first key in the set; the app
selects the verify key by kid (§4). So prepend a freshly generated key, keep the old
one for ~one token TTL (10m) so in-flight JWTs still verify, then drop it.
Type check & tests
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)
--no-deps keeps these off the Ory stack — units need no Postgres/Kratos/Keto, and web
otherwise drags up its depends_on services.
End-to-end (Playwright)
E2E runs in the official Playwright image (browsers preinstalled) against the live web
service — no Node/browsers on the host. There are four suites:
Visual + design system (visual.spec.ts) — Ory-free (mock-data dashboard), so it stays fast.
It screenshots the live pages and the html-css-foundation mockups, then asserts the live DOM
computes the same design-system styles as the reference (so a styling regression fails the
build, independent of the row data).
docker compose -f compose.yml -f compose.e2e.yml run --build --rm e2e # run the suite
docker compose -f compose.yml -f compose.e2e.yml down -v # tear down after
Auth — token timeout + refresh (auth-refresh.spec.ts) — the full-stack counterpart: it
boots the real Ory stack (Postgres + Kratos + Keto + bootstrap), shortens the session→JWT TTL to
8s (ory/kratos/e2e.yml) and sets JWT_CLOCK_SKEW_SEC=0, then logs in the seeded admin and proves
the §4 "stay signed in" hot path: the lapsed JWT is silently re-minted from the live Kratos
session (roles re-read from Keto), and once that session is revoked the stale cookie is cleared.
docker compose -f compose.yml -f compose.e2e-auth.yml run --build --rm e2e # run the suite
docker compose -f compose.yml -f compose.e2e-auth.yml down -v # tear down after
OAuth2 login + consent (oauth-login.spec.ts) — another app logs in through us: it boots the
real stack (incl. Hydra), registers an OAuth2 client, starts an authorization flow, and drives the
§6 handlers end-to-end — /oauth2/login bounces an unauthenticated user to the themed login and
accepts the challenge once a Kratos session exists; /oauth2/consent then shows the consent
screen for the third-party client and Allow drives Hydra to issue the authorization code.
docker compose -f compose.yml -f compose.e2e-oauth.yml run --build --rm e2e # run the suite
docker compose -f compose.yml -f compose.e2e-oauth.yml down -v # tear down after
Full browser flow (full-flow.spec.ts) — the real Playwright UI against the live stack: the
themed password login and a mocked-SSO login (an in-network mock OIDC provider,
e2e/mock-oidc.mjs), menu filtering by role, the users/groups/roles admin CRUD, a
permission-gated plugin page, and logout. Because the themed form posts straight to Kratos
and cookies are host-scoped, a tiny same-origin gateway (e2e/proxy.mjs) fronts web + Kratos on one
host (ory/kratos/e2e-proxy.yml points Kratos at it) — exactly as a production reverse proxy would.
docker compose -f compose.yml -f compose.e2e-full.yml run --build --rm e2e # run the suite
docker compose -f compose.yml -f compose.e2e-full.yml down -v # tear down after
--build rebuilds the runner so spec edits are always picked up (the image bakes in e2e/).
Screenshots + an HTML report land in e2e/artifacts/ (git-ignored). Every user-facing flow
is covered end-to-end; tests are independent and run fully in parallel for speed
(AGENTS.md §6) — keep new tests side-effect-free so the suite stays fast.
Building a plugin
A plugin is a folder under plugins/. The host discovers it at boot — no
registration step, no central wiring. The full, authoritative API surface —
manifest shape, handler/RequestContext contract, versioning, conflict rules,
hooks, and the dev/test story — is docs/plugin-contract.md
(src/plugin.ts holds the types). A complete, runnable reference ships in
plugins/scheduling/ — a list page fetching upstream data,
a CSRF-guarded form forwarding writes upstream, and permission-gated nav. Copy it and
adapt. The sketch below is the shape.
plugins/scheduling/ # folder name = the plugin id; mounted at /scheduling
plugin.ts # default export: the typed manifest (see below)
views/ # EJS templates for this plugin's pages
shifts.ejs
public/ # CSS / assets, served under /public/scheduling/
scheduling.css
The manifest is TypeScript — typed, commented, no separate schema to keep in
sync. The id and mount path are derived from the folder name, not declared:
import { definePlugin } from "../../src/plugin-api.ts"; // the stable author barrel (see docs)
import { listShifts } from "./shifts.ts";
export default definePlugin({
apiVersion: "1.0.0", // semver of the host contract this was built against (a literal — see docs)
// Nav fragment, composed into the global menu. Permission-gated: items the current user can't
// access are hidden. Arbitrary depth. `icon` is a Lucide icon by its sprite id (src/icons.ts).
nav: [
{
label: "Scheduling", icon: "i-cal",
children: [
{ label: "Shifts", href: "/scheduling/shifts", permission: "scheduling:read" },
],
},
],
// Route handlers, mounted under the plugin's path (/scheduling). `permission` is a coarse role
// (a JWT-claim check) enforced before the handler runs.
routes: [
{ method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts },
],
});
The handler (listShifts) fetches its data from an upstream service and renders
it — the plugin holds no state of its own (see below); the reference points
SCHEDULING_UPSTREAM at its backend (the dev compose ships a tiny mock,
examples/shifts-upstream/). A view result renders against the native app shell
via ctx.chrome (branding, the global nav, the signed-in user), and a write form
guards itself with ctx.verifyCsrf + the token in ctx.chrome.csrfToken. Each
plugin is self-contained (its own nav, routes, views, CSS), so installing one is
"drop the folder, restart." An operator stays in control via a central override.
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. There are two ways, depending on where the
plugin's source lives:
1. In your clone (the default dev loop). Create plugins/<id>/ in the working
tree. docker compose up already bind-mounts the whole tree (compose.override.yml:
.:/app), so the folder is live in the container — restart to pick it up. This is the
"copy the example plugin and go" path.
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. Plugins are
stateless, so mount it read-only:
# compose.plugins.yml — mount external plugin folders into the host
services:
web:
volumes:
- ../scheduling-plugin:/app/plugins/scheduling: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 or volume container works the same way (target /app/plugins/<id>),
but a bind mount matches the edit-and-reload loop. For a baked production image,
just keep the plugin in the build context and it's COPY'd in at build time — pinned
and reproducible; mount a volume only to add plugins to an already-built image.
Discovery — scanning
plugins/, importing eachplugin.tsdefault export, and validating it (id,apiVersion, conflicts) — runs at boot (src/discovery.ts); a bad plugin stops startup with a precise message. The router (src/router.ts) then mounts each route at/<id>, resolves:nameparams, runs the permission gate, and turns the handler'sRouteResultinto the response; aviewresult rendersplugins/<id>/views/<view>.ejs(src/view-resolver.ts), which mayinclude()the core building-block partials. A plugin'spublic/assets are served at/public/<id>/(src/static.ts). The mount mechanics above are how the files get into the container either way.
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/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.
Every nav item may carry a permission; the rendered tree is filtered per
user by reading the roles in the session JWT (no per-request authz call — see
Auth, sessions & permissions), so the menu
only ever shows what that person can reach. The markup is the recursive, zero-JS
nav tree from the design foundation (header/leaf × clickable/static, counts,
arbitrary depth). Branding (name, logo, default theme) renders in the app shell — the sidebar
brand shows the configured logo (else a default mark), and the theme sets the theme-switch default.
Building blocks
Plainpages is a component library, not a page generator — you assemble pages from partials
and helpers rather than declaring a schema and getting magic. The vocabulary is extracted from
html-css-foundation/ into 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 a handler calls to authorize (src/guards.ts):requireSession(assert a session — aGuardErrorthe host turns into a redirect to sign in),can(role)(a coarse JWT-claim check, zero I/O),check(relation, object)(the one live Keto call, for relationship rules).
Interactivity: zero-JS spine, opt-in enhancement
The core and all building blocks work with zero JavaScript — menus, theme
switching, and filtering are pure CSS + GET forms. On the low-end, low-bandwidth
targets we care about this is usually faster: a round-trip returning
a small, pre-rendered HTML page beats a client-side runtime that must boot, fetch JSON,
and re-render before anything shows. List state (?q=…&status=…&sort=…&page=…) lives
in the URL, so a view is bookmarkable, shareable, and reproducible — 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, or vanilla JS) on top of working server-rendered HTML. The baseline never depends on it.
Auth, sessions & permissions (planned)
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 → session JWT (the Kratos session tokenizer)
The themed sign-in / register / reset / SSO screens drive Kratos self-service flows.
SSO is optional and self-configuring: each provider's button renders only when its
credentials are present, and the whole SSO section disappears when none are configured —
leaving plain password login. A developer never has to touch SSO to get started. 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 roles 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, roles:[…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.roles
filter menu · gate routes
Keto is the single source of truth for roles. Coarse roles are Keto relations
(e.g. role:admin#members@user:alice); the admin screens write them only to Keto.
But the tokenizer's claims mapper can read only the identity, not call Keto — so at
login the app reads the roles from Keto and refreshes a derived projection: a
read-only copy written onto the identity's metadata_public for the tokenizer to see,
which the template maps into the JWT roles 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 coarse roles in their own JWT, so nothing is leaked.)
That projection is a per-login cache, authoritative nowhere; nothing edits it by hand, and
a stale one self-heals on the next login.
A role can be granted to a user directly or to a group the user belongs to; login
resolves both (enumerate the defined roles, ask Keto to resolve each membership), so the
JWT roles 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 even signature verification hits the network only on key rotation. The app stays stateless; "stay signed in" = re-mint the JWT on a short TTL, the one moment authz is recomputed from Keto.
Two trade-offs — both deliberate
This design buys an I/O-free hot path that scales to tens of thousands of concurrent users on modest hardware. In return:
- Role changes lag by up to one TTL (~10m). Gating reads the JWT, not Keto, so a granted or revoked role only takes effect when the token is next minted (re-login or TTL refresh). For an admin tool this is intentional — the alternative is a Keto call per request, which we traded away. For instant revoke, the optional revocation denylist (roadmap) closes the gap for security-critical cases without putting Keto back on the hot path.
- 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. That's the direct consequence of being stateless and delegating identity — no local fallback, by design. Run Ory with the availability you'd give any auth provider.
Three tiers of "may I?"
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's the source of truth and the check is free.
- Relationship-based rules (sharing, delegation, inherited/transitive access, or authz that must mean the same thing across several services) go to Keto — that's what ReBAC is for. Reserve it for those; don't pay its tuple-sync cost for rules a service can already answer from its own data.
The built-in users / groups / permissions screens write authorization only to Keto — coarse roles and fine-grained relationships alike. Roles reach the JWT by being read from Keto at login and projected through the tokenizer (above); nothing authors them anywhere else.
OAuth2 provider (Hydra)
Only relevant when other apps authenticate through plainpages. The app implements Hydra's login & consent steps — authenticating the user via their Kratos session — and Hydra issues the access / refresh / id tokens those apps use. Nothing in the menu or first-party pages needs Hydra.
The login challenge is wired (src/oauth-login.ts at /oauth2/login): Hydra hands
the browser here, the app resolves it against the Kratos session and accepts (or bounces
an unauthenticated user to the themed login, returning here once signed in). The consent
challenge is wired too (src/oauth-consent.ts at /oauth2/consent): a first-party client
(its Hydra metadata.first_party: true) — or one Hydra already skipped — is auto-granted the
requested scopes; any other client gets a themed consent screen (naming the signed-in account, with
a sign-out escape) whose CSRF-guarded Allow/Deny accepts or rejects. id_token claims (email, name)
come from the Kratos identity. RP-initiated logout is wired too (/oauth2/logout): Hydra hands
the browser here, the app accepts the logout_challenge and resumes to Hydra's post-logout redirect
— the first-party POST /logout still owns ending the Kratos session + our JWT cookie.
Those clients are registered from the admin OAuth2 clients screen (/admin/clients,
src/admin-clients.ts): register (Hydra shows the generated client_secret once, on the
confirmation page — confidential clients), list, and delete. Confidential vs public (PKCE) and the
first-party auto-consent flag are set at registration; writes go only to Hydra.
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.
Production / deployment
docker compose -f compose.yml up --build -d # base config only, no source mount
(Production compose grows to include the Ory services and Postgres — planned.)
Before going live, supply the production secrets and any SSO credentials — the only manual prep (What you must supply); the rest is auto-generated.
The server drains in-flight requests on SIGTERM/SIGINT rather than cutting them
mid-response, so container restarts are clean.
Layout
src/server.ts Entry point — starts the HTTP server (reads PORT, default 3000)
src/app.ts Request routing + EJS rendering (incl. the themed Kratos self-service routes, §4)
src/static.ts Static file serving (path-traversal protection) + routePublic(): /public/<id>/ → a plugin's public/
src/jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK)
src/jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.user/roles; flags a lapsed token for re-mint (§4)
src/jwks.ts JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload)
src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize (§4)
src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection, §4)
src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) (§4)
src/hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD (§6)
src/oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login (§6)
src/oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes (§6)
src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs (§4)
src/login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie (§4)
src/gen-jwks.ts generateJwks() + CLI: mint the ES256 session-tokenizer signing JWKS (§3); see JWT signing key & rotation
src/bootstrap.ts One-command bootstrap (§3): idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4)
src/csrf.ts CSRF for our own POST forms (§4): signed double-submit token — issue/verify, cookie, request gate
src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + §5 forms)
src/context.ts RequestContext handed to handlers + buildContext()
src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot
src/dashboard.ts buildDashboardModel(): the home "/" People list view model (mock data, wires the §1 helpers)
src/admin-users.ts Built-in Users admin screen (§5): list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded
src/admin-groups.ts Built-in Groups admin screen (§5): list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded
src/admin-roles.ts Built-in Roles admin screen (§5): list/create/delete Keto roles + assign to users/groups + "effective access" (Keto expand → transitive members); reuses the Groups membership helpers, writes only to Keto, gated + CSRF-guarded
src/admin-clients.ts Built-in OAuth2 clients admin screen (§6): list/register/delete Hydra OAuth2 clients (apps that log in through us); register shows the one-time client_secret; writes only to Hydra, gated + CSRF-guarded
src/admin-nav.ts adminSection(): the permission-gated "Admin" menu section (Users · Groups · Roles · OAuth2 clients), wired into the global dashboard menu + the in-screen admin nav (adminNav) so they can't drift
src/shell-context.ts buildShellContext(): brand/theme/user view-model shared by the dashboard + admin screens (real signed-in user, no demo profile)
src/chrome.ts buildPluginChrome(): the brand/global-nav/user/theme/csrf a plugin view renders the native shell from — exposed on ctx.chrome (§7)
src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
src/paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
src/plugin.ts Plugin contract: manifest types, definePlugin(), version + conflict rules + fullPath()
src/plugin-api.ts Stable plugin author barrel — the one module a plugin imports (definePlugin, ctx/result types, guards, body/CSRF/list-query helpers)
src/discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot (§2)
src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2)
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2)
src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2)
views/ Core EJS templates: index (app-shell dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent screen), 403/404/500, partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, flow + consent + admin bodies, menu/popover, theme switch, icon sprite)
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
config/menu.ts Central menu override + branding (optional; defaults apply if absent)
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships scheduling/ — the §7 reference plugin (list/form over an upstream + permission-gated nav) you copy
examples/ Non-app helpers; shifts-upstream/ is the dev mock backend the reference plugin reads/writes (stand-in for your real service)
docs/ Reference docs (plugin-contract.md — the authoritative plugin API)
e2e/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent → authorization code), full Ory stack; Dockerfile.e2e + compose.e2e[-auth|-oauth].yml run them
html-css-foundation/ HTML design mockups — the source for the building-block
partials; reference the stylesheets in public/css/.
Comments and docs cite roadmap phases as §N — the sections in todo.md.
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:
docker compose run --rm web npm install <pkg>(updatespackage.json+package-lock.json), thendocker compose build. Keep deps minimal — prefer the Node standard library, and prefer an Ory REST call over an SDK.
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
(e.g. node:24.16.0-alpine3.24, pinned Ory and Postgres tags).