Refine product description and roadmap per product-owner review; add lucide-static

This commit is contained in:
2026-06-14 18:12:32 +02:00
parent 638815af2e
commit d021fd701e
5 changed files with 102 additions and 16 deletions

View File

@@ -6,8 +6,9 @@ commands and layout.
## Project priorities (do not erode) ## Project priorities (do not erode)
1. **Simplicity** — prefer the smallest, most readable solution. 1. **Simplicity** — prefer the smallest, most readable solution.
2. **Few dependencies**the only npm runtime dep is `ejs`. Prefer the Node 2. **Few dependencies**runtime deps stay minimal (today `ejs` + `lucide-static`).
standard library; justify any new dependency; do not add frameworks. The app is 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** **stateless — no database**. Auth/identity/OAuth are **Ory sidecar services**
(Kratos/Keto/Hydra, backed by Postgres), reached over their REST APIs with (Kratos/Keto/Hydra, backed by Postgres), reached over their REST APIs with
built-in `fetch` — no SDK dependency. New capabilities ship as **plugin built-in `fetch` — no SDK dependency. New capabilities ship as **plugin

View File

@@ -15,6 +15,34 @@ TypeScript, no build step, Docker-only.** Heavy lifting that *isn't* simple to d
well — identity, sessions, SSO, OAuth2, permission checks — is delegated to **Ory** well — identity, sessions, SSO, OAuth2, permission checks — is delegated to **Ory**
sidecar services rather than reinvented. sidecar services rather than reinvented.
"Simple" here is about the **whole architecture staying simple** — not just at the
start, but after you've dropped in 240 plugins and run it hard in production. The
shape doesn't change as it grows: every plugin is the same self-contained folder,
the hot path is the same I/O-free JWT check, and there's no app database to scale
or migrate.
## Who this is for
**Experienced developers building back-office, admin, and dashboard products**
for their own use or for a client. You know your way around HTTP, Docker, and an
identity provider, and you'd rather assemble pages from solid building blocks than
fight a framework or hand-roll auth for the tenth time. Plainpages hands you the
boring-but-hard parts (auth, authz, menu, design system, plugin host) and stays out
of the way of your domain logic. It does not try to be a no-code tool or hide its
moving parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--permissions-planned))
reads as an obvious consequence rather than a surprise, you're the audience.
## Project goals
Beyond the priorities above, Plainpages deliberately targets **low-end systems, odd
hardware, and low-bandwidth environments** — a tablet on a factory floor, an old
thin client at a reception desk, a remote site on a flaky link. That's *why* the
baseline is standards-compliant, boring **HTML + CSS** with zero JavaScript: it
loads fast, degrades gracefully, and works on whatever browser the site already
has. Where a modern **CSS** feature removes the need to ship JavaScript (theme
switching, popovers, disclosure), we'll happily use it — the trade we avoid is
shipping a client-side runtime, not using the platform.
> **Status.** This README describes the target architecture (the project's scope). > **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 > 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/` > with static serving — plus the **design foundation** in `html-css-foundation/`
@@ -22,6 +50,17 @@ sidecar services rather than reinvented.
> integration (Kratos/Keto/Hydra + their Postgres) are the roadmap below, not yet > integration (Kratos/Keto/Hydra + their Postgres) are the roadmap below, not yet
> implemented. Sections marked _(planned)_ are not built yet. > implemented. Sections marked _(planned)_ are not built yet.
## The MVP — "clone, one command, hack on a plugin" _(planned)_
The bar for a first usable release: **clone the repo, run one command, and you have
a working register/login and can start building your own plugin** — no manual key
generation, no hand-edited Ory config, no separate database setup. One command
brings up the whole stack (web + Ory + Postgres), generates signing keys and seeds
an admin on first boot, and drops you at a login screen. From there you copy the
example plugin folder and you're writing your own page. That moment — clone → one
command → login → your plugin renders — *is* the MVP. SSO and the OAuth2-provider
role (Hydra) come after; they aren't required to start.
## Architecture ## Architecture
Plainpages runs as a small set of containers, orchestrated by Docker Compose: Plainpages runs as a small set of containers, orchestrated by Docker Compose:
@@ -43,9 +82,11 @@ acts as an OAuth2 **login & consent provider** for other apps. It reaches the Or
services over their **REST APIs using Node's built-in `fetch`** — no SDK services over their **REST APIs using Node's built-in `fetch`** — no SDK
dependency. See [Auth, sessions & permissions](#auth-sessions--permissions-planned). dependency. See [Auth, sessions & permissions](#auth-sessions--permissions-planned).
So the `web` app is **stateless** and its npm footprint stays at a single runtime So the `web` app is **stateless** and its npm footprint stays tiny — a small,
dependency — **`ejs`**. Auth, sessions, SSO, and OAuth2 add *services*, not npm pinned set of runtime deps (today **`ejs`** for templating and **`lucide-static`**
packages; data lives upstream (see [Stateless — no application database](#stateless--no-application-database)). for icons), grown only with justification and never a framework. Auth, sessions,
SSO, and OAuth2 add *services*, not npm packages; data lives upstream (see
[Stateless — no application database](#stateless--no-application-database)).
## What's included vs. what you add ## What's included vs. what you add
@@ -163,8 +204,13 @@ the work is extracting it into reusable EJS partials + TS helpers:
## Interactivity: zero-JS spine, opt-in enhancement ## Interactivity: zero-JS spine, opt-in enhancement
The core and all building blocks **work with zero JavaScript** — menus, theme 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 switching, and filtering are pure CSS + GET forms (server-side). This is the robust
robust default for back-office and industrial use. default for back-office and industrial use, and on the [low-end, low-bandwidth
targets](#project-goals) we care about it's usually *faster*: a full round-trip
that returns a small, already-rendered HTML page beats a client-side runtime that
must boot, fetch JSON, and re-render before the user sees anything. List state
(`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable,
shareable, and reproducible — the URL is the only state the UI keeps.
Plugins that genuinely need it — live dashboards, bulk actions, client-side Plugins that genuinely need it — live dashboards, bulk actions, client-side
validation — may **opt into progressive enhancement** (htmx, Alpine, or vanilla validation — may **opt into progressive enhancement** (htmx, Alpine, or vanilla
@@ -179,8 +225,12 @@ fine-grained, must-be-fresh check.
### Login → session JWT (the Kratos session tokenizer) ### Login → session JWT (the Kratos session tokenizer)
The themed sign-in / register / reset / SSO screens drive Kratos self-service The themed sign-in / register / reset / SSO screens drive Kratos self-service
flows. On success, instead of keeping the opaque Kratos cookie and calling flows. **SSO is entirely optional and self-configuring:** each provider's button
`whoami` on every request, the app **exchanges the session for a signed JWT once** renders only when its credentials are present, and if no provider is configured the
SSO section disappears altogether — leaving plain password login. A developer never
has to touch SSO to get started. 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 via the Kratos **session tokenizer**`whoami` with a `tokenize_as` template — and
stores that JWT as the session cookie. stores that JWT as the session cookie.
@@ -203,7 +253,8 @@ stores that JWT as the session cookie.
(e.g. `role:admin#member@user:alice`); the admin screens write them *only* to Keto. (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 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 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 projection** — a read-only copy of those roles written onto the identity's
`metadata_admin` so the tokenizer can see them — which the tokenizer template then
maps into the JWT `roles` claim. That projection is a per-login cache, authoritative 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. nowhere; nothing edits it by hand, and a stale one self-heals on the next login.
@@ -212,6 +263,23 @@ is cached, so even signature verification hits the network only on key rotation.
app stays stateless; "stay signed in" = re-mint the JWT on a short TTL, the one app stays stateless; "stay signed in" = re-mint the JWT on a short TTL, the one
moment authz is recomputed from Keto. moment authz is recomputed from Keto.
#### Two trade-offs — both deliberate
This design buys an I/O-free hot path that scales to **tens of thousands of
concurrent users** on modest hardware. In return:
- **Role changes lag by up to one TTL (~10m).** Because gating reads the JWT, not
Keto, a granted or revoked role only takes effect when the token is next minted
(re-login or TTL refresh). For an admin tool this is intentional: the alternative
is a Keto call on every request, which we explicitly traded away. If a deployment
needs instant revoke, the optional revocation denylist (roadmap) closes the gap
for the security-critical cases without putting Keto back on the hot path.
- **Ory is on the critical path for sign-in.** If Kratos is down, no one can log
in; if it stays down past the TTL, existing sessions can't refresh and the UI
goes dark. This is the direct consequence of being stateless and delegating
identity — there is no local fallback, by design. Run Ory with the same
availability you'd give any auth provider.
### Three tiers of "may I?" ### Three tiers of "may I?"
``` ```

9
package-lock.json generated
View File

@@ -8,7 +8,8 @@
"name": "plainpages", "name": "plainpages",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"ejs": "3.1.10" "ejs": "3.1.10",
"lucide-static": "1.18.0"
}, },
"devDependencies": { "devDependencies": {
"@types/ejs": "3.1.5", "@types/ejs": "3.1.5",
@@ -98,6 +99,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/lucide-static": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.18.0.tgz",
"integrity": "sha512-0WRXLQnjbte5SXuzom6yfeGlVSFsEsC9rzxn66DZN0pXows3+N34CQHy3BHI1qA3uH7u/SUzx8LQhjeAnxd8JQ==",
"license": "ISC"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "5.1.9", "version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",

View File

@@ -13,7 +13,8 @@
"test": "node --test \"src/**/*.test.ts\"" "test": "node --test \"src/**/*.test.ts\""
}, },
"dependencies": { "dependencies": {
"ejs": "3.1.10" "ejs": "3.1.10",
"lucide-static": "1.18.0"
}, },
"devDependencies": { "devDependencies": {
"@types/ejs": "3.1.5", "@types/ejs": "3.1.5",

17
todo.md
View File

@@ -5,6 +5,12 @@ Conventions: **write tests first** (node --test for units, Playwright for E2E),
tear down test containers after runs, keep deps minimal, pin all versions, run tear down test containers after runs, keep deps minimal, pin all versions, run
everything via Docker. everything via Docker.
> **North-star / MVP.** Done = a developer can **clone, run one command, get a
> working register/login, and start hacking on their own plugin** — no manual key
> generation, no hand-edited Ory config, no DB setup. Everything below serves that;
> the one-command bootstrap (§3) and the example plugin (§7) are what make the MVP
> real. Hydra/SSO are explicitly *post-MVP*.
## 0. Housekeeping / primitives ## 0. Housekeeping / primitives
- [ ] Decide JWT verify approach: `node:crypto` (RS256/ES256 via `createPublicKey({format:"jwk"})`) vs add `jose` — justify if adding. - [ ] 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). - [ ] Cookie helpers: parse `Cookie` header, build `Set-Cookie` (HttpOnly, Secure, SameSite).
@@ -14,7 +20,7 @@ everything via Docker.
## 1. Building blocks — extract from `html-css-foundation/` (no Ory needed; render mock data) ## 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`. - [ ] Move `styles.css` + `auth.css` into `public/css/`; reconcile with existing `style.css`.
- [ ] Lucide icon sprite `views/partials/icons.ejs`. - [ ] Lucide icon sprite from `lucide-static` (dep added) → `views/partials/icons.ejs`; serve/inline only the icons used.
- [ ] App-shell partial (sidebar + topbar + content slot). - [ ] App-shell partial (sidebar + topbar + content slot).
- [ ] Nav-tree partial — recursive, header/leaf × clickable/static, counts, `aria-current`. - [ ] Nav-tree partial — recursive, header/leaf × clickable/static, counts, `aria-current`.
- [ ] Filter-bar partial — GET form (search, segmented, selects, chips, daterange, applied pills). - [ ] Filter-bar partial — GET form (search, segmented, selects, chips, daterange, applied pills).
@@ -29,7 +35,7 @@ everything via Docker.
- [ ] Replace placeholder `index` with the app-shell dashboard. - [ ] Replace placeholder `index` with the app-shell dashboard.
## 2. Plugin host ## 2. Plugin host
- [ ] `definePlugin()` + manifest types: `id`, `basePath`, `nav[]`, `routes[] {method, path, permission?, handler}`. - [ ] **Specify the plugin contract** (big job, do first — it's the product's main API surface). Write it down as the authoritative reference: the full manifest shape; the `RequestContext` handed to handlers and what's guaranteed stable; **contract versioning** (a `apiVersion`/`engines`-style field so a plugin declares the host it targets, and the host refuses or warns on mismatch); **conflict rules** (two plugins claiming the same `basePath`, nav slot, or `permission` name → defined, loud resolution, not last-write-wins); the **local dev/test story** (how an author runs + tests one plugin in isolation against the host). Audience is experienced devs: optimise for a powerful, predictable, clearly-documented API. Crash-isolation (a bad plugin can't take down the host) is a *nice-to-have*, not a blocker — fail loud at boot/discovery over sandboxing at runtime.
- [ ] Discovery: scan `plugins/`, import each `plugin.ts` default export, validate. - [ ] 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. - [ ] Router: match method+path under `basePath`, resolve path params, run permission gate, call handler with context.
- [ ] Per-plugin view resolver (`plugins/<id>/views/*.ejs`). - [ ] Per-plugin view resolver (`plugins/<id>/views/*.ejs`).
@@ -42,20 +48,23 @@ everything via Docker.
- [ ] `postgres` service (pinned tag); separate DB/schema per Kratos/Keto/Hydra. - [ ] `postgres` service (pinned tag); separate DB/schema per Kratos/Keto/Hydra.
- [ ] `kratos` service (pinned) + `migrate`; identity schema (traits: email, name). - [ ] `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 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 OIDC/SSO providers (Google/Microsoft/SAML) config (secrets via env). **None enabled by default** — a clean clone runs password-only; a provider activates purely by supplying its env creds.
- [ ] Kratos session settings (cookie name, lifespan, sliding refresh). - [ ] 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`). - [ ] 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. - [ ] Generate + mount the JWT signing JWKS; document key rotation.
- [ ] `keto` service (pinned) + `migrate`; namespaces in OPL (`role`, `group`, resource permissions). - [ ] `keto` service (pinned) + `migrate`; namespaces in OPL (`role`, `group`, resource permissions).
- [ ] `hydra` service (pinned) + `migrate`; issuer + login/consent URLs → our app. - [ ] `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. - [ ] Split dev (`compose.override.yml`) vs prod (`compose.yml`) wiring; health checks + `depends_on` ordering.
- [ ] **One-command bootstrap** (the MVP bar): `docker compose up` brings up web + all Ory services + Postgres with *zero* manual prep. Commit working default Ory configs; auto-run migrations on first boot; auto-generate the JWKS signing key if absent; seed an admin identity + its Keto roles + a demo password (`admin`/`admin`) idempotently. Land an `OPL`/namespace bootstrap so Keto answers checks out of the box.
- [ ] First-run banner / log line printing the login URL + seeded admin creds, with a clear "change these before production" warning.
- [ ] Document the *only* things that can't be auto-generated: third-party **SSO provider** client id/secret (optional — password login works without them) and **production secrets** (real cookie/CSRF secret + signing key, supplied via env, replacing the dev throwaways). Everything else must work from a clean clone.
## 4. Auth — identity, session JWT, guards ## 4. Auth — identity, session JWT, guards
- [ ] Kratos public client (fetch): init/get/submit flows, `whoami`, `whoami?tokenize_as=plainpages`. - [ ] Kratos public client (fetch): init/get/submit flows, `whoami`, `whoami?tokenize_as=plainpages`.
- [ ] Kratos admin client (fetch): identity CRUD + `metadata_admin` update. - [ ] Kratos admin client (fetch): identity CRUD + `metadata_admin` update.
- [ ] Keto client (fetch): `check`, list/expand relations, write/delete tuples. - [ ] 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. - [ ] 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. - [ ] SSO buttons → Kratos OIDC flows. **Render per configured provider only**: derive the list from Kratos' enabled OIDC providers (no creds ⇒ no button); hide the whole SSO section when none are configured. No code change needed to add/remove a provider — config only.
- [ ] Login completion: read roles from Keto → write `metadata_admin` projection → tokenize → set JWT cookie. - [ ] 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). - [ ] JWT middleware: verify signature via cached JWKS, validate `exp`/`iss`/`aud` (+clock skew), build context (user, roles).
- [ ] JWKS fetch + cache + rotation handling. - [ ] JWKS fetch + cache + rotation handling.