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 from html-css-foundation/), and the plugin host (discovery, router, per-plugin views + static, the config/menu.ts override + branding). The Ory integration (Kratos/Keto/Hydra + Postgres) and auth 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 merges compose.override.yml, which mounts the source and restarts the server on change. (The Ory + Postgres services join this compose file as they land — planned.) 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)
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 Kratos tokenizer JWKS verifies the session JWT (§4)
COOKIE_SECRET / CSRF_SECRET dev throwaways enforced by REQUIRE_SECURE_SECRETS

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. No creds ⇒ no provider ⇒ no SSO button (§4 derives the buttons from this list). 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.

Type check & tests

docker compose run --rm web npm run typecheck   # strict tsc --noEmit
docker compose run --rm 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. 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

--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). 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.ts";
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 via Keto:
  // 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`
  // (a Keto check) is 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). 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 each plugin.ts default 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 :name params, runs the permission gate, and turns the handler's RouteResult into the response; a view result renders plugins/<id>/views/<view>.ejs (src/view-resolver.ts), which may include() the core building-block partials. A plugin's public/ 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:

  1. Plugin fragments — each plugin contributes its own nav (above).
  2. A central overrideconfig/menu.ts (loaded by src/menu-config.ts, validated at boot) — where the operator reorders, renames, groups, or hides items (by node id), and sets branding (app name, logo, default theme). The override always wins, applied before the per-user filter. A clean clone needs no config/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). Auth guards — requireSession (validate the JWT), can(role) (read a claim, in-process), check(relation, object) (a live Keto call) — land with §4.

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       (Keto = source of truth)
     └─► 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#member@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_admin for the tokenizer to see, which the template maps into the JWT roles claim. That projection is a per-login cache, authoritative nowhere; nothing edits it by hand, and a stale one self-heals on the next login.

Cost: one Keto read + 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; it can be added later without touching them.

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.)

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
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; claims+JWKS are §4
src/cookie.ts        Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4)
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/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/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 = the app-shell People dashboard, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, 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) + storage init (postgres/init/init.sql: one DB per service)
plugins/             Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in) (planned)
docs/                Reference docs (plugin-contract.md — the authoritative plugin API)
e2e/                 Playwright visual + functional E2E (Dockerfile.e2e + compose.e2e.yml run it)
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> (updates package.json + package-lock.json), then docker 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).

Description
A pluggable web GUI for systems that handle data, lists, and forms.
Readme MIT 6.4 MiB
Languages
TypeScript 78.2%
EJS 8.6%
HTML 6.9%
CSS 5.1%
JavaScript 0.6%
Other 0.5%