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