2 Commits

Author SHA1 Message Date
lilleman ca3d49ec45 Document the users, groups and roles model in README
CI / full-gate (push) Successful in 2m35s
2026-08-03 11:39:26 +02:00
lilleman acce2d2556 Rename the plugin-API permission gate to role 2026-08-03 11:31:04 +02:00
36 changed files with 314 additions and 191 deletions
+6 -2
View File
@@ -120,11 +120,15 @@ docker compose -f compose.yml up --build -d # production
running **building plugins** comes first, then **configuring and securing** the system running **building plugins** comes first, then **configuring and securing** the system
(Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are (Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are
deliberately deferred — they're not top of mind when starting out. Concretely: Overview → deliberately deferred — they're not top of mind when starting out. Concretely: Overview →
Building plugins → menu/blocks/interactivity → Configuration → Auth → Email → Users, groups & roles → Building plugins → menu/blocks/interactivity → Configuration → Auth →
Architecture → Testing → Production → Observability → the JWT-rotation runbook → the Email → Architecture → Testing → Production → Observability → the JWT-rotation runbook → the
Project-layout file map → Extending. When adding a section, place it by this value (how Project-layout file map → Extending. When adding a section, place it by this value (how
early an adopter needs it), not by where it sits in the stack. early an adopter needs it), not by where it sits in the stack.
**Users, groups & roles precedes Building plugins** because a manifest's `role:` gate is
unreadable without the model, and operators need it as much as plugin authors. It is the one
home for that model — the plugin and auth sections link to it rather than restating it.
When editing: put content in the section it belongs to (don't prepend rationale above Quick When editing: put content in the section it belongs to (don't prepend rationale above Quick
start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in
one home, linking to it rather than restating (credentials, env vars, rotation steps). one home, linking to it rather than restating (credentials, env vars, rotation steps).
+1 -1
View File
@@ -190,4 +190,4 @@ export default definePlugin({
``` ```
Restart (`docker compose restart web`) and visit <http://localhost:3000/hello>. Views, Restart (`docker compose restart web`) and visit <http://localhost:3000/hello>. Views,
forms, permissions, and the runnable reference plugin: repo README → Building plugins. forms, roles, and the runnable reference plugin: repo README → Building plugins.
+179 -62
View File
@@ -69,6 +69,10 @@ From here, render real pages against the app shell and fetch upstream data — s
- [Overview](#overview) - [Overview](#overview)
- [how it compares](#how-it-compares) - [how it compares](#how-it-compares)
- [Users, groups & roles](#users-groups--roles)
- [a worked example](#a-worked-example)
- [granting a role](#granting-a-role)
- [fine-grained, per-row access](#fine-grained-per-row-access)
- [Building plugins](#building-plugins) - [Building plugins](#building-plugins)
- [anatomy](#anatomy-of-a-plugin) - [anatomy](#anatomy-of-a-plugin)
- [the manifest](#the-manifest) - [the manifest](#the-manifest)
@@ -76,7 +80,7 @@ From here, render real pages against the app shell and fetch upstream data — s
- [landing pages](#the-landing-pages-home--dashboard) - [landing pages](#the-landing-pages-home--dashboard)
- [RequestContext](#requestcontext) - [RequestContext](#requestcontext)
- [system capabilities (ctx.system)](#system-capabilities-the-ctxsystem-surface) - [system capabilities (ctx.system)](#system-capabilities-the-ctxsystem-surface)
- [nav & permissions](#nav--permissions) - [nav & role gates](#nav--role-gates)
- [versioning](#contract-versioning) - [versioning](#contract-versioning)
- [conflict rules](#conflict-rules) - [conflict rules](#conflict-rules)
- [hooks](#hooks) - [hooks](#hooks)
@@ -89,7 +93,7 @@ From here, render real pages against the app shell and fetch upstream data — s
- [canonical host](#canonical-host-one-public-url) - [canonical host](#canonical-host-one-public-url)
- [what you must supply](#what-you-must-supply-the-only-manual-prep) - [what you must supply](#what-you-must-supply-the-only-manual-prep)
- [SSO](#social-sign-in-sso) - [SSO](#social-sign-in-sso)
- [Auth, sessions & permissions](#auth-sessions--permissions) - [Auth, sessions & access](#auth-sessions--access)
- [login & the session JWT](#login-and-the-session-jwt) - [login & the session JWT](#login-and-the-session-jwt)
- [instant revoke](#instant-revoke-the-optional-denylist) - [instant revoke](#instant-revoke-the-optional-denylist)
- [three tiers](#three-tiers-of-may-i) - [three tiers](#three-tiers-of-may-i)
@@ -116,7 +120,7 @@ or gated**, so the same foundation serves a purely public site, a fully locked-d
tool, or the common middle: a public front with an authenticated area behind it. Its **sweet 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 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 core itself ships **no domain screens at 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 all** — even the screens for running the system (**users, groups, roles**) are a **drop-in
plugin** you opt into ([`examples/plugins/admin/`](examples/plugins/admin/)). Everything is a plugin. 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 **Who it's for.** Experienced developers building server-rendered web products — back-office
@@ -124,7 +128,7 @@ and operational tools, dashboards, portals, or public sites with a gated area
use or for a client. You know HTTP, Docker, and identity 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 providers, and you'd rather assemble pages from building blocks than fight a framework or
hand-roll auth for the tenth time. It's not a no-code tool and doesn't hide its moving hand-roll auth for the tenth time. It's not a no-code tool and doesn't hide its moving
parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--permissions)) reads as parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--access)) reads as
obvious rather than surprising, you're the audience. obvious rather than surprising, you're the audience.
**Included vs. what you add.** **Included vs. what you add.**
@@ -142,7 +146,7 @@ obvious rather than surprising, you're the audience.
**Priorities (unchanged from day one):** **simplicity, few dependencies, strict **Priorities (unchanged from day one):** **simplicity, few dependencies, strict
TypeScript, no build step, Docker-only, environment-agnostic** (no `NODE_ENV` — every 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 — 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 identity, sessions, SSO, OAuth2, role checks — is delegated to **Ory** sidecar
services rather than reinvented. "Simple" is about the *whole architecture* staying simple services rather than reinvented. "Simple" 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 — 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 production. The shape doesn't change as it grows: every plugin is the same self-contained
@@ -190,10 +194,126 @@ Plainpages sits relative to them:
| **Themed auth UI on Ory** — Kratos self-service UIs (`ory/kratos-selfservice-ui-node`, `kratos-admin-ui`) | the **login / registration screens** over Ory | The one *slice* with a direct off-the-shelf alternative: Plainpages reimplements it inside its own shell, so you could swap it out to avoid maintaining that part. | | **Themed auth UI on Ory** — Kratos self-service UIs (`ory/kratos-selfservice-ui-node`, `kratos-admin-ui`) | the **login / registration screens** over Ory | The one *slice* with a direct off-the-shelf alternative: Plainpages reimplements it inside its own shell, so you could swap it out to avoid maintaining that part. |
No family combines the whole set: **[drop-in plugin folders](#building-plugins)**, a **zero-JS No family combines the whole set: **[drop-in plugin folders](#building-plugins)**, a **zero-JS
server-rendered** design system, **[optional auth](#auth-sessions--permissions)** (any page server-rendered** design system, **[optional auth](#auth-sessions--access)** (any page
public or gated), **no app database**, and a **framework-light TypeScript** core with no build public or gated), **no app database**, and a **framework-light TypeScript** core with no build
step. Each neighbour shares one trait and trades away the rest — Plainpages is the intersection. step. Each neighbour shares one trait and trades away the rest — Plainpages is the intersection.
## Users, groups & roles
Authorization here is **two hops, not three**: a user — directly, or through a group — is a
member of a **role**, and that role's *name* is exactly the string a plugin gates on. There is no
separate "permission" object to define, register, or wire up.
- **Group** answers *who* — a reusable set of people. Optional: a role can be granted straight to a user.
- **Role** answers *what* — its **name is the string** you write in a manifest's `role:` gate.
- **A relation tuple** is the grant: `Role:<name>#members@user:<id>`, or `@Group:<name>#members`.
- **Resource** answers *which row* — a live check, run only where a plugin explicitly asks for it.
| Entity | Lives in | Answers | Example |
| --- | --- | --- | --- |
| **User** | Kratos | who you are | the identity behind `user:0198f2c1-…` |
| **Group** | Keto | who — a reusable set | `Group:support` |
| **Role** | Keto | what you may do | `Role:scheduling:read` |
| **Resource** | Keto | which specific row | `Resource:shift-4471` |
Identities live in Kratos; every authorization edge is a Keto relation tuple. The app itself
stores none of it — it is [stateless](#stateless). The model is `ory/keto/namespaces.keto.ts`.
> **On the word "permission".** Ory uses it for the fine-grained `Resource` tier — the `permits`
> block (`view`/`edit`/`delete`). Plainpages therefore never uses it for the coarse tier: what a
> route or a menu item gates on is a **role**, always.
### A worked example
Alice works support and leads scheduling; Bob works support; Carol administers the system.
```
people groups roles
────── ────── ─────
alice ──┬─────────> Group:support ────┐
│ ├──> Group:staff ──> Role:scheduling:read
bob ────┘ │
alice ────────────> Group:sched-leads ┴──> Role:scheduling:write
carol ───────────────────────────────────────────────> Role:admin
```
At login the host asks Keto which roles the user holds, walking those arrows transitively, and
bakes the answer into the session JWT (see [Login and the session
JWT](#login-and-the-session-jwt)):
```
alice → roles: ["scheduling:read", "scheduling:write"]
bob → roles: ["scheduling:read"]
carol → roles: ["admin"]
```
Note what Carol does *not* have. **There is no role hierarchy and no superuser**`admin` is
just another name, granting nothing except where a route gates on `admin` itself.
Against the reference plugins' actual routes:
| Request | Gate | alice | bob | carol | anonymous |
| --- | --- | --- | --- | --- | --- |
| `GET /scheduling` | `public: true` | ✅ | ✅ | ✅ | ✅ |
| `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` |
| `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
| `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
| `GET /admin/users` | `admin` | 403 | 403 | ✅ | → `/login` |
Bob reaches the shifts list with no direct grant: he is in `support`, support's members are
`staff`, and staff holds `scheduling:read` — two hops, resolved by Keto at his login. He is
refused the new-shift form because `scheduling:write` hangs off `sched-leads`, which he is not in.
An anonymous visitor gets a **redirect**, not a 403, carrying `return_to` so signing in lands them
on the page they asked for; a signed-in user who merely lacks the role gets the 403 page, because
there is nothing to sign in *as* that would help. The menu is filtered by the same roles, so
nobody is shown a door they cannot open.
### Granting a role
Write the tuple. The admin plugin's **Groups** and **Roles** screens do exactly this, or use
Keto's write API directly:
```bash
# everyone in sched-leads may write shifts
curl -X PUT http://keto:4467/admin/relation-tuples -H 'content-type: application/json' -d '{
"namespace": "Role", "object": "scheduling:write", "relation": "members",
"subject_set": { "namespace": "Group", "object": "sched-leads", "relation": "members" }
}'
```
Roles are authored **only in Keto** — nothing else writes them. Role names are a shared global
namespace on purpose, so an operator grants `scheduling:read` once and every plugin referencing it
is gated consistently; namespace yours as `<id>:<action>`.
A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant
revoke](#instant-revoke-the-optional-denylist) when you need it sooner.
### Fine-grained, per-row access
The `Resource` namespace covers what a role cannot express: *this* row, shared with *this* person.
Its permissions nest — `owner``editor``viewer`.
**A per-row grant never widens a coarse gate.** The route's `role` is checked *before* the handler
runs, so a user rejected there never reaches the check. Gate the route on something they hold,
then narrow inside the handler:
```ts
{ method: "POST", path: "/shifts/:id", role: READ, handler: editShift }
async function editShift(ctx) {
if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id, relation: "editors" })))
throw new GuardError(403, "not an editor of this shift");
}
```
Reserve this tier for relationship rules (sharing, delegation, inheritance). Ownership and tenant
rules belong in the upstream service that holds the row — see [Three tiers of "may
I?"](#three-tiers-of-may-i).
## Building plugins ## Building plugins
A plugin is a self-contained folder under `plugins/` that the host discovers at boot — no A plugin is a self-contained folder under `plugins/` that the host discovers at boot — no
@@ -204,7 +324,7 @@ contract is **TypeScript** (`src/plugin-host/plugin.ts`), so the types there are
source of truth; the sections below explain them, the guarantees around them, and the rules source of truth; the sections below explain them, the guarantees around them, and the rules
the host enforces. A complete, runnable example lives in the host enforces. A complete, runnable example lives in
**[`examples/plugins/scheduling/`](examples/plugins/scheduling/)** — a public overview page, a **[`examples/plugins/scheduling/`](examples/plugins/scheduling/)** — a public overview page, a
permission-gated list page fetching upstream data (it points `SCHEDULING_UPSTREAM` at its backend; role-gated list page fetching upstream data (it points `SCHEDULING_UPSTREAM` at its backend;
the dev compose ships a tiny mock, `examples/shifts-upstream/`), a CSRF-guarded form forwarding the dev compose ships a tiny mock, `examples/shifts-upstream/`), a CSRF-guarded form forwarding
writes upstream, and a mix of public + role-gated nav. It is **not** pre-installed — `plugins/` writes upstream, and a mix of public + role-gated nav. It is **not** pre-installed — `plugins/`
ships empty so you mount your own. To run it in dev, copy it in ships empty so you mount your own. To run it in dev, copy it in
@@ -237,7 +357,7 @@ single `plugin.ts`.
must be **URL/path-safe** (`isValidPluginId`: lowercase `az`, digits, and dashes — dashes must be **URL/path-safe** (`isValidPluginId`: lowercase `az`, digits, and dashes — dashes
anywhere; no uppercase, underscores, dots, or slashes); the host rejects a malformed folder name anywhere; no uppercase, underscores, dots, or slashes); the host rejects a malformed folder name
at discovery. The id also namespaces the plugin's `views/`, its `/public/<id>/` assets, and (by at discovery. The id also namespaces the plugin's `views/`, its `/public/<id>/` assets, and (by
convention) its nav/permission tokens. convention) its nav/role names.
A handful of ids are **reserved** for the host's own first-party mounts — the gated `dashboard`, the 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`), Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`),
@@ -269,20 +389,20 @@ import { listThings, createThings } from "./handlers.ts";
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", // semver string of the host contract this plugin was built against (see Versioning) apiVersion: "1.0.0", // semver string of the host contract this plugin was built against (see Versioning)
// Nav fragment, merged into the global menu and permission-filtered per user. // Nav fragment, merged into the global menu and role-filtered per user.
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts). // `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }], nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", role: "things:read" }],
// Permission tokens this plugin introduces. Optional — see Nav & permissions. // Roles this plugin gates on. Optional — see Nav & role gates.
permissions: [ roles: [
{ token: "things:read", description: "View things" }, { description: "View things", name: "things:read" },
{ token: "things:write", description: "Create and edit things" }, { description: "Create and edit things", name: "things:write" },
], ],
// Route handlers, mounted under the plugin's path (/things). `permission` gates first. // Route handlers, mounted under the plugin's path (/things). `role` gates first.
routes: [ routes: [
{ method: "GET", path: "/", permission: "things:read", handler: listThings }, { method: "GET", path: "/", role: "things:read", handler: listThings },
{ method: "POST", path: "/", permission: "things:write", handler: createThings }, { method: "POST", path: "/", role: "things:write", handler: createThings },
], ],
}); });
``` ```
@@ -299,7 +419,7 @@ there is **no `id` or `basePath`** in the manifest — both come from the folder
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. | | `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. |
| `permissions` | no | Tokens this plugin introduces. See [Nav & permissions](#nav--permissions). | | `roles` | no | Roles this plugin gates on. See [Nav & role gates](#nav--role-gates). |
| `routes` | no | See [Routes & handlers](#routes--handlers). | | `routes` | no | See [Routes & handlers](#routes--handlers). |
| `hooks` | no | See [Hooks](#hooks). | | `hooks` | no | See [Hooks](#hooks). |
@@ -307,10 +427,10 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field
### Routes & handlers ### Routes & handlers
A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's A route is `{ method, path, role?, public?, handler }`. `path` is **relative to the plugin's
mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host
matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, 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)), runs the `role` gate (a coarse JWT-claim check — see [Nav & role gates](#nav--role-gates)),
and only then calls the handler with the [request context](#requestcontext). When the gate fails, an 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; 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 requested page is preserved as `return_to`, so after signing in they land **back on the page they
@@ -355,7 +475,7 @@ export async function listThings(ctx: RequestContext) {
partials/subfolders to render a full page — exactly as the admin plugin's 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 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`. extra stylesheet hrefs) — see the reference's `views/shifts.ejs`.
- **Finer authorization than the route `permission`** uses the guards from `#plugin-api`: - **Finer authorization than the route `role`** uses the guards from `#plugin-api`:
`requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect `requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect
to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx, to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx,
{namespace, object, relation})` (a live Keto check for relationship rules — the subject is the {namespace, object, relation})` (a live Keto check for relationship rules — the subject is the
@@ -414,7 +534,7 @@ points there.
For the gated `dashboard`, the host enforces the session gate first, so `ctx.user` is non-null; For the gated `dashboard`, the host enforces the session gate first, so `ctx.user` is non-null;
branch on `ctx.roles` *inside* to tailor the page per role. Don't gate `dashboard` itself behind a branch on `ctx.roles` *inside* to tailor the page per role. Don't gate `dashboard` itself behind a
single permission — there's no second dashboard to fall back to, so a user lacking it would land on a single role — there's no second dashboard to fall back to, so a user lacking it would land on a
403. (Both slots answer `GET` and `HEAD`.) 403. (Both slots answer `GET` and `HEAD`.)
Only **one** plugin may own each slot: two declaring `home` (or two declaring `dashboard`) is a Only **one** plugin may own each slot: two declaring `home` (or two declaring `dashboard`) is a
@@ -505,15 +625,15 @@ OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user role-chan
`ctx.system.revoke` so the change lands now instead of after the JWT TTL; where a capability is missing `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. the screen renders a themed 503.
This is a **privileged** surface — it hands a plugin the keys to identity and permissions. It's meant This is a **privileged** surface — it hands a plugin the keys to identity and authorization. It's meant
for first-party system plugins you author or vendor, the same trust level as any plugin (the host 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. doesn't sandbox — [crash-isolation is a non-goal](#overview)). An ordinary domain plugin ignores it.
### Nav & permissions ### Nav & role gates
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
applies the central override and then **filters per user** by the roles in the session JWT — a applies the central override and then **filters per user** by the roles in the session JWT — a
node shows iff it is `public`, declares no `permission`, or the user's roles include that token. Use node shows iff it is `public`, declares no `role`, or the user's roles include that name. Use
arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a
**Lucide icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids **Lucide icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids
are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there. are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
@@ -521,9 +641,9 @@ are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its luci
#### Public pages & menu items #### Public pages & menu items
A route or nav node may be marked **`public: true`** — reachable by **anyone, signed in or not**, A route or nav node may be marked **`public: true`** — reachable by **anyone, signed in or not**,
and the menu item shows for everyone. This is the same as omitting `permission` (a no-permission and the menu item shows for everyone. This is the same as omitting `role` (an ungated
route/node is already open) but stated outright, so "public" is a **deliberate choice, not the route/node is already open) but stated outright, so "public" is a **deliberate choice, not the
accident of a forgotten gate**. `public` and `permission` are **mutually exclusive** — declaring accident of a forgotten gate**. `public` and `role` are **mutually exclusive** — declaring
both is contradictory and discovery refuses the plugin at boot. both is contradictory and discovery refuses the plugin at boot.
A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor
@@ -533,18 +653,14 @@ empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/sch
**Overview** is a worked example: it's `public`, so the "Scheduling" menu header shows for everyone, **Overview** is a worked example: it's `public`, so the "Scheduling" menu header shows for everyone,
while the actual shifts list stays behind `scheduling:read`. while the actual shifts list stays behind `scheduling:read`.
**A `permission` token is a coarse role.** The route/nav gate passes iff the user's JWT `roles` The gate passes iff the user's JWT `roles` include that name. How roles are granted, why their
include the token; those roles come from Keto at login, so an operator grants a token by writing the names are a shared global namespace, and the fine-grained per-row tier are all covered in
Keto tuple `Role:<token>#members@user:<id>` (or to a group) — the admin **Roles** screen does this. [Users, groups & roles](#users-groups--roles).
(The fine-grained, per-row tier is the separate Keto `Resource` namespace — see
[Three tiers of "may I?"](#three-tiers-of-may-i); it is not what a route `permission` checks.)
Permission tokens are a **shared global namespace** — that's deliberate, so an operator grants Declaring the ones you gate on in `roles` is **optional but recommended**: it documents them,
`scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace feeds conflict detection, and lets the one-command bootstrap seed them — the demo admin is
your tokens as `<id>:<action>` to avoid accidental clashes. Declaring them in `permissions` is granted every discovered plugin's declared roles, so a dropped-in plugin works out of the box
optional but recommended: it documents them, feeds conflict detection, and lets the one-command without editing host config.
bootstrap seed them — the demo admin is granted every discovered plugin's declared tokens, so
a dropped-in plugin works out of the box without editing host config.
### Contract versioning ### Contract versioning
@@ -579,15 +695,15 @@ with `findConflicts` and resolves them **loudly — never last-write-wins**. `er
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. | | `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. | | `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. |
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). | | `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
| `permission` | warn | A permission token is declared by more than one plugin. Sharing is legitimate (shared role); namespace as `<id>:<action>` if unintended. | | `role` | warn | A role name is declared by more than one plugin. Sharing is legitimate; namespace as `<id>:<action>` if unintended. |
There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its
uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns uniqueness follows from the id check. `role` is the one intentional overlap, so it warns
rather than aborts; everything else is an error an author fixes before the host will start. rather than aborts; everything else is an error an author fixes before the host will start.
Beyond cross-plugin conflicts, discovery also rejects **per-manifest shape errors** at boot: a Beyond cross-plugin conflicts, discovery also rejects **per-manifest shape errors** at boot: a
non-array `nav`/`routes`/`permissions`, a non-function `home`/`dashboard`, or a route/nav node that non-array `nav`/`routes`/`roles`, a non-function `home`/`dashboard`, or a route/nav node that
sets both `public` and `permission` (mutually exclusive — [Public pages](#public-pages--menu-items)). sets both `public` and `role` (mutually exclusive — [Public pages](#public-pages--menu-items)).
### Hooks ### Hooks
@@ -654,7 +770,7 @@ can't escape its own package scope, so it can't point at the host's file directl
> Discovery — scanning `plugins/`, importing each `plugin.ts` default export, and > Discovery — scanning `plugins/`, importing each `plugin.ts` default export, and
> validating it (id, `apiVersion`, conflicts) — runs at boot (`src/plugin-host/discovery.ts`); a bad > validating it (id, `apiVersion`, conflicts) — runs at boot (`src/plugin-host/discovery.ts`); a bad
> plugin stops startup with a precise message. The router (`src/plugin-host/router.ts`) then mounts > plugin stops startup with a precise message. The router (`src/plugin-host/router.ts`) then mounts
> each route at `/<id>`, resolves `:name` params, runs the permission gate, and turns the > each route at `/<id>`, resolves `:name` params, runs the role gate, and turns the
> handler's `RouteResult` into the response; a `view` result renders > handler's `RouteResult` into the response; a `view` result renders
> `plugins/<id>/views/<view>.ejs` (`src/plugin-host/view-resolver.ts`), which may `include()` the core > `plugins/<id>/views/<view>.ejs` (`src/plugin-host/view-resolver.ts`), which may `include()` the core
> building-block partials. A plugin's `public/` assets are served at `/public/<id>/` > building-block partials. A plugin's `public/` assets are served at `/public/<id>/`
@@ -685,7 +801,7 @@ worked example: thin handlers bound to an injectable upstream client, unit-teste
3. **E2E the user-facing flow.** Per AGENTS.md §6, ship a side-effect-free Playwright test in 3. **E2E the user-facing flow.** Per AGENTS.md §6, ship a side-effect-free Playwright test in
`e2e-tests/` for each plugin page/form so the suite stays `fullyParallel`, run against the live `web` `e2e-tests/` for each plugin page/form so the suite stays `fullyParallel`, run against the live `web`
service with the plugin mounted. The reference's permission-gating is covered in `visual.spec.ts`; service with the plugin mounted. The reference's role-gating is covered in `visual.spec.ts`;
its authenticated list/form happy-path is the full-E2E item (needs cross-host login infra). its authenticated list/form happy-path is the full-E2E item (needs cross-host login infra).
The validation an author hits is the same the host runs: bad `apiVersion` or a conflict The validation an author hits is the same the host runs: bad `apiVersion` or a conflict
@@ -713,13 +829,13 @@ The menu is **driven entirely by config** and assembled from two sources:
export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } }); export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } });
``` ```
Every nav item may carry a `permission`; the rendered tree is **filtered per user** by Every nav item may carry a `role`; the rendered tree is **filtered per user** by
reading the roles in the session JWT (no per-request authz call — see reading the roles in the session JWT (no per-request authz call — see
[Auth, sessions & permissions](#auth-sessions--permissions)), so the menu only ever shows [Auth, sessions & access](#auth-sessions--access)), so the menu only ever shows
what that person can reach. An item (or a whole page) may instead be marked **`public: what that person can reach. An item (or a whole page) may instead be marked **`public:
true`** to show it to **everyone, signed in or not** — the blessed, explicit way to expose true`** to show it to **everyone, signed in or not** — the blessed, explicit way to expose
a public page and its menu entry (a no-permission item is already public; `public` just a public page and its menu entry (an ungated item is already public; `public` just
says so on purpose, and is mutually exclusive with `permission`). The markup is the says so on purpose, and is mutually exclusive with `role`). The markup is the
recursive, zero-JS nav tree from the design foundation (header/leaf × clickable/static, 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 — 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 the sidebar brand shows the configured logo (else a default mark), and the theme sets the
@@ -786,7 +902,7 @@ The app is **environment-agnostic**: there is no `NODE_ENV`. Behaviour that used
| `OTLP_ENDPOINT` | _unset_ | OpenTelemetry Collector HTTP base URI; set ⇒ export logs + traces (unset ⇒ console only) | | `OTLP_ENDPOINT` | _unset_ | OpenTelemetry Collector HTTP base URI; set ⇒ export logs + traces (unset ⇒ console only) |
| `OTLP_PROTOCOL` | `http/json` | OTLP wire format: `http/json` or `http/protobuf` | | `OTLP_PROTOCOL` | `http/json` | OTLP wire format: `http/json` or `http/protobuf` |
| `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) | | `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 | | `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | authorization check / write |
| `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (login/consent handshake) | | `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (login/consent handshake) |
| `JWKS_URL` | `file://…/tokenizer/jwks.json` | the Kratos tokenizer signing key; verifies the session JWT | | `JWKS_URL` | `file://…/tokenizer/jwks.json` | the Kratos tokenizer signing key; verifies the session JWT |
| `JWT_ISSUER` / `JWT_AUDIENCE` | _unset_ | optional: when set, the session JWT's `iss` / `aud` must match (the dev tokenizer sets neither) | | `JWT_ISSUER` / `JWT_AUDIENCE` | _unset_ | optional: when set, the session JWT's `iss` / `aud` must match (the dev tokenizer sets neither) |
@@ -880,7 +996,7 @@ button, and the whole SSO section disappears when none are configured — no cod
add or remove one. Open-source Kratos has **no native SAML** — front it with an OIDC bridge 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. (Ory Polis) and register that bridge as a generic OIDC provider the same way.
## Auth, sessions & permissions ## Auth, sessions & access
Identity comes from **Kratos**; the hot path stays I/O-free by carrying coarse authorization 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, in a **locally-validated JWT**, and **Keto** is reserved for the rare fine-grained,
@@ -969,6 +1085,9 @@ deactivate the user, or use a direct user-role change, for an instant effect.
### Three tiers of "may I?" ### Three tiers of "may I?"
[Users, groups & roles](#users-groups--roles) covers *what* the entities are; this is where each
**kind** of rule belongs.
``` ```
coarse (menu / route / feature) → JWT claim · in-process, zero I/O coarse (menu / route / feature) → JWT claim · in-process, zero I/O
fine + attribute (owner / tenant / …) → upstream service that owns the row fine + attribute (owner / tenant / …) → upstream service that owns the row
@@ -983,10 +1102,8 @@ deactivate the user, or use a direct user-role change, for an instant effect.
is for. Reserve it for those; don't pay its tuple-sync cost for rules a service can already is for. Reserve it for those; don't pay its tuple-sync cost for rules a service can already
answer from its own data. answer from its own data.
The built-in users / groups / permissions screens write authorization **only to Keto** — The admin plugin's users / groups / roles screens write authorization **only to Keto** — coarse
coarse roles and fine-grained relationships alike. Roles reach the JWT by being read from roles and fine-grained relationships alike.
Keto at login and projected through the tokenizer (above); nothing authors them anywhere
else.
### OAuth2 provider (Hydra) ### OAuth2 provider (Hydra)
@@ -1027,7 +1144,7 @@ what defends what, and which guarantees are deliberately not offered.
plus `nbf` and the optional `iss`/`aud`. Before that it is bytes. plus `nbf` and the optional `iss`/`aud`. Before that it is bytes.
- **The private container network is the *only* thing guarding the Ory APIs.** Kratos admin - **The private container network is the *only* thing guarding the Ory APIs.** Kratos admin
(`4434`), Hydra admin (`4445`) and Keto write (`4467`) authenticate no one — reaching them (`4434`), Hydra admin (`4445`) and Keto write (`4467`) authenticate no one — reaching them
*is* full identity and permission control. Keto **read** (`4466`) cannot write, but discloses *is* full identity and authorization control. Keto **read** (`4466`) cannot write, but discloses
the entire authorization graph, so treat it the same. `compose.yml` publishes none of the six the entire authorization graph, so treat it the same. `compose.yml` publishes none of the six
Ory ports (guarded by `src/compose.test.ts`); dev publishes only the two a browser must reach. Ory ports (guarded by `src/compose.test.ts`); dev publishes only the two a browser must reach.
Never expose one, and never front one with a proxy that lacks its own auth. Never expose one, and never front one with a proxy that lacks its own auth.
@@ -1126,7 +1243,7 @@ pages by **verifying the JWT in-process, with no per-request call to Ory**. Keto
the rarer fine-grained checks; Hydra is used only when the app acts as an OAuth2 **login & 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 consent provider** for other apps. It reaches the Ory services over their **REST APIs
using Node's built-in `fetch`** — no SDK dependency. See using Node's built-in `fetch`** — no SDK dependency. See
[Auth, sessions & permissions](#auth-sessions--permissions). [Auth, sessions & access](#auth-sessions--access).
In **dev** the host-facing Ory ports are published — Kratos public `4433` (where the browser In **dev** the host-facing Ory ports are published — Kratos public `4433` (where the browser
POSTs self-service flows) and Hydra public `4444`; **prod** (`docker compose -f compose.yml POSTs self-service flows) and Hydra public `4444`; **prod** (`docker compose -f compose.yml
@@ -1170,7 +1287,7 @@ service — no Node/browsers on the host. There are five suites:
**Visual + design system** (`visual.spec.ts`) — Ory-free, so it stays fast. It screenshots **Visual + design system** (`visual.spec.ts`) — Ory-free, so it stays fast. It screenshots
the live pages and asserts the rendered design system — the app shell, theme switch, mobile the live pages and asserts the rendered design system — the app shell, theme switch, mobile
off-canvas layout, icon sprite, CSRF-guarded sign-out, the public landing, the 404 page, and off-canvas layout, icon sprite, CSRF-guarded sign-out, the public landing, the 404 page, and
plugin permission-gating — the last exercised by bind-mounting the reference example plugin role-gating — the last exercised by bind-mounting the reference example
(`examples/plugins/scheduling/`) onto `/app/plugins/scheduling`. (`examples/plugins/scheduling/`) onto `/app/plugins/scheduling`.
```bash ```bash
@@ -1206,7 +1323,7 @@ docker compose -f compose.yml -f e2e-tests/compose.oauth.yml down -v
the themed **password login** and a **mocked-SSO** login (an in-network mock OIDC provider, the themed **password login** and a **mocked-SSO** login (an in-network mock OIDC provider,
`e2e-tests/mock-oidc.ts`), **menu filtering by role**, the **users/groups/roles** admin CRUD, the `e2e-tests/mock-oidc.ts`), **menu filtering by role**, the **users/groups/roles** admin CRUD, the
**OAuth2-clients** admin screen (register → one-time secret → delete; Hydra is part of this stack **OAuth2-clients** admin screen (register → one-time secret → delete; Hydra is part of this stack
for it), a permission-gated **plugin page**, and **logout**. Because the themed form posts straight to for it), a role-gated **plugin page**, and **logout**. Because the themed form posts straight to
Kratos and cookies are host-scoped, a tiny same-origin gateway (`e2e-tests/proxy.ts`) fronts web + Kratos and cookies are host-scoped, a tiny same-origin gateway (`e2e-tests/proxy.ts`) fronts web +
Kratos on one host (`ory/kratos/e2e-proxy.yml` points Kratos at it) — exactly as a production Kratos on one host (`ory/kratos/e2e-proxy.yml` points Kratos at it) — exactly as a production
reverse proxy would. reverse proxy would.
@@ -1401,7 +1518,7 @@ mid-response, so container restarts are clean.
The first-boot **bootstrap** is idempotent and runs on every `up` — it generates the JWT The first-boot **bootstrap** is idempotent and runs on every `up` — it generates the JWT
signing key if absent, creates the demo admin in Kratos, and grants it the `admin` role plus signing key if absent, creates the demo admin in Kratos, and grants it the `admin` role plus
every discovered plugin's declared permission tokens in Keto, so permission checks (and any every discovered plugin's declared role names in Keto, so role checks (and any
dropped-in plugin) resolve out of the box. The web app waits for Kratos + Keto to be healthy dropped-in plugin) resolve out of the box. The web app waits for Kratos + Keto to be healthy
*and* the bootstrap to finish before starting. **Change the demo admin before production.** *and* the bootstrap to finish before starting. **Change the demo admin before production.**
@@ -1538,7 +1655,7 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
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) 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)
gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation
login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie
guards.ts requireSession()/can()/check(): in-handler authorization — the imperative counterpart to the route permission gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call guards.ts requireSession()/can()/check(): in-handler authorization — the imperative counterpart to the route role gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call
csrf.ts CSRF for our own POST forms: signed double-submit token — issue/verify, cookie, request gate csrf.ts CSRF for our own POST forms: signed double-submit token — issue/verify, cookie, request gate
denylist.ts Optional instant-revoke denylist: in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST) denylist.ts Optional instant-revoke denylist: in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST)
flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs
@@ -1557,7 +1674,7 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
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) 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 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 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 router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, role 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 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
view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials
@@ -1576,7 +1693,7 @@ public/ Static assets under /public/ (css/styles.css + auth.css, fa
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 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) 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 example plugins onto /app/plugins/scheduling and /app/plugins/admin 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) examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + role-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 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`) ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`)
.gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main; .gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main;
+1 -1
View File
@@ -131,7 +131,7 @@ services:
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
# Base roles for the demo admin; bootstrap also grants every discovered plugin's declared # Base roles for the demo admin; bootstrap also grants every discovered plugin's declared
# permission tokens (so the reference plugin — and any drop-in — works out of the box). # role names (so the reference plugin — and any drop-in — works out of the box).
ADMIN_ROLES: ${ADMIN_ROLES:-admin} ADMIN_ROLES: ${ADMIN_ROLES:-admin}
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
+1 -1
View File
@@ -30,7 +30,7 @@ services:
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro - ./examples/plugins/scheduling:/app/plugins/scheduling:ro
- ./examples/plugins/admin:/app/plugins/admin:ro - ./examples/plugins/admin:/app/plugins/admin:ro
# bootstrap grants the demo admin every discovered plugin's permission tokens, so it needs the # bootstrap grants the demo admin every discovered plugin's role names, so it needs the
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403. # example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
bootstrap: bootstrap:
volumes: volumes:
+1 -1
View File
@@ -114,7 +114,7 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to
}); });
// The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is // The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is
// reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated, // reachable by anyone and its menu header shows for everyone; the shifts list stays role-gated,
// so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full // so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full
// E2E (full-flow.spec). Side-effect-free. // E2E (full-flow.spec). Side-effect-free.
test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => { test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => {
+1 -1
View File
@@ -5,7 +5,7 @@ across (or bind-mount your own) and restart.
| Path | Copy into | Example of | | Path | Copy into | Example of |
| --- | --- | --- | | --- | --- | --- |
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). | | [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and role-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). | | [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). | | [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. | | [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. |
+1 -1
View File
@@ -3,7 +3,7 @@
// config/ ships empty; mount your own or copy this in. Absent config = built-in defaults. // config/ ships empty; mount your own or copy this in. Absent config = built-in defaults.
// //
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins — // Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins —
// the override always wins, applied before the per-user permission filter. Every field is // the override always wins, applied before the per-user role filter. Every field is
// optional; delete one to fall back to the default. // optional; delete one to fall back to the default.
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README.md (The menu system). // See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README.md (The menu system).
+3 -3
View File
@@ -28,12 +28,12 @@ stack**, so they use the privileged **`ctx.system`** surface the host exposes to
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto, `ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered, than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered,
gated per route by `permission: "admin"`, rendering the core building blocks in `views/`. gated per route by `role: "admin"`, rendering the core building blocks in `views/`.
## Layout ## Layout
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission token, and the - `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` role, and the
route table — one thin handler per method+path, all gated by `permission: "admin"`. route table — one thin handler per method+path, all gated by `role: "admin"`.
- `admin-users.ts` · `admin-groups.ts` · `admin-roles.ts` · `admin-clients.ts` — each a set of pure - `admin-users.ts` · `admin-groups.ts` · `admin-roles.ts` · `admin-clients.ts` — each a set of pure
view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the `ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
+1 -1
View File
@@ -1,4 +1,4 @@
// Built-in Roles & permissions admin screen: the pure view-model + Keto builders. A role is a // Built-in Roles admin screen: the pure view-model + Keto builders. A role is a
// Keto subject set (Role:<name>#members); members are users (subject_id) or groups (subject_set) — // Keto subject set (Role:<name>#members); members are users (subject_id) or groups (subject_set) —
// "assign roles to users/groups". The "effective access" view flattens a Keto `expand` tree into the // "assign roles to users/groups". The "effective access" view flattens a Keto `expand` tree into the
// distinct set of users who hold the role directly or transitively via a group. The HTTP // distinct set of users who hold the role directly or transitively via a group. The HTTP
+5 -5
View File
@@ -1,4 +1,4 @@
// Roles & permissions admin screen: list / create / delete Keto roles and assign // Roles admin screen: list / create / delete Keto roles and assign
// them to users and groups. A role is a Keto subject set `Role:<name>#members` (OPL: members are users // them to users and groups. A role is a Keto subject set `Role:<name>#members` (OPL: members are users
// or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the // or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging) // Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
@@ -9,7 +9,7 @@
// ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded. // ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded.
import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api"; import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
import { ADMIN_PERMISSION, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import { ADMIN_ROLE, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import { import {
type GroupView, type GroupView,
groupsFromTuples, groupsFromTuples,
@@ -333,7 +333,7 @@ export const rolesAddMember = withRoleName(async (deps, name) => {
// GET /admin/roles/:name/delete — confirm, except the admin role can't be deleted. // GET /admin/roles/:name/delete — confirm, except the admin role can't be deleted.
export const rolesDeleteConfirm = withRoleName((deps, name) => { export const rolesDeleteConfirm = withRoleName((deps, name) => {
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access."); if (name === ADMIN_ROLE) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
const base = detailHref(name); const base = detailHref(name);
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }], breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }],
@@ -347,7 +347,7 @@ export const rolesDeleteConfirm = withRoleName((deps, name) => {
export const rolesDelete = withRoleName(async (deps, name) => { export const rolesDelete = withRoleName(async (deps, name) => {
const { ctx, keto, user } = deps; const { ctx, keto, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST await guardedForm(ctx); // CSRF-verify the POST
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access."); if (name === ADMIN_ROLE) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS }); await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS });
ctx.log.info("admin: role deleted", { actor: user.id, role: name }); ctx.log.info("admin: role deleted", { actor: user.id, role: name });
return { redirect: ADMIN_ROLES_BASE }; return { redirect: ADMIN_ROLES_BASE };
@@ -360,7 +360,7 @@ export const rolesRemoveMember = withRoleName(async (deps, name) => {
const { ctx, keto, revoke, user } = deps; const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!; const form = (await guardedForm(ctx))!;
const member = (form.get("member") ?? "").trim(); const member = (form.get("member") ?? "").trim();
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access."); if (name === ADMIN_ROLE && member === `user:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
const tuple = roleMemberTuple(name, member); const tuple = roleMemberTuple(name, member);
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); } if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); }
return { redirect: detailHref(name) }; return { redirect: detailHref(name) };
+3 -3
View File
@@ -7,7 +7,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { test } from "node:test"; import { test } from "node:test";
import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api"; import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api";
import { ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts"; import { ADMIN_NAV, ADMIN_ROLE, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] }; const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] };
const member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] }; const member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
@@ -27,11 +27,11 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => { test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => {
assert.equal(ADMIN_NAV.id, "admin"); assert.equal(ADMIN_NAV.id, "admin");
assert.equal(ADMIN_NAV.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin assert.equal(ADMIN_NAV.role, ADMIN_ROLE); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Roles", "OAuth2 clients"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Roles", "OAuth2 clients"]);
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.role === undefined)); // the header's gate covers the subtree
}); });
// ---- auth gates ---- // ---- auth gates ----
+5 -5
View File
@@ -5,7 +5,7 @@
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type User } from "#plugin-api"; import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type User } from "#plugin-api";
export const ADMIN_PERMISSION = "admin"; // role token gating the whole admin section export const ADMIN_ROLE = "admin"; // the role gating the whole admin section
export const ADMIN_USERS_BASE = "/admin/users"; export const ADMIN_USERS_BASE = "/admin/users";
export const ADMIN_GROUPS_BASE = "/admin/groups"; export const ADMIN_GROUPS_BASE = "/admin/groups";
export const ADMIN_ROLES_BASE = "/admin/roles"; export const ADMIN_ROLES_BASE = "/admin/roles";
@@ -14,7 +14,7 @@ export const ADMIN_CLIENTS_BASE = "/admin/clients";
export type AdminScreen = "clients" | "groups" | "roles" | "users"; export type AdminScreen = "clients" | "groups" | "roles" | "users";
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into // The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into
// the one global menu, filters per user (the header's `permission` drops the whole subtree for a // the one global menu, filters per user (the header's `role` drops the whole subtree for a
// non-admin), and current-marks the active item — so there is no `current`/`open` state here. // non-admin), and current-marks the active item — so there is no `current`/`open` state here.
export const ADMIN_NAV: NavNode = { export const ADMIN_NAV: NavNode = {
children: [ children: [
@@ -26,15 +26,15 @@ export const ADMIN_NAV: NavNode = {
icon: "i-shield", icon: "i-shield",
id: "admin", id: "admin",
label: "Admin", label: "Admin",
permission: ADMIN_PERMISSION, role: ADMIN_ROLE,
}; };
// The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the // The admin gate: a signed-in admin only. Each route already declares `role: "admin"`, so the
// host enforces this before the handler runs; this is defence-in-depth and what a direct unit test // host enforces this before the handler runs; this is defence-in-depth and what a direct unit test
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403. // relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
export function requireAdmin(ctx: RequestContext): User { export function requireAdmin(ctx: RequestContext): User {
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept) const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin role required"); if (!can(ctx, ADMIN_ROLE)) throw new GuardError(403, "admin role required");
return user; return user;
} }
+1 -1
View File
@@ -268,7 +268,7 @@ function readUserInput(form: URLSearchParams): UserInput {
// the Kratos capability (else a themed 503). Each route below is a thin handler over these. // the Kratos capability (else a themed 503). Each route below is a thin handler over these.
interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; } interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
// Resolve the shared deps, then run `inner`. The route's `permission: "admin"` already gated at the // Resolve the shared deps, then run `inner`. The route's `role: "admin"` already gated at the
// host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it. // host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandler { function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => { return async (ctx) => {
+4 -4
View File
@@ -11,19 +11,19 @@ import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clie
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts"; import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts";
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-roles.ts"; import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-roles.ts";
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts"; import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts"; import { ADMIN_NAV, ADMIN_ROLE } from "./admin-shared.ts";
// Every admin route is gated by the one `admin` permission — the host redirects an anonymous visitor // Every admin route is gated by the one `admin` role — the host redirects an anonymous visitor
// to /login, gives a signed-in non-admin the 403 page, and filters the nav the same way. Handlers are // to /login, gives a signed-in non-admin the 403 page, and filters the nav the same way. Handlers are
// thin and keyed on ctx.params (the host extracts :id / :name), the idiomatic per-route style. // thin and keyed on ctx.params (the host extracts :id / :name), the idiomatic per-route style.
const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, permission: ADMIN_PERMISSION }); const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, role: ADMIN_ROLE });
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV], nav: [ADMIN_NAV],
permissions: [{ description: "Administer users, groups, roles, and OAuth2 clients", token: ADMIN_PERMISSION }], roles: [{ description: "Administer users, groups, roles, and OAuth2 clients", name: ADMIN_ROLE }],
routes: [ routes: [
// Users // Users
+1 -1
View File
@@ -15,7 +15,7 @@ What it demonstrates:
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream, `POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`, then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
reusing the core `field` partial. reusing the core `field` partial.
- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` / - **Role-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
`scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant. `scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant.
The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and
+10 -10
View File
@@ -1,5 +1,5 @@
// Reference plugin: a worked example of the contract — a list page that fetches upstream // Reference plugin: a worked example of the contract — a list page that fetches upstream
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this // data, a CSRF-guarded form that forwards a write upstream, and role-gated nav. Copy this
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins. // folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
import { definePlugin } from "#plugin-api"; import { definePlugin } from "#plugin-api";
@@ -23,25 +23,25 @@ export default definePlugin({
nav: [{ nav: [{
children: [ children: [
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true }, { href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true },
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ }, { href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", role: READ },
], ],
icon: "i-cal", icon: "i-cal",
id: "scheduling", id: "scheduling",
label: "Scheduling", label: "Scheduling",
}], }],
// Tokens this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`. // Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
permissions: [ roles: [
{ description: "View shifts", token: READ }, { description: "View shifts", name: READ },
{ description: "Create and edit shifts", token: WRITE }, { description: "Create and edit shifts", name: WRITE },
], ],
// Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public` // Mounted under /scheduling; `role` gates before the handler runs. The overview is `public`
// (anyone may reach /scheduling, signed in or not); the rest need a role. // (anyone may reach /scheduling, signed in or not); the rest need a role.
routes: [ routes: [
{ handler: overview(), method: "GET", path: "/", public: true }, { handler: overview(), method: "GET", path: "/", public: true },
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ }, { handler: listShifts(upstream), method: "GET", path: "/shifts", role: READ },
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE }, { handler: newShiftForm(), method: "GET", path: "/shifts/new", role: WRITE },
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE }, { handler: createShift(upstream), method: "POST", path: "/shifts", role: WRITE },
], ],
}); });
+2 -2
View File
@@ -10,8 +10,8 @@ import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormB
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
export const SHIFTS_PATH = "/scheduling/shifts"; export const SHIFTS_PATH = "/scheduling/shifts";
export const READ = "scheduling:read"; // permission token gating the list + nav export const READ = "scheduling:read"; // the role gating the list + nav
export const WRITE = "scheduling:write"; // permission token gating create export const WRITE = "scheduling:write"; // the role gating create
export interface Shift { export interface Shift {
id: string; id: string;
+1 -1
View File
@@ -30,7 +30,7 @@ test("roleTuple grants a role to user:<id> in the Role namespace", () => {
}); });
}); });
test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared tokens", () => { test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared roles", () => {
// Clean clone: no ADMIN_ROLES, the scheduling plugin declares its two tokens → the demo admin // Clean clone: no ADMIN_ROLES, the scheduling plugin declares its two tokens → the demo admin
// gets exactly today's behaviour, but derived from discovery, not hardcoded in the host. // gets exactly today's behaviour, but derived from discovery, not hardcoded in the host.
assert.deepEqual(seedRoles(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]); assert.deepEqual(seedRoles(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
+7 -7
View File
@@ -2,8 +2,8 @@
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`: // kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net); // 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos; // 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
// 3. grant it its roles in Keto so menu/permission checks resolve out of the box — `admin` plus // 3. grant it its roles in Keto so menu/role checks resolve out of the box — `admin` plus
// every discovered plugin's declared permission tokens, so a dropped-in plugin is usable by // every discovered plugin's declared role names, so a dropped-in plugin is usable by
// the demo admin with no host config edit (the host stays plugin-agnostic). // the demo admin with no host config edit (the host stays plugin-agnostic).
// Then prints a first-run banner; fails loud on any unexpected upstream error. // Then prints a first-run banner; fails loud on any unexpected upstream error.
import { existsSync, writeFileSync } from "node:fs"; import { existsSync, writeFileSync } from "node:fs";
@@ -29,12 +29,12 @@ export function roleTuple(identityId: string, role: string) {
} }
// The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`) // The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`)
// unioned with every discovered plugin's declared permission tokens (a route/nav `permission` is a // unioned with every discovered plugin's declared role names (a route/nav `role` is a
// coarse role — granted as a Keto `Role:<token>#members` tuple). So the host names no plugin, yet a // coarse role — granted as a Keto `Role:<token>#members` tuple). So the host names no plugin, yet a
// dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped. // dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped.
export function seedRoles(adminRolesEnv: string | undefined, declaredTokens: string[]): string[] { export function seedRoles(adminRolesEnv: string | undefined, declaredRoles: string[]): string[] {
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean); const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredTokens)])]; return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredRoles)])];
} }
// --- JWKS safety net ----------------------------------------------------------------- // --- JWKS safety net -----------------------------------------------------------------
@@ -143,9 +143,9 @@ async function main() {
await runWithLog(log, async () => { await runWithLog(log, async () => {
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key"); if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
// Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared permission tokens, so the // Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared role names, so the
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit. // shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.token)); const declared = (await discoverPlugins()).flatMap((p) => (p.roles ?? []).map((d) => d.name));
const roles = seedRoles(env["ADMIN_ROLES"], declared); const roles = seedRoles(env["ADMIN_ROLES"], declared);
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local"; const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin"; const password = env["ADMIN_PASSWORD"] ?? "admin";
+1 -1
View File
@@ -1,5 +1,5 @@
// Auth guards: in-handler authorization, the imperative counterpart to the // Auth guards: in-handler authorization, the imperative counterpart to the
// declarative route `permission` gate. The middleware already verified the session JWT and put // declarative route `role` gate. The middleware already verified the session JWT and put
// the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps // the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps
// to a response); `can`/`check` are predicates a handler branches on. `check` is the one live // to a response); `can`/`check` are predicates a handler branches on. `check` is the one live
// Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules. // Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules.
+1 -1
View File
@@ -2,7 +2,7 @@
// the hot path that never calls Ory. Select the verify key by `kid` from the cached JWKS, // the hot path that never calls Ory. Select the verify key by `kid` from the cached JWKS,
// check the signature (src/auth/jwt.ts), validate the time/issuer/audience claims, project the // check the signature (src/auth/jwt.ts), validate the time/issuer/audience claims, project the
// User onto the request context. `authenticate` fails closed: any bad/expired token ⇒ null // User onto the request context. `authenticate` fails closed: any bad/expired token ⇒ null
// (anonymous), so the route renders signed-out and the permission gate denies. // (anonymous), so the route renders signed-out and the role gate denies.
import type { User } from "../http/context.ts"; import type { User } from "../http/context.ts";
import { parseCookies } from "../http/cookie.ts"; import { parseCookies } from "../http/cookie.ts";
import type { Denylist } from "./denylist.ts"; import type { Denylist } from "./denylist.ts";
+6 -6
View File
@@ -385,7 +385,7 @@ test("renders the 500 HTML page when a handler throws", async () => {
} }
}); });
// A test plugin exercising each RouteResult shape, a path param, and the permission gate. // A test plugin exercising each RouteResult shape, a path param, and the role gate.
const demoPlugin: Plugin = { const demoPlugin: Plugin = {
apiVersion: "1.0.0", apiVersion: "1.0.0",
id: "demo", id: "demo",
@@ -393,7 +393,7 @@ const demoPlugin: Plugin = {
{ handler: (ctx) => ({ html: `<p>Hi ${ctx.params.name}</p>` }), method: "GET", path: "/hello/:name" }, { handler: (ctx) => ({ html: `<p>Hi ${ctx.params.name}</p>` }), method: "GET", path: "/hello/:name" },
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" }, { handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" },
{ handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" }, { handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" },
{ handler: () => ({ html: "secret" }), method: "GET", path: "/secret", permission: "demo:read" }, { handler: () => ({ html: "secret" }), method: "GET", path: "/secret", role: "demo:read" },
{ handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // blessed public { handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // blessed public
{ handler: () => ({ data: { who: "Plainpages" }, view: "page" }), method: "GET", path: "/page" }, { handler: () => ({ data: { who: "Plainpages" }, view: "page" }), method: "GET", path: "/page" },
], ],
@@ -406,7 +406,7 @@ async function startApp(t: TestContext, plugins: Plugin[], pluginsDir?: string):
return `http://localhost:${(app.address() as AddressInfo).port}`; return `http://localhost:${(app.address() as AddressInfo).port}`;
} }
test("mounts plugin routes: params, html/json/redirect/view results, and the permission gate", async (t) => { test("mounts plugin routes: params, html/json/redirect/view results, and the role gate", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-")); const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
mkdirSync(join(dir, "demo", "views"), { recursive: true }); mkdirSync(join(dir, "demo", "views"), { recursive: true });
mkdirSync(join(dir, "demo", "public"), { recursive: true }); mkdirSync(join(dir, "demo", "public"), { recursive: true });
@@ -610,7 +610,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
{ handler: (ctx) => ({ html: `hi ${requireSession(ctx).email}` }), method: "GET", path: "/me" }, { handler: (ctx) => ({ html: `hi ${requireSession(ctx).email}` }), method: "GET", path: "/me" },
{ handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" }, { handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" },
{ handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" }, { handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" },
{ handler: () => ({ html: "gated" }), method: "GET", path: "/gated", permission: "secret:read" }, // declarative route gate { handler: () => ({ html: "gated" }), method: "GET", path: "/gated", role: "secret:read" }, // declarative route gate
], ],
}; };
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] }); const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] });
@@ -636,7 +636,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
assert.equal((await fetch(url + "/guarded/doc/open", auth([]))).status, 200); assert.equal((await fetch(url + "/guarded/doc/open", auth([]))).status, 200);
assert.equal((await fetch(url + "/guarded/doc/shut", auth([]))).status, 403); assert.equal((await fetch(url + "/guarded/doc/shut", auth([]))).status, 403);
// declarative route `permission` gate: anonymous → sign in, signed-in-without-role → the 403 page, with → 200. // declarative route `role` gate: anonymous → sign in, signed-in-without-role → the 403 page, with → 200.
const gAnon = await fetch(url + "/guarded/gated", { redirect: "manual" }); const gAnon = await fetch(url + "/guarded/gated", { redirect: "manual" });
assert.equal(gAnon.status, 303); assert.equal(gAnon.status, 303);
assert.equal(gAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fgated"); assert.equal(gAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fgated");
@@ -1225,7 +1225,7 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
assert.equal((await get("/admin/groups/%ZZ")).status, 404); assert.equal((await get("/admin/groups/%ZZ")).status, 404);
}); });
// Built-in Roles & permissions admin screen: gate + list/create/assign/revoke/delete over HTTP // Built-in Roles admin screen: gate + list/create/assign/revoke/delete over HTTP
// against a fake in-memory Keto whose `expand` mirrors Keto's transitive resolution, so the // against a fake in-memory Keto whose `expand` mirrors Keto's transitive resolution, so the
// effective-access view surfaces a user reachable only through a group. // effective-access view surfaces a user reachable only through a group.
test("admin Roles screen: gate, list, create, assign user/group, effective access (expand), revoke, delete", async (t) => { test("admin Roles screen: gate, list, create, assign user/group, effective access (expand), revoke, delete", async (t) => {
+2 -2
View File
@@ -240,7 +240,7 @@ export function createApp(options: AppOptions = {}): Server {
} }
} }
// Plugin routes (any method): gate on the route's permission, then run the handler. The // Plugin routes (any method): gate on the route's role, then run the handler. The
// handler gets ctx.chrome (native app shell) + ctx.verifyCsrf (guard its own forms); a fresh // handler gets ctx.chrome (native app shell) + ctx.verifyCsrf (guard its own forms); a fresh
// CSRF cookie is set so those forms have a valid double-submit token. // CSRF cookie is set so those forms have a valid double-submit token.
const match = matchRoute(plugins, method, pathname); const match = matchRoute(plugins, method, pathname);
@@ -250,7 +250,7 @@ export function createApp(options: AppOptions = {}): Server {
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as // Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
// return_to; a signed-in user who simply lacks the role gets the 403 page. // return_to; a signed-in user who simply lacks the role gets the 403 page.
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; } if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
reqLog.warn("forbidden: missing role", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id }); reqLog.warn("forbidden: missing role", { path: pathname, required: match.route.role ?? "", sub: routeCtx.user.id });
sendHtml(res, 403, await render("403", { title: "Forbidden" })); sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return; return;
} }
+6 -6
View File
@@ -50,8 +50,8 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s }, { name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s },
{ name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s }, { name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s },
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ }, { name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s }, { name: "a route marked public AND role is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, role: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*role/s },
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x" }] };` }, match: /contranav.*public.*permission/s }, { name: "a nav node marked public AND role is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, role: "x" }] };` }, match: /contranav.*public.*role/s },
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ }, { name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ },
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ }, { name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
]; ];
@@ -85,12 +85,12 @@ test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard)
assert.equal(typeof plugins[0]?.dashboard, "function"); assert.equal(typeof plugins[0]?.dashboard, "function");
}); });
test("a shared permission token only warns — both plugins still load", async (t) => { test("a shared role name only warns — both plugins still load", async (t) => {
const perm = `export default { apiVersion: "1.0.0", permissions: [{ token: "shared:read" }] };`; const shared = `export default { apiVersion: "1.0.0", roles: [{ name: "shared:read" }] };`;
const dir = scaffold(t, { "x/plugin.ts": perm, "y/plugin.ts": perm }); const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared });
const warnings: string[] = []; const warnings: string[] = [];
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } }); const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
assert.equal(plugins.length, 2); assert.equal(plugins.length, 2);
assert.ok(warnings.some((w) => /shared:read/.test(w)), "expected a permission-conflict warning"); assert.ok(warnings.some((w) => /shared:read/.test(w)), "expected a role-conflict warning");
}); });
+6 -6
View File
@@ -2,7 +2,7 @@
// validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules // validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules
// (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and // (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics // error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
// (older-minor apiVersion, shared permission token) log and load continues. Folder name = id. // (older-minor apiVersion, shared role name) log and load continues. Folder name = id.
import { existsSync, readdirSync } from "node:fs"; import { existsSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
@@ -85,7 +85,7 @@ function asManifest(value: unknown): PluginManifest | null {
// The collection fields feed findConflicts, which iterates them — a non-array crashes it opaquely. // The collection fields feed findConflicts, which iterates them — a non-array crashes it opaquely.
function shapeError(manifest: PluginManifest): string | null { function shapeError(manifest: PluginManifest): string | null {
for (const field of ["nav", "permissions", "routes"] as const) { for (const field of ["nav", "roles", "routes"] as const) {
if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`; if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`;
} }
// `home` / `dashboard` (the landing-page overrides) are route handlers; the host calls them, so // `home` / `dashboard` (the landing-page overrides) are route handlers; the host calls them, so
@@ -93,20 +93,20 @@ function shapeError(manifest: PluginManifest): string | null {
for (const slot of ["home", "dashboard"] as const) { for (const slot of ["home", "dashboard"] as const) {
if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`; if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`;
} }
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs // `public` and `role` are contradictory on the same route/nav node — "open to all" vs
// "needs this role". Refuse rather than silently pick one, so the author's intent is unambiguous. // "needs this role". Refuse rather than silently pick one, so the author's intent is unambiguous.
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) { for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
if (route?.public === true && route.permission != null) return `route "${route.method} ${route.path}" sets both public and permission — they are mutually exclusive`; if (route?.public === true && route.role != null) return `route "${route.method} ${route.path}" sets both public and role — they are mutually exclusive`;
} }
const navContradiction = findPublicNavContradiction(manifest.nav); const navContradiction = findPublicNavContradiction(manifest.nav);
if (navContradiction) return navContradiction; if (navContradiction) return navContradiction;
return null; return null;
} }
// Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory. // Recurse the nav fragment: a node that is both `public` and `role`-gated is contradictory.
function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null { function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null {
for (const node of Array.isArray(nodes) ? nodes : []) { for (const node of Array.isArray(nodes) ? nodes : []) {
if (node?.public === true && node.permission != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and permission — they are mutually exclusive`; if (node?.public === true && node.role != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and role — they are mutually exclusive`;
const inChild = findPublicNavContradiction(node?.children); const inChild = findPublicNavContradiction(node?.children);
if (inChild) return inChild; if (inChild) return inChild;
} }
+1 -1
View File
@@ -5,7 +5,7 @@
// a plugin should import from here, never reach into deeper modules. See README.md → Building plugins. // a plugin should import from here, never reach into deeper modules. See README.md → Building plugins.
export { definePlugin } from "./plugin.ts"; export { definePlugin } from "./plugin.ts";
export type { HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts"; export type { HttpMethod, Plugin, PluginHooks, PluginManifest, RoleDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
export type { RequestContext, User } from "../http/context.ts"; export type { RequestContext, User } from "../http/context.ts";
export type { PageChrome } from "../ui/chrome.ts"; export type { PageChrome } from "../ui/chrome.ts";
export type { NavNode } from "../ui/nav.ts"; export type { NavNode } from "../ui/nav.ts";
+10 -10
View File
@@ -21,13 +21,13 @@ const scheduling: PluginManifest = definePlugin({
apiVersion: "1.0.0", apiVersion: "1.0.0",
hooks: { onBoot: () => {} }, hooks: { onBoot: () => {} },
nav: [{ nav: [{
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }], children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", role: "scheduling:read" }],
icon: "i-cal", id: "scheduling:root", label: "Scheduling", icon: "i-cal", id: "scheduling:root", label: "Scheduling",
}], }],
permissions: [{ description: "View shifts", token: "scheduling:read" }], roles: [{ description: "View shifts", name: "scheduling:read" }],
routes: [ routes: [
{ handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", permission: "scheduling:read" }, { handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", role: "scheduling:read" },
{ handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", permission: "scheduling:write" }, { handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", role: "scheduling:write" },
{ handler: (ctx) => void ctx.res.end("raw"), method: "GET", path: "/raw" }, // void = handler wrote res itself { handler: (ctx) => void ctx.res.end("raw"), method: "GET", path: "/raw" }, // void = handler wrote res itself
], ],
}); });
@@ -87,19 +87,19 @@ test("findConflicts: a duplicate id and a colliding route are loud errors", () =
assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t"))); assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t")));
}); });
test("findConflicts: duplicate nav id is an error, a shared permission token only warns", () => { test("findConflicts: duplicate nav id is an error, a shared role name only warns", () => {
const navDup = findConflicts([ const navDup = findConflicts([
p({ id: "a", nav: [{ id: "dup", label: "A" }] }), p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
p({ id: "b", nav: [{ id: "dup", label: "B" }] }), p({ id: "b", nav: [{ id: "dup", label: "B" }] }),
]); ]);
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b"))); assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
// Sharing a permission across plugins is legitimate (shared role) → warn, not error. // Sharing a role across plugins is legitimate → warn, not error.
const permDup = findConflicts([ const roleDup = findConflicts([
p({ id: "a", permissions: [{ token: "shared:read" }] }), p({ id: "a", roles: [{ name: "shared:read" }] }),
p({ id: "b", permissions: [{ token: "shared:read" }] }), p({ id: "b", roles: [{ name: "shared:read" }] }),
]); ]);
assert.ok(permDup.some((c) => c.kind === "permission" && c.level === "warn")); assert.ok(roleDup.some((c) => c.kind === "role" && c.level === "warn"));
}); });
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => { test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => {
+15 -15
View File
@@ -29,18 +29,18 @@ export interface Route {
handler: RouteHandler; handler: RouteHandler;
method: HttpMethod; method: HttpMethod;
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
permission?: string; // coarse gate (a role token); checked before the handler runs role?: string; // coarse gate — the Keto Role the caller must hold; checked before the handler runs
// Mark the page reachable by anyone, signed in or not. The same as omitting `permission` // Mark the page reachable by anyone, signed in or not. The same as omitting `role`
// — a no-permission route is already open — but stated outright, so "public" is a deliberate // — an ungated route is already open — but stated outright, so "public" is a deliberate
// choice, not an accident. Mutually exclusive with `permission` (discovery refuses both). // choice, not an accident. Mutually exclusive with `role` (discovery refuses both).
public?: boolean; public?: boolean;
} }
// A permission token this plugin introduces — declared for docs/seeding. Tokens are a shared // A Keto Role this plugin gates on — declared for docs/seeding. Role names are a shared
// global namespace (so an operator grants them in Keto); namespace as `<id>:<action>`. // global namespace (so an operator grants them once in Keto); namespace as `<id>:<action>`.
export interface PermissionDecl { export interface RoleDecl {
description?: string; description?: string;
token: string; name: string;
} }
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud. // Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
@@ -63,7 +63,7 @@ export interface PluginManifest {
home?: RouteHandler; home?: RouteHandler;
hooks?: PluginHooks; hooks?: PluginHooks;
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
permissions?: PermissionDecl[]; roles?: RoleDecl[];
routes?: Route[]; routes?: Route[];
} }
@@ -147,7 +147,7 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
} }
export interface PluginConflict { export interface PluginConflict {
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route"; kind: "dashboard" | "home" | "id" | "nav-id" | "role" | "route";
level: "error" | "warn"; level: "error" | "warn";
message: string; message: string;
plugins: string[]; // unique ids involved plugins: string[]; // unique ids involved
@@ -155,8 +155,8 @@ export interface PluginConflict {
// The conflict rules: defined, loud resolution — never last-write-wins. Pure over the discovered // The conflict rules: defined, loud resolution — never last-write-wins. Pure over the discovered
// plugins; discovery throws on any "error" and logs every "warn". Mount-path (`/<id>`) uniqueness // plugins; discovery throws on any "error" and logs every "warn". Mount-path (`/<id>`) uniqueness
// is structural — it follows from the id check, so it needs no rule of its own. Shared permission // is structural — it follows from the id check, so it needs no rule of its own. Shared role
// tokens are the one intentional overlap, so they warn rather than error. // names are the one intentional overlap, so they warn rather than error.
export function findConflicts(plugins: Plugin[]): PluginConflict[] { export function findConflicts(plugins: Plugin[]): PluginConflict[] {
const out: PluginConflict[] = []; const out: PluginConflict[] = [];
@@ -184,9 +184,9 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
}); });
collect(plugins, (plugin, push) => { collect(plugins, (plugin, push) => {
for (const decl of plugin.permissions ?? []) push(decl.token); for (const decl of plugin.roles ?? []) push(decl.name);
}).forEach((owners, token) => { }).forEach((owners, name) => {
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${token}" declared by ${uniq(owners).length} plugins; namespace as "<id>:<action>" unless shared on purpose`, plugins: uniq(owners) }); if (owners.length > 1) out.push({ kind: "role", level: "warn", message: `role "${name}" declared by ${uniq(owners).length} plugins; namespace as "<id>:<action>" unless shared on purpose`, plugins: uniq(owners) });
}); });
return out; return out;
+2 -2
View File
@@ -57,11 +57,11 @@ test("allowedMethods lists methods at a path (GET implies HEAD); empty when the
test("isAuthorized: open routes pass; gated routes require the role token; public is explicitly open", () => { test("isAuthorized: open routes pass; gated routes require the role token; public is explicitly open", () => {
const open: Route = { handler: noop, method: "GET", path: "/" }; const open: Route = { handler: noop, method: "GET", path: "/" };
const gated: Route = { handler: noop, method: "GET", path: "/", permission: "x:read" }; const gated: Route = { handler: noop, method: "GET", path: "/", role: "x:read" };
const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // blessed public alias const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // blessed public alias
assert.equal(isAuthorized(open, []), true); assert.equal(isAuthorized(open, []), true);
assert.equal(isAuthorized(gated, []), false); assert.equal(isAuthorized(gated, []), false);
assert.equal(isAuthorized(gated, ["x:read"]), true); assert.equal(isAuthorized(gated, ["x:read"]), true);
assert.equal(isAuthorized(gated, ["other"]), false); assert.equal(isAuthorized(gated, ["other"]), false);
assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting permission — but stated outright assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting role — but stated outright
}); });
+3 -3
View File
@@ -74,9 +74,9 @@ export function allowedMethods(plugins: Plugin[], pathname: string): string[] {
return [...methods].sort(); return [...methods].sort();
} }
// Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise // Coarse role gate: a route marked `public` (or one with no `role`) is open; otherwise
// the user's roles (from the session JWT) must include the token. The same rule composeNav uses // the user's roles (from the session JWT) must include the token. The same rule composeNav uses
// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both). // for the menu. `public` and `role` are mutually exclusive (discovery refuses both).
export function isAuthorized(route: Route, roles: string[]): boolean { export function isAuthorized(route: Route, roles: string[]): boolean {
return route.public === true || route.permission == null || roles.includes(route.permission); return route.public === true || route.role == null || roles.includes(route.role);
} }
+4 -4
View File
@@ -9,13 +9,13 @@ const scheduling: Plugin = {
apiVersion: "1.0.0", apiVersion: "1.0.0",
id: "scheduling", id: "scheduling",
nav: [{ nav: [{
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }], children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", role: "scheduling:read" }],
icon: "i-cal", id: "scheduling", label: "Scheduling", icon: "i-cal", id: "scheduling", label: "Scheduling",
}], }],
}; };
// A plugin with a public nav node (reachable by anyone, signed in or not). // A plugin with a public nav node (reachable by anyone, signed in or not).
const portal: Plugin = { apiVersion: "1.0.0", id: "portal", nav: [{ href: "/portal", id: "portal", label: "Portal", public: true }] }; const portal: Plugin = { apiVersion: "1.0.0", id: "portal", nav: [{ href: "/portal", id: "portal", label: "Portal", public: true }] };
// A gated section fragment like the admin plugin's nav: the header carries the permission, so // A gated section fragment like the admin plugin's nav: the header carries the role, so
// composeNav drops the whole subtree for a non-holder (the admin screens ship as a drop-in plugin). // composeNav drops the whole subtree for a non-holder (the admin screens ship as a drop-in plugin).
const adminLike: Plugin = { const adminLike: Plugin = {
apiVersion: "1.0.0", id: "admin", apiVersion: "1.0.0", id: "admin",
@@ -24,7 +24,7 @@ const adminLike: Plugin = {
{ href: "/admin/users", id: "users", label: "Users" }, { href: "/admin/users", id: "users", label: "Users" },
{ href: "/admin/groups", id: "groups", label: "Groups" }, { href: "/admin/groups", id: "groups", label: "Groups" },
], ],
icon: "i-shield", id: "admin", label: "Admin", permission: "admin", icon: "i-shield", id: "admin", label: "Admin", role: "admin",
}], }],
}; };
@@ -45,7 +45,7 @@ test("anonymous shell Sign-in link carries the current page as return_to", () =>
assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal"); assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal");
}); });
test("a permission holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => { test("a role holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
const chrome = buildPluginChrome({ const chrome = buildPluginChrome({
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling], currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
user: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] }, user: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] },
+1 -1
View File
@@ -1,7 +1,7 @@
// Central menu config: config/menu.ts lets an operator set branding (app name, logo, // Central menu config: config/menu.ts lets an operator set branding (app name, logo,
// default theme) and reorder/rename/group/hide nav nodes across all plugins. The reorder/rename/ // default theme) and reorder/rename/group/hide nav nodes across all plugins. The reorder/rename/
// group/hide part is the NavOverride composeNav already applies (the override always wins, before // group/hide part is the NavOverride composeNav already applies (the override always wins, before
// the per-user permission filter). Authored as TypeScript (defineMenu types it); loaded once at // the per-user role filter). Authored as TypeScript (defineMenu types it); loaded once at
// boot — fail-loud on a malformed file, defaults when absent (clean clone needs no config). // boot — fail-loud on a malformed file, defaults when absent (clean clone needs no config).
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
+11 -11
View File
@@ -2,23 +2,23 @@ import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { composeNav, type NavNode } from "./nav.ts"; import { composeNav, type NavNode } from "./nav.ts";
// Two plugin fragments; ids let the override target nodes, `permission` gates per role. // Two plugin fragments; ids let the override target nodes, `role` gates per role.
const fragments: NavNode[][] = [ const fragments: NavNode[][] = [
[{ [{
icon: "i-cal", id: "sched", label: "Scheduling", icon: "i-cal", id: "sched", label: "Scheduling",
children: [ children: [
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" }, { href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" },
{ href: "/scheduling/manage", id: "manage", label: "Manage", permission: "scheduling:admin" }, { href: "/scheduling/manage", id: "manage", label: "Manage", role: "scheduling:admin" },
], ],
}], }],
[{ href: "/reports", id: "reports", label: "Reports", permission: "reports:read" }], [{ href: "/reports", id: "reports", label: "Reports", role: "reports:read" }],
]; ];
test("composeNav merges fragments, filters by role, and emits clean render nodes", () => { test("composeNav merges fragments, filters by role, and emits clean render nodes", () => {
const tree = composeNav(fragments, {}, ["scheduling:read"]); const tree = composeNav(fragments, {}, ["scheduling:read"]);
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts. // Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
// Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs. // Output carries no `id`/`role` and omits absent fields — ready for nav-tree.ejs.
assert.deepEqual(tree, [ assert.deepEqual(tree, [
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling/shifts", label: "Shifts" }] }, { icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling/shifts", label: "Shifts" }] },
]); ]);
@@ -27,7 +27,7 @@ test("composeNav merges fragments, filters by role, and emits clean render nodes
test("composeNav drops gated subtrees, empty headers, and (with no roles) all gated nodes", () => { test("composeNav drops gated subtrees, empty headers, and (with no roles) all gated nodes", () => {
// A header the user can't reach takes its whole subtree, even visible children. // A header the user can't reach takes its whole subtree, even visible children.
const gatedHeader: NavNode[][] = [[ const gatedHeader: NavNode[][] = [[
{ id: "admin", label: "Admin", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] }, { id: "admin", label: "Admin", role: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] }, { id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
]]; ]];
assert.deepEqual(composeNav(gatedHeader, {}, []), [ assert.deepEqual(composeNav(gatedHeader, {}, []), [
@@ -36,8 +36,8 @@ test("composeNav drops gated subtrees, empty headers, and (with no roles) all ga
// A pure header whose children are all filtered is dropped; a header with an href survives as a leaf. // A pure header whose children are all filtered is dropped; a header with an href survives as a leaf.
const emptyHeader: NavNode[][] = [[ const emptyHeader: NavNode[][] = [[
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x" }] }, { id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", role: "x" }] },
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y" }] }, { href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", role: "y" }] },
]]; ]];
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]); assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
@@ -52,10 +52,10 @@ test("composeNav keeps a node marked public for everyone — the blessed public
icon: "i-cal", id: "sched", label: "Scheduling", icon: "i-cal", id: "sched", label: "Scheduling",
children: [ children: [
{ href: "/scheduling", id: "overview", label: "Overview", public: true }, { href: "/scheduling", id: "overview", label: "Overview", public: true },
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" }, { href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" },
], ],
}]]; }]];
// `public` is filter-only (like id/permission) — never rendered into the output node. // `public` is filter-only (like id/role) — never rendered into the output node.
assert.deepEqual(composeNav(frag, {}, []), [ assert.deepEqual(composeNav(frag, {}, []), [
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] }, { icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] },
]); ]);
@@ -66,7 +66,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
{ href: "/a", id: "a", label: "Alpha" }, { href: "/a", id: "a", label: "Alpha" },
{ href: "/b", id: "b", label: "Beta" }, { href: "/b", id: "b", label: "Beta" },
{ href: "/c", id: "c", label: "Gamma" }, { href: "/c", id: "c", label: "Gamma" },
{ href: "/secret", id: "secret", label: "Secret", permission: "root" }, { href: "/secret", id: "secret", label: "Secret", role: "root" },
]]; ]];
const tree = composeNav(base, { const tree = composeNav(base, {
+8 -8
View File
@@ -1,10 +1,10 @@
// composeNav: merge each plugin's nav fragment into one tree, apply the central // composeNav: merge each plugin's nav fragment into one tree, apply the central
// override, then permission-filter per user. Pure and I/O-free — menu gating reads the JWT // override, then role-filter per user. Pure and I/O-free — menu gating reads the JWT
// `roles` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or // `roles` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
// declares no `permission`, or `roles` includes that permission token; a gated header hides its whole // declares no `role`, or `roles` includes that role name; a gated header hides its whole
// subtree, and a pure header left with no children is dropped. The config/menu.ts supplies // subtree, and a pure header left with no children is dropped. The config/menu.ts supplies
// the override (+ branding); this helper only transforms data, so its result is per-deployment // the override (+ branding); this helper only transforms data, so its result is per-deployment
// up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/permission). // up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/role).
export interface NavNode { export interface NavNode {
id?: string; // stable key for override targeting; stripped from the rendered tree id?: string; // stable key for override targeting; stripped from the rendered tree
@@ -15,12 +15,12 @@ export interface NavNode {
icon?: string; icon?: string;
label: string; label: string;
open?: boolean; open?: boolean;
permission?: string; // required role token; consumed by the filter, never rendered role?: string; // required role token; consumed by the filter, never rendered
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (discovery refuses both). public?: boolean; // show to everyone, signed in or not — the blessed alias for "no role", stated outright; consumed by the filter, never rendered. Mutually exclusive with role (discovery refuses both).
} }
// Central override (config/menu.ts). Targets nodes by `id`; applied rename → group → // Central override (config/menu.ts). Targets nodes by `id`; applied rename → group →
// order → hide, then the per-user permission filter runs last. // order → hide, then the per-user role filter runs last.
export interface NavOverride { export interface NavOverride {
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
hide?: string[]; // remove nodes by id, at any depth (incl. a group's id) hide?: string[]; // remove nodes by id, at any depth (incl. a group's id)
@@ -106,7 +106,7 @@ function hideTree(nodes: NavNode[], hide: Set<string>): NavNode[] {
function filterByRoles(nodes: NavNode[], roles: Set<string>): NavNode[] { function filterByRoles(nodes: NavNode[], roles: Set<string>): NavNode[] {
const out: NavNode[] = []; const out: NavNode[] = [];
for (const n of nodes) { for (const n of nodes) {
if (n.public !== true && n.permission != null && !roles.has(n.permission)) continue; // gated → drop node + subtree (public always shows) if (n.public !== true && n.role != null && !roles.has(n.role)) continue; // gated → drop node + subtree (public always shows)
if (!n.children) { out.push(n); continue; } if (!n.children) { out.push(n); continue; }
const children = filterByRoles(n.children, roles); const children = filterByRoles(n.children, roles);
if (children.length === 0 && n.href == null) continue; // empty pure header → drop if (children.length === 0 && n.href == null) continue; // empty pure header → drop
@@ -115,7 +115,7 @@ function filterByRoles(nodes: NavNode[], roles: Set<string>): NavNode[] {
return out; return out;
} }
// Strip the helper-only fields (id/permission) and drop absent ones, so the tree is exactly // Strip the helper-only fields (id/role) and drop absent ones, so the tree is exactly
// what nav-tree.ejs reads. // what nav-tree.ejs reads.
function toRenderNode(n: NavNode): NavNode { function toRenderNode(n: NavNode): NavNode {
const out: NavNode = { label: n.label }; const out: NavNode = { label: n.label };
+3 -1
View File
@@ -15,8 +15,10 @@
- [x] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (`renovate.yml` gains an `auto-release` job (`needs: renovate`) that cuts one `vX.Y.Z` tag per run for what Renovate merged; level = highest `Release-Bump:` trailer Renovate stamps via `commitBody`, any dep's major/minor/patch mapped straight through (default patch). Decoupled from `apiVersion` (tag-only, `HOST_API_VERSION` untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure `auto-release/next-version.ts` + unit tests; tag pushed with renovate-bot's PAT so `release.yml` fires; documented in README → CI/CD.) - [x] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (`renovate.yml` gains an `auto-release` job (`needs: renovate`) that cuts one `vX.Y.Z` tag per run for what Renovate merged; level = highest `Release-Bump:` trailer Renovate stamps via `commitBody`, any dep's major/minor/patch mapped straight through (default patch). Decoupled from `apiVersion` (tag-only, `HOST_API_VERSION` untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure `auto-release/next-version.ts` + unit tests; tag pushed with renovate-bot's PAT so `release.yml` fires; documented in README → CI/CD.)
- [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser. (compose.full.yml now includes Hydra (`serve all --dev`) and full-flow.spec.ts drives /admin/clients register → one-time secret → list → detail → delete in the browser; documented in README → Testing.) - [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser. (compose.full.yml now includes Hydra (`serve all --dev`) and full-flow.spec.ts drives /admin/clients register → one-time secret → list → detail → delete in the browser; documented in README → Testing.)
- [x] Build and publish docker image as CI/CD. (Duplicate of the CI/CD items above: `ci.yml` builds and pushes `gitea.larvit.se/larvit/plainpages:<commit hash>` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.) - [x] Build and publish docker image as CI/CD. (Duplicate of the CI/CD items above: `ci.yml` builds and pushes `gitea.larvit.se/larvit/plainpages:<commit hash>` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.)
- [x] The human developer understands the security model in the auth in this project. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim, and the JWT's ~10m TTL is separated from the 30-day Kratos session that re-mints it. Every row of the threat table is enforced by a test — the mandatory-`exp` guard was the one gap, now asserted in `src/auth/jwt-middleware.test.ts`; the trust-boundary bullets are not testable claims. Review also corrected the hardening checklist — `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres dev secrets are now listed in "What you must supply".) - [x] The human developer understands the security model in the auth in this project. (README → Auth → [Security model](README.md#security-model): trust boundaries — browser untrusted, JWT untrusted until verified, the private network as the *only* guard on the unauthenticated Ory admin APIs, plugins trusted and unsandboxed, row rules upstream — plus a threat→defense table, the fail-closed rule, and pointers to the limits that are deliberately not guaranteed. Signed-not-encrypted is called out so nothing secret lands in a claim, and the JWT's ~10m TTL is separated from the 30-day Kratos session that re-mints it. Every row of the threat table is enforced by a test — the mandatory-`exp` guard was the one gap, now asserted in `src/auth/jwt-middleware.test.ts`; the trust-boundary bullets are not testable claims. Review also corrected the hardening checklist — `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres dev secrets are now listed in "What you must supply". Follow-up: the *threat* model turned out not to be the part that was unclear — the **authorization** model was. README gained a top-level [Users, groups & roles](README.md#users-groups--roles) section (entity table, worked graph, per-route can/cannot walkthrough, the "a per-row grant never widens a coarse gate" trap), placed before Building plugins because a manifest's `role:` gate is unreadable without it.)
- [ ] Add i18n support. - [ ] Add i18n support.
- [ ] Decide whether the Keto `User` namespace should follow Kratos and become `Identity`. Kratos never says "user" — it is `/admin/identities`, `identity.traits`, `session.identity` — but our OPL declares `class User` with subjects `user:<kratos-identity-id>`, and the code already mixes both (`login.ts` passes `identityId`, `context.ts` exports `User`). Aligning means renaming the namespace *and* the `user:` subject prefix, which rewrites every relation tuple in Keto's Postgres — a data migration, not a code change. Raised 2026-08-03 while renaming the coarse gate to `role`; deliberately left out of that change because it is a different and much heavier class of edit.
- [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule (`utils`, `helpers`, `misc`) applied to namespaces. Raised 2026-08-03; a design question, not a naming one.
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin (a sibling subdomain, or a plaintext hop with `SECURE_COOKIES=false`) can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md → "Deliberate architectural deviations" and in README → Security model under "Not guaranteed"; not accepted ⇒ bind the nonce to `sub` (small change). Raised by review 2026-08-02; left undecided because it is a maintainer call, and an undocumented exception reads as a bug to the next reviewer. - [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin (a sibling subdomain, or a plaintext hop with `SECURE_COOKIES=false`) can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md → "Deliberate architectural deviations" and in README → Security model under "Not guaranteed"; not accepted ⇒ bind the nonce to `sub` (small change). Raised by review 2026-08-02; left undecided because it is a maintainer call, and an undocumented exception reads as a bug to the next reviewer.
## Architectural review findings (2026-07-02) ## Architectural review findings (2026-07-02)