Project plan

This commit is contained in:
2026-06-14 17:45:12 +02:00
parent 4eed701419
commit 638815af2e
3 changed files with 359 additions and 27 deletions

279
README.md
View File

@@ -1,14 +1,60 @@
# Plainpages
A minimal **Node.js 24 + TypeScript** web backend that serves server-rendered HTML
(via **EJS** templates), CSS, and static files.
A self-hostable **foundation for admin and operational web UIs** — the kind of
back-office you build for a webshop, a scheduling system for schools, a water
treatment plant, or any tool where staff register, find, and work with data.
Priorities: **simplicity, few dependencies, strict TypeScript type checking.**
Development and deployment are **entirely Docker / Docker Compose based — no other
tooling is required** (no local Node, npm, or `tsc`).
Plainpages gives you the parts that are the same every time — **authentication,
authorization, a config-driven menu, and a server-rendered, zero-JS design
system** — and lets you add everything domain-specific by **dropping in plugin
folders**. The only screens it ships itself are the ones for running the system:
**users, groups, and permissions**. Everything else is a plugin.
The only runtime dependency is `ejs`. Node 24 runs the TypeScript sources directly
(type stripping), so there is **no build step** and no compiled output.
Priorities (unchanged from day one): **simplicity, few dependencies, strict
TypeScript, no build step, Docker-only.** Heavy lifting that *isn't* simple to do
well — identity, sessions, SSO, OAuth2, permission checks — is delegated to **Ory**
sidecar services rather than reinvented.
> **Status.** This README describes the target architecture (the project's scope).
> What exists in the repo today is the **scaffold** — a Node 24 + EJS HTTP server
> with static serving — plus the **design foundation** in `html-css-foundation/`
> (a complete zero-JS app shell + auth screens). The plugin host and Ory
> integration (Kratos/Keto/Hydra + their Postgres) are the roadmap below, not yet
> implemented. Sections marked _(planned)_ are not built yet.
## Architecture
Plainpages runs as a small set of containers, orchestrated by Docker Compose:
| Container | Role |
| -------------- | ---- |
| `web` | The Node 24 + TypeScript app: server-rendered EJS, the plugin host, the building-block partials. Stays tiny. |
| `kratos` | **Ory Kratos** — identity: login, registration, password reset, SSO, sessions. |
| `keto` | **Ory Keto** — permissions: the authorization decisions (`can user X do Y on Z?`). |
| `hydra` | **Ory Hydra** — OAuth2/OIDC provider, so other apps can log in *through* plainpages. |
| `postgres` | **Ory's** storage only (Kratos/Keto/Hydra). The `web` app never connects to it. |
The `web` app is an Ory **relying party**: it never stores passwords. At login it
turns the Kratos session into a short-lived, **locally-validated JWT** (the Kratos
session tokenizer) carrying the user's coarse roles — so every later request gates
the menu and pages by **verifying the JWT in-process, with no per-request call to
Ory**. Keto answers the rarer fine-grained checks; Hydra is used only when the app
acts as an OAuth2 **login & consent provider** for other apps. It reaches the Ory
services over their **REST APIs using Node's built-in `fetch`** — no SDK
dependency. See [Auth, sessions & permissions](#auth-sessions--permissions-planned).
So the `web` app is **stateless** and its npm footprint stays at a single runtime
dependency — **`ejs`**. Auth, sessions, SSO, and OAuth2 add *services*, not npm
packages; data lives upstream (see [Stateless — no application database](#stateless--no-application-database)).
## What's included vs. what you add
- **Included:** sign-in / register / reset (themed, Kratos-backed), and the admin
screens for **users, groups, permissions** (users via Kratos, the relationship
graph via Keto).
- **You add:** everything domain-specific, as **plugins** — a list page, a form, a
scheduler, a register, a dashboard. Plugins get the same building blocks the
built-in screens use.
## Requirements
@@ -23,8 +69,9 @@ That's it. Do not install or run Node/npm on the host — use the commands below
docker compose up # http://localhost:3000, live reload via `node --watch`
```
`docker compose up` merges `compose.override.yml`, which mounts the source
and restarts the server on change.
`docker compose up` merges `compose.override.yml`, which mounts the source and
restarts the server on change. _(The Ory + Postgres services join this compose
file as they land — planned.)_
## Type check & tests
@@ -33,34 +80,216 @@ docker compose run --rm web npm run typecheck # strict tsc --noEmit
docker compose run --rm web npm test # node --test
```
## Building a plugin _(planned)_
A plugin is a folder under `plugins/`. The host discovers it at boot — no
registration step, no central wiring.
```
plugins/scheduling/
plugin.ts # default export: the typed manifest (see below)
views/ # EJS templates for this plugin's pages
shifts.ejs
public/ # CSS / assets, served under /public/scheduling/
scheduling.css
```
The manifest is **TypeScript** — typed, commented, no separate schema to keep in
sync:
```ts
import { definePlugin } from "../../src/plugin.ts";
import { listShifts } from "./shifts.ts";
export default definePlugin({
id: "scheduling",
basePath: "/scheduling",
// Nav fragment, composed into the global menu. Permission-gated via Keto:
// items the current user can't access are hidden. Arbitrary depth.
nav: [
{
label: "Scheduling", icon: "i-cal",
children: [
{ label: "Shifts", href: "/scheduling/shifts", permission: "scheduling:read" },
],
},
],
// Route handlers. The host's hand-rolled router mounts them under basePath
// and enforces `permission` (a Keto check) before the handler runs.
routes: [
{ method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts },
],
});
```
The handler (`listShifts`) fetches its data from an upstream service and renders
it — the plugin holds no state of its own (see below). Each plugin is
**self-contained** (its own nav, routes, views, CSS), so installing one is "drop
the folder, restart." An operator stays in control via a central override.
## The menu system _(planned)_
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` — where the operator reorders,
renames, groups, or hides items, and sets branding (app name, logo, default
theme). The override always wins.
Every nav item may carry a `permission`; the rendered tree is **filtered per
user** by reading the roles in the session JWT (no per-request authz call — see
[Auth, sessions & permissions](#auth-sessions--permissions-planned)), so the menu
only ever shows what that person can reach. The markup is the recursive, zero-JS
nav tree from the design foundation (header/leaf × clickable/static, counts,
arbitrary depth).
## Building blocks _(partly designed, planned to extract)_
Plainpages is a **component library, not a page generator** — you assemble pages
from partials and helpers rather than declaring a schema and getting magic. The
vocabulary already exists, fully styled and zero-JS, in `html-css-foundation/`;
the work is extracting it into reusable EJS partials + TS helpers:
- **Partials:** app shell, nav tree, filter bar, data table (sort / select / row
actions), pagination, form fields, badges, menus, auth cards.
- **Helpers:** compose nav from config, parse a list-page query
(`?q=…&status=…&sort=…&page=…`) into filter/sort/pagination, pagination math,
guards — `requireSession` (validate the JWT), `can(role)` (read a claim,
in-process), and `check(relation, object)` (a live Keto call, for the rare
fine-grained case).
## Interactivity: zero-JS spine, opt-in enhancement
The core and all building blocks **work with zero JavaScript** — menus, theme
switching, and filtering are pure CSS + GET forms (server-side). This is the
robust default for back-office and industrial use.
Plugins that genuinely need it — live dashboards, bulk actions, client-side
validation — may **opt into progressive enhancement** (htmx, Alpine, or vanilla
JS) on top of working server-rendered HTML. The baseline never depends on it.
## Auth, sessions & permissions _(planned)_
Identity comes from **Kratos**; the hot path stays I/O-free by carrying coarse
authorization in a **locally-validated JWT**, and **Keto** is reserved for the rare
fine-grained, must-be-fresh check.
### Login → session JWT (the Kratos session tokenizer)
The themed sign-in / register / reset / SSO screens drive Kratos self-service
flows. On success, instead of 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 that JWT as the session cookie.
```
── AT LOGIN / REFRESH (the only time Ory is on the path) ──────────
Kratos verifies credentials
└─► app reads the user's roles from Keto (Keto = source of truth)
└─► app writes them as a derived projection on the identity (admin API)
└─► whoami(tokenize_as: "plainpages") ─► signed JWT
claims: { sub, email, roles:[…from Keto], exp ≈ 10m }
└─► stored as the session cookie
── EVERY REQUEST (hot path — pure CPU, no I/O) ───────────────────
Browser ─cookie(JWT)─► web : verify signature (cached JWKS)
read claims.roles
filter menu · gate routes
```
**Keto is the single source of truth for roles.** Coarse roles are Keto relations
(e.g. `role:admin#member@user:alice`); the admin screens write them *only* to Keto.
But the tokenizer's claims mapper can only read the **identity**, not call Keto — so
at login the app reads the user's roles from Keto and refreshes a **derived
projection** onto the identity's `metadata_admin`, which the tokenizer template then
maps into the JWT `roles` claim. That projection is a per-login cache, authoritative
nowhere; nothing edits it by hand, and a stale one self-heals on the next login.
Cost: **one Keto read + one identity refresh per login** — never per request. JWKS
is cached, so even signature verification hits the network only on key rotation. The
app stays stateless; "stay signed in" = re-mint the JWT on a short TTL, the one
moment authz is recomputed from Keto.
### Three tiers of "may I?"
```
coarse (menu / route / feature) → JWT claim · in-process, zero I/O
fine + attribute (owner / tenant / …) → upstream service that owns the row
fine + relationship (shared / inherited)→ Keto, live check at the action
```
- **Coarse** gates the menu and routes — read straight from the JWT.
- **Attribute-based row rules** (ownership, tenant, status) live in the **upstream
service** that holds the data: it's the source of truth and the check is free.
- **Relationship-based rules** (sharing, delegation, inherited/transitive access,
or authz that must mean the same thing across several services) go to **Keto**
that's what ReBAC is for. Reserve it for those; don't pay its tuple-sync cost for
rules a service can already answer from its own data.
The built-in users / groups / permissions screens write authorization **only to
Keto** — coarse roles and fine-grained relationships alike. Roles reach the JWT by
being read from Keto at login and projected through the tokenizer (above); nothing
authors them anywhere else.
### OAuth2 provider (Hydra)
Only relevant when **other apps** authenticate *through* plainpages. The app
implements Hydra's login & consent steps — authenticating the user via their Kratos
session — and Hydra issues the access / refresh / id tokens those apps use. Nothing
in the menu or first-party pages needs Hydra; it can be added later without
touching them.
## Stateless — no application database
Plainpages and its plugins hold **no state of their own**. The only database in the
stack is **Postgres, and it belongs to Ory** (Kratos/Keto/Hydra); the `web` app
never connects to it.
A plugin gets its data by **calling an upstream service** from its route handler —
a REST API, an ERP, a plant historian, the customer's own backend — and renders
the response with the building blocks; writes are forwarded the same way. The
partials only need rows to render and don't care where they came from.
This keeps `web` trivially scalable and crash-safe: any instance can serve any
request, because the session lives in Kratos and the data lives upstream.
## Production / deployment
```bash
docker compose -f compose.yml up --build -d # base config only, no source mount
```
_(Production compose grows to include the Ory services and Postgres — planned.)_
## Layout
```
src/server.ts Entry point — starts the HTTP server (reads PORT, default 3000)
src/app.ts Request routing + EJS rendering
src/static.ts Static file serving with path-traversal protection
views/ EJS templates (index, 404, partials/)
public/ Static assets served under /public/ (css/, favicon, robots.txt)
src/server.ts Entry point — starts the HTTP server (reads PORT, default 3000)
src/app.ts Request routing + EJS rendering
src/static.ts Static file serving with path-traversal protection
src/plugin.ts definePlugin() + the host's plugin discovery/router (planned)
views/ Core EJS templates (index, 404, partials/)
public/ Static assets under /public/ (css/, favicon, robots.txt)
config/menu.ts Central menu override + branding (planned)
plugins/ Drop-in plugin folders, auto-discovered (planned)
html-css-foundation/ Raw HTML/CSS design reference — the source for the
building-block partials; not served.
```
## Extending
## Extending the core
- **New page:** add a route in `src/app.ts` and a template in `views/`.
- **Static asset:** drop it in `public/`; it is served at `/public/<path>`.
- **New page in a plugin:** add a route + handler to the plugin manifest and a
template in its `views/`.
- **Static asset:** drop it in the plugin's `public/`; served at
`/public/<plugin>/<path>`.
- **New dependency:** `docker compose run --rm web npm install <pkg>` (updates
`package.json` + `package-lock.json`), then rebuild with `docker compose build`.
Keep dependencies minimal — prefer the Node standard library.
`package.json` + `package-lock.json`), then `docker compose build`. Keep deps
minimal — prefer the Node standard library, and prefer an Ory REST call over an
SDK.
All versions are pinned to **exact, human-readable semantic versions** (no ranges,
no digests): deps via `.npmrc` (`save-exact=true`) and the committed lockfile
(`npm ci`), and the Node base image by tag in the `Dockerfile`
(e.g. `node:24.16.0-alpine3.24`).
`html-css-foundation/` holds the raw HTML/CSS design reference; it is not served and
is meant to be converted into EJS templates and `public/` assets over time.
no digests): npm deps via `.npmrc` (`save-exact=true`) + the committed lockfile
(`npm ci`), and container images by tag in the `Dockerfile` / compose files
(e.g. `node:24.16.0-alpine3.24`, pinned Ory and Postgres tags).