Files
plainpages/README.md
T

952 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plainpages
A self-hostable **foundation for admin and operational web UIs** — the back-office you
build for a webshop, a school scheduling system, a water-treatment plant, or any tool
where staff register, find, and work with data. It ships 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**.
## Quick start
> **Requirements:** **Docker** and **Docker Compose** — and nothing else. Never run
> `node`/`npm`/`tsc` on the host; every command goes through Compose.
**1. Clone and start the whole stack.**
```bash
git clone ssh://git@gitea.larvit.se:21022/larvit/plainpages.git
cd plainpages
docker compose up # http://localhost:3000, live-reloads on source changes
```
One command brings up `web` + Postgres + Ory (Kratos/Keto/Hydra), generates the JWT
signing key, seeds a demo admin, and prints a banner with the login URL — no key
generation, no hand-edited Ory config, no separate database.
**2. Sign in.** Open <http://localhost:3000> and sign in as the seeded admin —
**`admin@plainpages.local` / `admin`** (change before production). `/` is the public
landing; the gated app home is `/dashboard`.
**3. Add your first plugin.** The clone is bind-mounted into the container, so a new
folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`:
```ts
import { definePlugin } from "../../src/plugin-api.ts";
export default definePlugin({
apiVersion: "1.0.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
],
});
```
```bash
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.**
From here, render real pages against the app shell and fetch upstream data — see
[Building plugins](#building-plugins) and the runnable reference in
[`plugins/scheduling/`](plugins/scheduling/).
## Contents
- [Overview](#overview)
- [Architecture](#architecture)
- [Stateless — no application database](#stateless--no-application-database)
- [Building plugins](#building-plugins)
- [the shape](#the-plugin-shape)
- [landing pages](#landing-pages)
- [where they live & mounting](#where-plugins-live-and-how-to-mount-them)
- [The menu system](#the-menu-system)
- [Building blocks](#building-blocks)
- [Interactivity: zero-JS spine](#interactivity-zero-js-spine)
- [Configuration](#configuration)
- [canonical host](#canonical-host-one-public-url)
- [what you must supply](#what-you-must-supply-the-only-manual-prep)
- [SSO](#social-sign-in-sso)
- [Auth, sessions & permissions](#auth-sessions--permissions)
- [login & the session JWT](#login-and-the-session-jwt)
- [instant revoke](#instant-revoke-the-optional-denylist)
- [three tiers](#three-tiers-of-may-i)
- [OAuth2 (Hydra)](#oauth2-provider-hydra)
- [Email](#email)
- [Testing](#testing)
- [end-to-end](#end-to-end-playwright)
- [the full gate](#the-full-gate-one-command)
- [Production & deployment](#production--deployment)
- [Observability](#observability)
- [JWT signing key & rotation](#jwt-signing-key--rotation)
- [Project layout](#project-layout)
- [Extending the core](#extending-the-core)
## Overview
Plainpages gives you the boring-but-hard parts of a back-office and stays out of your
domain logic. The only screens it ships itself are the ones for running the system:
**users, groups, and permissions**. Everything else is a plugin.
**Who it's for.** Experienced developers building back-office, admin, and dashboard
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. It's not a no-code tool and doesn't hide its moving
parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--permissions)) reads as
obvious rather than surprising, you're the audience.
**Included vs. what you add.**
- **Included:** themed sign-in / register / reset (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 — built from the same building blocks the built-in
screens use.
**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" 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.
**Low-end by design.** Plainpages deliberately targets **low-end systems, odd hardware,
and low-bandwidth environments** — a tablet on a factory floor, an old thin client at a
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](AGENTS.md)).
> **Status.** The full architecture this README describes is built and exercised
> end-to-end by the Playwright suites (see [Testing](#testing)): the Node 24 + EJS server,
> the zero-JS **design system** (app shell, nav tree, data table, filters, pagination,
> forms), the **plugin host** (discovery, router, per-plugin views + static, the
> `config/menu.ts` override + branding), the **Ory stack** (Postgres, Kratos + the
> session→JWT tokenizer, Keto, Hydra), the **auth** wiring that consumes it (themed
> sign-in / register / reset / SSO, the session→JWT hot path, the users/groups/roles admin
> screens), **Hydra's login / consent / logout handlers**, and **production & ops
> hardening** (the prod compose profile, response security headers, **structured logging +
> OTLP observability**, the [JWT key-rotation runbook](#jwt-signing-key--rotation)).
## Architecture
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](#auth-sessions--permissions).
In **dev** the host-facing Ory ports are published — Kratos public `4433` (where the browser
POSTs self-service flows) and Hydra public `4444`; **prod** (`docker compose -f compose.yml
up`) keeps them internal.
So the `web` app is **stateless** and its npm footprint stays tiny — a small, pinned set
of runtime deps (today **`ejs`** for templating, **`lucide-static`** for icons, and
**`@larvit/log`** — itself zero-dependency — for structured/OTLP logging), grown only with
justification and never a framework. Auth, sessions, SSO, and OAuth2 add *services*, not
npm packages; data lives upstream.
### Stateless — no application database
Plainpages and its plugins hold **no state of their own**. The only database in the stack
is **Postgres, and it belongs to Ory** (Kratos/Keto/Hydra); the `web` app never connects
to it.
A plugin gets its data by **calling an upstream service** from its route handler — a REST
API, an ERP, a plant historian, the customer's own backend — and renders the response with
the building blocks; writes are forwarded the same way. The partials only need rows to
render and don't care where they came from.
This keeps `web` trivially scalable and crash-safe: any instance can serve any request,
because the session lives in Kratos and the data lives upstream.
## Building plugins
A plugin is a folder under `plugins/`. The host discovers it at boot — no registration
step, no central wiring. The folder name is the plugin **id** *and* its **mount path**
(`plugins/scheduling/``/scheduling`); neither is declared in the manifest, so they
can't drift or be claimed twice. 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 (see [The menu system](#the-menu-system)).
The full, authoritative API surface — manifest shape, handler/`RequestContext` contract,
versioning, conflict rules, hooks, and the dev/test story — is
**[docs/plugin-contract.md](docs/plugin-contract.md)** (`src/plugin.ts` holds the types). A
complete, runnable reference ships in **[`plugins/scheduling/`](plugins/scheduling/)** — a
public overview page, a permission-gated list page fetching upstream data, a CSRF-guarded
form forwarding writes upstream, and a mix of public + role-gated nav. Copy it and adapt.
### The plugin 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:
```ts
import { definePlugin } from "../../src/plugin-api.ts"; // the stable author barrel (see docs)
import { listShifts, overview } 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. `public: true` shows an item to everyone (signed in or not). Arbitrary
// depth. `icon` is a Lucide icon by its sprite id (src/icons.ts).
nav: [
{
label: "Scheduling", icon: "i-cal",
children: [
{ label: "Overview", href: "/scheduling", public: true }, // shown to everyone
{ 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; `public: true` makes a page reachable by
// anyone (mutually exclusive with `permission`).
routes: [
{ method: "GET", path: "/", public: true, handler: overview },
{ method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts },
],
});
```
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`. The handler fetches its data from an upstream service
and renders it — the plugin holds no state of its own (see
[Stateless](#stateless--no-application-database)); the reference points
`SCHEDULING_UPSTREAM` at its backend (the dev compose ships a tiny mock,
`examples/shifts-upstream/`). It logs through **`ctx.log`** and traces upstream calls with
**`ctx.log.fetch`** (or `tracedFetch`), joining the request's trace (see
[Observability](#observability)). The simplest possible handler skips views and returns
`{ html }` (the [Quick-start](#quick-start) plugin); most pages return a `view` so they get
the shell for free.
### Landing pages
There are two replaceable landing slots: `/` is a **public** front page (default: an intro
with sign-in / register links) and `/dashboard` is the **gated** post-login app home
(default: the People list). A plugin owns either by exporting a `home` (public `/`) or
`dashboard` (gated `/dashboard`) handler — one owner each. See the contract's
[landing pages section](docs/plugin-contract.md#the-landing-pages-home--dashboard).
### 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
[Quick-start](#quick-start) 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:
```yaml
# compose.plugins.yml — mount external plugin folders into the host
services:
web:
volumes:
- ../scheduling-plugin:/app/plugins/scheduling:ro # host path : /app/plugins/<id>
```
```bash
# 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 override**`config/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](#auth-sessions--permissions)), so the menu only ever shows
what that person can reach. An item (or a whole page) may instead be marked **`public:
true`** to show it to **everyone, signed in or not** — the blessed, explicit way to expose
a public page and its menu entry (a no-permission item is already public; `public` just
says so on purpose, and is mutually exclusive with `permission`). 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.
**One menu, one shell, everywhere.** There is a single menu (`src/chrome.ts`
`buildPluginChrome`), rendered by the same app shell on **every** page — the dashboard, the
admin screens, plugin pages, and the login / registration / recovery / front (`/`) pages.
So it looks identical signed in or out; it just shows fewer items to an anonymous visitor
(only `public` ones, plus a Sign-in link), filtered by the same per-user rule. The sidebar
collapses to a burger on a narrow screen. A page that wants a focused, chrome-free layout
(e.g. a print view) opts out with the shell's `menu: false`.
## 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 a
set of 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 — a `GuardError` the 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
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](#overview)
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.
## 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 |
| --- | --- | --- |
| `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](#canonical-host-one-public-url)) |
| `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` 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` | permission 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 role/session revoke denylist](#instant-revoke-the-optional-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` |
### 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.`, an IP vs a domain. That matters here 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. (The original
symptom: open the banner's `http://localhost:3000`, sign in, land on
`http://127.0.0.1:3000/error` "Page not found".)
`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 all 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 `APP_URL`: 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`). Set `APP_URL` and the whole stack
follows; there is no second place to edit. A genuine Kratos flow error now renders a themed
**`/error`** page (a path back to sign-in), not the catch-all 404.
The redirect is an **explicit opt-in** (per the no-`NODE_ENV` rule): **unset ⇒ no
redirect**, so a deploy that forgets `APP_URL` never bounces real users to a stale default.
The clean clone still works with zero config because the bundled Kratos and the dev stack
both default to `localhost` (the dev override sets `APP_URL=http://localhost:3000`); browse
`localhost:3000` and login just works, and `127.0.0.1` is canonicalised onto it.
> **Behind a reverse proxy:** the proxy must pass the public `Host` through (or rewrite
> Kratos' `base_url`/`ui_url`s to match what the browser sees). If it rewrites `Host` to an
> internal upstream name while `APP_URL` is the public domain, the canonical redirect will
> loop — preserve `Host`.
>
> **Dev caveat (custom host).** Only if you point `APP_URL` at 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: set `KRATOS_PUBLIC_BROWSER_URL=http://<that-host>:4433/` (it shares `APP_URL`'s host
> but keeps the Ory port, so it can't be `APP_URL` verbatim). 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 — `docker compose up` brings up the whole stack
with dev-throwaway secrets, an auto-generated signing key, and a seeded admin (see
[Quick start](#quick-start)). Exactly **two** things can't be auto-generated, and **both
are production-only** — neither blocks a clean clone:
1. **Production secrets** — replace the committed dev throwaway `CSRF_SECRET` (env), plus
the **JWT signing key** (mount a real `jwks.json` or set `…_JWKS_URL` — see
[JWT signing key & rotation](#jwt-signing-key--rotation)). Set
`REQUIRE_SECURE_SECRETS=true` and the app refuses to boot until `CSRF_SECRET` is supplied
and differs from the throwaway.
2. **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)](#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.
## Auth, sessions & permissions
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. **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, turn on the optional
[revocation denylist](#instant-revoke-the-optional-denylist) — it 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.
### Instant revoke: the optional denylist
Off by default; turn it on with `REVOCATION_DENYLIST=true` (`src/denylist.ts`). For
security-critical revoke (offboarding, a compromised account) the ~10m role/session lag
above is too long. When enabled, an admin **deactivating** or **deleting** a user, or
**granting/revoking** a role 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 roles from Keto, or clears a now-dead session. A fresh re-login (its JWT issued
*after* the revoke) passes, so a role downgrade lands immediately without locking the
account.
It's an in-memory, auto-evicting map — no database, like the JWKS cache, so it stays inside
the stateless model. Entries self-evict after `REVOCATION_TTL_SEC` (default 900s ≥ the 10m
token TTL + skew), by which point any pre-revoke token has expired anyway. The check is pure
CPU — **Keto stays off the hot path**. Two deliberate bounds: it's instant on the **single
instance** that handled the revoke (across replicas/restarts the guarantee falls back to the
token TTL — back the denylist with a shared store for hard multi-instance instant-revoke),
and a **group** membership change is transitive across many users, so it's left to lag —
deactivate the user, or use a direct user-role change, for an instant effect.
### 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.
## Email
The only emails are the **recovery** and **verification** codes from Kratos' self-service
flows, and **Kratos renders and sends them** (delegated, like the rest of identity — `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](https://www.ory.sh/docs/kratos/emails-sms/custom-message-templates)
docs for the full template-type list and the data each template receives.
## Testing
Type check and unit tests run off the Ory stack — units need no Postgres/Kratos/Keto, and
`--no-deps` keeps `web` from dragging up its `depends_on` services:
```bash
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. There are five suites:
**Visual + design system** (`visual.spec.ts`) — Ory-free, so it stays fast. It screenshots
the live pages and asserts the rendered design system — the app shell, theme switch, mobile
off-canvas layout, icon sprite, CSRF-guarded sign-out, the public landing, the 404 page, and
plugin permission-gating.
```bash
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 "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**.
```bash
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 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.
```bash
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.
```bash
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/`).
**Dev-stack login regression** (`devstack-login.spec.ts`) — drives the *plain* `docker
compose up` topology (not the same-origin gateway above) with the runner on the **host
network**, so the browser sees `http://localhost:3000` (web) and `http://127.0.0.1:4433`
(Kratos public) exactly as a host browser does. It signs in the seeded admin from the URL the
first-run banner advertises (`http://localhost:3000`) **and** from the wrong host
(`http://127.0.0.1:3000`), asserting both reach the dashboard signed in — the latter via the
[canonical-host redirect](#canonical-host-one-public-url). It guards against the regression
where the advertised login URL dumps the user on the `/error` "Page not found" page; the
proxied full-flow suite can't catch this (it fronts web + Kratos on one origin). Part of
`scripts/ci.sh` — it needs host networking and the host ports `3000`/`4433` free (Linux).
```bash
docker compose -f compose.yml -f compose.override.yml -f compose.e2e-devstack.yml run --build --rm e2e # run it
docker compose -f compose.yml -f compose.override.yml -f compose.e2e-devstack.yml down -v # tear down
```
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](AGENTS.md)) — keep new tests side-effect-free so the suite stays fast.
### The full gate (one command)
`scripts/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
bash scripts/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/roles to Keto), which is why the
gate runs them serially, one stack up/down per suite.
## Production & deployment
```bash
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
(Kratos/Keto/Hydra, with migrations + the one-shot bootstrap) — and mounts no source. Secrets
come from the environment (`CSRF_SECRET`, `POSTGRES_USER`/`POSTGRES_PASSWORD`); the base
already 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](#what-you-must-supply-the-only-manual-prep)); the rest is
auto-generated.
Every response carries security headers (`src/security-headers.ts`, set once per request): a
strict `Content-Security-Policy` (the core is **zero-JS**`script-src 'self'`, no inline
scripts, so an injected `<script>` can't run), `X-Content-Type-Options: nosniff`,
`X-Frame-Options: DENY` + `frame-ancestors 'none'`, `Referrer-Policy`, and — when
`SECURE_COOKIES=true` (https) — HSTS. 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` (e.g. to ship its own JS).
A deep link reached while signed out — or after the ~10m session JWT lapses mid-task —
bounces to the themed sign-in and, once authenticated, returns to the **page that was
requested** (`return_to`, validated **host-relative** by `localPath` in `src/safe-url.ts`, so
a crafted `?return_to=` 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** ("sign-in is
temporarily unavailable"), distinct from the catch-all 500.
The server drains in-flight requests on `SIGTERM`/`SIGINT` rather than cutting them
mid-response, so container restarts are clean.
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 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. The web app waits for Kratos + Keto to be healthy
*and* the bootstrap to finish before starting. **Change the demo admin before production.**
## Observability
Logging is **structured** and **OTLP-native**, on
[`@larvit/log`](https://www.npmjs.com/package/@larvit/log) (zero-dependency). One app logger
tags every line with `service.name` (`SERVICE_NAME`, default `plainpages` — brand your own
deployment); 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
(no `NODE_ENV`):
- `LOG_LEVEL` (default `info`) — `error` · `warn` · `info` · `verbose` · `debug` · `silly` ·
`none`.
- `LOG_FORMAT``text` in dev (human-readable), `json` in prod (the base compose sets it)
for a log pipeline.
- `SERVICE_NAME` — the `service.name` on every log and span.
Every request emits one access line (`method`, `path` — the query is dropped, it can carry
tokens — `status`, `ms`, `requestId`); login/logout, admin writes (who-did-what), and
missing-role/CSRF rejections log at `info`/`warn`, and the catch-all 500 + the
Ory-unreachable re-mint at `error`/`warn`. An inbound W3C `traceparent` is **adopted**, so a
request continues a trace started by an upstream proxy/gateway.
**Distributed tracing — every outbound call.** Because the request logger is ambient, **all**
outbound HTTP — the Kratos/Keto/Hydra clients and the JWKS fetch — runs through it
(`tracedFetch`), so each becomes a **client span** under the request and carries the
`traceparent` downstream (Ory continues the same trace). A **plugin** does the same:
`ctx.log` is its request logger and `ctx.log.fetch(url)` (or defaulting an upstream client to
the exported `tracedFetch`, as the reference plugin does) traces its upstream calls too. The
result is one trace per request spanning web → Ory/upstream.
**OTLP export (off by default).** Point `OTLP_ENDPOINT` at an OpenTelemetry Collector's HTTP
base URI (e.g. `http://otel-collector:4318`) and logs **and** spans also export there — feed
Grafana Loki (logs) + Tempo (traces), or any OTLP backend. `OTLP_PROTOCOL` selects the wire
format (`http/json` default, or `http/protobuf` for collectors that only accept protobuf).
Export is fire-and-forget — it never blocks or fails a served request, and nothing exports
when the endpoint is unset (zero cost). A collector outage is survivable but noisy: each
request's failed export writes a line to stderr (it's retried per request, not queued), so
run a local collector/agent 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** (like the
cookie/cipher secrets in `kratos.yml`) — a clean clone works; **never run it in
production**. Mint a fresh key with the bundled generator:
```bash
docker compose run --rm -T --no-deps web node src/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 set
`SESSION_WHOAMI_TOKENIZER_TEMPLATES_PLAINPAGES_JWKS_URL=base64://<the JWKS JSON, base64>`.
- **web (verifier)** — `JWKS_URL` (default `file://…/tokenizer/jwks.json`). A `file://` set
is re-read live (5-min TTL, plus an immediate reload on an unknown `kid`); a `base64://` set
is immutable and rotates only on a web redeploy. **For rotation, use `file://` on the web
side** so it picks up new keys without a restart.
**Why rotation is zero-downtime.** Kratos signs with the **first** key in the set and stamps
its `kid` in each JWT header; web selects the verify key by that `kid`. So a set can hold the
new key *and* the old one at once — 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, round up to **~12 min**. Run from the repo root (paths are
container-relative; with the dev bind-mount they edit the real file).
1. **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:
```bash
docker compose run --rm -T --no-deps web sh -c \
'node src/gen-jwks.ts --prepend ory/kratos/tokenizer/jwks.json' > /tmp/jwks.json \
&& mv /tmp/jwks.json ory/kratos/tokenizer/jwks.json
```
2. **Restart Kratos** so it signs with the new first key: `docker compose restart kratos`.
(web needs no restart — it hot-reloads the file. The hot path verifies JWTs locally, so a
brief Kratos blip only touches login/re-mint.)
3. **Verify** new logins mint the new `kid` — decode the `plainpages_session` cookie's JWT
header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients
present the new key.
4. **Wait ~12 min**, then **prune** the superseded key:
```bash
docker compose run --rm -T --no-deps web sh -c \
'node src/gen-jwks.ts --prune ory/kratos/tokenizer/jwks.json' > /tmp/jwks.json \
&& mv /tmp/jwks.json ory/kratos/tokenizer/jwks.json
```
No Kratos restart needed — it already signs with that key; this only drops a now-unused
verify key.
**Rollback** (before the prune): the old key is still in the set, so revert step 1's file and
`restart kratos` — in-flight tokens never broke.
### Emergency rotation (key compromise)
Skip the overlap — you want every token signed with the leaked key to die now. **Replace**
the set with a single fresh key (no `--prepend`):
```bash
docker compose run --rm -T --no-deps web node src/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/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)
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
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
src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection)
src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API)
src/hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD
src/fetch-timeout.ts withTimeout(): bound every outbound Ory call — wrap the injected fetch so each request aborts after a deadline unless the caller passed its own signal; server.ts wires it into the Kratos/Keto/Hydra clients
src/oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login
src/oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes
src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs
src/login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie
src/gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation
src/bootstrap.ts One-command bootstrap: 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)
src/csrf.ts CSRF for our own POST forms: signed double-submit token — issue/verify, cookie, request gate
src/denylist.ts Optional instant-revoke denylist: in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST)
src/security-headers.ts Response security headers set on every reply: strict CSP (zero-JS), nosniff, X-Frame-Options/frame-ancestors, Referrer-Policy, HSTS over https
src/safe-url.ts safeUrl() (sanitise an untrusted href/src to relative-or-http(s), exposed to plugins) + localPath() (host-relative redirect-allowlist guard for return_to)
src/logger.ts createLogger()/requestLogger() + the ambient request log (runWithLog/currentLog) and tracedFetch: structured logger (service.name) + per-request trace span on @larvit/log; every outbound fetch joins the trace; OTLP export when OTLP_ENDPOINT set
src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + 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 gated "/dashboard" app home — a short instructional starter (replace it with a plugin `dashboard` handler); "/" is the public landing (a plugin `home` handler). Both render the one unified menu (ctx.chrome)
src/admin-users.ts Built-in Users admin screen: list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded
src/admin-groups.ts Built-in Groups admin screen: 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: 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: 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 one global menu + brand/user/theme/csrf every page renders the shell from (unified across all pages) — exposed on ctx.chrome
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
src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate
src/guards.ts requireSession()/can()/check(): in-handler authorization — the imperative counterpart to the route permission gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call
src/hooks.ts runBootHooks()/runRequestHooks()/runResponseHooks(): invoke a plugin's optional lifecycle hooks in discovery order; no sandbox (a throwing hook fails loud), skipped when no plugin declares one
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials
src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot
views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/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 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) + full-flow.spec (browser UI: password/SSO login, menu-by-role, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.mjs (same-origin gateway) + mock-oidc.mjs (mock SSO provider) back full-flow. Dockerfile.e2e + compose.e2e[-auth|-oauth|-full|-devstack].yml run them
scripts/ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash scripts/ci.sh`)
```
## 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).