lilleman 1c324b18e3 Built-in OAuth2 client-registration admin screen (todo §6); /admin/clients lists/registers/deletes the Hydra OAuth2 clients other apps log in through us with. New src/admin-clients.ts (pure builders + handleAdminClients, mirroring the §5 Users/Roles screens): list (search/paginate over one fetched Hydra page), register (GET form + POST), read-only detail, delete-confirm. src/hydra-admin.ts gains the client half of the admin API — createClient/listClients/getClient/deleteClient over /admin/clients (+ a nextPageToken Link parser like kratos-admin) and the registration fields on OAuth2Client. Register builds a standard authorization-code client (+ refresh_token), confidential (client_secret_basic) or public (PKCE/none), with an optional first-party auto-consent flag; Hydra returns the client_secret once, so the register POST renders the new client's detail page with the one-time secret directly (no PRG) and it is never re-shown (getClient carries no secret; the detail test asserts it). Writes go only to Hydra; gated admin-only (anon->/login, non-admin->403) + every mutation CSRF-guarded via requireAdmin/guardedForm like §5; a Hydra 4xx (bad redirect/scope) re-renders the form (400), a 5xx -> 500 (mirrors oauth-login.ts); :id via safeDecode (malformed->404). Wired into app.ts (/admin/clients, gated on the hydra client present) and the shared adminSection (Users.Groups.Roles.OAuth2 clients, i-globe) so it shows for admins and is invisible otherwise. New views (admin/clients, client-form, client-detail + partials/client-{form,detail}-body) reuse the shell/filter-bar/data-table/field blocks; one .detail-list CSS rule; README Layout/§6 updated. Tests-first: hydra-admin.test.ts (client CRUD contracts incl. Link pagination/404->null/204), admin-clients.test.ts (builder/validation/payload matrix), app.test.ts HTTP integration (gate/list/register-shows-secret-once/invalid+CSRF-reject/detail-hides-secret/delete + malformed-%->404). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one nit (dropped a dead URL.protocol check in validateClientInput). Boot-verified the client CRUD live against real Hydra v26.2.0 (create->201 w/ one-time secret -> list finds it -> get -> delete -> get null); torn down. typecheck + 274 units green.
2026-06-19 11:23:27 +02:00
Built-in OAuth2 consent-challenge handler (todo §6); /oauth2/consent grants scopes to a client logging in through us. New src/oauth-consent.ts (pure, sibling of oauth-login.ts): resolveConsentChallenge auto-accepts a first-party client (Hydra metadata.first_party===true) or a Hydra-skipped one, else returns a view to show the themed consent screen; acceptConsent re-reads the challenge so scopes/audience are never client-supplied; rejectConsent → access_denied. The grant carries an OIDC session.id_token with email/name projected from the Kratos identity (whoami traits, omitted when absent). src/hydra-admin.ts gains the consent half (get/accept/reject consent + types; login/consent URL builder folded into one reqUrl(kind,…) + shared put()). Wired in app.ts at GET|POST /oauth2/consent (gated on hydra+kratos): GET shows/auto-accepts (sets the CSRF cookie when fresh), POST is CSRF-guarded (same signed double-submit as /logout) and dispatches allow→accept / else→reject → 303 to Hydra; a stale/consumed challenge (Hydra 4xx) degrades to a recoverable 400, a real outage (5xx) → 500 (mirrors /oauth2/login). views/oauth-consent.ejs + partials/consent-body.ejs reuse the auth-card, listing the requested scopes (friendly labels for the standard OIDC ones) with Allow/Deny submit buttons. Tests-first: hydra-admin consent contracts + oauth-consent skip/first-party/third-party/audience/id_token/refetch/reject matrix + app HTTP integration (auto-accept / screen+CSRF cookie / allow+deny / forged-CSRF→403 / missing→400 / stale→400 / outage→500). Stability-reviewer run as a local PR: APPROVE, no Critical/High. Extended e2e/oauth-login.spec.ts to drive the whole authorization-code flow against real Hydra — login accept → follow login_verifier through Hydra → web's consent screen (third-party e2e-login, scopes listed) → Allow → consent_verifier → client callback with a real code (per-host cookie jars, Hydra resume URLs rebased onto the compose host). typecheck + 262 units + 8 visual + OAuth login+consent E2E green. OAuth2 client registration is the next §6 item.
2026-06-19 10:53:21 +02:00
Built-in OAuth2 client-registration admin screen (todo §6); /admin/clients lists/registers/deletes the Hydra OAuth2 clients other apps log in through us with. New src/admin-clients.ts (pure builders + handleAdminClients, mirroring the §5 Users/Roles screens): list (search/paginate over one fetched Hydra page), register (GET form + POST), read-only detail, delete-confirm. src/hydra-admin.ts gains the client half of the admin API — createClient/listClients/getClient/deleteClient over /admin/clients (+ a nextPageToken Link parser like kratos-admin) and the registration fields on OAuth2Client. Register builds a standard authorization-code client (+ refresh_token), confidential (client_secret_basic) or public (PKCE/none), with an optional first-party auto-consent flag; Hydra returns the client_secret once, so the register POST renders the new client's detail page with the one-time secret directly (no PRG) and it is never re-shown (getClient carries no secret; the detail test asserts it). Writes go only to Hydra; gated admin-only (anon->/login, non-admin->403) + every mutation CSRF-guarded via requireAdmin/guardedForm like §5; a Hydra 4xx (bad redirect/scope) re-renders the form (400), a 5xx -> 500 (mirrors oauth-login.ts); :id via safeDecode (malformed->404). Wired into app.ts (/admin/clients, gated on the hydra client present) and the shared adminSection (Users.Groups.Roles.OAuth2 clients, i-globe) so it shows for admins and is invisible otherwise. New views (admin/clients, client-form, client-detail + partials/client-{form,detail}-body) reuse the shell/filter-bar/data-table/field blocks; one .detail-list CSS rule; README Layout/§6 updated. Tests-first: hydra-admin.test.ts (client CRUD contracts incl. Link pagination/404->null/204), admin-clients.test.ts (builder/validation/payload matrix), app.test.ts HTTP integration (gate/list/register-shows-secret-once/invalid+CSRF-reject/detail-hides-secret/delete + malformed-%->404). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one nit (dropped a dead URL.protocol check in validateClientInput). Boot-verified the client CRUD live against real Hydra v26.2.0 (create->201 w/ one-time secret -> list finds it -> get -> delete -> get null); torn down. typecheck + 274 units green.
2026-06-19 11:23:27 +02:00
Built-in OAuth2 client-registration admin screen (todo §6); /admin/clients lists/registers/deletes the Hydra OAuth2 clients other apps log in through us with. New src/admin-clients.ts (pure builders + handleAdminClients, mirroring the §5 Users/Roles screens): list (search/paginate over one fetched Hydra page), register (GET form + POST), read-only detail, delete-confirm. src/hydra-admin.ts gains the client half of the admin API — createClient/listClients/getClient/deleteClient over /admin/clients (+ a nextPageToken Link parser like kratos-admin) and the registration fields on OAuth2Client. Register builds a standard authorization-code client (+ refresh_token), confidential (client_secret_basic) or public (PKCE/none), with an optional first-party auto-consent flag; Hydra returns the client_secret once, so the register POST renders the new client's detail page with the one-time secret directly (no PRG) and it is never re-shown (getClient carries no secret; the detail test asserts it). Writes go only to Hydra; gated admin-only (anon->/login, non-admin->403) + every mutation CSRF-guarded via requireAdmin/guardedForm like §5; a Hydra 4xx (bad redirect/scope) re-renders the form (400), a 5xx -> 500 (mirrors oauth-login.ts); :id via safeDecode (malformed->404). Wired into app.ts (/admin/clients, gated on the hydra client present) and the shared adminSection (Users.Groups.Roles.OAuth2 clients, i-globe) so it shows for admins and is invisible otherwise. New views (admin/clients, client-form, client-detail + partials/client-{form,detail}-body) reuse the shell/filter-bar/data-table/field blocks; one .detail-list CSS rule; README Layout/§6 updated. Tests-first: hydra-admin.test.ts (client CRUD contracts incl. Link pagination/404->null/204), admin-clients.test.ts (builder/validation/payload matrix), app.test.ts HTTP integration (gate/list/register-shows-secret-once/invalid+CSRF-reject/detail-hides-secret/delete + malformed-%->404). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one nit (dropped a dead URL.protocol check in validateClientInput). Boot-verified the client CRUD live against real Hydra v26.2.0 (create->201 w/ one-time secret -> list finds it -> get -> delete -> get null); torn down. typecheck + 274 units green.
2026-06-19 11:23:27 +02:00
Built-in OAuth2 client-registration admin screen (todo §6); /admin/clients lists/registers/deletes the Hydra OAuth2 clients other apps log in through us with. New src/admin-clients.ts (pure builders + handleAdminClients, mirroring the §5 Users/Roles screens): list (search/paginate over one fetched Hydra page), register (GET form + POST), read-only detail, delete-confirm. src/hydra-admin.ts gains the client half of the admin API — createClient/listClients/getClient/deleteClient over /admin/clients (+ a nextPageToken Link parser like kratos-admin) and the registration fields on OAuth2Client. Register builds a standard authorization-code client (+ refresh_token), confidential (client_secret_basic) or public (PKCE/none), with an optional first-party auto-consent flag; Hydra returns the client_secret once, so the register POST renders the new client's detail page with the one-time secret directly (no PRG) and it is never re-shown (getClient carries no secret; the detail test asserts it). Writes go only to Hydra; gated admin-only (anon->/login, non-admin->403) + every mutation CSRF-guarded via requireAdmin/guardedForm like §5; a Hydra 4xx (bad redirect/scope) re-renders the form (400), a 5xx -> 500 (mirrors oauth-login.ts); :id via safeDecode (malformed->404). Wired into app.ts (/admin/clients, gated on the hydra client present) and the shared adminSection (Users.Groups.Roles.OAuth2 clients, i-globe) so it shows for admins and is invisible otherwise. New views (admin/clients, client-form, client-detail + partials/client-{form,detail}-body) reuse the shell/filter-bar/data-table/field blocks; one .detail-list CSS rule; README Layout/§6 updated. Tests-first: hydra-admin.test.ts (client CRUD contracts incl. Link pagination/404->null/204), admin-clients.test.ts (builder/validation/payload matrix), app.test.ts HTTP integration (gate/list/register-shows-secret-once/invalid+CSRF-reject/detail-hides-secret/delete + malformed-%->404). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one nit (dropped a dead URL.protocol check in validateClientInput). Boot-verified the client CRUD live against real Hydra v26.2.0 (create->201 w/ one-time secret -> list finds it -> get -> delete -> get null); torn down. typecheck + 274 units green.
2026-06-19 11:23:27 +02:00
Built-in OAuth2 login-challenge handler (todo §6); /oauth2/login resolves a Hydra login challenge via the Kratos session — skip→accept(subject), live session→accept(identity id), no session→bounce to /login?return_to back here so Kratos lands on the challenge once signed in. New src/hydra-admin.ts (fetch client: get/accept/reject login request + HydraError, mirrors the kratos/keto clients) + src/oauth-login.ts (pure resolveLoginChallenge); wired in app.ts (the absolute return URL derives from the request Host + the SECURE_COOKIES scheme — a spoofed Host can't escape, Kratos validates return_to against its allow-list; /login now bakes return_to into the flow init), config.hydraAdminUrl (default http://hydra:4445), server builds the client, compose web now gates on hydra healthy (the app consumes it). A stale/invalid/consumed challenge (Hydra 4xx — back button, slow login) degrades to a recoverable 400, not a 500; a genuine Hydra 5xx outage still surfaces as 500. Tests-first: hydra-admin/oauth-login units + app/config/compose HTTP integration + full-stack e2e/oauth-login.spec.ts (compose.e2e-oauth.yml — registers an OAuth2 client, starts an auth flow, asserts the unauthenticated bounce and the authenticated accept; boot-verified then torn down). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one warning (4xx→400 degrade). Deferred §9: document that prod allowed_return_urls entries must be exact origins with a trailing /. typecheck + 253 units + 8 visual + oauth-login E2E green. Consent handler + client registration are the next §6 items.
2026-06-18 21:45:24 +02:00
Built-in OAuth2 login-challenge handler (todo §6); /oauth2/login resolves a Hydra login challenge via the Kratos session — skip→accept(subject), live session→accept(identity id), no session→bounce to /login?return_to back here so Kratos lands on the challenge once signed in. New src/hydra-admin.ts (fetch client: get/accept/reject login request + HydraError, mirrors the kratos/keto clients) + src/oauth-login.ts (pure resolveLoginChallenge); wired in app.ts (the absolute return URL derives from the request Host + the SECURE_COOKIES scheme — a spoofed Host can't escape, Kratos validates return_to against its allow-list; /login now bakes return_to into the flow init), config.hydraAdminUrl (default http://hydra:4445), server builds the client, compose web now gates on hydra healthy (the app consumes it). A stale/invalid/consumed challenge (Hydra 4xx — back button, slow login) degrades to a recoverable 400, not a 500; a genuine Hydra 5xx outage still surfaces as 500. Tests-first: hydra-admin/oauth-login units + app/config/compose HTTP integration + full-stack e2e/oauth-login.spec.ts (compose.e2e-oauth.yml — registers an OAuth2 client, starts an auth flow, asserts the unauthenticated bounce and the authenticated accept; boot-verified then torn down). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one warning (4xx→400 degrade). Deferred §9: document that prod allowed_return_urls entries must be exact origins with a trailing /. typecheck + 253 units + 8 visual + oauth-login E2E green. Consent handler + client registration are the next §6 items.
2026-06-18 21:45:24 +02:00
Built-in OAuth2 client-registration admin screen (todo §6); /admin/clients lists/registers/deletes the Hydra OAuth2 clients other apps log in through us with. New src/admin-clients.ts (pure builders + handleAdminClients, mirroring the §5 Users/Roles screens): list (search/paginate over one fetched Hydra page), register (GET form + POST), read-only detail, delete-confirm. src/hydra-admin.ts gains the client half of the admin API — createClient/listClients/getClient/deleteClient over /admin/clients (+ a nextPageToken Link parser like kratos-admin) and the registration fields on OAuth2Client. Register builds a standard authorization-code client (+ refresh_token), confidential (client_secret_basic) or public (PKCE/none), with an optional first-party auto-consent flag; Hydra returns the client_secret once, so the register POST renders the new client's detail page with the one-time secret directly (no PRG) and it is never re-shown (getClient carries no secret; the detail test asserts it). Writes go only to Hydra; gated admin-only (anon->/login, non-admin->403) + every mutation CSRF-guarded via requireAdmin/guardedForm like §5; a Hydra 4xx (bad redirect/scope) re-renders the form (400), a 5xx -> 500 (mirrors oauth-login.ts); :id via safeDecode (malformed->404). Wired into app.ts (/admin/clients, gated on the hydra client present) and the shared adminSection (Users.Groups.Roles.OAuth2 clients, i-globe) so it shows for admins and is invisible otherwise. New views (admin/clients, client-form, client-detail + partials/client-{form,detail}-body) reuse the shell/filter-bar/data-table/field blocks; one .detail-list CSS rule; README Layout/§6 updated. Tests-first: hydra-admin.test.ts (client CRUD contracts incl. Link pagination/404->null/204), admin-clients.test.ts (builder/validation/payload matrix), app.test.ts HTTP integration (gate/list/register-shows-secret-once/invalid+CSRF-reject/detail-hides-secret/delete + malformed-%->404). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one nit (dropped a dead URL.protocol check in validateClientInput). Boot-verified the client CRUD live against real Hydra v26.2.0 (create->201 w/ one-time secret -> list finds it -> get -> delete -> get null); torn down. typecheck + 274 units green.
2026-06-19 11:23:27 +02:00
Built-in OAuth2 client-registration admin screen (todo §6); /admin/clients lists/registers/deletes the Hydra OAuth2 clients other apps log in through us with. New src/admin-clients.ts (pure builders + handleAdminClients, mirroring the §5 Users/Roles screens): list (search/paginate over one fetched Hydra page), register (GET form + POST), read-only detail, delete-confirm. src/hydra-admin.ts gains the client half of the admin API — createClient/listClients/getClient/deleteClient over /admin/clients (+ a nextPageToken Link parser like kratos-admin) and the registration fields on OAuth2Client. Register builds a standard authorization-code client (+ refresh_token), confidential (client_secret_basic) or public (PKCE/none), with an optional first-party auto-consent flag; Hydra returns the client_secret once, so the register POST renders the new client's detail page with the one-time secret directly (no PRG) and it is never re-shown (getClient carries no secret; the detail test asserts it). Writes go only to Hydra; gated admin-only (anon->/login, non-admin->403) + every mutation CSRF-guarded via requireAdmin/guardedForm like §5; a Hydra 4xx (bad redirect/scope) re-renders the form (400), a 5xx -> 500 (mirrors oauth-login.ts); :id via safeDecode (malformed->404). Wired into app.ts (/admin/clients, gated on the hydra client present) and the shared adminSection (Users.Groups.Roles.OAuth2 clients, i-globe) so it shows for admins and is invisible otherwise. New views (admin/clients, client-form, client-detail + partials/client-{form,detail}-body) reuse the shell/filter-bar/data-table/field blocks; one .detail-list CSS rule; README Layout/§6 updated. Tests-first: hydra-admin.test.ts (client CRUD contracts incl. Link pagination/404->null/204), admin-clients.test.ts (builder/validation/payload matrix), app.test.ts HTTP integration (gate/list/register-shows-secret-once/invalid+CSRF-reject/detail-hides-secret/delete + malformed-%->404). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one nit (dropped a dead URL.protocol check in validateClientInput). Boot-verified the client CRUD live against real Hydra v26.2.0 (create->201 w/ one-time secret -> list finds it -> get -> delete -> get null); torn down. typecheck + 274 units green.
2026-06-19 11:23:27 +02:00

Plainpages

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.

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.

Priorities (unchanged from day one): simplicity, few dependencies, strict TypeScript, no build step, Docker-only, environment-agnostic (no NODE_ENV — every behaviour is an explicit config toggle). Heavy lifting that isn't simple to do well — identity, sessions, SSO, OAuth2, permission checks — is delegated to Ory sidecar services rather than reinvented.

"Simple" 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 HTTP, Docker, and identity providers, and you'd rather assemble pages from building blocks than fight a framework or hand-roll auth for the tenth time. Plainpages hands you the boring-but-hard parts (auth, authz, menu, design system, plugin host) and stays out of your domain logic. It's not a no-code tool and doesn't hide its moving parts: if "Ory is down ⇒ no logins" (see Auth) reads as obvious rather than a surprise, you're the audience.

Project goals

Plainpages deliberately targets low-end systems, odd hardware, and low-bandwidth environments — a tablet on a factory floor, an old thin client at a reception desk, a remote site on a flaky link. That's why the baseline is boring, standards-compliant HTML + CSS with zero JavaScript: it loads fast, degrades gracefully, and works on whatever browser is already there. Where a modern CSS feature removes the need for JavaScript (theme switching, popovers, disclosure) we use it — the trade we avoid is shipping a client-side runtime, not using the platform. That standards-first stance also makes semantic, accessible markup a priority: real landmarks, one <h1> per page, lists and tables with proper headers, a skip link, and ARIA (aria-current/aria-sort) only where the platform leaves a gap (see AGENTS.md).

Status. This README describes the target architecture. Built today (see todo.md): the Node 24 + EJS server, the zero-JS design system (app shell, nav tree, data table, filters, pagination, forms — extracted from html-css-foundation/), the plugin host (discovery, router, per-plugin views + static, the config/menu.ts override + branding), and the Ory stack wiring — Postgres, Kratos (+ session→JWT tokenizer) and Keto (authorization, OPL namespaces) and Hydra (OAuth2 provider: issuer + login/consent URLs). The auth wiring that consumes these — and Hydra's login/consent handlers — are the roadmap; 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, run one command, get a working register/login, and start building your own plugin — no manual key generation, no hand-edited Ory config, no separate database. That command brings up the whole stack (web + Ory + Postgres), generates signing keys, seeds an admin on first boot, and drops you at a login screen; from there you copy the example plugin folder and write your own page. SSO and the OAuth2-provider role (Hydra) come after — not required to start.

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.

So the web app is stateless and its npm footprint stays tiny — a small, pinned set of runtime deps (today ejs for templating and lucide-static 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).

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

  • Docker
  • Docker Compose

That's it. Do not install or run Node/npm on the host — use the commands below.

Development

docker compose up            # http://localhost:3000, live reload via `node --watch`

docker compose up brings up the full stack — web + Postgres + Kratos/Keto/Hydra — merging compose.override.yml, which mounts the source and restarts the server on change. A one-shot bootstrap service then seeds first-boot state with zero manual prep — it generates the JWT signing key if absent, creates a demo admin (admin@plainpages.local / admin) in Kratos, and grants it the admin role in Keto so permission checks resolve out of the box; it is idempotent, so every up re-runs it safely. It finishes by printing a banner with the login URL and seeded credentials. Change the demo admin before production. The web app waits for Kratos + Keto to be healthy and the bootstrap to finish before starting (each Ory service has a readiness healthcheck). Dev publishes the host-facing Ory ports — Kratos public 4433 (the browser POSTs self-service flows there) and Hydra public 4444; prod (docker compose -f compose.yml up) keeps them internal. Kratos recovery/verification emails are caught by mailpit in dev — read the codes at http://localhost:8025. To work on your own plugin, see Where plugins live.

Configuration

Read from the environment once at boot (src/config.ts) and validated there — a bad URL, an out-of-range PORT, a non-boolean toggle, or a missing/throwaway enforced secret fails loud before the server starts. A clean clone needs none of these; every value defaults to the dev stack.

The app is environment-agnostic: there is no NODE_ENV. Behaviour that used to flip on "production" is now its own explicit toggle, so a deployment turns on exactly what it wants. compose.yml (base) sets the hardened toggles; compose.override.yml (dev, auto-merged by docker compose up) turns them back off for live editing.

Var Default Notes
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, the two secrets must be supplied and differ from the dev throwaways
KRATOS_PUBLIC_URL / KRATOS_ADMIN_URL http://kratos:4433 / :4434 identity (self-service / admin)
KETO_READ_URL / KETO_WRITE_URL http://keto:4466 / :4467 permission check / write
JWKS_URL file://…/tokenizer/jwks.json the Kratos tokenizer signing key; verifies the session JWT (§4)
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)
COOKIE_SECRET / CSRF_SECRET dev throwaways enforced by REQUIRE_SECURE_SECRETS

What you must supply (the only manual prep)

A clean clone needs none of the above — docker compose up brings up the whole stack with dev-throwaway secrets, an auto-generated signing key, and a seeded admin (see Development). Exactly two things can't be auto-generated, and both are production-only — neither blocks a clean clone:

  1. Production secrets — replace the committed dev throwaways: COOKIE_SECRET and CSRF_SECRET (env), plus the JWT signing key (mount a real jwks.json or set …_JWKS_URL — see JWT signing key & rotation). Set REQUIRE_SECURE_SECRETS=true and the app refuses to boot until the two secrets are supplied and differ from the throwaways.
  2. SSO provider client id/secretoptional; password login works without them. Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (see Social sign-in (SSO)).

Everything else is generated or seeded on first boot — Ory migrations, the dev signing key, the demo admin identity and its Keto roles, the Keto OPL model — so there is nothing else to hand-configure.

Social sign-in (SSO)

Off by default — a clean clone is password-only. Kratos activates a provider purely from the environment (no code, no rebuild): set SELFSERVICE_METHODS_OIDC_ENABLED=true and SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS to a JSON array of providers (google, microsoft, …), each carrying its client_id/client_secret and referencing the committed claims mapper ory/kratos/oidc/claims.jsonnet. The themed sign-in/register pages derive one button per provider from the live flow's oidc nodes, so no creds ⇒ no provider ⇒ no button, and the whole SSO section disappears when none are configured — no code change to add or remove one. Open-source Kratos has no native SAML — front it with an OIDC bridge (Ory Polis) and register that bridge as a generic OIDC provider the same way.

JWT signing key & rotation

The session tokenizer (§3) signs each session→JWT with an ES256 key at ory/kratos/tokenizer/jwks.json. The committed one is a dev throwaway (like the cookie/cipher secrets in kratos.yml) — a clean clone works; never run it in production. (Re)generate with the bundled generator:

docker compose run --rm -T --no-deps web node src/gen-jwks.ts > ory/kratos/tokenizer/jwks.json

Production: mount a real key over that path, or set SESSION_WHOAMI_TOKENIZER_TEMPLATES_PLAINPAGES_JWKS_URL=base64://<the JWKS JSON, base64>.

Rotation (zero downtime): Kratos signs with the first key in the set; the app selects the verify key by kid (§4). So prepend a freshly generated key, keep the old one for ~one token TTL (10m) so in-flight JWTs still verify, then drop it.

Type check & tests

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)

--no-deps keeps these off the Ory stack — units need no Postgres/Kratos/Keto, and web otherwise drags up its depends_on services.

End-to-end (Playwright)

E2E runs in the official Playwright image (browsers preinstalled) against the live web service — no Node/browsers on the host. There are two suites:

Visual + design system (visual.spec.ts) — Ory-free (mock-data dashboard), so it stays fast. It screenshots the live pages and the html-css-foundation mockups, then asserts the live DOM computes the same design-system styles as the reference (so a styling regression fails the build, independent of the row data).

docker compose -f compose.yml -f compose.e2e.yml run --build --rm e2e   # run the suite
docker compose -f compose.yml -f compose.e2e.yml down -v                 # tear down after

Auth — token timeout + refresh (auth-refresh.spec.ts) — the full-stack counterpart: it boots the real Ory stack (Postgres + Kratos + Keto + bootstrap), shortens the session→JWT TTL to 8s (ory/kratos/e2e.yml) and sets JWT_CLOCK_SKEW_SEC=0, then logs in the seeded admin and proves the §4 "stay signed in" hot path: the lapsed JWT is silently re-minted from the live Kratos session (roles re-read from Keto), and once that session is revoked the stale cookie is cleared.

docker compose -f compose.yml -f compose.e2e-auth.yml run --build --rm e2e   # run the suite
docker compose -f compose.yml -f compose.e2e-auth.yml down -v                 # tear down after

OAuth2 login + consent (oauth-login.spec.ts) — another app logs in through us: it boots the real stack (incl. Hydra), registers an OAuth2 client, starts an authorization flow, and drives the §6 handlers end-to-end — /oauth2/login bounces an unauthenticated user to the themed login and accepts the challenge once a Kratos session exists; /oauth2/consent then shows the consent screen for the third-party client and Allow drives Hydra to issue the authorization code.

docker compose -f compose.yml -f compose.e2e-oauth.yml run --build --rm e2e   # run the suite
docker compose -f compose.yml -f compose.e2e-oauth.yml down -v                 # tear down after

--build rebuilds the runner so spec edits are always picked up (the image bakes in e2e/).

Screenshots + an HTML report land in e2e/artifacts/ (git-ignored). Every user-facing flow is covered end-to-end; tests are independent and run fully in parallel for speed (AGENTS.md §6) — keep new tests side-effect-free so the suite stays fast.

Building a plugin

A plugin is a folder under plugins/. The host discovers it at boot — no registration step, no central wiring. The full, authoritative API surface — manifest shape, handler/RequestContext contract, versioning, conflict rules, hooks, and the dev/test story — is docs/plugin-contract.md (src/plugin.ts holds the types). The sketch below is the shape.

plugins/scheduling/      # folder name = the plugin id; mounted at /scheduling
  plugin.ts              # default export: the typed manifest (see below)
  views/                 # EJS templates for this plugin's pages
    shifts.ejs
  public/                # CSS / assets, served under /public/scheduling/
    scheduling.css

The manifest is TypeScript — typed, commented, no separate schema to keep in sync. The id and mount path are derived from the folder name, not declared:

import { definePlugin } from "../../src/plugin.ts";
import { listShifts } from "./shifts.ts";

export default definePlugin({
  apiVersion: "1.0.0",      // semver of the host contract this was built against (a literal — see docs)

  // Nav fragment, composed into the global menu. Permission-gated via Keto:
  // items the current user can't access are hidden. Arbitrary depth.
  // `icon` is a Lucide icon by its sprite id (src/icons.ts).
  nav: [
    {
      label: "Scheduling", icon: "i-cal",
      children: [
        { label: "Shifts", href: "/scheduling/shifts", permission: "scheduling:read" },
      ],
    },
  ],

  // Route handlers, mounted under the plugin's path (/scheduling). `permission`
  // (a Keto check) is enforced 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.

Where plugins live (and how to mount them)

The host scans /app/plugins/ inside the web container — so "installing a plugin" means getting its folder there. There are two ways, depending on where the plugin's source lives:

1. In your clone (the default dev loop). Create plugins/<id>/ in the working tree. docker compose up already bind-mounts the whole tree (compose.override.yml: .:/app), so the folder is live in the container — restart to pick it up. This is the "copy the example plugin and go" path.

2. A plugin kept in its own repo, or added to a prebuilt image. Bind-mount the plugin folder onto /app/plugins/<id> with a small compose override. Plugins are stateless, so mount it read-only:

# compose.plugins.yml — mount external plugin folders into the host
services:
  web:
    volumes:
      - ../scheduling-plugin:/app/plugins/scheduling:ro   # host path : /app/plugins/<id>
# Dev: list the files explicitly (a third file disables the implicit override merge)
docker compose -f compose.yml -f compose.override.yml -f compose.plugins.yml up
# Prod (image already built, no source mount):
docker compose -f compose.yml -f compose.plugins.yml up -d

A named volume or volume container works the same way (target /app/plugins/<id>), but a bind mount matches the edit-and-reload loop. For a baked production image, just keep the plugin in the build context and it's COPY'd in at build time — pinned and reproducible; mount a volume only to add plugins to an already-built image.

Discovery — scanning plugins/, importing each plugin.ts default export, and validating it (id, apiVersion, conflicts) — runs at boot (src/discovery.ts); a bad plugin stops startup with a precise message. The router (src/router.ts) then mounts each route at /<id>, resolves :name params, runs the permission gate, and turns the handler's RouteResult into the response; a view result renders plugins/<id>/views/<view>.ejs (src/view-resolver.ts), which may include() the core building-block partials. A plugin's public/ assets are served at /public/<id>/ (src/static.ts). The mount mechanics above are how the files get into the container either way.

The menu system

The menu is driven entirely by config and assembled from two sources:

  1. Plugin fragments — each plugin contributes its own nav (above).
  2. A central overrideconfig/menu.ts (loaded by src/menu-config.ts, validated at boot) — where the operator reorders, renames, groups, or hides items (by node id), and sets branding (app name, logo, default theme). The override always wins, applied before the per-user filter. A clean clone needs no config/menu.ts; defaults apply.

Every nav item may carry a permission; the rendered tree is filtered per user by reading the roles in the session JWT (no per-request authz call — see Auth, sessions & permissions), 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). Branding (name, logo, default theme) renders in the app shell — the sidebar brand shows the configured logo (else a default mark), and the theme sets the theme-switch default.

Building blocks

Plainpages is a component library, not a page generator — you assemble pages from partials and helpers rather than declaring a schema and getting magic. The vocabulary is extracted from html-css-foundation/ into reusable EJS partials + TS helpers, fully styled and zero-JS:

  • Partials: app shell, nav tree, filter bar, data table (sort / select / row actions), pagination, form fields, badges, menus, auth cards.
  • Helpers: composeNav (menu from config), parseListQuery (?q=…&status=…&sort=…&page=… → filter/sort/pagination), paginate (page math), and the auth guards a handler calls to authorize (src/guards.ts): requireSession (assert a session — a GuardError the host turns into a redirect to sign in), can(role) (a coarse JWT-claim check, zero I/O), check(relation, object) (the one live Keto call, for relationship rules).

Interactivity: zero-JS spine, opt-in enhancement

The core and all building blocks work with zero JavaScript — menus, theme switching, and filtering are pure CSS + GET forms. On the low-end, low-bandwidth targets we care about this is usually faster: a round-trip returning a small, pre-rendered HTML page beats a client-side runtime that must boot, fetch JSON, and re-render before anything shows. List state (?q=…&status=…&sort=…&page=…) lives in the URL, so a view is bookmarkable, shareable, and reproducible — the URL is the only state the UI keeps.

Plugins that genuinely need it — live dashboards, bulk actions, client-side validation — may opt into progressive enhancement (htmx, Alpine, or vanilla JS) on top of working server-rendered HTML. The baseline never depends on it.

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. SSO is optional and self-configuring: each provider's button renders only when its credentials are present, and the whole SSO section disappears when none are configured — leaving plain password login. A developer never has to touch SSO to get started. On success, rather than keeping the opaque Kratos cookie and calling whoami on every request, the app exchanges the session for a signed JWT once via the Kratos session tokenizer (whoami with a tokenize_as template) and stores it as the session cookie.

  ── AT LOGIN / REFRESH  (the only time Ory is on the path) ──────────
   Kratos verifies credentials
     └─► app reads the user's roles from Keto       (direct + transitive via groups)
     └─► app writes them as a derived projection on the identity (admin API)
     └─► whoami(tokenize_as: "plainpages")  ─►  signed JWT
           claims: { sub, email, roles:[…from Keto], exp ≈ 10m }
     └─► stored as the session cookie

  ── EVERY REQUEST  (hot path — pure CPU, no I/O) ───────────────────
   Browser ─cookie(JWT)─► web : verify signature (cached JWKS)
                                read claims.roles
                                filter menu · gate routes

Keto is the single source of truth for roles. Coarse roles are Keto relations (e.g. role:admin#members@user:alice); the admin screens write them only to Keto. But the tokenizer's claims mapper can read only the identity, not call Keto — so at login the app reads the roles from Keto and refreshes a derived projection: a read-only copy written onto the identity's metadata_public for the tokenizer to see, which the template maps into the JWT roles claim. (It must be metadata_public, not metadata_admin: the session Kratos hands the tokenizer carries only public metadata — and the user can already read these coarse roles in their own JWT, so nothing is leaked.) That projection is a per-login cache, authoritative nowhere; nothing edits it by hand, and a stale one self-heals on the next login.

A role can be granted to a user directly or to a group the user belongs to; login resolves both (enumerate the defined roles, ask Keto to resolve each membership), so the JWT roles match what the admin Effective access view shows.

Cost: a handful of Keto reads + one identity refresh per login — never per request. JWKS is cached, so even signature verification hits the network only on key rotation. The app stays stateless; "stay signed in" = re-mint the JWT on a short TTL, the one moment authz is recomputed from Keto.

Two trade-offs — both deliberate

This design buys an I/O-free hot path that scales to tens of thousands of concurrent users on modest hardware. In return:

  • Role changes lag by up to one TTL (~10m). Gating reads the JWT, not Keto, so a granted or revoked role only takes effect when the token is next minted (re-login or TTL refresh). For an admin tool this is intentional — the alternative is a Keto call per request, which we traded away. For instant revoke, the optional revocation denylist (roadmap) closes the gap for security-critical cases without putting Keto back on the hot path.
  • Ory is on the critical path for sign-in. If Kratos is down no one can log in; if it stays down past the TTL, existing sessions can't refresh and the UI goes dark. That's the direct consequence of being stateless and delegating identity — no local fallback, by design. Run Ory with the availability you'd give any auth provider.

Three tiers of "may I?"

  coarse  (menu / route / feature)        → JWT claim     · in-process, zero I/O
  fine + attribute (owner / tenant / …)   → upstream service that owns the row
  fine + relationship (shared / inherited)→ Keto, live check at the action
  • Coarse gates the menu and routes — read straight from the JWT.
  • Attribute-based row rules (ownership, tenant, status) live in the upstream service that holds the data: it's the source of truth and the check is free.
  • Relationship-based rules (sharing, delegation, inherited/transitive access, or authz that must mean the same thing across several services) go to Keto — that's what ReBAC is for. Reserve it for those; don't pay its tuple-sync cost for rules a service can already answer from its own data.

The built-in users / groups / permissions screens write authorization only to Keto — coarse roles and fine-grained relationships alike. Roles reach the JWT by being read from Keto at login and projected through the tokenizer (above); nothing authors them anywhere else.

OAuth2 provider (Hydra)

Only relevant when other apps authenticate through plainpages. The app implements Hydra's login & consent steps — authenticating the user via their Kratos session — and Hydra issues the access / refresh / id tokens those apps use. Nothing in the menu or first-party pages needs Hydra.

The login challenge is wired (src/oauth-login.ts at /oauth2/login): Hydra hands the browser here, the app resolves it against the Kratos session and accepts (or bounces an unauthenticated user to the themed login, returning here once signed in). The consent challenge is wired too (src/oauth-consent.ts at /oauth2/consent): a first-party client (its Hydra metadata.first_party: true) — or one Hydra already skipped — is auto-granted the requested scopes; any other client gets a themed consent screen whose CSRF-guarded Allow/Deny accepts or rejects. id_token claims (email, name) come from the Kratos identity.

Those clients are registered from the admin OAuth2 clients screen (/admin/clients, src/admin-clients.ts): register (Hydra shows the generated client_secret once, on the confirmation page — confidential clients), list, and delete. Confidential vs public (PKCE) and the first-party auto-consent flag are set at registration; writes go only to Hydra.

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

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.)

Before going live, supply the production secrets and any SSO credentials — the only manual prep (What you must supply); the rest is auto-generated.

The server drains in-flight requests on SIGTERM/SIGINT rather than cutting them mid-response, so container restarts are clean.

Layout

src/server.ts        Entry point — starts the HTTP server (reads PORT, default 3000)
src/app.ts           Request routing + EJS rendering (incl. the themed Kratos self-service routes, §4)
src/static.ts        Static file serving (path-traversal protection) + routePublic(): /public/<id>/ → a plugin's public/
src/jwt.ts           JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK)
src/jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.user/roles; flags a lapsed token for re-mint (§4)
src/jwks.ts          JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload)
src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize (§4)
src/kratos-admin.ts  createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection, §4)
src/keto-client.ts   createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) (§4)
src/hydra-admin.ts   createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD (§6)
src/oauth-login.ts   resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login (§6)
src/oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes (§6)
src/flow-view.ts     buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs (§4)
src/login.ts         completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie (§4)
src/gen-jwks.ts      generateJwks() + CLI: mint the ES256 session-tokenizer signing JWKS (§3); see JWT signing key & rotation
src/bootstrap.ts     One-command bootstrap (§3): idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
src/cookie.ts        Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4)
src/csrf.ts          CSRF for our own POST forms (§4): signed double-submit token — issue/verify, cookie, request gate
src/body.ts          readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + §5 forms)
src/context.ts       RequestContext handed to handlers + buildContext()
src/config.ts        Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot
src/dashboard.ts     buildDashboardModel(): the home "/" People list view model (mock data, wires the §1 helpers)
src/admin-users.ts   Built-in Users admin screen (§5): list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded
src/admin-groups.ts  Built-in Groups admin screen (§5): list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded
src/admin-roles.ts   Built-in Roles admin screen (§5): list/create/delete Keto roles + assign to users/groups + "effective access" (Keto expand → transitive members); reuses the Groups membership helpers, writes only to Keto, gated + CSRF-guarded
src/admin-clients.ts Built-in OAuth2 clients admin screen (§6): list/register/delete Hydra OAuth2 clients (apps that log in through us); register shows the one-time client_secret; writes only to Hydra, gated + CSRF-guarded
src/admin-nav.ts     adminSection(): the permission-gated "Admin" menu section (Users · Groups · Roles · OAuth2 clients), wired into the global dashboard menu + the in-screen admin nav (adminNav) so they can't drift
src/shell-context.ts buildShellContext(): brand/theme/user view-model shared by the dashboard + admin screens (real signed-in user, no demo profile)
src/icons.ts         Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
src/list-query.ts    parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
src/nav.ts           composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
src/paginate.ts      paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
src/plugin.ts        Plugin contract: manifest types, definePlugin(), version + conflict rules + fullPath()
src/discovery.ts     discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot (§2)
src/router.ts        matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2)
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2)
src/menu-config.ts   loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2)
views/               Core EJS templates: index (app-shell dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent screen), 403/404/500, partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, flow + consent + admin bodies, menu/popover, theme switch, icon sprite)
public/              Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
config/menu.ts       Central menu override + branding (optional; defaults apply if absent)
ory/                 Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
plugins/             Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in) (planned)
docs/                Reference docs (plugin-contract.md — the authoritative plugin API)
e2e/                 Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent → authorization code), full Ory stack; Dockerfile.e2e + compose.e2e[-auth|-oauth].yml run them
html-css-foundation/ HTML design mockups — the source for the building-block
                     partials; reference the stylesheets in public/css/.

Comments and docs cite roadmap phases as §N — the sections in todo.md.

Extending the core

  • New page in a plugin: add a route + handler to the plugin manifest and a template in its views/.
  • Static asset: drop it in the plugin's public/; served at /public/<plugin>/<path>.
  • New dependency: docker compose run --rm web npm install <pkg> (updates package.json + package-lock.json), then docker compose build. Keep deps minimal — prefer the Node standard library, and prefer an Ory REST call over an SDK.

All versions are pinned to exact, human-readable semantic versions (no ranges, no digests): npm deps via .npmrc (save-exact=true) + the committed lockfile (npm ci), and container images by tag in the Dockerfile / compose files (e.g. node:24.16.0-alpine3.24, pinned Ory and Postgres tags).

Description
A pluggable web GUI for systems that handle data, lists, and forms.
Readme MIT 6.4 MiB
Languages
TypeScript 78.2%
EJS 8.6%
HTML 6.9%
CSS 5.1%
JavaScript 0.6%
Other 0.5%