Move admin screens (users/groups/roles/oauth2-clients) into a drop-in example plugin; add the ctx.system capability surface

This commit is contained in:
2026-07-02 08:01:15 +02:00
parent 2202bdbaa0
commit e8ea911b80
59 changed files with 1095 additions and 1022 deletions
+80 -36
View File
@@ -23,7 +23,18 @@ docker compose up -d # http://localhost:3000, live-reloads on source chan
**2. Sign in.** Open <http://localhost:3000> and sign in as the seeded admin —
**`admin@plainpages.local` / `admin`**.
**3. Add your first plugin.** The clone is bind-mounted into the container, so a new
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups
/ Roles / OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`:
```bash
cp -r examples/plugins/admin plugins/admin
docker compose restart web
```
The seeded admin already holds the `admin` role, so the **Admin** section now shows in the menu.
See [`examples/plugins/admin/`](examples/plugins/admin/).
**4. Add your first plugin.** The clone is bind-mounted into the container, so a new
folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`:
```ts
@@ -60,6 +71,7 @@ From here, render real pages against the app shell and fetch upstream data — s
- [routes & handlers](#routes--handlers)
- [landing pages](#the-landing-pages-home--dashboard)
- [RequestContext](#requestcontext)
- [system capabilities (ctx.system)](#system-capabilities-the-ctxsystem-surface)
- [nav & permissions](#nav--permissions)
- [versioning](#contract-versioning)
- [conflict rules](#conflict-rules)
@@ -97,8 +109,9 @@ sessions, and access control — and stays out of your domain logic. **Any page
or gated**, so the same foundation serves a purely public site, a fully locked-down internal
tool, or the common middle: a public front with an authenticated area behind it. Its **sweet
spot** is the **back-office and operational tooling** you'd otherwise hand-roll for the tenth
time, but nothing ties it to internal-only use. The only screens it ships itself are the ones
for running the system: **users, groups, and permissions**. Everything else is a plugin.
time, but nothing ties it to internal-only use. The core itself ships **no domain screens at
all** — even the screens for running the system (**users, groups, permissions**) are a **drop-in
plugin** you opt into ([`examples/plugins/admin/`](examples/plugins/admin/)). Everything is a plugin.
**Who it's for.** Experienced developers building server-rendered web products — back-office
and operational tools, dashboards, portals, or public sites with a gated area — for their own
@@ -110,11 +123,15 @@ obvious rather than surprising, you're the audience.
**Included vs. what you add.**
- **Included:** themed sign-in / register / reset (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 — built from the same building blocks the built-in
screens use.
- **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design
system + app shell, the config-driven menu, sessions, and access control. No domain screens.
- **Opt-in admin plugin:** the **users, groups, roles, and OAuth2-clients** screens (users via
Kratos, the relationship graph via Keto, OAuth2 clients via Hydra) ship as
[`examples/plugins/admin/`](examples/plugins/admin/) — copy it into `plugins/` to get a GUI for
user & group admin. It's an ordinary plugin, using the privileged
[`ctx.system`](#system-capabilities-the-ctxsystem-surface) surface to reach Ory.
- **You add:** everything else domain-specific, as **plugins** — a list page, a form, a
scheduler, a register, a dashboard — built from the same building blocks the admin plugin uses.
**Priorities (unchanged from day one):** **simplicity, few dependencies, strict
TypeScript, no build step, Docker-only, environment-agnostic** (no `NODE_ENV` — every
@@ -218,9 +235,10 @@ convention) its nav/permission tokens.
A handful of ids are **reserved** for the host's own first-party mounts — the gated `dashboard`, the
Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`),
the `admin` screens, the `oauth2` provider routes, and `public` (static). Since plugin routes resolve
first, a folder claiming one would silently shadow a built-in route, so discovery refuses it loud
(`RESERVED_PLUGIN_IDS`). (`/` is owned by the `home` field, not a route, so it needs no reservation.)
the `oauth2` provider routes, and `public` (static). Since plugin routes resolve first, a folder
claiming one would silently shadow a built-in route, so discovery refuses it loud
(`RESERVED_PLUGIN_IDS`). (`/` is owned by the `home` field, not a route, so it needs no reservation;
`admin` is **not** reserved — the admin screens are themselves a drop-in plugin mounted at `/admin`.)
Installing a plugin is "drop the folder, restart." Removing one is "delete the folder, restart."
Nothing else references it; the operator stays in control through the central menu override
@@ -288,7 +306,7 @@ mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:
matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`,
runs the `permission` gate (a coarse JWT-claim check — see [Nav & permissions](#nav--permissions)),
and only then calls the handler with the [request context](#requestcontext). When the gate fails, an
**anonymous** visitor is redirected to `/login` to sign in (same as the built-in admin screens); the
**anonymous** visitor is redirected to `/login` to sign in; the
requested page is preserved as `return_to`, so after signing in they land **back on the page they
asked for**, not the dashboard. A **signed-in** user who simply lacks the role gets the **403** page.
A route marked **`public: true`** has no gate at all — anyone reaches it (see [Public pages & menu
@@ -328,7 +346,7 @@ export async function listThings(ctx: RequestContext) {
- **`view`** resolves against the plugin's own `views/` (`src/plugin-host/view-resolver.ts`) — nested names
like `"things/edit"` work, and an out-of-bounds name is refused. The template may `include()`
the core building-block partials (app shell, nav tree, data table, …) and its own
partials/subfolders to render a full page — exactly as the built-in screens do. To load the
partials/subfolders to render a full page — exactly as the admin plugin's screens do. To load the
plugin's own CSS, pass its `/public/<id>/x.css` href in the shell's `styles` slot (an array of
extra stylesheet hrefs) — see the reference's `views/shifts.ejs`.
- **Finer authorization than the route `permission`** uses the guards from `#plugin-api`:
@@ -412,6 +430,7 @@ interface RequestContext {
req: IncomingMessage;
res: ServerResponse;
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them
url: URL;
user: User | null; // { id, email, roles } from the verified session JWT, or null
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie
@@ -420,9 +439,10 @@ interface RequestContext {
**`ctx.chrome`** is the page chrome the host builds per request — `{ brand, csrfToken, nav, signInHref,
theme, user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same
sidebar, branding, theme switch and signed-in profile as the built-in screens); `chrome.nav` is the
global menu — your plugin's nav fragment plus the others and the admin section — already composed,
role-filtered, and current-marked for this request (the gated **Dashboard** link is omitted for an
sidebar, branding, theme switch and signed-in profile every page uses); `chrome.nav` is the
global menu — your plugin's nav fragment plus every other installed plugin's (the admin section among
them, when that plugin is present) — already composed, role-filtered, and current-marked for this
request (the gated **Dashboard** link is omitted for an
anonymous visitor). `chrome.signInHref` is where the shell's anonymous **Sign in** link points — the
current page baked in as `return_to`. Map each `chrome.*` to the matching `partials/shell` local —
`brand`, `csrfToken`, `nav` (the rendered nav-tree), `signInHref`, `theme`, `user` — exactly as the
@@ -432,7 +452,7 @@ state-changing form: render `chrome.csrfToken` in a hidden `_csrf` field, then o
body and `if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, …)`. The host owns the
secret and sets the cookie; the plugin never touches it. (See the reference: `examples/plugins/scheduling/`.)
The same shell renders **every** page (the dashboard, the admin screens, your plugin pages, and the
The same shell renders **every** page (the dashboard, your plugin pages — the admin plugin's included, and the
login/registration/front pages), so the menu looks identical signed in or out — it just role-filters.
A page that wants a focused, chrome-free layout passes **`menu: false`** to `partials/shell` (drops the
sidebar, single column); everything else still renders.
@@ -453,6 +473,36 @@ breaking). `req`/`res` are the raw Node objects and the full escape hatch; readi
but prefer the typed fields so a handler keeps working as the host evolves. `user`/`roles` come
from the JWT middleware and are `null`/`[]` until a session exists.
### System capabilities (the `ctx.system` surface)
Most plugins fetch their own data from an upstream service they configure ([the scheduling
reference](examples/plugins/scheduling/) points `SCHEDULING_UPSTREAM` at its backend). A **system
plugin** — one that administers *Plainpages' own* identity stack rather than a domain service —
needs the host's Ory admin clients and the instant-revoke hook instead. The host exposes those on
**`ctx.system`**, and re-exports the client types + their error classes from `#plugin-api`:
```ts
interface SystemCapabilities { // every field optional — present only when the host wired it
hydra?: HydraAdmin; // OAuth2 client admin (register/list/delete Hydra clients)
keto?: KetoClient; // relationship read/write (groups, roles)
kratosAdmin?: KratosAdmin; // identity admin (create/edit/deactivate/delete users)
revoke?: (sub: string) => void; // instant-revoke a subject's live tokens (needs the denylist)
}
```
`ctx.system` is **`undefined` unless the host wired at least one** of these (Kratos/Keto configured,
Hydra configured, the [revocation denylist](#instant-revoke-the-optional-denylist) enabled). A system
plugin treats every field as optional and **degrades when absent** — the host never fails a request
over it. The built-in **admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the
reference consumer: its Users screen uses `ctx.system.kratosAdmin`, Groups/Roles use `ctx.system.keto`,
OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user role-change calls
`ctx.system.revoke` so the change lands now instead of after the JWT TTL; where a capability is missing
the screen renders a themed 503.
This is a **privileged** surface — it hands a plugin the keys to identity and permissions. It's meant
for first-party system plugins you author or vendor, the same trust level as any plugin (the host
doesn't sandbox — [crash-isolation is a non-goal](#overview)). An ordinary domain plugin ignores it.
### Nav & permissions
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
@@ -670,8 +720,8 @@ the sidebar brand shows the configured logo (else a default mark), and the theme
theme-switch default.
**One menu, one shell, everywhere.** There is a single menu (`src/ui/chrome.ts`
`buildPluginChrome`), rendered by the same app shell on **every** page — the dashboard, the
admin screens, plugin pages, and the login / registration / recovery / front (`/`) pages.
`buildPluginChrome`), rendered by the same app shell on **every** page — the dashboard, plugin
pages (the admin plugin's screens included), and the login / registration / recovery / front (`/`) pages.
So it looks identical signed in or out; it just shows fewer items to an anonymous visitor
(only `public` ones, plus a Sign-in link), filtered by the same per-user rule. The sidebar
collapses to a burger on a narrow screen. A page that wants a focused, chrome-free layout
@@ -936,11 +986,11 @@ claims (email, name) come from the Kratos identity. RP-initiated **logout** is w
resumes to Hydra's post-logout redirect — the first-party `POST /logout` still owns ending
the Kratos session + our JWT cookie.
Those clients are registered from the admin **OAuth2 clients** screen (`/admin/clients`,
`src/admin/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.
Those clients are registered from the admin plugin's **OAuth2 clients** screen (`/admin/clients`,
`examples/plugins/admin/admin-clients.ts`, when that plugin is installed): 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.
## Email
@@ -1296,16 +1346,10 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD
fetch-timeout.ts withTimeout(): bound every outbound Ory call — wrap the injected fetch so each request aborts after a deadline unless the caller passed its own signal; server.ts wires it into the Kratos/Keto/Hydra clients
admin/ Built-in admin screens — the only domain screens plainpages ships
admin-users.ts Users: list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded
admin-groups.ts Groups: list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded
admin-roles.ts Roles: 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
admin-clients.ts OAuth2 clients: 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
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
plugin-host/ Plugin discovery, routing, hooks, view resolution + the stable author barrel
plugin.ts Plugin contract: manifest types, definePlugin(), version + conflict rules + fullPath()
plugin-api.ts Stable plugin author barrel — the one module a plugin imports, as `#plugin-api` (definePlugin, ctx/result types, guards, body/CSRF/list-query helpers)
plugin-api.ts Stable plugin author barrel — the one module a plugin imports, as `#plugin-api` (definePlugin, ctx/result types, guards, body/CSRF/list-query/paginate helpers, and the ctx.system Ory client types)
system.ts SystemCapabilities: the privileged ctx.system surface (Ory admin clients + instant-revoke) a system plugin uses; the host populates it from the wired clients, the admin plugin consumes it
discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot
router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate
hooks.ts runBootHooks()/runRequestHooks()/runResponseHooks(): invoke a plugin's optional lifecycle hooks in discovery order; no sandbox (a throwing hook fails loud), skipped when no plugin declares one
@@ -1313,7 +1357,7 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
ui/ Design-system view-models + menu/chrome — the building blocks pages render from
chrome.ts buildPluginChrome(): the one global menu + brand/user/theme/csrf every page renders the shell from (unified across all pages) — exposed on ctx.chrome
shell-context.ts buildShellContext(): brand/theme/user view-model shared by the dashboard + admin screens (real signed-in user, no demo profile)
shell-context.ts buildShellContext(): brand/theme/user view-model for the dashboard shell (real signed-in user, no demo profile)
dashboard.ts buildDashboardModel(): the gated "/dashboard" app home — a short instructional starter (replace it with a plugin `dashboard` handler); "/" is the public landing (a plugin `home` handler). Both render the one unified menu (ctx.chrome)
nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding, imported as `#menu-config`), validated at boot
@@ -1321,12 +1365,12 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent/admin bodies, menu/popover, theme switch, icon sprite)
views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Roles/Clients + confirm bodies)
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
config/ Drop-in mount point for the central menu override + branding (config/menu.ts). Ships empty (.gitkeep, git-ignored otherwise) — mount your own or copy the template from examples/config/; defaults apply when 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). Ships empty (.gitkeep, git-ignored otherwise) — mount your own; the E2E suites bind-mount the reference example onto /app/plugins/scheduling
examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + permission-gated nav copied into plugins/) and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the plugin reads/writes (stand-in for your real service)
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships empty (.gitkeep, git-ignored otherwise) — mount your own; the E2E suites bind-mount the example plugins onto /app/plugins/scheduling and /app/plugins/admin
examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + permission-gated nav), plugins/admin/ (the system-admin plugin — Users/Groups/Roles/OAuth2-clients over Ory via ctx.system), both copied into plugins/; and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the scheduling plugin reads/writes (stand-in for your real service)
e2e-tests/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-role, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.ts (same-origin gateway) + mock-oidc.ts (mock SSO provider) back full-flow. e2e-tests/Dockerfile + e2e-tests/compose.{visual,auth,oauth,full,devstack}.yml run them
ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`)
```