9 Commits

Author SHA1 Message Date
lilleman f7d70cfff8 Rename the Keto User namespace to Identity, matching Kratos
CI / full-gate (push) Successful in 2m34s
2026-08-03 12:19:12 +02:00
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
lilleman 702e42de09 Reflow the security-model prose to the file's wrap width
CI / full-gate (push) Successful in 2m41s
2026-08-02 17:34:57 +02:00
lilleman 98bdd2c1b5 Record the open CSRF token-binding decision as a todo item
CI / full-gate (push) Successful in 2m35s
2026-08-02 17:33:44 +02:00
lilleman 12913402a6 Add the seeded admin login to the production secrets checklist
CI / full-gate (push) Successful in 2m37s
2026-08-02 17:33:04 +02:00
lilleman 7dee80a976 Review fixes: denylist-conditional revoke, Ory secret wiring, exp guard test
CI / full-gate (push) Successful in 2m40s
2026-08-02 17:25:07 +02:00
lilleman c0fe8b0a82 Review fixes: re-mint path, real session lifetime, full secret checklist
CI / full-gate (push) Successful in 6s
2026-08-02 17:14:20 +02:00
lilleman fb7d20a7db Document the auth security model in README
CI / full-gate (push) Successful in 5s
2026-08-02 17:03:09 +02:00
62 changed files with 632 additions and 412 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ jobs:
full-gate:
runs-on: docker-host
steps:
- uses: actions/checkout@v4.4.0
- uses: actions/checkout@v4.2.2
with:
fetch-depth: 0 # ci.sh's docs-only check needs history; checkout defaults to depth 1
- run: bash ci.sh
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
github-mirror:
runs-on: docker-host
steps:
- uses: actions/checkout@v4.4.0
- uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- run: |
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
prune-stale-images:
runs-on: docker-host
steps:
- uses: actions/checkout@v4.4.0
- uses: actions/checkout@v4.2.2
- name: Delete hash images that are neither release-tagged nor a branch head
env:
REGISTRY_TOKEN: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
+1 -1
View File
@@ -7,7 +7,7 @@ jobs:
retag-image:
runs-on: docker-host
steps:
- uses: actions/checkout@v4.4.0
- uses: actions/checkout@v4.2.2
- name: Promote the commit-hash image to semver + latest
env:
GIT_TAG: ${{ github.ref_name }}
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
-e RENOVATE_PLATFORM=gitea \
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
-e RENOVATE_TOKEN \
renovate/renovate:44.7.2
renovate/renovate:44.6.0
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
@@ -34,7 +34,7 @@ jobs:
runs-on: docker-host
needs: renovate
steps:
- uses: actions/checkout@v4.4.0
- uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Tag a release for what Renovate merged
+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
(Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are
deliberately deferred — they're not top of mind when starting out. Concretely: Overview →
Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
Architecture → Testing → Production → Observability → the JWT-rotation runbook → the
Users, groups & roles → Building plugins → menu/blocks/interactivity → Configuration → Auth →
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
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
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).
+1 -1
View File
@@ -190,4 +190,4 @@ export default definePlugin({
```
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.
+286 -76
View File
@@ -69,6 +69,10 @@ From here, render real pages against the app shell and fetch upstream data — s
- [Overview](#overview)
- [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)
- [anatomy](#anatomy-of-a-plugin)
- [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)
- [RequestContext](#requestcontext)
- [system capabilities (ctx.system)](#system-capabilities-the-ctxsystem-surface)
- [nav & permissions](#nav--permissions)
- [nav & role gates](#nav--role-gates)
- [versioning](#contract-versioning)
- [conflict rules](#conflict-rules)
- [hooks](#hooks)
@@ -89,11 +93,12 @@ From here, render real pages against the app shell and fetch upstream data — s
- [canonical host](#canonical-host-one-public-url)
- [what you must supply](#what-you-must-supply-the-only-manual-prep)
- [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)
- [instant revoke](#instant-revoke-the-optional-denylist)
- [three tiers](#three-tiers-of-may-i)
- [OAuth2 (Hydra)](#oauth2-provider-hydra)
- [security model](#security-model)
- [Email](#email)
- [Architecture](#architecture)
- [Stateless](#stateless)
@@ -115,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
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
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.
**Who it's for.** Experienced developers building server-rendered web products — back-office
@@ -123,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
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
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.
**Included vs. what you add.**
@@ -141,7 +146,7 @@ obvious rather than surprising, you're the audience.
**Priorities (unchanged from day one):** **simplicity, few dependencies, strict
TypeScript, no build step, Docker-only, environment-agnostic** (no `NODE_ENV` — every
behaviour is an explicit config toggle). Heavy lifting that *isn't* simple to do well —
identity, sessions, SSO, OAuth2, permission checks — is delegated to **Ory** sidecar
identity, sessions, SSO, OAuth2, role checks — is delegated to **Ory** sidecar
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
production. The shape doesn't change as it grows: every plugin is the same self-contained
@@ -189,10 +194,133 @@ 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. |
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
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@identity:<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 |
| --- | --- | --- | --- |
| **Identity** | Kratos | who you are | `identity: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).
**Keto ships no entities of its own.** Its entire model is one primitive —
`namespace:object#relation@subject` — so the four namespaces above are *ours*, declared in
`ory/keto/namespaces.keto.ts`; Keto only supplies the machinery that resolves them (including
transitively, through nested groups). `Identity` is named to match Kratos, which owns that
record. `Group`, `Role` and `Resource` have no upstream counterpart to match, so they use the
ordinary words.
> **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
A plugin is a self-contained folder under `plugins/` that the host discovers at boot — no
@@ -203,7 +331,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
the host enforces. A complete, runnable example lives in
**[`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
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
@@ -236,7 +364,7 @@ single `plugin.ts`.
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
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
Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`),
@@ -268,20 +396,20 @@ import { listThings, createThings } from "./handlers.ts";
export default definePlugin({
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).
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.
permissions: [
{ token: "things:read", description: "View things" },
{ token: "things:write", description: "Create and edit things" },
// Roles this plugin gates on. Optional — see Nav & role gates.
roles: [
{ description: "View things", name: "things:read" },
{ 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: [
{ method: "GET", path: "/", permission: "things:read", handler: listThings },
{ method: "POST", path: "/", permission: "things:write", handler: createThings },
{ method: "GET", path: "/", role: "things:read", handler: listThings },
{ method: "POST", path: "/", role: "things:write", handler: createThings },
],
});
```
@@ -298,7 +426,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). |
| `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. |
| `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). |
| `hooks` | no | See [Hooks](#hooks). |
@@ -306,10 +434,10 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field
### 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
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
**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
@@ -354,7 +482,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
plugin's own CSS, pass its `/public/<id>/x.css` href in the shell's `styles` slot (an array of
extra stylesheet hrefs) — see the reference's `views/shifts.ejs`.
- **Finer authorization than the route `permission`** uses the guards from `#plugin-api`:
- **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
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
@@ -406,14 +534,14 @@ export default definePlugin({
Each is a `RouteHandler` like any route's — it receives the [`RequestContext`](#requestcontext) and
returns a `RouteResult`, typically a `view` from the plugin's own `views/`. A `dashboard` handler
renders against the native app shell via `ctx.chrome` exactly as a route handler does; a `home`
handler is a **public** page, so `ctx.user` may be `null` (use it to show a "go to dashboard" link to
handler is a **public** page, so `ctx.identity` may be `null` (use it to show a "go to dashboard" link to
a signed-in visitor, or sign-in / register to an anonymous one). After login the user lands on
`/dashboard` (or the `return_to` they were headed to), and the global menu's **Dashboard** link
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.identity` is non-null;
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`.)
Only **one** plugin may own each slot: two declaring `home` (or two declaring `dashboard`) is a
@@ -429,15 +557,15 @@ request:
```ts
interface RequestContext {
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell
identity: SessionIdentity | null; // { id, email, roles } from the verified session JWT, or null
log: Log; // request-scoped logger, in this request's trace
params: Record<string, string>; // path params from the route match, e.g. /things/:id → { id }
query: URLSearchParams; // alias of url.searchParams
req: IncomingMessage;
res: ServerResponse;
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
roles: string[]; // identity?.roles ?? [] — coarse gate without a null-check
system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them
url: URL;
user: User | null; // { id, email, roles } from the verified session JWT, or null
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie
}
```
@@ -504,15 +632,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
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
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
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
**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.
@@ -520,30 +648,26 @@ are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its luci
#### Public pages & menu items
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
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.
A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor
`ctx.user` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page)
`ctx.identity` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page)
in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.roles` is
empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/scheduling`
**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`.
**A `permission` token is a coarse role.** The route/nav gate passes iff the user's JWT `roles`
include the token; those roles come from Keto at login, so an operator grants a token by writing the
Keto tuple `Role:<token>#members@user:<id>` (or to a group) — the admin **Roles** screen does this.
(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.)
The gate passes iff the user's JWT `roles` include that name. How roles are granted, why their
names are a shared global namespace, and the fine-grained per-row tier are all covered in
[Users, groups & roles](#users-groups--roles).
Permission tokens are a **shared global namespace** — that's deliberate, so an operator grants
`scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace
your tokens as `<id>:<action>` to avoid accidental clashes. Declaring them in `permissions` is
optional but recommended: it documents them, feeds conflict detection, and lets the one-command
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.
Declaring the ones you gate on in `roles` is **optional but recommended**: it documents them,
feeds conflict detection, and lets the one-command bootstrap seed them — the demo admin is
granted every discovered plugin's declared roles, so a dropped-in plugin works out of the box
without editing host config.
### Contract versioning
@@ -578,15 +702,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. |
| `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)). |
| `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
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.
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
sets both `public` and `permission` (mutually exclusive — [Public pages](#public-pages--menu-items)).
non-array `nav`/`routes`/`roles`, a non-function `home`/`dashboard`, or a route/nav node that
sets both `public` and `role` (mutually exclusive — [Public pages](#public-pages--menu-items)).
### Hooks
@@ -653,7 +777,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
> 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
> 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
> `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>/`
@@ -684,7 +808,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
`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).
The validation an author hits is the same the host runs: bad `apiVersion` or a conflict
@@ -712,13 +836,13 @@ The menu is **driven entirely by config** and assembled from two sources:
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
[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:
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
says so on purpose, and is mutually exclusive with `permission`). The markup is the
a public page and its menu entry (an ungated item is already public; `public` just
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,
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
@@ -785,7 +909,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_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) |
| `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) |
| `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) |
@@ -836,14 +960,29 @@ both default to `localhost` (the dev override sets `APP_URL=http://localhost:300
A clean clone needs **none** of the above — `docker compose up` brings up the whole stack
with dev-throwaway secrets, an auto-generated signing key, and a seeded admin (see
[Quick start](#quick-start)). Exactly **two** things can't be auto-generated, and **both
are production-only** — neither blocks a clean clone:
[Quick start](#quick-start)). What can't be auto-generated is **production-only** — none of it
blocks a clean clone:
1. **Production secrets** — every value below ships as a committed dev throwaway that works
out of the box and **must** be replaced before a deploy faces the internet. Only the first
is enforced: `REQUIRE_SECURE_SECRETS=true` refuses to boot on a missing or throwaway
`CSRF_SECRET` and **nothing else** — the rest fail silently, so treat this as a checklist.
| Secret | Where | Protects |
| --- | --- | --- |
| `CSRF_SECRET` | web env | signs our double-submit CSRF token |
| JWT signing key | mount a real `jwks.json` or set `…_JWKS_URL` | mints/verifies the session JWT — see [rotation](#jwt-signing-key--rotation) |
| `SECRETS_COOKIE` | kratos env | signs Kratos' session + anti-CSRF cookies |
| `SECRETS_CIPHER` | kratos env (32 chars) | encrypts credentials at rest |
| `SECRETS_SYSTEM` | hydra env | encrypts OAuth2 tokens + consent at rest |
| `POSTGRES_USER` / `POSTGRES_PASSWORD` | compose env | the Ory databases (default `ory`/`ory`) |
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | bootstrap env | the seeded first admin login (default `admin@plainpages.local` / `admin`) |
`CSRF_SECRET`, the Postgres pair and the admin pair are interpolated from the host
environment. The three Ory secrets are **not**: `compose.yml` passes only `DSN` to
`kratos`/`hydra`, so add them to those services' `environment:` (or an `env_file:`) or they
silently stay on the throwaways.
1. **Production secrets** — replace the committed dev throwaway `CSRF_SECRET` (env), plus
the **JWT signing key** (mount a real `jwks.json` or set `…_JWKS_URL` — see
[JWT signing key & rotation](#jwt-signing-key--rotation)). Set
`REQUIRE_SECURE_SECRETS=true` and the app refuses to boot until `CSRF_SECRET` is supplied
and differs from the throwaway.
2. **SSO provider client id/secret** — **optional**; password login works without them.
Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (see
[Social sign-in (SSO)](#social-sign-in-sso)).
@@ -864,7 +1003,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
(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
in a **locally-validated JWT**, and **Keto** is reserved for the rare fine-grained,
@@ -896,7 +1035,7 @@ the session for a signed JWT once** via the Kratos **session tokenizer** (`whoam
```
**Keto is the single source of truth for roles.** Coarse roles are Keto relations (e.g.
`role:admin#members@user:alice`); the admin screens write them *only* to Keto. But the
`Role:admin#members@identity:alice`); the admin screens write them *only* to Keto. But the
tokenizer's claims mapper can read only the **identity**, not call Keto — so at login the
app reads the roles from Keto and refreshes a **derived projection**: a read-only copy
written onto the identity's `metadata_public` for the tokenizer to see, which the template
@@ -953,6 +1092,9 @@ deactivate the user, or use a direct user-role change, for an instant effect.
### 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
fine + attribute (owner / tenant / …) → upstream service that owns the row
@@ -967,10 +1109,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
answer from its own data.
The built-in users / groups / permissions screens write authorization **only to Keto** —
coarse roles and fine-grained relationships alike. Roles reach the JWT by being read from
Keto at login and projected through the tokenizer (above); nothing authors them anywhere
else.
The admin plugin's users / groups / roles screens write authorization **only to Keto** — coarse
roles and fine-grained relationships alike.
### OAuth2 provider (Hydra)
@@ -997,6 +1137,76 @@ generated `client_secret` **once**, on the confirmation page — confidential cl
delete. Confidential vs public (PKCE) and the first-party auto-consent flag are set at registration;
writes go only to Hydra.
### Security model
Everything above is *how* auth works. This is what to check a change against: who is trusted,
what defends what, and which guarantees are deliberately not offered.
**Trust boundaries.**
- **The browser is not trusted.** Cookies, form fields, URLs and headers are attacker-controlled
until verified or escaped. Nothing is believed because of where it arrived from.
- **The session JWT is trusted only after verification** — signature against the JWKS key its
`kid` names (or the sole key, when the token carries no `kid`), then a **mandatory** `exp`,
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
(`4434`), Hydra admin (`4445`) and Keto write (`4467`) authenticate no one — reaching them
*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
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.
- **Plugins are trusted code**, in-process and unsandboxed — a plugin can do anything the host
can. Vetting happens when you mount one, not at runtime (AGENTS.md: crash isolation is a
deliberate non-goal).
- **Attribute-based row rules belong upstream**, in the service that owns the data;
relationship-based ones go to Keto — see [three tiers](#three-tiers-of-may-i).
**The JWT is signed, not encrypted.** Claims are base64: a signed-in user can read their own
`sub`, `email` and `roles`. `HttpOnly` keeps page JavaScript out of the cookie, not the user.
Never put anything in a claim you wouldn't show them.
**What defends what.**
| Threat | Defense |
| --- | --- |
| Forged or tampered token | signature verified by `kid`; `alg` allowlist is `RS256`/`ES256` only — **never `HS*` or `none`**, either of which lets a forged token verify (`src/auth/jwt.ts`) |
| `alg` confusion | a key that pins an `alg` must match the header's; the key type must always match the family |
| Replayed expired token | `exp` is mandatory — a token without one is rejected, never treated as eternal; `JWT_CLOCK_SKEW_SEC` leeway |
| Token minted for another deployment | optional `JWT_ISSUER` / `JWT_AUDIENCE` pinning |
| Stolen session cookie | `HttpOnly`, `SameSite=Lax`, `Secure` (`SECURE_COOKIES`) — but see the real session lifetime below |
| CSRF on our own forms | signed double-submit token, **opt-in per handler** via `ctx.verifyCsrf` (`src/auth/csrf.ts`) + `SameSite=Lax`; Kratos' flows carry Kratos' own token |
| XSS | EJS `<%= %>` escapes; the CSP blocks inline script ([headers](#production--deployment)) — the `*.html` slots stay [raw by contract](#escaping--the-trust-boundary) |
| Clickjacking | `frame-ancestors 'none'` + `X-Frame-Options: DENY` |
| Open redirect via `return_to` | validated host-relative (`localPath`, `src/http/safe-url.ts`) |
| Privilege escalation | roles authored only in Keto, re-read at every mint — see [login & the session JWT](#login-and-the-session-jwt) |
| Downgrade / MIME sniffing | HSTS when `SECURE_COOKIES=true`, `X-Content-Type-Options: nosniff` |
| A hung Ory parking requests | `ORY_TIMEOUT_SEC` per outbound call |
**The JWT's ~10m TTL is not the session lifetime.** The browser also holds Kratos'
`plainpages_session` cookie (30 days, sliding), and *that* is what silently re-mints a lapsed
JWT. So a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our
two cookies obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
**Fail closed — with one deliberate exception.** A token that cannot be verified (missing,
malformed, bad signature, wrong `iss`/`aud`) yields *anonymous*, never a partly-trusted user,
and anonymous or under-privileged is denied (`requireSession` bounces to `/login`; `can`/`check`
return `false`). An **expired** token instead triggers a re-mint: re-validation against the live
Kratos session, roles re-read from Keto, or a cleared cookie if that session is dead; Ory
unreachable ⇒ anonymous. None of this is a session kill — a *revoked* state exists only with the
[denylist](#instant-revoke-the-optional-denylist) on (off by default), and it resolves through
that same re-mint. **Offboarding:** with the denylist on, revoking a role downgrades the user at
once (on the instance that handled it) and deactivating or deleting the identity ends the
session; with it off, both land within one token TTL.
**Not guaranteed** — accepted, and stated where each mechanism is: role changes
[lag up to one token TTL and sign-in needs Ory up](#two-trade-offs--both-deliberate), and the
denylist is [single-instance and skips group changes](#instant-revoke-the-optional-denylist).
Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true`, `SECURE_COOKIES=true`, and replacing
**every** committed dev secret — see
[what you must supply](#what-you-must-supply-the-only-manual-prep). `REQUIRE_SECURE_SECRETS`
guards only `CSRF_SECRET`; nothing fails loud if you ship Ory's, Postgres' or the demo admin's
throwaways.
## Email
The only emails are the **recovery** and **verification** codes from Kratos' self-service
@@ -1040,7 +1250,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 &
consent provider** for other apps. It reaches the Ory services over their **REST APIs
using Node's built-in `fetch`** — no SDK dependency. See
[Auth, sessions & permissions](#auth-sessions--permissions).
[Auth, sessions & access](#auth-sessions--access).
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
@@ -1084,7 +1294,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
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
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`.
```bash
@@ -1120,7 +1330,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,
`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
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 on one host (`ory/kratos/e2e-proxy.yml` points Kratos at it) — exactly as a production
reverse proxy would.
@@ -1315,7 +1525,7 @@ mid-response, so container restarts are clean.
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
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
*and* the bootstrap to finish before starting. **Change the demo admin before production.**
@@ -1397,7 +1607,7 @@ container-relative; with the dev bind-mount they edit the real file).
2. **Restart Kratos** so it signs with the new first key: `docker compose restart kratos`.
(web needs no restart — it hot-reloads the file. The hot path verifies JWTs locally, so a
brief Kratos blip only touches login/re-mint.)
3. **Verify** new logins mint the new `kid` — decode the `plainpages_session` cookie's JWT
3. **Verify** new logins mint the new `kid` — decode the `plainpages_jwt` cookie's JWT
header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients
present the new key.
4. **Wait ~12 min**, then **prune** the superseded key:
@@ -1448,11 +1658,11 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
auth/ Identity, the session-JWT hot path, guards, and the Ory REST clients
jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK)
jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.user/roles; flags a lapsed token for re-mint
jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.identity/roles; flags a lapsed token for re-mint
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
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
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
@@ -1471,7 +1681,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)
system.ts SystemCapabilities: the privileged ctx.system surface (Ory admin clients + instant-revoke) a system plugin uses; the host populates it from the wired clients, the admin plugin consumes it
discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot
router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate
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
view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials
@@ -1490,7 +1700,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
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
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
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;
+1 -1
View File
@@ -131,7 +131,7 @@ services:
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
# 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}
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
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/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.
bootstrap:
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
// 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
// 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 }) => {
+1 -1
View File
@@ -5,7 +5,7 @@ across (or bind-mount your own) and restart.
| 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). |
| [`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. |
+1 -1
View File
@@ -3,7 +3,7 @@
// 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 —
// 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.
// 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,
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,
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
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission token, and the
route table — one thin handler per method+path, all gated by `permission: "admin"`.
- `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 `role: "admin"`.
- `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
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
+2 -2
View File
@@ -5,7 +5,7 @@
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api";
import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
@@ -235,7 +235,7 @@ function readClientInput(form: URLSearchParams): ClientInput {
// Shared per-request deps for the OAuth2-clients screen, resolved by `withClients`: the gate + the
// Hydra capability (else a themed 503). Each route below is a thin handler over these.
interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; }
interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: SessionIdentity; }
function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
+10 -10
View File
@@ -18,7 +18,7 @@ import type { RelationTuple } from "#plugin-api";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (group: string, n: number): RelationTuple =>
({ namespace: "Group", object: group, relation: "members", subject_id: `user:${uid(n)}` });
({ namespace: "Group", object: group, relation: "members", subject_id: `identity:${uid(n)}` });
const groupTuple = (group: string, child: string): RelationTuple =>
({ namespace: "Group", object: group, relation: "members", subject_set: { namespace: "Group", object: child, relation: "members" } });
@@ -28,12 +28,12 @@ test("isValidGroupName accepts URL-safe names, rejects empties/spaces/uppercase/
});
test("parseSubject + memberTuple map the form value to the user/nested-group subject (else null)", () => {
assert.deepEqual(parseSubject(`user:${uid(1)}`), { subject_id: `user:${uid(1)}` });
assert.deepEqual(parseSubject(`identity:${uid(1)}`), { subject_id: `identity:${uid(1)}` });
assert.deepEqual(parseSubject("group:eng"), { subject_set: { namespace: "Group", object: "eng", relation: "members" } });
// Both forms are validated: a non-UUID user / invalid group name is rejected, not written blindly.
for (const bad of ["", "user:", "user:not-a-uuid", "group:", "group:Bad Name", "nope:x", "plain"]) assert.equal(parseSubject(bad), null, bad);
for (const bad of ["", "identity:", "identity:not-a-uuid", "group:", "group:Bad Name", "nope:x", "plain"]) assert.equal(parseSubject(bad), null, bad);
assert.deepEqual(memberTuple("design", `user:${uid(2)}`), { namespace: "Group", object: "design", relation: "members", subject_id: `user:${uid(2)}` });
assert.deepEqual(memberTuple("design", `identity:${uid(2)}`), { namespace: "Group", object: "design", relation: "members", subject_id: `identity:${uid(2)}` });
assert.deepEqual(memberTuple("design", "group:eng"), { namespace: "Group", object: "design", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
assert.equal(memberTuple("design", "bad"), null);
});
@@ -48,8 +48,8 @@ test("groupsFromTuples collapses membership tuples → distinct groups + member
test("memberView resolves a user subject to its email (else the raw id) and a subject_set to the group", () => {
const emails = new Map([[uid(1), "ada@example.com"]]);
assert.deepEqual(memberView(userTuple("eng", 1), emails), { kind: "user", label: "ada@example.com", subject: `user:${uid(1)}` });
assert.deepEqual(memberView(userTuple("eng", 9), emails), { kind: "user", label: `user:${uid(9)}`, subject: `user:${uid(9)}` });
assert.deepEqual(memberView(userTuple("eng", 1), emails), { kind: "identity", label: "ada@example.com", subject: `identity:${uid(1)}` });
assert.deepEqual(memberView(userTuple("eng", 9), emails), { kind: "identity", label: `identity:${uid(9)}`, subject: `identity:${uid(9)}` });
assert.deepEqual(memberView(groupTuple("eng", "design"), emails), { kind: "group", label: "design", subject: "group:design" });
});
@@ -76,7 +76,7 @@ test("buildGroupsListModel filters by search, sorts, paginates; the name links t
});
test("buildGroupFormModel: a create form with a required name field + member options, no group of its own", () => {
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
const options = [{ label: "ada@example.com", value: `identity:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.title, "New group");
assert.equal(m.form.action, "/admin/groups");
@@ -96,8 +96,8 @@ test("buildGroupFormModel: a create form with a required name field + member opt
test("buildGroupDetailModel: members → rows, add-options exclude current members + the group itself, delete/remove wired", () => {
const members = [memberView(userTuple("eng", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("eng", "design"), new Map())];
const candidates = [
{ label: "ada@example.com", value: `user:${uid(1)}` }, // already a member → excluded
{ label: "grace@example.com", value: `user:${uid(2)}` },
{ label: "ada@example.com", value: `identity:${uid(1)}` }, // already a member → excluded
{ label: "grace@example.com", value: `identity:${uid(2)}` },
{ label: "design (group)", value: "group:design" }, // already a member → excluded
{ label: "eng (group)", value: "group:eng" }, // the group itself → excluded
{ label: "ops (group)", value: "group:ops" },
@@ -107,6 +107,6 @@ test("buildGroupDetailModel: members → rows, add-options exclude current membe
assert.equal(m.members.rows.length, 2);
assert.equal(m.members.action, "/admin/groups/eng/members/delete");
assert.equal(m.add.action, "/admin/groups/eng/members");
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
assert.deepEqual(m.add.options.map((o) => o.value), [`identity:${uid(2)}`, "group:ops"]);
assert.equal(m.delete.action, "/admin/groups/eng/delete");
});
+9 -9
View File
@@ -6,7 +6,7 @@
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
// each returning a RouteResult.
import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type User } from "#plugin-api";
import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type SessionIdentity } from "#plugin-api";
import { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
@@ -25,9 +25,9 @@ export interface GroupView {
}
// A member's view model: a user (label = email) or a nested group (label = group name). `subject`
// is the form value that round-trips it — `user:<id>` or `group:<name>` (see parseSubject).
// is the form value that round-trips it — `identity:<id>` or `group:<name>` (see parseSubject).
export interface MemberView {
kind: "group" | "user";
kind: "group" | "identity";
label: string;
subject: string;
}
@@ -35,7 +35,7 @@ export interface MemberView {
// One option in a member <select>.
export interface MemberOption {
label: string;
value: string; // `user:<id>` | `group:<name>`
value: string; // `identity:<id>` | `group:<name>`
}
export function isValidGroupName(name: string): boolean {
@@ -51,7 +51,7 @@ export function parseSubject(value: string): { subject_id: string } | { subject_
if (!rest) return null;
// Validate both subject forms so a crafted POST can't write a dangling tuple (the pickers only
// ever offer real users/groups): a user id is a Kratos UUID, a nested group a valid group name.
if (value.slice(0, sep) === "user") return UUID.test(rest) ? { subject_id: `user:${rest}` } : null;
if (value.slice(0, sep) === "identity") return UUID.test(rest) ? { subject_id: `identity:${rest}` } : null;
if (value.slice(0, sep) === "group") return isValidGroupName(rest) ? { subject_set: { namespace: GROUP_NS, object: rest, relation: MEMBERS } } : null;
return null;
}
@@ -72,8 +72,8 @@ export function groupsFromTuples(tuples: RelationTuple[]): GroupView[] {
export function memberView(tuple: RelationTuple, emailById: Map<string, string>): MemberView {
if (tuple.subject_set) return { kind: "group", label: tuple.subject_set.object, subject: `group:${tuple.subject_set.object}` };
const subjectId = tuple.subject_id ?? "";
const id = subjectId.startsWith("user:") ? subjectId.slice("user:".length) : subjectId;
return { kind: "user", label: emailById.get(id) ?? subjectId, subject: subjectId };
const id = subjectId.startsWith("identity:") ? subjectId.slice("identity:".length) : subjectId;
return { kind: "identity", label: emailById.get(id) ?? subjectId, subject: subjectId };
}
// ---- list view model ----
@@ -267,7 +267,7 @@ export async function memberCandidates(keto: KetoClient, kratosAdmin: KratosAdmi
const trait = it.traits?.["email"];
const email = typeof trait === "string" ? trait : it.id;
emailById.set(it.id, email);
userOptions.push({ label: email, value: `user:${it.id}` });
userOptions.push({ label: email, value: `identity:${it.id}` });
}
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
return { emailById, options: [...userOptions, ...groups.map((g) => ({ label: `${g.name} (group)`, value: `group:${g.name}` }))] };
@@ -281,7 +281,7 @@ async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate + the Keto and
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: User; }
interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: SessionIdentity; }
function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
+9 -9
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) —
// "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
@@ -18,7 +18,7 @@ import type { ExpandTree, RelationTuple } from "#plugin-api";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (role: string, n: number): RelationTuple =>
({ namespace: "Role", object: role, relation: "members", subject_id: `user:${uid(n)}` });
({ namespace: "Role", object: role, relation: "members", subject_id: `identity:${uid(n)}` });
const groupTuple = (role: string, group: string): RelationTuple =>
({ namespace: "Role", object: role, relation: "members", subject_set: { namespace: "Group", object: group, relation: "members" } });
@@ -26,14 +26,14 @@ test("isValidRoleName + roleMemberTuple map the form value to a Role tuple over
for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok);
for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad);
assert.deepEqual(roleMemberTuple("editor", `user:${uid(2)}`), { namespace: "Role", object: "editor", relation: "members", subject_id: `user:${uid(2)}` });
assert.deepEqual(roleMemberTuple("editor", `identity:${uid(2)}`), { namespace: "Role", object: "editor", relation: "members", subject_id: `identity:${uid(2)}` });
assert.deepEqual(roleMemberTuple("editor", "group:eng"), { namespace: "Role", object: "editor", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
for (const bad of ["", "user:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(roleMemberTuple("editor", bad), null, bad);
for (const bad of ["", "identity:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(roleMemberTuple("editor", bad), null, bad);
});
test("expandToEffectiveUsers flattens an expand tree → sorted distinct user ids, transitive through groups", () => {
// The subject rides on each node's `tuple` (Keto v26.2.0 shape, verified live).
const leaf = (n: number): ExpandTree => ({ tuple: { namespace: "", object: "", relation: "", subject_id: `user:${uid(n)}` }, type: "leaf" });
const leaf = (n: number): ExpandTree => ({ tuple: { namespace: "", object: "", relation: "", subject_id: `identity:${uid(n)}` }, type: "leaf" });
const tree: ExpandTree = {
children: [
leaf(1), // direct
@@ -71,7 +71,7 @@ test("buildRolesListModel filters by search, sorts, paginates; the name links to
});
test("buildRoleFormModel: a create form with a required name field + member options (user or group)", () => {
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
const options = [{ label: "ada@example.com", value: `identity:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
const m = buildRoleFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.title, "New role");
assert.equal(m.form.action, "/admin/roles");
@@ -89,8 +89,8 @@ test("buildRoleFormModel: a create form with a required name field + member opti
test("buildRoleDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())];
const candidates = [
{ label: "ada@example.com", value: `user:${uid(1)}` }, // already a member → excluded
{ label: "grace@example.com", value: `user:${uid(2)}` },
{ label: "ada@example.com", value: `identity:${uid(1)}` }, // already a member → excluded
{ label: "grace@example.com", value: `identity:${uid(2)}` },
{ label: "eng (group)", value: "group:eng" }, // already a member → excluded
{ label: "ops (group)", value: "group:ops" },
];
@@ -100,7 +100,7 @@ test("buildRoleDetailModel: members → rows, add-options exclude current member
assert.equal(m.members.rows.length, 2);
assert.equal(m.members.action, "/admin/roles/admin/members/delete");
assert.equal(m.add.action, "/admin/roles/admin/members");
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
assert.deepEqual(m.add.options.map((o) => o.value), [`identity:${uid(2)}`, "group:ops"]);
assert.deepEqual(m.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]);
assert.equal(m.delete.action, "/admin/roles/admin/delete");
});
+11 -11
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
// 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)
@@ -8,8 +8,8 @@
// Kratos is read only to label members. Below the builders are thin per-route handlers (keyed on
// 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 { ADMIN_PERMISSION, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api";
import { ADMIN_ROLE, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import {
type GroupView,
groupsFromTuples,
@@ -54,7 +54,7 @@ export function expandToEffectiveUsers(tree: ExpandTree | null | undefined): str
const walk = (node?: ExpandTree | null): void => {
if (!node) return;
const subjectId = node.tuple?.subject_id;
if (subjectId?.startsWith("user:")) ids.add(subjectId.slice("user:".length));
if (subjectId?.startsWith("identity:")) ids.add(subjectId.slice("identity:".length));
node.children?.forEach(walk);
};
walk(tree);
@@ -231,11 +231,11 @@ export function buildRoleDetailModel(opts: {
// ---- request handler (imperative shell) ----
// instant-revoke: a role change for a `user:<id>` member must take effect now, so revoke that
// instant-revoke: a role change for a `identity:<id>` member must take effect now, so revoke that
// user's live tokens (a re-mint then re-reads roles from Keto). A `group:<name>` change is
// transitive across many users — left to lag (documented), so only direct user members revoke.
function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void {
if (revoke && member.startsWith("user:")) revoke(member.slice("user:".length));
if (revoke && member.startsWith("identity:")) revoke(member.slice("identity:".length));
}
// A role exists exactly while it has ≥1 member (Keto has no create-object).
@@ -250,13 +250,13 @@ async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolea
if (!hasMembers) return [];
const tree = await keto.expand({ namespace: ROLE_NS, object: name, relation: MEMBERS }, { maxDepth: EXPAND_MAX_DEPTH });
return expandToEffectiveUsers(tree)
.map((id) => ({ label: emailById.get(id) ?? `user:${id}` }))
.map((id) => ({ label: emailById.get(id) ?? `identity:${id}` }))
.sort((a, b) => a.label.localeCompare(b.label));
}
// Shared per-request deps for the Roles screen, resolved by `withRoles`: the gate + the Keto and
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
interface RolesDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
interface RolesDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: SessionIdentity; }
function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
@@ -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.
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);
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
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) => {
const { ctx, keto, user } = deps;
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 });
ctx.log.info("admin: role deleted", { actor: user.id, role: name });
return { redirect: ADMIN_ROLES_BASE };
@@ -360,7 +360,7 @@ export const rolesRemoveMember = withRoleName(async (deps, name) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
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 === `identity:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
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 }); }
return { redirect: detailHref(name) };
+9 -9
View File
@@ -6,20 +6,20 @@ import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
import { test } from "node:test";
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 { GuardError, type Log, type PageChrome, type RequestContext, type SessionIdentity } from "#plugin-api";
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 member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
const admin: SessionIdentity = { email: "ada@x.io", id: "u1", roles: ["admin"] };
const member: SessionIdentity = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
function fakeCtx(opts: { body?: string; method?: string; user?: SessionIdentity | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
const url = new URL("http://localhost/admin/users");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
req.method = opts.method ?? "GET";
return {
chrome: CHROME, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
roles: opts.user?.roles ?? [], url, user: opts.user ?? null, verifyCsrf: opts.verifyCsrf ?? (() => true),
chrome: CHROME, identity: opts.user ?? null, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
roles: opts.user?.roles ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
};
}
@@ -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", () => {
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.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.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 ----
+7 -7
View File
@@ -3,9 +3,9 @@
// (themed not-found / capability-unavailable). Ported from the former built-in admin screens;
// everything imports the host only through the #plugin-api barrel.
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 SessionIdentity } 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_GROUPS_BASE = "/admin/groups";
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";
// 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.
export const ADMIN_NAV: NavNode = {
children: [
@@ -26,15 +26,15 @@ export const ADMIN_NAV: NavNode = {
icon: "i-shield",
id: "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
// 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): SessionIdentity {
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;
}
+3 -3
View File
@@ -4,7 +4,7 @@
// models; below them are thin per-route handlers (keyed on ctx.params) over a shared `withUser` gate
// — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG).
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api";
import { ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
@@ -266,9 +266,9 @@ function readUserInput(form: URLSearchParams): UserInput {
// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (admin only) and
// 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: SessionIdentity; }
// 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.
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandler {
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 { 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 { 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
// 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({
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
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: [
// Users
@@ -1,7 +1,7 @@
<%#
Admin group membership body, captured into the shell content slot. Config:
group { name }
members { action, rows: { kind:"group"|"user", label, subject }[] } action = remove-member endpoint
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = remove-member endpoint
add { action, options: {label,value}[] } action = add-member endpoint
del { action } delete the whole group
csrfToken, error?
@@ -1,7 +1,7 @@
<%#
Admin role detail body, captured into the shell content slot. Config:
role { name }
members { action, rows: { kind:"group"|"user", label, subject }[] } action = revoke endpoint
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = revoke endpoint
effective { label }[] users who hold the role (expand)
add { action, options: {label,value}[] } action = assign endpoint
del { action } delete the whole role
+1 -1
View File
@@ -15,7 +15,7 @@ What it demonstrates:
`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`,
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.
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
// 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.
import { definePlugin } from "#plugin-api";
@@ -23,25 +23,25 @@ export default definePlugin({
nav: [{
children: [
{ 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",
id: "scheduling",
label: "Scheduling",
}],
// Tokens this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
permissions: [
{ description: "View shifts", token: READ },
{ description: "Create and edit shifts", token: WRITE },
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
roles: [
{ description: "View shifts", name: READ },
{ 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.
routes: [
{ handler: overview(), method: "GET", path: "/", public: true },
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
{ handler: listShifts(upstream), method: "GET", path: "/shifts", role: READ },
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", role: WRITE },
{ handler: createShift(upstream), method: "POST", path: "/shifts", role: WRITE },
],
});
+2 -2
View File
@@ -16,8 +16,8 @@ function fakeCtx(opts: { body?: string; roles?: string[]; url?: string; verifyCs
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
return {
chrome: CHROME, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
roles: opts.roles ?? [], url, user: null, verifyCsrf: opts.verifyCsrf ?? (() => true),
chrome: CHROME, identity: null, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
roles: opts.roles ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
};
}
+3 -3
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 SHIFTS_PATH = "/scheduling/shifts";
export const READ = "scheduling:read"; // permission token gating the list + nav
export const WRITE = "scheduling:write"; // permission token gating create
export const READ = "scheduling:read"; // the role gating the list + nav
export const WRITE = "scheduling:write"; // the role gating create
export interface Shift {
id: string;
@@ -188,7 +188,7 @@ export function newShiftForm(): RouteHandler {
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
// else a prompt to sign in. ctx.user may be null here, so read the role via can() (zero I/O).
// else a prompt to sign in. ctx.identity may be null here, so read the role via can() (zero I/O).
export function overview(): RouteHandler {
return (ctx) => ({
data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" },
+8 -8
View File
@@ -4,23 +4,23 @@
// identity ids (== the JWT `sub`).
import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types"
// A human identity. Subjects are written as `user:<kratos-identity-id>`.
class User implements Namespace {}
// A Kratos identity. Subjects are written as `identity:<kratos-identity-id>`.
class Identity implements Namespace {}
// A subject set: a named collection of users (and nested groups), resolved transitively.
// The admin "Groups" screen manages membership; checks expand it automatically.
class Group implements Namespace {
related: {
members: (User | SubjectSet<Group, "members">)[]
members: (Identity | SubjectSet<Group, "members">)[]
}
}
// A coarse role — the source of truth for the JWT `roles` claim. At login the app reads
// `role:<name>#members@user:<id>` from Keto and projects the result into the token
// `Role:<name>#members@identity:<id>` from Keto and projects the result into the token
// (README: Login → session JWT). A group can hold a role, so members can be users or groups.
class Role implements Namespace {
related: {
members: (User | SubjectSet<Group, "members">)[]
members: (Identity | SubjectSet<Group, "members">)[]
}
}
@@ -29,9 +29,9 @@ class Role implements Namespace {
// Grants accept a user directly or any member of a group.
class Resource implements Namespace {
related: {
owners: (User | SubjectSet<Group, "members">)[]
editors: (User | SubjectSet<Group, "members">)[]
viewers: (User | SubjectSet<Group, "members">)[]
owners: (Identity | SubjectSet<Group, "members">)[]
editors: (Identity | SubjectSet<Group, "members">)[]
viewers: (Identity | SubjectSet<Group, "members">)[]
}
permits = {
+6 -6
View File
@@ -20,17 +20,17 @@ test("identityPayload is a valid Kratos create-identity body with a password cre
assert.equal(body.credentials.password.config.password, "admin");
});
test("roleTuple grants a role to user:<id> in the Role namespace", () => {
test("roleTuple grants a role to identity:<id> in the Role namespace", () => {
const id = randomUUID();
assert.deepEqual(roleTuple(id, "admin"), {
namespace: "Role",
object: "admin",
relation: "members",
subject_id: `user:${id}`,
subject_id: `identity:${id}`,
});
});
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
// 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"]);
@@ -65,8 +65,8 @@ test("seedAdmin on a fresh stack creates the identity and grants every role (one
assert.equal(puts.length, 2); // one grant per role
assert.ok(puts.every((p) => p.method === "PUT"));
assert.deepEqual(puts.map((p) => p.body), [
{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` },
{ namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `user:${id}` },
{ namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` },
{ namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `identity:${id}` },
]);
});
@@ -94,7 +94,7 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants
});
assert.deepEqual(result, { created: false, id, roles: ["admin"] });
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` });
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` });
});
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
+9 -9
View File
@@ -2,8 +2,8 @@
// 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);
// 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
// every discovered plugin's declared permission tokens, so a dropped-in plugin is usable by
// 3. grant it its roles in Keto so menu/role checks resolve out of the box — `admin` plus
// 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).
// Then prints a first-run banner; fails loud on any unexpected upstream error.
import { existsSync, writeFileSync } from "node:fs";
@@ -22,19 +22,19 @@ export function identityPayload(email: string, password: string) {
};
}
// Coarse-role grant: `Role:<role>#members@user:<id>`. Subject ids are `user:<kratos-id>`
// Coarse-role grant: `Role:<role>#members@identity:<id>`. Subject ids are `identity:<kratos-id>`
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT roles.
export function roleTuple(identityId: string, role: string) {
return { namespace: "Role", object: role, relation: "members", subject_id: `user:${identityId}` };
return { namespace: "Role", object: role, relation: "members", subject_id: `identity:${identityId}` };
}
// 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
// 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);
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredTokens)])];
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredRoles)])];
}
// --- JWKS safety net -----------------------------------------------------------------
@@ -143,9 +143,9 @@ async function main() {
await runWithLog(log, async () => {
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.
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 email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin";
+5 -5
View File
@@ -2,17 +2,17 @@ import assert from "node:assert/strict";
import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
import { test } from "node:test";
import { buildContext, type RequestContext, type User } from "../http/context.ts";
import { buildContext, type RequestContext, type SessionIdentity } from "../http/context.ts";
import { can, check, GuardError, requireSession } from "./guards.ts";
import type { KetoClient, RelationTuple } from "./keto-client.ts";
function ctxFor(user: User | null, url = "/"): RequestContext {
function ctxFor(user: SessionIdentity | null, url = "/"): RequestContext {
const req = new IncomingMessage(new Socket());
req.url = url;
return buildContext(req, new ServerResponse(req), { user });
return buildContext(req, new ServerResponse(req), { identity: user });
}
const alice: User = { email: "a@b.c", id: "u1", roles: ["admin", "scheduling:read"] };
const alice: SessionIdentity = { email: "a@b.c", id: "u1", roles: ["admin", "scheduling:read"] };
test("requireSession returns the user, or throws GuardError(401)→/login (preserving return_to) when anonymous", () => {
assert.equal(requireSession(ctxFor(alice)), alice);
@@ -44,7 +44,7 @@ test("check asks Keto with the current user as subject; anonymous is denied with
const tuple = { namespace: "Resource", object: "doc1", relation: "view" };
assert.equal(await check(keto, ctxFor(alice), tuple), true);
assert.deepEqual(asked, { ...tuple, subject_id: "user:u1" }); // subject is the signed-in user
assert.deepEqual(asked, { ...tuple, subject_id: "identity:u1" }); // subject is the signed-in user
asked = undefined;
assert.equal(await check(keto, ctxFor(null), tuple), false); // fail-closed, no Keto call
+7 -7
View File
@@ -1,9 +1,9 @@
// 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
// 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.
import type { RequestContext, User } from "../http/context.ts";
import type { RequestContext, SessionIdentity } from "../http/context.ts";
import type { KetoClient } from "./keto-client.ts";
import { localPath } from "../http/safe-url.ts";
@@ -32,9 +32,9 @@ export class GuardError extends Error {
}
// Assert a signed-in session and return the user. Anonymous ⇒ GuardError → /login (return_to kept).
export function requireSession(ctx: RequestContext): User {
if (!ctx.user) throw new GuardError(401, "authentication required", loginRedirect(ctx));
return ctx.user;
export function requireSession(ctx: RequestContext): SessionIdentity {
if (!ctx.identity) throw new GuardError(401, "authentication required", loginRedirect(ctx));
return ctx.identity;
}
// Coarse role check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
@@ -49,6 +49,6 @@ export async function check(
ctx: RequestContext,
tuple: { namespace: string; object: string; relation: string },
): Promise<boolean> {
if (!ctx.user) return false;
return keto.check({ ...tuple, subject_id: `user:${ctx.user.id}` });
if (!ctx.identity) return false;
return keto.check({ ...tuple, subject_id: `identity:${ctx.identity.id}` });
}
+21 -19
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { generateKeyPairSync, sign, type JsonWebKey, type KeyObject } from "node:crypto";
import { test } from "node:test";
import { staticJwks } from "./jwks.ts";
import { authenticate, claimsToUser, resolveSession, verifyToken } from "./jwt-middleware.ts";
import { authenticate, claimsToIdentity, resolveSession, verifyToken } from "./jwt-middleware.ts";
import { SESSION_COOKIE } from "./login.ts";
const b64url = (input: Buffer | string): string => Buffer.from(input).toString("base64url");
@@ -29,8 +29,10 @@ test("verifyToken: a valid token → User, selecting the verify key by kid acros
assert.deepEqual(user, { email: "a@b.c", id: "u1", roles: ["admin"] });
});
test("verifyToken rejects expiry and future nbf, with clock-skew leeway", async () => {
test("verifyToken requires exp, rejects expiry and future nbf, with clock-skew leeway", async () => {
const opts = { clockSkewSec: 60, now: NOW };
// No exp ⇒ rejected outright: an exp-less token must never read as eternal.
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: undefined }), jwks, opts), /missing exp/);
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 120 }), jwks, opts), /expired/);
// exp 30s in the past but inside the 60s skew → still accepted.
await verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 30 }), jwks, opts);
@@ -57,31 +59,31 @@ test("verifyToken rejects a bad signature and an unknown kid", async () => {
await assert.rejects(verifyToken(mint(k1.privateKey, "nope", valid), jwks, { now: NOW }), /no JWKS key/);
});
test("claimsToUser requires sub + email, defaults roles to [], keeps only string roles", () => {
assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW }), /sub/);
assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too
assert.throws(() => claimsToUser({ exp: NOW, sub: "u" }), /email/);
assert.throws(() => claimsToUser({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it)
assert.deepEqual(claimsToUser({ email: "a@b.c", sub: "u" }).roles, []); // roles absent
assert.deepEqual(claimsToUser({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]);
test("claimsToIdentity requires sub + email, defaults roles to [], keeps only string roles", () => {
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW }), /sub/);
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too
assert.throws(() => claimsToIdentity({ exp: NOW, sub: "u" }), /email/);
assert.throws(() => claimsToIdentity({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it)
assert.deepEqual(claimsToIdentity({ email: "a@b.c", sub: "u" }).roles, []); // roles absent
assert.deepEqual(claimsToIdentity({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]);
});
test("resolveSession classifies the cookie; authenticate is its fail-closed user projection", async () => {
test("resolveSession classifies the cookie; authenticate is its fail-closed identity projection", async () => {
const cookie = (extra: Record<string, unknown> = {}, kid = "k1") => `${SESSION_COOKIE}=${mint(k1.privateKey, kid, { ...valid, ...extra })}`;
const user = { email: "a@b.c", id: "u1", roles: ["admin"] };
const identity = { email: "a@b.c", id: "u1", roles: ["admin"] };
// A valid token → the user, not expired.
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, user });
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, identity });
// Present but past exp → the re-mint trigger (expired flagged, no user).
assert.deepEqual(await resolveSession(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), { expired: true, user: null });
assert.deepEqual(await resolveSession(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), { expired: true, identity: null });
// No cookie / non-ours / garbage / bad-signature are NOT re-mint candidates (no Ory round-trip).
assert.deepEqual(await resolveSession(undefined, jwks, { now: NOW }), { expired: false, user: null });
assert.deepEqual(await resolveSession("other=1", jwks, { now: NOW }), { expired: false, user: null });
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=not.a.jwt`, jwks, { now: NOW }), { expired: false, user: null });
assert.deepEqual(await resolveSession(cookie({}, "nope"), jwks, { now: NOW }), { expired: false, user: null });
assert.deepEqual(await resolveSession(undefined, jwks, { now: NOW }), { expired: false, identity: null });
assert.deepEqual(await resolveSession("other=1", jwks, { now: NOW }), { expired: false, identity: null });
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=not.a.jwt`, jwks, { now: NOW }), { expired: false, identity: null });
assert.deepEqual(await resolveSession(cookie({}, "nope"), jwks, { now: NOW }), { expired: false, identity: null });
// authenticate() is the convenience wrapper — resolveSession(...).user, dropping the flag.
assert.deepEqual(await authenticate(cookie(), jwks, { now: NOW }), user);
assert.deepEqual(await authenticate(cookie(), jwks, { now: NOW }), identity);
assert.equal(await authenticate(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), null); // expired ⇒ null
assert.equal(await authenticate(undefined, jwks, { now: NOW }), null);
});
@@ -92,7 +94,7 @@ test("verifyToken honours an optional denylist: a revoked subject's token reject
// Revoked: thrown as *expired* so resolveSession flags it for the re-mint (re-read Keto / clear).
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 }), jwks, { denylist, now: NOW }), /revoked/);
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, user: null });
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, identity: null });
// A token minted after the revoke (fresh login) is accepted; a different subject is untouched.
assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", roles: ["admin"] });
await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5, sub: "u2" }), jwks, { denylist, now: NOW });
+11 -11
View File
@@ -2,8 +2,8 @@
// 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
// 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.
import type { User } from "../http/context.ts";
// (anonymous), so the route renders signed-out and the role gate denies.
import type { SessionIdentity } from "../http/context.ts";
import { parseCookies } from "../http/cookie.ts";
import type { Denylist } from "./denylist.ts";
import { decodeJws, verifyJws } from "./jwt.ts";
@@ -61,7 +61,7 @@ export function validateClaims(payload: Record<string, unknown>, options: Verify
// Map verified claims → the request User. sub/email are required and non-empty (the tokenizer
// always sets them; an empty email would read as anonymous in the shell); roles defaults to [] and
// keeps only string entries (defensive).
export function claimsToUser(payload: Record<string, unknown>): User {
export function claimsToIdentity(payload: Record<string, unknown>): SessionIdentity {
const sub = payload["sub"];
if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub");
const email = payload["email"];
@@ -72,13 +72,13 @@ export function claimsToUser(payload: Record<string, unknown>): User {
// Verify a session JWT end-to-end: select the key by `kid`, check the signature, validate
// claims, project the User. Throws TokenError / the underlying verify error on any failure.
export async function verifyToken(token: string, jwks: JwksProvider, options: VerifyOptions = {}): Promise<User> {
export async function verifyToken(token: string, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionIdentity> {
const { header } = decodeJws(token); // unverified — only to read `kid` for key selection
const jwk = await jwks.getKey(header.kid);
if (!jwk) throw new TokenError(`no JWKS key for kid ${header.kid ?? "(none)"}`);
const verified = verifyJws(token, jwk); // throws on a bad signature / disallowed alg
validateClaims(verified.payload, options);
const user = claimsToUser(verified.payload);
const user = claimsToIdentity(verified.payload);
// Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so
// resolveSession routes it through the re-mint (fresh roles from Keto, or a cleared session).
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
@@ -87,7 +87,7 @@ export async function verifyToken(token: string, jwks: JwksProvider, options: Ve
export interface SessionAuth {
expired: boolean; // a token was present but rejected as *expired* → a re-mint candidate
user: User | null;
identity: SessionIdentity | null;
}
// The request middleware: read our session cookie, verify it → the User (fail-closed: any
@@ -96,15 +96,15 @@ export interface SessionAuth {
// expired session, never for anonymous or garbage requests.
export async function resolveSession(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionAuth> {
const token = parseCookies(cookieHeader)[SESSION_COOKIE];
if (!token) return { expired: false, user: null };
if (!token) return { expired: false, identity: null };
try {
return { expired: false, user: await verifyToken(token, jwks, options) };
return { expired: false, identity: await verifyToken(token, jwks, options) };
} catch (err) {
return { expired: err instanceof TokenError && err.expired, user: null };
return { expired: err instanceof TokenError && err.expired, identity: null };
}
}
// Convenience for callers that don't re-mint: just the User, or null.
export async function authenticate(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<User | null> {
return (await resolveSession(cookieHeader, jwks, options)).user;
export async function authenticate(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionIdentity | null> {
return (await resolveSession(cookieHeader, jwks, options)).identity;
}
+2 -2
View File
@@ -8,7 +8,7 @@ import { createKetoClient, KetoError } from "./keto-client.ts";
const READ = "http://keto:4466";
const WRITE = "http://keto:4467";
const USER = "user:01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
const USER = "identity:01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
function res(status: number, body?: unknown): Response {
const h = new Headers();
@@ -35,7 +35,7 @@ test("check GETs the read API and returns the allowed boolean (true and false)",
assert.match(allow.calls[0]!.url, new RegExp(`subject_id=${encodeURIComponent(USER).replace(/[.]/g, "\\.")}`));
// A denied check is 403 {allowed:false} (not a 200) — both statuses carry the verdict.
const deny = recorder(() => res(403, { allowed: false }));
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: "user:nobody" }), false);
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: "identity:nobody" }), false);
});
test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => {
+6 -6
View File
@@ -9,7 +9,7 @@ import type { KratosPublic, Session } from "./kratos-public.ts";
import { completeLogin, readRoles, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `user:${ID}` });
const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `identity:${ID}` });
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
check: async () => false,
@@ -49,11 +49,11 @@ test("readRoles returns roles held directly OR transitively (enumerate defined r
// subjects vary (a direct user, a group) and a name repeats across pages → de-duped.
listRelations: async (q) => {
listQ.push(q);
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [role("editor", { subject_id: "user:other" })] };
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [role("editor", { subject_id: "identity:other" })] };
return { nextPageToken: "p2", tuples: [
role("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
role("admin", { subject_id: `user:${ID}` }),
role("viewer", { subject_id: "user:stranger" }),
role("admin", { subject_id: `identity:${ID}` }),
role("viewer", { subject_id: "identity:stranger" }),
] };
},
// Keto resolves transitively: the user holds editor (via a group) + admin (direct), not viewer.
@@ -105,12 +105,12 @@ test("remintSession: a live Kratos session → fresh cookie + refreshed user; a
// TTL lapsed but the Kratos session lives → re-read roles from Keto, re-tokenize, fresh cookie.
const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s");
assert.deepEqual(live.user, { email: "admin@plainpages.local", id: ID, roles: ["admin"] });
assert.deepEqual(live.identity, { email: "admin@plainpages.local", id: ID, roles: ["admin"] });
assert.match(live.setCookie, /^plainpages_jwt=h\.p\.s;.*Max-Age=2592000.*HttpOnly/);
// Kratos session also gone → clear the stale JWT so the next request falls through to anonymous.
const dead = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic: publicStub() }, undefined);
assert.equal(dead.user, null);
assert.equal(dead.identity, null);
assert.match(dead.setCookie, /^plainpages_jwt=;.*Max-Age=0/);
});
+6 -6
View File
@@ -6,7 +6,7 @@
// 4. whoami(tokenize_as) → the signed JWT { sub, email, roles }, stored as our cookie
// Order matters: the projection is written before tokenizing, because the claims mapper
// reads only the identity, never Keto.
import type { User } from "../http/context.ts";
import type { SessionIdentity } from "../http/context.ts";
import { serializeCookie, type CookieOptions } from "../http/cookie.ts";
import { currentLog } from "../logger.ts";
import type { KetoClient } from "./keto-client.ts";
@@ -37,13 +37,13 @@ export interface CompletedLogin {
roles: string[];
}
// The coarse roles a user holds — directly (`Role:<name>#members@user:<id>`) or transitively via a
// The coarse roles a user holds — directly (`Role:<name>#members@identity:<id>`) or transitively via a
// group that is a member of the role. Enumerates the defined roles (the distinct objects in the Role
// namespace) and asks Keto to resolve each membership, so a role granted to a group reaches the JWT —
// matching the OPL model and the admin "Effective access" view. At login/refresh only, never per
// request; role count is small, so the per-role checks are cheap and run in parallel.
export async function readRoles(keto: KetoClient, identityId: string): Promise<string[]> {
const subject_id = `user:${identityId}`;
const subject_id = `identity:${identityId}`;
const names = new Set<string>();
let pageToken: string | undefined;
do {
@@ -76,7 +76,7 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
export interface Reminted {
setCookie: string; // a fresh JWT cookie on success, else a cookie that clears the stale one
user: User | null;
identity: SessionIdentity | null;
}
// Re-mint the session JWT on TTL expiry — "stay signed in" (README): the ~10m token lapsed but
@@ -86,8 +86,8 @@ export interface Reminted {
// anonymous instead of re-hitting Ory on every one.
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
const completed = await completeLogin(deps, cookie);
if (!completed) return { setCookie: clearSessionCookie(options), user: null };
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } };
if (!completed) return { setCookie: clearSessionCookie(options), identity: null };
return { setCookie: sessionCookie(completed.jwt, options), identity: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } };
}
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
+3 -3
View File
@@ -44,7 +44,7 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
const pathname = ctx.url.pathname;
// Already signed in? Re-authenticating / re-registering is pointless — send them to the app
// dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.)
if (ctx.user && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" };
if (ctx.identity && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" };
const cookie = ctx.req.headers.cookie;
const flowId = ctx.url.searchParams.get("flow");
// Only the Kratos calls are in the try, so a render/buildFlowView bug below falls through to
@@ -75,7 +75,7 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
// Expired/unknown flow → restart by re-initialising (drop the stale ?flow=).
if (err instanceof KratosError && [403, 404, 410].includes(err.status)) return { redirect: pathname };
// Already authenticated at Kratos but no app JWT yet (e.g. straight after registration, whose
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.user
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.identity
// is null and the "already signed in" short-circuit above can't fire). Initialising a login/
// registration flow then returns Kratos 400 `session_already_available`. Recover by completing
// login (mint the JWT from the live session), honouring return_to — never a 500.
@@ -218,7 +218,7 @@ function logout(kratos: KratosPublic, secureCookies: boolean): BuiltinRoute["han
}
const flow = await kratos.createLogoutFlow(ctx.req.headers.cookie ? { cookie: ctx.req.headers.cookie } : {});
ctx.res.appendHeader("set-cookie", clearSessionCookie({ secure: secureCookies }));
ctx.log.info("logout", { sub: ctx.user?.id ?? "" });
ctx.log.info("logout", { sub: ctx.identity?.id ?? "" });
return { redirect: flow?.logoutUrl ?? "/login" };
};
}
+34 -34
View File
@@ -105,7 +105,7 @@ test("plugins replace either landing: `home` owns the public /, `dashboard` owns
t.after(() => rmSync(dir, { force: true, recursive: true }));
const portal: Plugin = {
apiVersion: "1.0.0",
dashboard: (ctx) => ({ data: { chrome: ctx.chrome, user: ctx.user }, view: "board" }),
dashboard: (ctx) => ({ data: { chrome: ctx.chrome, user: ctx.identity }, view: "board" }),
home: () => ({ data: { brand: "Acme" }, view: "welcome" }),
id: "portal",
};
@@ -125,7 +125,7 @@ test("plugins replace either landing: `home` owns the public /, `dashboard` owns
assert.equal(board.status, 200);
const html = await board.text();
assert.match(html, /<h1 class="page-title">My Portal<\/h1>/); // its own title in the native shell
assert.match(html, /Hi a@b\.c/); // its handler rendered, with ctx.user
assert.match(html, /Hi a@b\.c/); // its handler rendered, with ctx.identity
assert.doesNotMatch(html, /Avery Kline/); // the built-in mock People list is gone — fully replaced
});
@@ -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 = {
apiVersion: "1.0.0",
id: "demo",
@@ -393,7 +393,7 @@ const demoPlugin: Plugin = {
{ handler: (ctx) => ({ html: `<p>Hi ${ctx.params.name}</p>` }), method: "GET", path: "/hello/:name" },
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" },
{ 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: () => ({ 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}`;
}
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-"));
mkdirSync(join(dir, "demo", "views"), { recursive: true });
mkdirSync(join(dir, "demo", "public"), { recursive: true });
@@ -516,7 +516,7 @@ test("a plugin view renders the native chrome; its forms are CSRF-guarded via ct
assert.equal(ok.status, 303);
});
// JWT middleware: a verified session cookie populates ctx.user/roles, which the gate reads.
// JWT middleware: a verified session cookie populates ctx.identity/roles, which the gate reads.
// The key + mintJwt + session() helper are hoisted above the shared `server` (top of file).
test("a verified session JWT authorizes a role-gated route; no cookie / expired token → sign in", async (t) => {
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
@@ -569,7 +569,7 @@ test("session re-mint: an expired JWT backed by a live Kratos session is silentl
const nowSec = Math.floor(Date.now() / 1000);
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" });
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "demo:read", relation: "members", subject_id: "user:u1" }] }) });
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "demo:read", relation: "members", subject_id: "identity:u1" }] }) });
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
// Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response.
@@ -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) => { 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: () => ({ 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] });
@@ -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/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" });
assert.equal(gAnon.status, 303);
assert.equal(gAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fgated");
@@ -727,7 +727,7 @@ test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a s
});
test("themed auth GET: an existing Kratos session (no app JWT yet) recovers via /auth/complete, never 500", async (t) => {
// After registration's `session` hook the user holds a Kratos session but no app JWT — so ctx.user
// After registration's `session` hook the user holds a Kratos session but no app JWT — so ctx.identity
// is null and the "already signed in" short-circuit can't fire. Initialising a login/registration
// flow then returns Kratos 400 `session_already_available`; recover by completing login (mint the
// JWT from the live session), preserving return_to — never fall through to the catch-all 500.
@@ -884,7 +884,7 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
let projected: unknown;
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${identity.id}` }] }) });
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${identity.id}` }] }) });
const complete = async (app: ReturnType<typeof createApp>, cookie?: string, returnTo?: string) => {
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
@@ -1178,7 +1178,7 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
];
const tuples: RelationTuple[] = [{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }];
const tuples: RelationTuple[] = [{ namespace: "Group", object: "eng", relation: "members", subject_id: `identity:${ada}` }];
const keto = fakeKeto(tuples);
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin });
@@ -1192,26 +1192,26 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
// Create: the form renders; a valid post writes the first-member tuple and redirects to the detail.
assert.match(await (await get("/admin/groups/new")).text(), /Create group/);
const created = await post("/admin/groups", `_csrf=${token}&name=design&member=user:${grace}`);
const created = await post("/admin/groups", `_csrf=${token}&name=design&member=identity:${grace}`);
assert.equal(created.status, 303);
assert.equal(created.headers.get("location"), "/admin/groups/design");
assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `user:${grace}`));
assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `identity:${grace}`));
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
const before = tuples.length;
assert.equal((await post("/admin/groups", `_csrf=${token}&name=Bad Name&member=user:${grace}`)).status, 400);
assert.equal((await post("/admin/groups", `_csrf=${token}&name=eng&member=user:${grace}`)).status, 400); // already exists
assert.equal((await post("/admin/groups", `name=x&member=user:${grace}`)).status, 403);
assert.equal((await post("/admin/groups", `_csrf=${token}&name=Bad Name&member=identity:${grace}`)).status, 400);
assert.equal((await post("/admin/groups", `_csrf=${token}&name=eng&member=identity:${grace}`)).status, 400); // already exists
assert.equal((await post("/admin/groups", `name=x&member=identity:${grace}`)).status, 403);
assert.equal(tuples.length, before);
// Detail: lists the current member by email.
assert.match(await (await get("/admin/groups/eng")).text(), /ada@example\.com/);
// Add a member, then remove it.
await post("/admin/groups/eng/members", `_csrf=${token}&member=user:${grace}`);
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
await post("/admin/groups/eng/members", `_csrf=${token}&member=identity:${grace}`);
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `identity:${grace}`));
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=identity:${grace}`);
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `identity:${grace}`));
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
assert.match(await (await get("/admin/groups/eng/delete")).text(), /Cancel/);
@@ -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);
});
// 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
// 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) => {
@@ -1237,8 +1237,8 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
];
// grace is in the `eng` group; `editor` is an existing role whose only direct member is ada.
const tuples: RelationTuple[] = [
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${grace}` },
{ namespace: "Role", object: "editor", relation: "members", subject_id: `user:${ada}` },
{ namespace: "Group", object: "eng", relation: "members", subject_id: `identity:${grace}` },
{ namespace: "Role", object: "editor", relation: "members", subject_id: `identity:${ada}` },
];
// Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children.
const expandSet = (set: SubjectSet): ExpandTree => ({
@@ -1262,17 +1262,17 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
// Create: a valid post writes the first-member tuple and redirects to the detail.
assert.match(await (await get("/admin/roles/new")).text(), /Create role/);
const created = await post("/admin/roles", `_csrf=${token}&name=viewer&member=user:${ada}`);
const created = await post("/admin/roles", `_csrf=${token}&name=viewer&member=identity:${ada}`);
assert.equal(created.status, 303);
assert.equal(created.headers.get("location"), "/admin/roles/viewer");
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "viewer" && tp.subject_id === `user:${ada}`));
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "viewer" && tp.subject_id === `identity:${ada}`));
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a role to a user revokes their stale token so the grant lands now
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
const before = tuples.length;
assert.equal((await post("/admin/roles", `_csrf=${token}&name=Bad Name&member=user:${ada}`)).status, 400);
assert.equal((await post("/admin/roles", `_csrf=${token}&name=editor&member=user:${ada}`)).status, 400); // already exists
assert.equal((await post("/admin/roles", `name=x&member=user:${ada}`)).status, 403);
assert.equal((await post("/admin/roles", `_csrf=${token}&name=Bad Name&member=identity:${ada}`)).status, 400);
assert.equal((await post("/admin/roles", `_csrf=${token}&name=editor&member=identity:${ada}`)).status, 400); // already exists
assert.equal((await post("/admin/roles", `name=x&member=identity:${ada}`)).status, 403);
assert.equal(tuples.length, before);
// Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not
@@ -1293,8 +1293,8 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
// Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate.
await post("/admin/roles/editor/members", `_csrf=${token}&member=user:${grace}`);
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=user:${grace}`);
await post("/admin/roles/editor/members", `_csrf=${token}&member=identity:${grace}`);
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=identity:${grace}`);
assert.equal(denylist.isRevoked(grace, 0), true);
// Delete the role: a confirm step (GET) then the POST removes every member tuple, back to the list.
@@ -1305,11 +1305,11 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor"));
// Self-protection: the admin role can't be deleted, nor can you revoke your own admin (sub admin1).
tuples.push({ namespace: "Role", object: "admin", relation: "members", subject_id: "user:admin1" });
tuples.push({ namespace: "Role", object: "admin", relation: "members", subject_id: "identity:admin1" });
assert.equal((await post("/admin/roles/admin/delete", `_csrf=${token}`)).status, 400);
assert.ok(tuples.some((tp) => tp.object === "admin"));
assert.equal((await post("/admin/roles/admin/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400);
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "user:admin1"));
assert.equal((await post("/admin/roles/admin/members/delete", `_csrf=${token}&member=identity:admin1`)).status, 400);
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "identity:admin1"));
// An invalid role name in the path → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/admin/roles/Bad%20Name")).status, 404);
+15 -15
View File
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
import ejs from "ejs";
import { type BuiltinRoute, matchBuiltinRoute, type RequestCsrf } from "./builtin-routes.ts";
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
import { buildContext, type RequestContext, type User } from "./context.ts";
import { buildContext, type RequestContext, type SessionIdentity } from "./context.ts";
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
import type { Denylist } from "../auth/denylist.ts";
import { buildDashboardModel } from "../ui/dashboard.ts";
@@ -40,7 +40,7 @@ export interface AppOptions {
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles; absent ⇒ always anonymous
jwks?: JwksProvider; // verify the session JWT → ctx.identity/roles; absent ⇒ always anonymous
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion
@@ -124,7 +124,7 @@ export function createApp(options: AppOptions = {}): Server {
await sendResult(ctx.res, result, (view, data) => renderView(homePlugin.id, view, data));
return null;
}
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
return { data: { chrome: ctx.chrome, user: ctx.identity }, view: "home" };
};
// The post-login app home "/dashboard", gated to a signed-in user: anonymous bounces to sign
@@ -132,7 +132,7 @@ export function createApp(options: AppOptions = {}): Server {
// handler renders against its own views, same path as a plugin route. Else the built-in
// mock-data People list with the one global menu (ctx.chrome.nav) + branding from config/menu.ts.
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult | null> => {
if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
if (!ctx.identity) return { redirect: loginRedirect(ctx), status: 303 };
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
csrf.setCookie();
if (dashboardPlugin) {
@@ -141,7 +141,7 @@ export function createApp(options: AppOptions = {}): Server {
await sendResult(ctx.res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
return null;
}
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, nav: ctx.chrome.nav, user: ctx.user }) }, view: "index" };
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, identity: ctx.identity, nav: ctx.chrome.nav }) }, view: "index" };
};
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
@@ -186,19 +186,19 @@ export function createApp(options: AppOptions = {}): Server {
}
}
// Verify the session JWT once (cached JWKS) → ctx.user/roles; none/invalid ⇒ anonymous.
// Verify the session JWT once (cached JWKS) → ctx.identity/roles; none/invalid ⇒ anonymous.
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory
// clients), silently re-mint it — "stay signed in": re-read roles from Keto, re-tokenize,
// and set the fresh cookie via setHeader so it rides whatever response this request produces
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
let user: User | null = null;
let user: SessionIdentity | null = null;
if (jwks) {
const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
user = auth.user;
user = auth.identity;
if (!user && auth.expired && keto && kratos && kratosAdmin) {
try {
const reminted = await remintSession({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie, { secure: secureCookies });
user = reminted.user;
user = reminted.identity;
res.appendHeader("set-cookie", reminted.setCookie);
} catch (err) {
// Ory unreachable (Kratos/Keto 5xx, refused, timeout) — degrade to anonymous instead of
@@ -223,10 +223,10 @@ export function createApp(options: AppOptions = {}): Server {
// ctx.chrome getter only triggers it when a handler actually reads it (a json/redirect handler,
// or the public "/" with a standalone home, never composes the menu).
let chromeMemo: PageChrome | undefined;
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }));
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, identity: user }));
// base context (no route params yet); reused for onRequest hooks and the landing routes.
const ctx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf, ...(system ? { system } : {}) });
const ctx = buildContext(req, res, { chrome, identity: user, log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
// Plugin onRequest hooks run before routing and may short-circuit the request.
if (anyRequestHooks) {
@@ -240,17 +240,17 @@ 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
// CSRF cookie is set so those forms have a valid double-submit token.
const match = matchRoute(plugins, method, pathname);
if (match) {
const routeCtx = buildContext(req, res, { chrome, log: reqLog, params: match.params, user, verifyCsrf, ...(system ? { system } : {}) });
const routeCtx = buildContext(req, res, { chrome, identity: user, log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) });
if (!isAuthorized(match.route, routeCtx.roles)) {
// 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.
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 });
if (!routeCtx.identity) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
reqLog.warn("forbidden: missing role", { path: pathname, required: match.route.role ?? "", sub: routeCtx.identity.id });
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return;
}
+5 -5
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
import { test } from "node:test";
import { buildContext, type User } from "./context.ts";
import { buildContext, type SessionIdentity } from "./context.ts";
import { createLogger } from "../logger.ts";
// A req/res pair without a live server — enough to build and inspect a context.
@@ -22,7 +22,7 @@ test("buildContext parses the URL, exposes query, and defaults to an anonymous u
assert.equal(ctx.query, ctx.url.searchParams); // same instance, not a copy
assert.equal(ctx.query.get("q"), "ann");
assert.equal(ctx.query.get("page"), "2");
assert.equal(ctx.user, null);
assert.equal(ctx.identity, null);
assert.deepEqual(ctx.roles, []);
assert.deepEqual(ctx.params, {});
});
@@ -35,9 +35,9 @@ test("buildContext threads path params supplied by the router", () => {
test("buildContext threads the user and derives roles from it", () => {
const { req, res } = reqRes("/");
const user: User = { email: "a@b.c", id: "u1", roles: ["admin", "editor"] };
const ctx = buildContext(req, res, { user });
assert.equal(ctx.user, user);
const user: SessionIdentity = { email: "a@b.c", id: "u1", roles: ["admin", "editor"] };
const ctx = buildContext(req, res, { identity: user });
assert.equal(ctx.identity, user);
assert.equal(ctx.roles, user.roles); // same reference, never a divergent copy — buildContext is the only writer
});
+10 -9
View File
@@ -5,11 +5,11 @@ import { createLogger, type Log } from "../logger.ts";
// The request context threaded to every route handler (plugin + built-in), built once
// per request by `buildContext`: the router supplies matched path `params`, the JWT
// middleware supplies `user` (null until then). The host's single handler argument.
// middleware supplies `identity` (null until then). The host's single handler argument.
// The authenticated user, projected from verified session JWT claims:
// The authenticated Kratos identity, projected from verified session JWT claims:
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token.
export interface User {
export interface SessionIdentity {
email: string;
id: string;
roles: string[];
@@ -19,6 +19,8 @@ export interface RequestContext {
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its
// page renders the native app shell; the host builds it per request (anonymous default otherwise).
chrome: PageChrome;
// The signed-in Kratos identity, or null when anonymous.
identity: SessionIdentity | null;
// Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
@@ -27,12 +29,11 @@ export interface RequestContext {
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
req: IncomingMessage;
res: ServerResponse;
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
roles: string[]; // identity?.roles ?? [] — coarse gate without a null-check
// Privileged host services (Ory admin clients + instant-revoke) for a system plugin. Undefined
// unless the host wired them; every field optional. Ordinary domain plugins ignore it.
system?: SystemCapabilities;
url: URL;
user: User | null;
// Gate a first-party form submission: true iff `submitted` matches this request's signed CSRF
// cookie (double-submit). The host binds the secret; a plugin calls it after reading its body.
verifyCsrf(submitted: string | null | undefined): boolean;
@@ -43,10 +44,10 @@ export interface BuildContextOptions {
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
// The host's factory is memoised, so the menu composes at most once per request across contexts.
chrome?: () => PageChrome;
identity?: SessionIdentity | null;
log?: Log;
params?: Record<string, string>;
system?: SystemCapabilities;
user?: User | null;
verifyCsrf?: (submitted: string | null | undefined) => boolean;
}
@@ -62,20 +63,20 @@ export function buildContext(
options: BuildContextOptions = {},
): RequestContext {
const url = new URL(req.url ?? "/", "http://localhost");
const user = options.user ?? null;
const identity = options.identity ?? null;
const buildChrome = options.chrome;
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
return {
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
identity,
log: options.log ?? SILENT_LOG,
params: options.params ?? {},
query: url.searchParams,
req,
res,
roles: user?.roles ?? [],
roles: identity?.roles ?? [],
...(options.system ? { system: options.system } : {}),
url,
user,
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
};
}
+3 -3
View File
@@ -1,6 +1,6 @@
// Guards the Ory Keto config: migrations run before the server (keto-migrate →
// keto), the DSN targets the keto database, read/write APIs serve on the ports config.ts
// points at, and the OPL declares the role/group/resource namespaces. Version pinning is
// points at, and the OPL declares the identity/role/group/resource namespaces. Version pinning is
// in compose.test.ts. Real boot is verified by running the stack; this catches edits.
import { test } from "node:test";
import assert from "node:assert/strict";
@@ -35,8 +35,8 @@ test("keto loads the OPL namespaces from the mounted file", () => {
"namespaces come from the committed OPL");
});
test("the OPL declares role, group and a resource namespace over user subjects", () => {
for (const ns of ["User", "Group", "Role", "Resource"])
test("the OPL declares role, group and a resource namespace over identity subjects", () => {
for (const ns of ["Identity", "Group", "Role", "Resource"])
assert.match(opl, new RegExp(`class ${ns} implements Namespace`), `defines ${ns}`);
// role + group are subject sets read at login → JWT roles claim (README).
assert.match(opl, /class Role implements Namespace\s*{\s*related:\s*{\s*members:/,
+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: "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: "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 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 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 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 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");
});
test("a shared permission token only warns — both plugins still load", async (t) => {
const perm = `export default { apiVersion: "1.0.0", permissions: [{ token: "shared:read" }] };`;
const dir = scaffold(t, { "x/plugin.ts": perm, "y/plugin.ts": perm });
test("a shared role name only warns — both plugins still load", async (t) => {
const shared = `export default { apiVersion: "1.0.0", roles: [{ name: "shared:read" }] };`;
const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared });
const warnings: string[] = [];
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
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
// (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and
// 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 { 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.
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`;
}
// `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) {
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.
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);
if (navContradiction) return navContradiction;
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 {
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);
if (inChild) return inChild;
}
+2 -2
View File
@@ -5,8 +5,8 @@
// a plugin should import from here, never reach into deeper modules. See README.md → Building plugins.
export { definePlugin } from "./plugin.ts";
export type { HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
export type { RequestContext, User } from "../http/context.ts";
export type { HttpMethod, Plugin, PluginHooks, PluginManifest, RoleDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
export type { RequestContext, SessionIdentity } from "../http/context.ts";
export type { PageChrome } from "../ui/chrome.ts";
export type { NavNode } from "../ui/nav.ts";
export { can, check, GuardError, requireSession } from "../auth/guards.ts";
+10 -10
View File
@@ -21,13 +21,13 @@ const scheduling: PluginManifest = definePlugin({
apiVersion: "1.0.0",
hooks: { onBoot: () => {} },
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",
}],
permissions: [{ description: "View shifts", token: "scheduling:read" }],
roles: [{ description: "View shifts", name: "scheduling:read" }],
routes: [
{ handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", permission: "scheduling:read" },
{ handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", permission: "scheduling:write" },
{ handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", role: "scheduling:read" },
{ 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
],
});
@@ -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")));
});
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([
p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
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")));
// Sharing a permission across plugins is legitimate (shared role) → warn, not error.
const permDup = findConflicts([
p({ id: "a", permissions: [{ token: "shared:read" }] }),
p({ id: "b", permissions: [{ token: "shared:read" }] }),
// Sharing a role across plugins is legitimate → warn, not error.
const roleDup = findConflicts([
p({ id: "a", roles: [{ name: "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", () => {
+15 -15
View File
@@ -29,18 +29,18 @@ export interface Route {
handler: RouteHandler;
method: HttpMethod;
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
// Mark the page reachable by anyone, signed in or not. The same as omitting `permission`
// — a no-permission route is already open — but stated outright, so "public" is a deliberate
// choice, not an accident. Mutually exclusive with `permission` (discovery refuses both).
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 `role`
// — an ungated route is already open — but stated outright, so "public" is a deliberate
// choice, not an accident. Mutually exclusive with `role` (discovery refuses both).
public?: boolean;
}
// A permission token this plugin introduces — declared for docs/seeding. Tokens are a shared
// global namespace (so an operator grants them in Keto); namespace as `<id>:<action>`.
export interface PermissionDecl {
// A Keto Role this plugin gates on — declared for docs/seeding. Role names are a shared
// global namespace (so an operator grants them once in Keto); namespace as `<id>:<action>`.
export interface RoleDecl {
description?: string;
token: string;
name: string;
}
// 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;
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
permissions?: PermissionDecl[];
roles?: RoleDecl[];
routes?: Route[];
}
@@ -147,7 +147,7 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
}
export interface PluginConflict {
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route";
kind: "dashboard" | "home" | "id" | "nav-id" | "role" | "route";
level: "error" | "warn";
message: string;
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
// 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
// tokens are the one intentional overlap, so they warn rather than error.
// is structural — it follows from the id check, so it needs no rule of its own. Shared role
// names are the one intentional overlap, so they warn rather than error.
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
const out: PluginConflict[] = [];
@@ -184,9 +184,9 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
});
collect(plugins, (plugin, push) => {
for (const decl of plugin.permissions ?? []) push(decl.token);
}).forEach((owners, token) => {
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) });
for (const decl of plugin.roles ?? []) push(decl.name);
}).forEach((owners, name) => {
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;
+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", () => {
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
assert.equal(isAuthorized(open, []), true);
assert.equal(isAuthorized(gated, []), false);
assert.equal(isAuthorized(gated, ["x:read"]), true);
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();
}
// 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
// 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 {
return route.public === true || route.permission == null || roles.includes(route.permission);
return route.public === true || route.role == null || roles.includes(route.role);
}
+6 -6
View File
@@ -9,13 +9,13 @@ const scheduling: Plugin = {
apiVersion: "1.0.0",
id: "scheduling",
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",
}],
};
// 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 }] };
// 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).
const adminLike: Plugin = {
apiVersion: "1.0.0", id: "admin",
@@ -24,7 +24,7 @@ const adminLike: Plugin = {
{ href: "/admin/users", id: "users", label: "Users" },
{ 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,10 +45,10 @@ 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");
});
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({
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
user: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] },
identity: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] },
});
assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]); // Dashboard shown to a signed-in user
const section = chrome.nav.find((n) => n.label === "Scheduling")!;
@@ -58,7 +58,7 @@ test("a permission holder sees the Dashboard link + plugin nav; current path ope
});
test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => {
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], user: { email: "a@b.c", id: "u1", roles: ["admin"] } });
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], identity: { email: "a@b.c", id: "u1", roles: ["admin"] } });
const admin = chrome.nav.find((n) => n.label === "Admin")!;
assert.ok(admin); // gated section visible to an admin
assert.equal(admin.open, true); // ancestor of the current leaf opened
+5 -5
View File
@@ -5,7 +5,7 @@
// admin plugin is installed) — run through composeNav (override + per-user filter) and
// current-marked for the request path.
import type { User } from "../http/context.ts";
import type { SessionIdentity } from "../http/context.ts";
import { type MenuConfig } from "./menu-config.ts";
import { composeNav, type NavNode } from "./nav.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
@@ -29,17 +29,17 @@ export interface ChromeOptions {
currentPath?: string; // request pathname; the matching nav leaf is marked current
menu: MenuConfig;
plugins?: Plugin[];
user?: User | null;
identity?: SessionIdentity | null;
}
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
// The Dashboard link targets the gated /dashboard, so show it only to a signed-in user — to an
// anonymous visitor (a public page in the shell) it would only dead-end at /login. The admin
// section, when present, is just another plugin's nav fragment (examples/plugins/admin).
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
const fragments: NavNode[][] = opts.identity ? [[DASHBOARD_NAV]] : [];
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
const roles = opts.user?.roles ?? [];
const roles = opts.identity?.roles ?? [];
const nav = composeNav(fragments, opts.menu.override, roles);
if (opts.currentPath) {
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
@@ -56,7 +56,7 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
// Anonymous "Sign in" returns to the current page (it's host-relative, our own pathname).
signInHref: opts.currentPath ? `/login?return_to=${encodeURIComponent(opts.currentPath)}` : "/login",
...(b.theme != null ? { theme: b.theme } : {}),
user: shellUser(opts.user),
user: shellUser(opts.identity),
};
}
+1 -1
View File
@@ -8,7 +8,7 @@ import type { NavNode } from "./nav.ts";
const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => {
const m = buildDashboardModel({ csrfToken: "tok.sig", nav: NAV, user: { email: "ada@x.io", id: "u1", roles: ["admin"] } });
const m = buildDashboardModel({ csrfToken: "tok.sig", identity: { email: "ada@x.io", id: "u1", roles: ["admin"] }, nav: NAV });
assert.equal(m.shell.title, "Dashboard");
assert.equal(m.shell.csrfToken, "tok.sig");
assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
+3 -3
View File
@@ -4,12 +4,12 @@
// this placeholder renders until then. Pure: `nav` is the one global menu (ctx.chrome.nav), built
// once per request by the host, so the dashboard shows the exact same menu as every other page.
import type { User } from "../http/context.ts";
import type { SessionIdentity } from "../http/context.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import { buildShellContext } from "./shell-context.ts";
export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; user?: User | null } = {}) {
export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; identity?: SessionIdentity | null } = {}) {
return {
nav: opts.nav ?? [],
shell: buildShellContext({
@@ -17,7 +17,7 @@ export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfi
csrfToken: opts.csrfToken ?? "",
menu: opts.menu ?? DEFAULT_MENU,
title: "Dashboard",
user: opts.user ?? null,
identity: opts.identity ?? null,
}),
};
}
+1 -1
View File
@@ -1,7 +1,7 @@
// 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/
// 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).
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 { 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[][] = [
[{
icon: "i-cal", id: "sched", label: "Scheduling",
children: [
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
{ href: "/scheduling/manage", id: "manage", label: "Manage", permission: "scheduling:admin" },
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" },
{ 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", () => {
const tree = composeNav(fragments, {}, ["scheduling:read"]);
// 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, [
{ 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", () => {
// A header the user can't reach takes its whole subtree, even visible children.
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" }] },
]];
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.
const emptyHeader: NavNode[][] = [[
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x" }] },
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y" }] },
{ 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", role: "y" }] },
]];
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",
children: [
{ 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, {}, []), [
{ 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: "/b", id: "b", label: "Beta" },
{ 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, {
+8 -8
View File
@@ -1,10 +1,10 @@
// 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
// 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
// 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 {
id?: string; // stable key for override targeting; stripped from the rendered tree
@@ -15,12 +15,12 @@ export interface NavNode {
icon?: string;
label: string;
open?: boolean;
permission?: 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).
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 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 →
// order → hide, then the per-user permission filter runs last.
// order → hide, then the per-user role filter runs last.
export interface NavOverride {
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)
@@ -106,7 +106,7 @@ function hideTree(nodes: NavNode[], hide: Set<string>): NavNode[] {
function filterByRoles(nodes: NavNode[], roles: Set<string>): NavNode[] {
const out: NavNode[] = [];
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; }
const children = filterByRoles(n.children, roles);
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;
}
// 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.
function toRenderNode(n: NavNode): NavNode {
const out: NavNode = { label: n.label };
+1 -1
View File
@@ -22,7 +22,7 @@ test("buildShellContext maps branding + breadcrumbs, omitting unset optional fie
menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} },
signInHref: "/login?return_to=%2Fx",
title: "Users",
user: { email: "a@b.c", id: "u1", roles: ["admin"] },
identity: { email: "a@b.c", id: "u1", roles: ["admin"] },
});
assert.deepEqual(full.brand, { logo: "/l.svg", name: "Acme", sub: "Ops" });
assert.equal(full.theme, "dark");
+7 -7
View File
@@ -5,7 +5,7 @@
// the profile shows the email's local part as the name with the full email beneath, initials from
// the local part; anonymous ⇒ "Guest".
import type { User } from "../http/context.ts";
import type { SessionIdentity } from "../http/context.ts";
import { type MenuConfig } from "./menu-config.ts";
export interface ShellUser {
@@ -24,10 +24,10 @@ export interface ShellModel {
user: ShellUser;
}
export function shellUser(user: User | null | undefined): ShellUser {
if (!user) return { email: "", initials: "G", name: "Guest" };
const local = user.email.split("@")[0] || user.email;
return { email: user.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
export function shellUser(identity: SessionIdentity | null | undefined): ShellUser {
if (!identity) return { email: "", initials: "G", name: "Guest" };
const local = identity.email.split("@")[0] || identity.email;
return { email: identity.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
}
export function buildShellContext(opts: {
@@ -36,7 +36,7 @@ export function buildShellContext(opts: {
menu: MenuConfig;
signInHref?: string;
title: string;
user?: User | null;
identity?: SessionIdentity | null;
}): ShellModel {
const b = opts.menu.branding;
return {
@@ -46,6 +46,6 @@ export function buildShellContext(opts: {
...(opts.signInHref != null ? { signInHref: opts.signInHref } : {}),
...(b.theme != null ? { theme: b.theme } : {}),
title: opts.title,
user: shellUser(opts.user),
user: shellUser(opts.identity),
};
}
+5 -2
View File
@@ -14,9 +14,12 @@
- [x] CI/CD - Renovate: set a read-only `GITHUB_COM_TOKEN` env in `renovate.yml` so Renovate stops hitting github.com rate limits when resolving github-hosted deps (Playwright, lucide, `actions/checkout`) and can fetch changelogs. Non-blocking refinement; needs a read-only GitHub PAT stored as an Actions secret. (The renovate job forwards the `RENOVATE_GITHUB_TOKEN` secret — a scopeless read-only github.com PAT; Gitea rejects `GITHUB_`-prefixed secret names — into the container as `GITHUB_COM_TOKEN`; 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.)
- [ ] Build and publish docker image as CI/CD.
- [ ] The human developer understands the security model in the auth in this project.
- [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". 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.
- [x] Follow Kratos and rename the Keto `User` namespace to `Identity`. (OPL `class Identity`, subjects `identity:<kratos-id>`, and the session type `User``SessionIdentity` with `ctx.user``ctx.identity`. No migration was needed after all: `keto-migrate` runs Keto's *own* bundled schema migrations and our tuples are runtime data written by `bootstrap.ts` and the admin plugin — with zero installations, `docker compose down -v` is the whole story. The name collided with the existing `Identity` DTO that `#plugin-api` re-exports from `kratos-admin.ts` — that one is the full Kratos record (traits, state, addresses) and kept the plain name; ours is the JWT projection `{ id, email, roles }`, hence `SessionIdentity`. The presentation layer deliberately still says "user" — `ShellUser`, `chrome.user`, the EJS `user` locals — because that is the avatar/profile view-model, not the identity entity.)
- [ ] 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.
## Architectural review findings (2026-07-02)