diff --git a/AGENTS.md b/AGENTS.md index 784e6ad..55a6c2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,13 @@ commands and layout. ## Project priorities (do not erode) 1. **Simplicity** — prefer the smallest, most readable solution. -2. **Few dependencies** — the only runtime dep is `ejs`. Prefer the Node standard - library. Justify any new dependency; do not add frameworks. +2. **Few dependencies** — the only npm runtime dep is `ejs`. Prefer the Node + standard library; justify any new dependency; do not add frameworks. The app is + **stateless — no database**. Auth/identity/OAuth are **Ory sidecar services** + (Kratos/Keto/Hydra, backed by Postgres), reached over their REST APIs with + built-in `fetch` — no SDK dependency. New capabilities ship as **plugin + folders** under `plugins/` that fetch their data from upstream services, not as + core code. See `README.md` for the architecture. 3. **Strict TypeScript** — `tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. diff --git a/README.md b/README.md index 3b4249a..aa0aa8b 100644 --- a/README.md +++ b/README.md @@ -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/`. +- **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//`. - **New dependency:** `docker compose run --rm web npm install ` (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). diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..9aca84d --- /dev/null +++ b/todo.md @@ -0,0 +1,98 @@ +# Plainpages — implementation TODO + +Build order is top → bottom; each phase is roughly independent and testable. +Conventions: **write tests first** (node --test for units, Playwright for E2E), +tear down test containers after runs, keep deps minimal, pin all versions, run +everything via Docker. + +## 0. Housekeeping / primitives +- [ ] Decide JWT verify approach: `node:crypto` (RS256/ES256 via `createPublicKey({format:"jwk"})`) vs add `jose` — justify if adding. +- [ ] Cookie helpers: parse `Cookie` header, build `Set-Cookie` (HttpOnly, Secure, SameSite). +- [ ] Request context type threaded to handlers: `{ req, res, url, params, query, user|null, roles }`. +- [ ] Error templates: add 403 + 500 (404 exists). +- [ ] Config/env loader: Ory endpoints, cookie/CSRF secret, JWKS location, ports. + +## 1. Building blocks — extract from `html-css-foundation/` (no Ory needed; render mock data) +- [ ] Move `styles.css` + `auth.css` into `public/css/`; reconcile with existing `style.css`. +- [ ] Lucide icon sprite → `views/partials/icons.ejs`. +- [ ] App-shell partial (sidebar + topbar + content slot). +- [ ] Nav-tree partial — recursive, header/leaf × clickable/static, counts, `aria-current`. +- [ ] Filter-bar partial — GET form (search, segmented, selects, chips, daterange, applied pills). +- [ ] Data-table partial — sortable headers, row-select, badges, kebab row actions. +- [ ] Pagination partial — rows-per-page + page numbers, query-param driven. +- [ ] Form-field partials (input/label/hint/error) + auth-card partial. +- [ ] Menu/popover + theme-switch partials (pure CSS `details`/`summary`). +- [ ] Helper `composeNav(fragments, override, roles)` → merged, permission-filtered tree. +- [ ] Helper `parseListQuery(url)` → `{ q, filters, sort, page, pageSize }`. +- [ ] Helper `paginate(total, page, pageSize)` → page model. +- [ ] Unit tests for all helpers (first). +- [ ] Replace placeholder `index` with the app-shell dashboard. + +## 2. Plugin host +- [ ] `definePlugin()` + manifest types: `id`, `basePath`, `nav[]`, `routes[] {method, path, permission?, handler}`. +- [ ] Discovery: scan `plugins/`, import each `plugin.ts` default export, validate. +- [ ] Router: match method+path under `basePath`, resolve path params, run permission gate, call handler with context. +- [ ] Per-plugin view resolver (`plugins//views/*.ejs`). +- [ ] Per-plugin static serving: `plugins//public/` → `/public//`. +- [ ] `config/menu.ts` central override: reorder/rename/hide/group + branding (app name, logo, default theme). +- [ ] Wire branding into the app shell. +- [ ] Tests: discovery, routing, param matching, permission gate, nav merge + filter. + +## 3. Ory stack — compose + config +- [ ] `postgres` service (pinned tag); separate DB/schema per Kratos/Keto/Hydra. +- [ ] `kratos` service (pinned) + `migrate`; identity schema (traits: email, name). +- [ ] Kratos self-service flows (login, registration, recovery, verification, settings) → return URLs at our themed pages. +- [ ] Kratos OIDC/SSO providers (Google/Microsoft/SAML) config (placeholders + secrets via env). +- [ ] Kratos session settings (cookie name, lifespan, sliding refresh). +- [ ] Kratos tokenizer template `plainpages`: claims `{ sub, email, roles }`, `ttl ≈ 10m`, `jwks_url` signer, `claims_mapper_url` (Jsonnet reading `metadata_admin.roles`). +- [ ] Generate + mount the JWT signing JWKS; document key rotation. +- [ ] `keto` service (pinned) + `migrate`; namespaces in OPL (`role`, `group`, resource permissions). +- [ ] `hydra` service (pinned) + `migrate`; issuer + login/consent URLs → our app. +- [ ] Split dev (`compose.override.yml`) vs prod (`compose.yml`) wiring; health checks + `depends_on` ordering. + +## 4. Auth — identity, session JWT, guards +- [ ] Kratos public client (fetch): init/get/submit flows, `whoami`, `whoami?tokenize_as=plainpages`. +- [ ] Kratos admin client (fetch): identity CRUD + `metadata_admin` update. +- [ ] Keto client (fetch): `check`, list/expand relations, write/delete tuples. +- [ ] Render Kratos flows: fetch flow → render fields against our themed pages → POST to `flow.ui.action` (Kratos handles its CSRF), map field errors/messages. +- [ ] SSO buttons → Kratos OIDC flows. +- [ ] Login completion: read roles from Keto → write `metadata_admin` projection → tokenize → set JWT cookie. +- [ ] JWT middleware: verify signature via cached JWKS, validate `exp`/`iss`/`aud` (+clock skew), build context (user, roles). +- [ ] JWKS fetch + cache + rotation handling. +- [ ] Guards: `requireSession` (validate JWT), `can(role)` (claim, in-process), `check(relation, object)` (live Keto). +- [ ] Session re-mint on TTL expiry (re-read roles from Keto). +- [ ] Logout: revoke Kratos session + clear cookie. +- [ ] Secure cookie flags; CSRF for our own POST forms. +- [ ] Tests: JWT verify (valid/expired/bad-sig), guard behavior, login→projection→tokenize flow (Ory mocked). + +## 5. Built-in admin screens (writes go only to Keto/Kratos) +- [ ] Users: list (Kratos identities) with filter/sort/pagination; create/edit/deactivate/delete; trigger recovery. +- [ ] Groups: Keto subject sets — list/create/delete + membership management. +- [ ] Roles & permissions: Keto relations — assign roles to users/groups; "effective access" view via Keto expand. +- [ ] Wire into the menu (admin section, permission-gated). +- [ ] Tests: CRUD flows (Ory mocked) + permission gating. + +## 6. Hydra — OAuth2/OIDC provider (can ship after the rest) +- [ ] Login-challenge handler: authenticate via Kratos session, accept/reject. +- [ ] Consent-challenge handler: show / auto-accept first-party, grant scopes, accept/reject. +- [ ] OAuth2 client registration (admin UI or CLI). +- [ ] Tests: authorization-code login+consent happy path; token + refresh. + +## 7. Example plugin (reference) +- [ ] Reference plugin (e.g. people directory or scheduling): list page fetching upstream data, a form that forwards writes upstream, permission-gated nav. +- [ ] Verify the full plugin contract end-to-end against the README. + +## 8. Testing & CI +- [ ] node --test units across helpers / router / nav / auth (tests-first throughout). +- [ ] **Playwright full E2E**: login (password + mocked SSO), menu filtering by role, users/groups/permissions CRUD, a plugin page, logout. +- [ ] E2E harness: bring up the full compose stack, seed Keto roles + a test identity, **tear down after**. +- [ ] Typecheck + tests green in Docker (`docker compose run --rm web …`). + +## 9. Production, security, ops +- [ ] `compose.yml` prod: Ory + Postgres, secrets via env, no source mount. +- [ ] Security headers; secure/HttpOnly/SameSite cookies; CSRF; clock-skew tolerance. +- [ ] Optional revocation denylist for instant role/session revoke. +- [ ] Structured logging / basic observability. use @larvit/log for OTLP compability - but add subtasks and stuff for supporting incoming trace id etc from a reverse-proxy etc. +- [ ] JWT signing-key rotation runbook. +- [ ] Refresh README `Layout` + drop `_(planned)_` markers as pieces land. +