6 Commits

Author SHA1 Message Date
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
174 changed files with 1864 additions and 5219 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ jobs:
full-gate:
runs-on: docker-host
steps:
- uses: actions/checkout@v7.0.1
- 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
+2 -5
View File
@@ -9,13 +9,10 @@ jobs:
github-mirror:
runs-on: docker-host
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
fetch-tags: true # load-bearing for --prune below: no local tags would delete every remote one
# --prune so a tag deleted here doesn't live on at GitHub forever. It only removes refs a
# refspec DESTINATION matches — so tags; main is a non-glob dst, other branches match nothing.
- run: |
git push --force --prune \
git push --force \
"https://x-access-token:${{ secrets.MIRROR_GITHUB_TOKEN }}@github.com/larvit/plainpages.git" \
refs/remotes/origin/main:refs/heads/main 'refs/tags/*:refs/tags/*'
+2 -2
View File
@@ -8,7 +8,7 @@ jobs:
prune-stale-images:
runs-on: docker-host
steps:
- uses: actions/checkout@v7.0.1
- 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 }}
@@ -19,4 +19,4 @@ jobs:
run: |
docker run --rm -v "$PWD:/repo" -w /repo \
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
node:24.19.0-alpine3.24 node registry-cleanup/cleanup.ts
node:24.18.1-alpine3.24 node registry-cleanup/cleanup.ts
+1 -1
View File
@@ -7,7 +7,7 @@ jobs:
retag-image:
runs-on: docker-host
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/checkout@v4.2.2
- name: Promote the commit-hash image to semver + latest
env:
GIT_TAG: ${{ github.ref_name }}
+3 -6
View File
@@ -21,7 +21,7 @@ jobs:
-e RENOVATE_PLATFORM=gitea \
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
-e RENOVATE_TOKEN \
renovate/renovate:44.11.7
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
@@ -30,14 +30,11 @@ jobs:
# authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer;
# pre-1.0 shifts down (auto-release/next-version.ts). Tag-only — release.yml promotes the
# already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't).
# Off until the Actions variable AUTO_RELEASE is set to 'true': Plainpages is pre-announcement and
# deliberately carries no tags, so an automated bump would only invent a version nobody consumes.
auto-release:
runs-on: docker-host
needs: renovate
if: vars.AUTO_RELEASE == 'true'
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Tag a release for what Renovate merged
@@ -57,7 +54,7 @@ jobs:
fi
BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \
--format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; })
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.18.1-alpine3.24 \
node auto-release/next-version.ts "$LATEST" $BUMPS)
echo "Releasing $LATEST -> $NEXT"
git tag "$NEXT" origin/main
-4
View File
@@ -13,7 +13,3 @@ e2e-tests/artifacts/
# config/ is a drop-in mount point for your menu/branding override — keep it empty (see examples/config/ for the template)
/config/*
!/config/.gitkeep
# locales/ is a drop-in mount point for extra (or replacement) language catalogs — keep it empty
/locales/*
!/locales/.gitkeep
+6 -246
View File
@@ -52,14 +52,13 @@ Intentional, reasoned choices — an architecture review should honor them, not
them. Revisit only if the stated reason stops holding.
- **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/`
(session-JWT hot path, guards, and the Ory REST clients), `i18n/` (locale resolution + the
catalogs, `locales/` holding the data), `plugin-host/`
(session-JWT hot path, guards, and the Ory REST clients), `plugin-host/`
(discovery/router/hooks/view-resolver + the `plugin-api.ts` author barrel + `system.ts`, the
`ctx.system` capability surface), and `ui/` (design-system view-models + menu/chrome);
`server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` stay at the root. Tests
are co-located (`foo.test.ts` beside `foo.ts`). Add a new module to the folder that owns its
concern rather than to the root; don't reintroduce a flat tree. The core ships **no domain
screens** — even the admin GUI (users/groups/permissions) is a drop-in plugin (`examples/plugins/admin/`),
screens** — even the admin GUI (users/groups/roles) is a drop-in plugin (`examples/plugins/admin/`),
not `src/` code.
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the
base request context. It protects the I/O-free hot path on the public, bot-hit landing
@@ -87,144 +86,6 @@ them. Revisit only if the stated reason stops holding.
`tsconfig.include` and resolve the host surface via `#`-imports, so each example typechecks
in place *and* copies across unchanged. Never commit real plugins/config into the root
mount dirs (`plugins/`, `config/`) — they ship empty (`.gitkeep`, git-ignored otherwise).
- **Authorization vocabulary: `User``Group``Permission`, and there is no `Role`.** Keto ships
no namespaces — all four in `ory/keto/namespaces.keto.ts` are ours. `Permission` follows RBAC,
where a permission is one operation ("read shifts") and a role is a *bundle* of them; a route
gates on one operation, so it gates on a permission, and a bundle is just a group with several
grants (groups nest). Ory's own "permission" (the `Resource` `permits`: view/edit/delete) is the
separate per-row tier.
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
word names *who someone is* — a role — and roles are groups here; the old catch-all `admin`
permission was exactly that mistake, split into `users:`/`groups:`/`permissions:`/`oauth2-clients:`
× `read`/`write` 2026-08-05. **Enforced at discovery** (`isValidPermissionName` in
`plugin-host/plugin.ts`, checked by `shapeError` over every route/nav `permission` and every
declared name), fail-loud like every other manifest rule — not only in the admin GUI, which an
operator removes by not copying the example in. Decisions around it:
- **Names are authored in plugin code; only grants live in Keto.** The host collects every
installed plugin's declarations into one catalog (`declaredPermissions``ctx.declaredPermissions`),
and that catalog *is* the fixed list the admin screens offer. So there is **no Permissions admin
screen**: nothing in a GUI invents a name, and holding one is a property of a user or a group,
edited as a checkbox list on those two screens. A tuple in Keto naming something no installed
plugin declares gates nothing and is not offered — and a save never revokes it, since the picker
only speaks for what it showed. Decided with the maintainer 2026-08-05, replacing the CRUD
Permissions screen.
- `<resource>` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`). Deliberate
cross-plugin sharing is a goal, so the pre-2026-08-05 `<id>:<action>` guidance was wrong: users
are the *host's*, not the admin plugin's. Cost: collision-freedom became a convention rather than
structural. Accepted — the alternative penalizes the sharing case.
- **Declaring a permission stays optional.** Requiring every gated route to declare its permission
would make `findConflicts` see all overlaps, but would then warn on exactly the legitimate
sharing case above. Shape is enforced; declaration is not.
- **There is no name-minting path in the GUI at all**, which is what makes the discovery check the
whole story: the only way a name comes into being is a plugin declaring it, and discovery refuses
a badly-shaped declaration at boot. An earlier revision of this branch enforced the rule in the
Permissions screen's create form instead and needed a second guard for the assign form, which
could also mint one — deleting the screen removed both.
- `ADMIN_PERMISSIONS` **defaults to empty**: every permission is owned by the plugin that gates on
it, and a host-invented default would gate nothing. This makes the seed a function of what
`bootstrap` discovers, and a plugin dropped in after first boot therefore needs
`docker compose up -d` (which re-runs the one-shot), not `restart web`. The base file gives
`bootstrap` and `web` the same baked `plugins/`; only `compose.override.yml`'s dev-only `.:/app`
makes `web` diverge onto the host tree, so the matching `./plugins` mount for `bootstrap` lives
**there and only there** — in the base file it would desynchronise prod and collide with the e2e
stacks, which bind individual plugins *inside* `/app/plugins` (a nested mount into a read-only
parent is EROFS and the container never starts). Valid while bootstrap is the only writer of
grants.
- **`actionForMethod` is plugin-local and must not migrate into `#plugin-api`.** Inside the admin
example it buys one thing: the route table and the in-handler guard derive from one function, so
29 routes × 2 gate sites cannot drift. As a general mechanism it would make authorization a
function of the transport verb, and a route table must answer "what does this need?" on its own.
- **A `:read`-only holder must never be shown a write affordance.** The split created a real read-only
operator (a helpdesk account with `users:read`), and the host's 403 is the backstop, not the UX: the
list/detail models carry `canWrite` and the views drop create/save/delete/add/remove, while the
permission picker still renders — disabled — because *seeing* who holds what is the point of `:read`.
A *write-intent GET* — a create form or a delete-confirm page — is the exception to
`actionForMethod`: it gates on `:write` (declared in the route table and passed to the handler's
guard, so the two still agree), because a page whose only purpose is to start a write should refuse
a reader rather than render a form whose submit 403s.
Two grant-specific guards go with it, both restoring behaviour the deleted Permissions screen had:
you cannot revoke your own **direct** grants on the Users screen (self-lockout would need a `curl`
against Keto to undo, which the operator persona can't do — same shape as the self-deactivate/
self-delete guards), and a permission held *through a group* renders ticked-but-disabled rather than
unticked, because showing it unticked stated the opposite of the truth and unticking it wrote
nothing while looking like a successful revoke. **Known gap, same scope the deleted screen had:**
the group paths are unguarded — unticking a permission on a group you belong to, removing yourself
from it, or deleting it can all still strip your own effective access. The robust "last effective
holder" check needs a reverse Keto query and is deferred. Raised by the architecture + product +
stability reviews 2026-08-05.
- **`users:write` and `groups:write` are equivalent to full administrative access**, and the split
does not change that: `groups:write` adds you to any group, including one holding every permission;
`users:write` mints a recovery code for any account. The containment the split buys is real on the
**read** half only (`users:read` is a safe helpdesk grant). Don't let the per-resource naming imply
otherwise in docs. Raised by the architecture review 2026-08-05.
- **Plainpages says "user" everywhere; Ory's word for it is "identity".** Kratos calls the record
an identity, but Ory's own docs state it uses that term *interchangeably* with "users" and
"accounts" — so this is house style, not a renamed concept, and "user" is the word readers
already know (Nielsen's heuristic #2: match between the system and the real world). One note in
README → Auth records the mapping so nobody has to rediscover it. The single exception is the
`Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape and keeps Ory's
name — don't rename that one.
- **The locale lives in the URL, never in a cookie.** `?locale=sv-SE``Accept-Language``en-US`,
and when the URL asked for one the host carries it onto the links it renders (`ctx.localeHref`).
A cookie would make a page's language invisible in its address and unshareable; the cost is that a
plugin must wrap its own hrefs. Matching is exact on a full tag (`sv-FI``sv-SE`), except that a
lone language from `Accept-Language` takes the first regional catalog for it. Decided 2026-08-03.
- **Catalogs are checked at boot, not at render.** Every locale is compared against its set's `en-US`
— keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` says that locale needs —
and a mismatch stops startup, same fail-loud contract as a bad manifest. A plugin may ship fewer
locales than the host (its strings fall back to `en-US` per key), never one the host lacks.
- **The core building blocks carry the locale; a plugin doesn't have to.** The shell (breadcrumbs),
`pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every
href they render in `localeHref`; the nav and the sign-in link are wrapped upstream in `chrome.ts`;
and the two GET forms
(filter bar, rows-per-page) carry it as a hidden `locale` input, since a GET submit replaces the
whole query string and no href wrapper can reach it. Putting the obligation on each call site was
tried first and missed five of eight sites inside one commit — including the admin screens.
`ctx.localeHref` remains for hrefs a plugin's own markup emits (the admin example's delete links).
**A form's `action` counts as a link** — a POST replaces the URL as completely as a GET submit, so
the sign-out, consent and auth-card forms carry it too; without that, picking a language and then
saving anything drops back to `Accept-Language`. The one round-trip that cannot carry it is the
Kratos sign-in POST, whose action is an absolute off-site URL.
Decided 2026-08-03 after an architecture review; a second pass then found breadcrumbs still raw,
so: when a link renders from the core chrome, it is the chrome's job to carry the locale.
- **`locale` is a host-owned query param.** It is in `parseListQuery`'s reserved set (`list-query.ts`),
so a localized list page doesn't hand a plugin a phantom `locale` filter; the i18n view locals (`t`, `locale`, `locales`, `localeHref`,
`localeParam`, `localeSwitch`, `dir`) are likewise reserved names, merged after a handler's `data`
so a collision loses the key instead of breaking the shell.
- **The language picker is on every page, POST-rendered ones included.** Maintainer's call
2026-08-04, overriding an earlier decision to hide it there. The problem it was hiding is real: a
POST-rendered URL frequently answers no GET (`POST /admin/users/:id/recovery`), so a link back to
it dead-ends on a 405. The host therefore resolves the picker's target (`app.ts``switchBase`):
this path when it answers GET, else the same-origin Referer, else `/`. Accepted cost: switching
language on such a page leaves that POST's own result behind (a re-rendered form's input, or a
one-time recovery code). Valid while the picker is expected on literally every page — if that ever
softens, hiding it after a POST is the simpler answer.
- **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`,
`dashboard`) and an `onRequest` short-circuit dispatch a plugin's handler, so they build the
context with `contextFor(pluginId)` exactly as a plugin route does — otherwise `ctx.t` is the core
translator and the plugin's own keys render as bare keys on the pages it owns. Found by review
2026-08-03 after all three paths shipped with the host's context.
- **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`** — `locales/<tag>.ts`
for the core and `locales/plugins/<id>/<tag>.ts` for an installed plugin, each adding a language or
replacing that tag's catalog wholesale. Adding a language must never require forking the image or a
vendored plugin folder. The SHIPPED `en-US` (core's, or the plugin's own) stays the parity baseline
even when the mount replaces it, so a mounted catalog is checked rather than trusted (one compared
only against itself would boot green with the whole UI rendering keys), and each half is reported
under the folder it actually lives in.
- **RTL is out of scope until there is a real use case.** `textDirection` sets `<html dir>` from the
locale's script because that is free and correct, but the stylesheet keeps physical `left`/`right`
properties — a genuine RTL locale needs those moved to logical ones first. Don't convert the CSS or
file findings about it on spec. Maintainer's call 2026-08-04; valid while no deployment needs an
RTL language. A catalog there
for a new tag adds a language; one for a tag the image ships replaces that catalog wholesale, held
to the same parity check. Adding a language must not require forking the image.
- **An unknown translation key renders as itself.** That single rule is what lets a nav label,
branding, or a menu `rename` be either a key or plain text without a second field or a migration.
Don't "fix" it into a loud failure: a manifest with plain labels must keep working.
- **`t()` returns raw text; the view escapes it.** Messages go through `<%= %>` like any other value,
so nothing is double-escaped; a message carrying markup uses `<%- %>`, and then its `{{vars}}` are
escaped at the call site (see `views/partials/pagination.ejs`). Don't move escaping into `t()`
every other value in a view would then be the odd one out.
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`:
concurrent jobs can race (one job's logout can 401 another's push — recover by re-running),
@@ -232,73 +93,6 @@ them. Revisit only if the stated reason stops holding.
workspace dir, so ci.sh's web-image build races another run's container creation on the
`<project>-web` tag. Accepted for a single-maintainer cadence; serialize with a workflow
`concurrency` group if it ever bites.
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** No test, build step or
workflow reads a markdown file (`README-dockerhub.md` is pasted into Docker Hub by hand), so a
nested `examples/plugins/admin/README.md` edit is as safe to skip as `README.md`, and narrowing it
would spend the full gate on one. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`:
rename detection names only the destination, so `git mv src/app.ts notes.md` otherwise read as docs
and skipped the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a
*text* guard — the test image (`node:24.19.0-alpine3.24`) ships neither `git` nor `bash`, so it
cannot exercise the function; behaviour was verified against a scratch repo across ten scenarios.
Revisit if a `.md` ever becomes load-bearing. Decided 2026-08-05.
- **Plainpages is pre-announcement: no tags, no releases.** The repo carried tags up to `v0.2.2` from
the `auto-release` job; all of them — and the semver container tags — were deleted 2026-08-05, and
the job is gated behind the `AUTO_RELEASE` Actions variable (unset ⇒ skipped, the fail-safe
direction on every unknown-`vars` path). A version only communicates to consumers, and there are
none; same reasoning that freezes `HOST_API_VERSION` at 1.0.0. Note the coupling:
`registry-cleanup` keeps a hash image only while its commit is a branch head *or* release-tagged,
so with zero tags only branch heads survive the nightly prune — a hand-cut tag must sit on `main`'s
tip. `mirror.yml` pushes tags with `--prune` so the deletions actually reach the public GitHub
mirror; that makes the runner's tag view load-bearing (hence `fetch-tags: true`) and means a tag
or Release created on GitHub is swept away, so releases are cut on Gitea only. Valid until the
maintainer says Plainpages is ready to show people.
- **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then
owns open/close, which is the only zero-JS way to dismiss a menu by clicking outside it (the whole
point), and the panel sits in the top layer so a row kebab is no longer clipped by `.table-wrap`'s
`overflow`. Four rules hold it together, none of them cosmetic. The panel carries
**`position-anchor: auto`** — a bare `anchor()` resolves to nothing in Chromium, Firefox *and*
WebKit alike, which is why the popover test in `visual.spec.ts` runs in all three rather
than resting on a one-time manual measurement. The panel stays the trigger's **next sibling inside
the `.menu` wrapper**, because the open-state style and the old-browser fallback both read that
adjacency, and a two-element partial cannot be dropped into an arbitrary layout. The `menu` partial
**requires a caller-named `id`** and fails loud without one: it is the `popovertarget` idref, and
generated random ids were tried and rejected the same day — nondeterministic HTML forecloses the
still-open caching decision and names nothing a reader can use. And **neither `aria-expanded` nor
`aria-haspopup` is written**: a zero-JS invoker cannot keep the first truthful, and the second would
promise `role="menu"` keyboard semantics these panels do not implement. `<details>` stays where it
means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
profile menu because its trigger composes escaped user values and its one item is a CSRF POST form,
neither of which the partial's `Item` shapes cover — keep the two in step, or fold it in if
`todo.md`'s "does the profile dropdown still earn a dropdown" settles the other way. Decided 2026-08-05.
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract.** It is
deliberately not re-exported from `#plugin-api`, and README → Nav & permission gates already tells an
author that using a new icon means registering it there. So the palette may narrow when the last
reference to an id goes — `i-gear` left with the settings menu 2026-08-05 — and a plugin needing one
gets it re-registered in the same change. Accepted cost: an unknown sprite id renders a blank icon
instead of failing loud; the `every icon <use> resolves to a defined <symbol>` e2e test catches it for
anything reaching the nav. Removing an id is a core edit, so weigh it per icon rather than sweeping the
registry — a few ids are registered ahead of a caller (see `todo.md`).
- **Anything the browser logs fails the E2E test that provoked it.** Every spec takes its `test` from
`e2e-tests/console-guard.ts`, which watches every page a test opens: a console error or warning, or
an uncaught exception, fails that test. A zero-JS app has nothing to say in the console, so the bar
is *zero* rather than a curated list of tolerated noise — and the two exceptions are explicit and
narrow: one module-level allowance for the COOP header Chromium drops because the e2e stacks serve
plain http over container hostnames (a deployment serves https, where it applies), and
`allowConsole(re)` for a test whose own page provokes a message on purpose — the 404 spec, whose
navigation Chromium and WebKit log. Each record carries the message's origin URL, so that allowance
can name the page under test and still see a sub-resource of it 404. `src/e2e-console-guard.test.ts`
locks the wiring in the *unit* gate: a spec importing `test` straight from Playwright — or minting a
page with a raw `newPage()` instead of `watchedPage()` — would run unwatched and green. The buffer
clears at teardown rather than setup so a `beforeAll` is watched too (full-flow runs a whole login in
one); the accepted cost is that a page outliving its test, as a serial describe's does, can log late
and fail the next test instead of its own. Verified by negative control in all three engines.
- **The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium.**
`visual.spec.ts` + `language.spec.ts` are side-effect-free, so three parallel runs don't collide,
and a console message only appears in the engine that renders the page — the reason the per-test
`@engines` tag is gone: the whole Ory-free suite is the engine matrix now (`ORY_FREE` in
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
where a second engine's run would race the first, so widening them means giving each engine its own
stack. Screenshots are written per project name for the same reason. Decided 2026-08-05.
## Docker only — no host tooling
@@ -326,15 +120,10 @@ 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 →
Users, groups & permissions → 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 & permissions precedes Building plugins** because a manifest's
`permission:` 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.
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.
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
@@ -371,17 +160,6 @@ Same test before adding a row to a table or the file map — a clause, not a par
versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept
exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
`node:24.16.0-alpine3.24`).
- **`HOST_API_VERSION` is frozen at 1.0.0 until the first external install**, even for additive
contract changes (i18n added four `RequestContext` fields and several barrel exports without a
minor bump). Valid while nothing is installed against it: with no third-party plugin in the wild,
a version bump can only produce noise. The promotion trigger is the first external plugin — from
then on, follow the versioning table in README → Contract versioning as written. Decided 2026-08-03.
**The frozen surface includes `views/partials/*.ejs`**, not just the manifest and the barrel: the
view resolver makes every core partial an `include()` root for a plugin's views, so their option
names and emitted markup are author-visible (under this freeze the popover change dropped the `menu`
partial's `open?` and rewrote its markup). Know the hole that leaves — discovery fails loud on a bad
`apiVersion`, but `include("menu", { open: true })` silently ignores the option and a plugin styling
`.menu > summary` silently loses it. Promotion must cover the partial vocabulary too. Added 2026-08-05.
- A plugin's `apiVersion` is a **hand-written literal** semver — the host version the
plugin was built against — bumped by hand on rebuild, **never** the host's
`HOST_API_VERSION` constant. Importing the constant makes every plugin always equal the
@@ -392,26 +170,8 @@ Same test before adding a row to a table or the file map — a clause, not a par
that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
- **`handleRequest` (`src/http/app.ts`) is a known complexity hotspot** — ~160 lines tracking
canonical host, static, locale, session + re-mint, CSRF, chrome, hooks, plugin routing, builtin
routing, 405/404 and error mapping. The pure parts are already extracted and separately tested; what
remains is orchestration. Planned split along those seams; don't grow it further without taking one
out. Raised by the architecture review 2026-08-03, deliberately not done inside the i18n change.
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer
agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule.
- **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in
`src/i18n/locales/en-US.ts` (then every other locale, or the boot fails); a plugin's go in its own
`i18n/`. Operator/developer-facing text — boot errors, log messages, guard messages — stays English.
A pure view-model builder takes an optional `t` defaulting to its own English, so a unit test reads
in words; handlers pass `ctx.t`.
- **One verb per action in the English UI: sign in, sign out, create account.** Not "log in",
"log out" or "sign up", inflections included — a second spelling for one button reads as a second
thing; the noun ("a sign-in error", "the sign-in identifier") is unaffected. A plugin's catalog and
every other locale follow the same rule in their own language. An unmapped Kratos id renders
Kratos' own wording — map the id when it matters. **Held by the author, never by a test:** as the
UI grows, slightly different wording is often the right call, and a check that fails the build on
a word takes that judgment away. Maintainer's call 2026-08-05, dropping the guard that shipped
with the rule.
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for
for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y"
and not "ids=x,y".
+1 -1
View File
@@ -1,5 +1,5 @@
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
FROM node:24.19.0-alpine3.24
FROM node:24.18.1-alpine3.24
WORKDIR /app
+161 -507
View File
File diff suppressed because it is too large Load Diff
+1 -5
View File
@@ -15,17 +15,13 @@ step() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
# Docs-only fast path: nothing but *.md changed since main, so there is nothing here to break.
# The working tree counts too — a dirty tree carrying real code must never skip. Anything
# undeterminable (no git, no reachable main, no merge-base) falls through to the gate, never a skip.
# --no-renames on both channels: rename detection names only the destination, so `git mv src/app.ts
# notes.md` reads as a lone *.md — under --porcelain as one `R src/app.ts -> notes.md` line still
# ending in .md after cut -c4- — and the gate would skip over a source file that is gone.
docs_only() {
local base changed
git rev-parse --git-dir >/dev/null 2>&1 || return 1
git fetch --no-tags --quiet origin +refs/heads/main:refs/remotes/origin/main 2>/dev/null || true
base=$(git merge-base refs/remotes/origin/main HEAD 2>/dev/null) || return 1
changed=$(
{ git diff --name-only --no-renames "$base" HEAD \
&& git status --porcelain --no-renames --untracked-files=all | cut -c4-; } 2>/dev/null
{ git diff --name-only "$base" HEAD && git status --porcelain --untracked-files=all | cut -c4-; } 2>/dev/null
) || return 1
[ -n "$changed" ] || return 1
! printf '%s\n' "$changed" | grep -qvE '\.md$'
+1 -10
View File
@@ -22,21 +22,12 @@ services:
# Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise):
# - ./config:/app/config:ro # your config/menu.ts — see examples/config/menu.ts for a template
# The seed grants what the installed plugins declare, so bootstrap must discover the same plugins
# as web. Only dev needs saying: the base file gives both services the image's baked plugins/, and
# it is the `.:/app` above — dev-only — that makes web diverge onto the host tree. Mirror it here
# rather than in the base file, where it would instead desynchronise them (and collide with the
# e2e stacks, which mount individual plugins *inside* this path).
bootstrap:
volumes:
- ./plugins:/app/plugins:ro
# Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so
# the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this
# backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
# stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at the real backend instead.
shifts-upstream:
image: node:24.19.0-alpine3.24
image: node:24.18.1-alpine3.24
command: node /srv/server.ts
restart: unless-stopped
volumes:
+3 -3
View File
@@ -130,9 +130,9 @@ services:
environment:
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
# Base permissions for the demo admin; bootstrap also grants every discovered plugin's declared
# permission names (so the reference plugin — and any drop-in — works out of the box).
ADMIN_PERMISSIONS: ${ADMIN_PERMISSIONS:-}
# 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).
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
KETO_WRITE_URL: http://keto:4467
+7 -7
View File
@@ -1,4 +1,4 @@
import { expect, test } from "./console-guard.ts";
import { expect, test } from "@playwright/test";
// Full-stack auth E2E: token timeout + silent re-mint ("stay signed in"). Runs against the
// real Ory stack via e2e-tests/compose.auth.yml, where the session→JWT TTL is shortened to 8s and the
@@ -9,7 +9,7 @@ import { expect, test } from "./console-guard.ts";
const WEB = process.env.BASE_URL ?? "http://web:3000";
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin permission granted in Keto
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin role granted in Keto
const ADMIN_PASSWORD = "admin";
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
@@ -29,8 +29,8 @@ function relayCookies(res: Response): string {
.filter((kv) => kv.split("=")[1] !== "")
.join("; ");
}
// Read a JWT's claims without verifying (web already verified it; we only inspect exp/permissions).
function jwtClaims(jwt: string): { email: string; exp: number; permissions: string[]; sub: string } {
// Read a JWT's claims without verifying (web already verified it; we only inspect exp/roles).
function jwtClaims(jwt: string): { email: string; exp: number; roles: string[]; sub: string } {
return JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString());
}
@@ -72,7 +72,7 @@ async function awaitJwtSetCookie(session: string, jwt: string): Promise<string>
test("an expired session JWT is silently re-minted while Kratos lives, then cleared once it dies", async () => {
test.setTimeout(90_000); // two short-TTL windows (8s each) + Ory round-trips
// 1. Log in for real, then complete login on web → our session JWT (permissions read from Keto).
// 1. Log in for real, then complete login on web → our session JWT (roles read from Keto).
const session = await kratosLogin();
const complete = await fetch(`${WEB}/auth/complete`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
expect(complete.status, "auth/complete redirects home").toBe(303);
@@ -83,7 +83,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
const claims1 = jwtClaims(jwt1);
expect(claims1.email).toBe(ADMIN_EMAIL);
expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy();
expect(claims1.permissions, "permissions are projected from Keto").toContain("users:read");
expect(claims1.roles, "roles are projected from Keto").toContain("admin");
// 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT.
const jwt2Line = await awaitJwtSetCookie(session, jwt1);
@@ -91,7 +91,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
expect(jwt2, "a different token was minted").not.toBe(jwt1);
const claims2 = jwtClaims(jwt2);
expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp);
expect(claims2.permissions, "re-mint re-reads permissions from Keto").toContain("users:read");
expect(claims2.roles, "re-mint re-reads roles from Keto").toContain("admin");
// 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared.
const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" });
-7
View File
@@ -30,13 +30,6 @@ services:
timeout: 4s
retries: 30
# This stack mounts no plugins, so nothing declares a permission for the bootstrap to seed — and
# the suite asserts that Keto's grants reach the JWT claim. Name one explicitly so there is
# something to project.
bootstrap:
environment:
ADMIN_PERMISSIONS: users:read
# Shorten the session→JWT TTL and expose a network-resolvable base_url (ory/kratos/e2e.yml),
# merged after the base config.
kratos:
+5 -5
View File
@@ -1,5 +1,5 @@
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
# login, menu filtering by permission, users/groups/OAuth2-clients CRUD + permission granting, a plugin page, logout. A
# login, menu filtering by role, users/groups/roles/OAuth2-clients CRUD, a plugin page, logout. A
# tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --build --rm e2e
@@ -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 names, so it needs the
# bootstrap grants the demo admin every discovered plugin's permission tokens, so it needs the
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
bootstrap:
volumes:
@@ -53,7 +53,7 @@ services:
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
shifts-upstream:
image: node:24.19.0-alpine3.24
image: node:24.18.1-alpine3.24
command: ["node", "/server.ts"]
volumes:
- ./examples/shifts-upstream/server.ts:/server.ts:ro
@@ -66,7 +66,7 @@ services:
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
mock-oidc:
image: node:24.19.0-alpine3.24
image: node:24.18.1-alpine3.24
command: ["node", "/mock-oidc.ts"]
environment:
ISSUER: http://mock-oidc:9000
@@ -81,7 +81,7 @@ services:
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
proxy:
image: node:24.19.0-alpine3.24
image: node:24.18.1-alpine3.24
command: ["node", "/proxy.ts"]
depends_on:
web:
+3 -5
View File
@@ -1,6 +1,5 @@
# Playwright E2E. Brings up the app + a Playwright runner and exercises the live pages (design
# system, theme switch, mobile layout, CSRF, landing, 404, plugin gating, language switching) —
# Ory-free, so it's fast.
# system, theme switch, mobile layout, CSRF, landing, 404, plugin gating) — Ory-free, so it's fast.
# docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --build --rm e2e
# docker compose -f compose.yml -f e2e-tests/compose.visual.yml down -v # tear down after
# --build rebuilds the runner (the image bakes in e2e-tests/) so spec edits are picked up.
@@ -30,9 +29,8 @@ services:
build:
context: .
dockerfile: e2e-tests/Dockerfile
# The Ory-free suites (design system + language switching); the full-stack auth spec runs via
# e2e-tests/compose.auth.yml.
command: ["npx", "playwright", "test", "visual.spec.ts", "language.spec.ts"]
# Just the Ory-free visual suite; the full-stack auth spec runs via e2e-tests/compose.auth.yml.
command: ["npx", "playwright", "test", "visual.spec.ts"]
depends_on:
web:
condition: service_healthy
-58
View File
@@ -1,58 +0,0 @@
import { expect, test as base, type BrowserContext, type Page } from "@playwright/test";
// The `test` every spec imports: it fails a test whose browser logged a console error or warning,
// or threw, at any step — in whichever engine ran it. A zero-JS app has nothing to say in the
// console, so anything there is a defect (a broken sub-resource, a rejected attribute, an engine
// refusing a feature) that no assertion looks for.
//
// One module-level buffer is enough: a Playwright worker runs one test at a time. It is cleared at
// teardown, not at setup, so what a `beforeAll` provoked — full-flow's whole login runs in one —
// still lands on the first test rather than being wiped before it. The cost of the same choice: a
// page that outlives its test (a serial describe's) can log late and fail the next test instead.
const problems: string[] = [];
const allowed: RegExp[] = [];
// The one message the stack itself provokes: the runner reaches `web`/`proxy` by container name over
// plain http, and only a `localhost` origin is trustworthy without TLS — so Chromium drops the COOP
// header the app sends and says so on every page. Over https, where a deployment serves, it applies.
const EXPECTED = [/^console\.error: The Cross-Origin-Opener-Policy header has been ignored/];
// Allow a message for the current test only, when the page under test provokes it on purpose.
export function allowConsole(...patterns: RegExp[]): void {
allowed.push(...patterns);
}
function watch(page: Page): void {
page.on("console", (msg) => {
const type = msg.type();
// The origin is part of the record: a 404 reads the same whether it was the page or its
// stylesheet, and a failure nobody can locate is half a failure.
if (type === "error" || type === "warning") problems.push(`console.${type}: ${msg.text()} @ ${msg.location().url}`);
});
page.on("pageerror", (err) => problems.push(`pageerror: ${err.message}`));
}
// Every page of the context, however it is opened — `context.newPage()` fires this event too, so
// watching the context is the whole job and a page must never be watched a second time on top.
function watchContext(context: BrowserContext): BrowserContext {
context.on("page", watch);
return context;
}
// A spec that opens its own context — a page shared across a serial describe — goes through this.
export function watchedPage(context: BrowserContext): Promise<Page> {
return watchContext(context).newPage();
}
export const test = base.extend<{ consoleGuard: void }>({
context: async ({ context }, use) => { await use(watchContext(context)); },
consoleGuard: [async ({}, use) => {
await use();
const unexpected = problems.filter((p) => ![...EXPECTED, ...allowed].some((re) => re.test(p)));
problems.length = 0;
allowed.length = 0;
expect(unexpected, "the browser logged nothing while this test ran").toEqual([]);
}, { auto: true }],
});
export { expect };
+3 -3
View File
@@ -1,4 +1,4 @@
import { expect, test } from "./console-guard.ts";
import { expect, test } from "@playwright/test";
// Regression: the from-scratch dev experience the README/banner advertises must work. `docker compose
// up`, open the printed login URL (http://localhost:3000), sign in as the seeded admin → you land on
@@ -26,9 +26,9 @@ async function signIn(page: import("@playwright/test").Page): Promise<void> {
test("seeded admin logs in from the advertised URL (http://localhost:3000) and reaches the dashboard", async ({ page }) => {
test.setTimeout(90_000);
// Open the app at the URL the first-run banner prints, then follow the landing's "Sign in" action.
// Open the app at the URL the first-run banner prints, then follow its "Log in" call to action.
await page.goto("/");
await page.locator("#main-content").getByRole("link", { name: "Sign in" }).click();
await page.getByRole("link", { name: "Log in" }).click();
await signIn(page);
// Signed in on the app — NOT dumped on the Kratos /error "Page not found" page.
+16 -93
View File
@@ -1,5 +1,4 @@
import type { Browser, Page } from "@playwright/test";
import { expect, test, watchedPage } from "./console-guard.ts";
import { type Browser, type Page, expect, test } from "@playwright/test";
import { randomUUID } from "node:crypto";
// Full browser E2E: the real Playwright UI against the live stack via the same-origin
@@ -10,7 +9,7 @@ import { randomUUID } from "node:crypto";
// journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
// (no cross-group shared backend writes) or serialise the file if that ever changes.
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin permission in Keto
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin role in Keto
const ADMIN_PASSWORD = "admin";
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
@@ -25,86 +24,21 @@ async function loginPassword(page: Page): Promise<void> {
await expect(page.locator(".profile-mail")).toHaveText(ADMIN_EMAIL); // waits through the redirect chain
}
// The themed Kratos page in another language: our own chrome, Kratos' own strings mapped by id, and
// the card's own links keeping the choice (they are rendered by the flow body, not by the menu).
test("the login page speaks the visitor's language, links included", async ({ browser }) => {
const page = await watchedPage(await browser.newContext());
await page.goto("/login?locale=sv-SE");
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await expect(page.getByRole("heading", { name: "Logga in" })).toBeVisible();
await expect(page.getByLabel("Lösenord", { exact: true })).toBeVisible(); // Kratos' own field, labelled via auth.field.password
await expect(page.getByRole("link", { name: "Glömt lösenordet?" })).toHaveAttribute("href", /locale=sv-SE/);
await expect(page.getByRole("link", { name: "Skapa ett" })).toHaveAttribute("href", /locale=sv-SE/);
await page.context().close();
});
test.describe.serial("authenticated admin journey", () => {
let browser: Browser;
let page: Page;
test.beforeAll(async ({ browser: b }) => {
browser = b;
page = await watchedPage(await browser.newContext());
page = await (await browser.newContext()).newPage();
test.setTimeout(90_000);
await loginPassword(page);
});
test.afterAll(async () => { await page.context().close(); });
// The list screens rebuild their query from the list state (sort/page/filter), so they are where
// a chosen language used to get dropped — the core building blocks carry it now.
test("a sorted, paged admin list keeps the visitor's language", async () => {
await page.goto("/admin/users?locale=sv-SE");
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await expect(page.getByRole("heading", { name: "Användare" })).toBeVisible();
await page.getByRole("link", { name: /E-postadress/ }).click(); // a sort header
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await page.getByRole("button", { name: "Använd filter" }).click(); // the filter bar's GET form
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await page.getByRole("button", { name: "Visa" }).click(); // the rows-per-page GET form
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
// The breadcrumb is the chrome's way back up — it is rendered by the shell, not by the screen.
await page.getByRole("navigation", { name: "Sidsökväg" }).getByRole("link").first().click();
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
});
// A POST that re-renders a page: the write must keep the language, and the picker — which is on
// every page — must point somewhere that answers GET rather than at the POST-only URL.
test("a write keeps the visitor's language, and the picker still works on the POST-rendered page", async () => {
await page.goto("/admin/users?locale=sv-SE");
await page.getByRole("link", { name: "Ny användare" }).click();
await page.fill('input[name="email"]', `lang-${suffix}@plainpages.local`);
await page.getByRole("button", { name: "Skapa användare" }).click();
await expect(page).toHaveURL(/locale=sv-SE/); // the POST → redirect → GET keeps it
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
// Open the new user's edit page the way the CRUD test does — the row's Edit link carries the id.
const row = page.locator("tr", { hasText: `lang-${suffix}@plainpages.local` });
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
await page.goto(`${editHref}`);
await expect(page.locator('button[aria-label="Språk"]')).toHaveCount(1);
await page.getByRole("button", { name: "Skapa återställningskod" }).click(); // POST-only route
await expect(page.getByText("Återställningskod skapad")).toBeVisible();
// The picker is here too, and following it lands on a real page in the other language.
await page.locator('button[aria-label="Språk"]').click();
await page.getByRole("link", { name: /English/i }).click();
expect(page.url()).toContain("locale=en-US");
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
await expect(page.getByRole("heading", { name: "Edit user" })).toBeVisible(); // not a 405
});
test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => {
// The signed-in admin holds every permission the two mounted plugins declare (the bootstrap
// seeds exactly those), so both gated sections are present in the menu (collapsed by default →
// assert they're in the DOM, not necessarily visible).
test("menu filters by role: an admin sees the gated Admin section + the plugin", async () => {
// The signed-in admin holds admin + scheduling:read/write, so both gated sections are present
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
await page.goto("/dashboard");
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(1);
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1);
@@ -122,11 +56,6 @@ test.describe.serial("authenticated admin journey", () => {
const row = page.locator("tr", { hasText: email });
await expect(row).toBeVisible();
// Row actions sit behind the kebab popover: opening it reveals them, in the top layer, so the
// scrolling table around the row cannot clip the panel.
await row.locator("button.kebab").click();
await expect(row.locator('a[href^="/admin/users/"]').first()).toBeVisible();
// Delete through the confirm interstitial (the row's Edit link carries the id).
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
await page.goto(`${editHref}/delete`);
@@ -136,7 +65,7 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
});
test("groups CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async () => {
test("groups + roles CRUD: create one of each (writes go to Keto) and see them listed", async () => {
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form
// enforces it); pick the first option (a user) from the required picker.
const group = `e2e-grp-${suffix}`;
@@ -147,17 +76,13 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
await expect(page.locator("main")).toContainText(group);
// Permissions are declared in plugin code, so the group's detail page offers them as a fixed
// checkbox list rather than a create form — there is no Permissions screen to visit.
await page.goto(`/admin/groups/${group}`);
const scheduling = page.locator('input[name="permission"][value="scheduling:read"]');
await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer
await expect(scheduling).not.toBeChecked();
await scheduling.check();
await page.locator('form:has(input[name="permission"]) button[type="submit"]').click();
await expect(page).toHaveURL(new RegExp(`/admin/groups/${group}`));
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
const role = `e2e-role-${suffix}`;
await page.goto("/admin/roles/new");
await page.fill('input[name="name"]', role);
await page.locator('select[name="member"]').selectOption({ index: 1 });
await page.locator('.form-card button[type="submit"]').click();
await expect(page).toHaveURL(/\/admin\/roles(\?|\/|$)/);
await expect(page.locator("main")).toContainText(role);
});
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
@@ -197,9 +122,7 @@ test.describe.serial("authenticated admin journey", () => {
test("logout: signing out ends the session and returns to the login page", async () => {
await page.goto("/dashboard");
await page.locator("button.profile").click(); // open the profile dropdown
// Sign out is the only item in it — the menu offers nothing that goes nowhere.
await expect(page.locator("#profile-menu .menu-item")).toHaveText(["Sign out"]);
await page.locator("summary.profile").click(); // open the profile dropdown
await page.locator('form[action="/logout"] button[type="submit"]').click();
await page.waitForURL(/\/login(\?|$)/);
// The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav).
@@ -230,6 +153,6 @@ test("mocked SSO login: the provider button signs a user in via OIDC", async ({
await page.locator(".sso-btn").click();
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
// A fresh SSO identity holds no permissions, so the gated Admin section stays hidden.
// A fresh SSO identity holds no roles, so the gated Admin section stays hidden.
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
});
-81
View File
@@ -1,81 +0,0 @@
import { readFileSync } from "node:fs";
import { createPrivateKey, sign } from "node:crypto";
import { expect, test, watchedPage } from "./console-guard.ts";
// Language switching in a real browser, Ory-free (the visual stack). Proves the whole path a
// visitor takes: pick a language, read the page in it, and stay in it while clicking around —
// including into a plugin, whose words come from its own catalog (plugins/scheduling/i18n/).
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
const SESSION_COOKIE = "plainpages_jwt";
// Same trick as visual.spec.ts: sign a session JWT with the committed dev tokenizer key so the
// gated pages render without standing up Ory.
function devSession(permissions: string[] = []): string {
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
const key = createPrivateKey({ format: "jwk", key: jwk });
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
const now = Math.floor(Date.now() / 1000);
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, permissions, sub: "lang-demo" })}`;
return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`;
}
test("the switcher changes language, and the choice survives clicking through the app", async ({ page, context }) => {
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession(["scheduling:read"]) }]);
await page.goto("/dashboard");
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible();
// The picker sits in the sidebar footer beside the theme switch; each entry is a plain link to
// this same page in that language (zero-JS).
await page.locator('button[aria-label="Language"]').click();
await page.getByRole("link", { name: /svenska/i }).click();
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.getByRole("heading", { name: "Startpanel" })).toBeVisible(); // the starter dashboard, in Swedish
await expect(page.getByRole("link", { name: "Översikt", exact: true })).toBeVisible(); // the menu too
await page.screenshot({ fullPage: true, path: `artifacts/screenshots/${test.info().project.name}/live-05-swedish.png` });
// Clicking a menu item keeps Swedish — the host carries the choice onto the links it renders,
// and the plugin's own page is translated from its own catalog. The section's own label comes
// from the plugin's catalog too, so opening it proves the nav fragment was translated.
await page.locator('summary[aria-label="Visa eller dölj Schemaläggning"]').click();
await page.getByRole("link", { name: "Pass", exact: true }).click();
await expect(page).toHaveURL(/\/scheduling\/shifts\?locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await expect(page.getByRole("heading", { name: "Pass" })).toBeVisible();
await expect(page.getByRole("button", { name: "Sök" })).toBeVisible(); // the core filter bar, in Swedish
// The filter bar is a GET form: submitting it replaces the whole query string, so the choice
// survives only because the form carries it as a hidden field.
await page.getByRole("button", { name: "Sök" }).click();
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
// …and back to English the same way.
await page.locator('button[aria-label="Språk"]').click();
await page.getByRole("link", { name: /English/i }).click();
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
await expect(page.getByRole("heading", { name: "Shifts" })).toBeVisible();
});
test("a browser that asks for Swedish gets it without touching the URL", async ({ browser }) => {
const context = await browser.newContext({ locale: "sv" }); // a browser set to Swedish, no region
const page = await watchedPage(context);
await page.goto(`${BASE_URL}/`);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
const signIn = page.locator("#main-content").getByRole("link", { name: "Logga in" });
await expect(signIn).toBeVisible();
// Nothing was chosen in the URL, so the links stay plain — the browser asks again on the next hit.
await expect(signIn).toHaveAttribute("href", "/login");
await context.close();
});
test("an uninstalled language falls back to English rather than failing", async ({ page }) => {
const response = await page.goto("/?locale=sv-FI"); // sv-SE is installed; sv-FI is not
expect(response?.status()).toBe(200);
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "./console-guard.ts";
import { expect, test } from "@playwright/test";
// Full-stack OAuth2 login + consent E2E: another app logs in *through* plainpages. Hydra
// starts an authorization flow and hands the browser to web's /oauth2/login; web resolves it via
+1 -13
View File
@@ -3,9 +3,6 @@ import { defineConfig, devices } from "@playwright/test";
// Visual + functional checks against the live app (the `web` compose service, BASE_URL). Run via
// e2e-tests/compose.visual.yml. Parallel per the project's E2E principle; deterministic colorScheme/viewport
// so the rendered design is stable across runs.
const ORY_FREE = /\/(visual|language)\.spec\.ts$/;
export default defineConfig({
testDir: ".",
outputDir: "artifacts/test-output",
@@ -18,14 +15,5 @@ export default defineConfig({
screenshot: "only-on-failure",
viewport: { width: 1280, height: 800 },
},
// The Ory-free suites run in all three engines: the console guard (console-guard.ts) only sees an
// engine's warnings when that engine renders the page, and the newest platform features in the app
// (popover, CSS anchor positioning, `:has()`) are exactly where engines disagree. They stay
// side-effect-free, so three parallel runs of them don't collide. The Ory-backed suites write to
// one shared backend and stay on chromium.
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", testMatch: ORY_FREE, use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", testMatch: ORY_FREE, use: { ...devices["Desktop Safari"] } },
],
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
+13 -43
View File
@@ -1,29 +1,31 @@
import { createPrivateKey, sign } from "node:crypto";
import { readFileSync } from "node:fs";
import type { Page } from "@playwright/test";
import { allowConsole, expect, test } from "./console-guard.ts";
import { mkdir } from "node:fs/promises";
import { expect, test, type Page } from "@playwright/test";
const SHOTS = "artifacts/screenshots";
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
const SESSION_COOKIE = "plainpages_jwt"; // src/auth/login.ts — web verifies it against the committed dev JWKS
// Per engine: the three projects run this suite in parallel and would otherwise write one file.
const shot = (page: Page, name: string): Promise<Buffer> =>
page.screenshot({ fullPage: true, path: `artifacts/screenshots/${test.info().project.name}/${name}.png` });
page.screenshot({ fullPage: true, path: `${SHOTS}/${name}.png` });
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
function devSession(permissions: string[] = []): string {
function devSession(roles: string[] = []): string {
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
const key = createPrivateKey({ format: "jwk", key: jwk });
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
const now = Math.floor(Date.now() / 1000);
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, permissions, sub: "visual-demo" })}`;
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, roles, sub: "visual-demo" })}`;
return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`;
}
test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); });
// The dashboard is gated: a page navigation needs a session. Plant one per test — a plain
// member (no permissions) so the gated scheduling nav stays filtered out.
// member (no roles) so the gated scheduling nav stays filtered out.
test.beforeEach(async ({ context }) => {
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
});
@@ -68,35 +70,6 @@ test("theme switch flips the palette with no JavaScript", async ({ page }) => {
expect(dark).not.toBe(light);
});
// The menus are <button popovertarget> + [popover], so the browser dismisses them: the visitor no
// longer has to click the trigger again to get rid of one. Driven through the language picker; the
// profile menu is the same block. Anchoring is asserted too — without `position-anchor` the panel
// silently detaches and lands in the middle of the viewport.
test("a popover menu sits on its trigger and closes on an outside click or Esc — no JavaScript", async ({ page }) => {
await page.goto("/dashboard");
const trigger = page.locator('button[aria-label="Language"]');
const panel = page.locator('button[aria-label="Language"] + .menu-pop');
await expect(panel).toBeHidden();
await trigger.click();
await expect(panel).toBeVisible();
// Anchored to the button that opened it: directly above (.up), right edges flush.
const t = (await trigger.boundingBox())!;
const p = (await panel.boundingBox())!;
expect(Math.abs(p.x + p.width - (t.x + t.width))).toBeLessThan(2);
expect(t.y - (p.y + p.height)).toBeGreaterThan(-1); // above the trigger, subpixel-tolerant
expect(t.y - (p.y + p.height)).toBeLessThan(12);
await page.getByRole("heading", { name: "Starter dashboard" }).click(); // anywhere else on the page
await expect(panel).toBeHidden();
await trigger.click();
await expect(panel).toBeVisible();
await page.keyboard.press("Escape");
await expect(panel).toBeHidden();
});
test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/dashboard");
@@ -126,17 +99,14 @@ test("the public landing at / is ungated and links to sign in + register", async
await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
await page.goto("/");
await expect(page.locator(".landing")).toBeVisible();
// the same app shell every page renders — the menu shows even signed out (permission-filtered).
// the same app shell every page renders — the menu shows even signed out (role-filtered).
await expect(page.locator(".sidebar")).toBeVisible();
await expect(page.locator('use[href="#i-gear"]')).toHaveCount(0); // no settings cog to offer a signed-out visitor
// Scoped to the landing itself: the anonymous sidebar offers a "Sign in" link of its own.
await expect(page.locator("#main-content").getByRole("link", { name: "Sign in" })).toHaveAttribute("href", "/login");
await expect(page.locator("#main-content").getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
await shot(page, "live-05-public-landing");
});
test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => {
allowConsole(/status of 404 .*\/no-such-page$/); // the navigation under test, which Chromium and WebKit log — not a sub-resource of it
const res = await page.goto("/no-such-page");
expect(res?.status()).toBe(404);
await expect(page.getByRole("heading", { name: "Page not found" })).toBeVisible();
@@ -166,7 +136,7 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red
expect(res.status()).toBe(303);
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
// The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav,
// The signed-in member (no scheduling role) sees the public Scheduling → Overview leaf in the nav,
// but the gated Shifts leaf is filtered out.
await page.goto("/dashboard");
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
+1 -1
View File
@@ -6,6 +6,6 @@ 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/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
| [`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. |
+3 -3
View File
@@ -12,15 +12,15 @@ import { defineMenu } from "#menu-config";
export default defineMenu({
branding: {
name: "Plainpages", // app name shown in the sidebar
sub: "Console", // optional subtitle under the name — a catalog key here would be translated
sub: "Console", // optional subtitle under the name
// logo: "/public/logo.svg", // optional logo asset (rendered in the sidebar brand)
// theme: "auto", // default color theme: auto | light | dark
},
// Operator override (rename → group → order → hide), keyed by node id.
override: {
// rename: { people: "Staff" }, // node id → new label (or a catalog key)
// groups: [{ id: "admin", label: "Admin", children: ["users", "groups"] }],
// rename: { people: "Staff" }, // node id → new label
// groups: [{ id: "admin", label: "Admin", children: ["users", "roles"] }],
// order: ["people", "reports"], // top-level order by id
// hide: ["teams"], // remove nodes (any depth)
},
+13 -31
View File
@@ -1,22 +1,17 @@
# Admin — the system-administration plugin
The Users / Groups / OAuth2-clients screens for running Plainpages itself. These used to be
The Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. These used to be
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
screens live at `/admin/*`) and restart:
```bash
cp -r examples/plugins/admin plugins/admin
docker compose up -d
docker compose restart web
```
The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so the
section appears in the menu and the screens work immediately.
Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`) — the nav
labels included, which are catalog keys in `admin-shared.ts`. Each pure view-model builder takes an
optional `t`; the handlers pass `ctx.t`, and the default is the plugin's own English so a unit test
reads in words rather than keys. (README → [Languages](../../../README.md#languages-i18n).)
The seeded `admin@plainpages.local` already holds the `admin` role, so the section appears in the
menu and the screens work immediately.
## What it demonstrates — a *system* plugin
@@ -25,42 +20,29 @@ reference](../scheduling/README.md)). The admin screens instead administer **Pla
stack**, so they use the privileged **`ctx.system`** surface the host exposes to a system plugin:
- **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users).
- **`ctx.system.keto`** — read/write the Keto relationship graph (group membership, permission grants).
- **`ctx.system.keto`** — read/write the Keto relationship graph (Groups, Roles).
- **`ctx.system.hydra`** — register/list/delete Ory Hydra OAuth2 clients.
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
user's role change kills that subject's live tokens at once instead of waiting out the JWT TTL.
`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 its screen's `<resource>:<action>` permission, rendering the core building blocks
in `views/`.
Each screen is its own resource — `users`, `groups`, `oauth2-clients` — and each splits into `:read`
and `:write`, so a helpdesk account can be given `users:read` alone. The nav is filtered by the same
permissions: holding none of the three hides the Admin section entirely.
There is **no Permissions screen**. Permission names are declared in plugin code, not created in a
GUI, so the host's catalog (`ctx.declaredPermissions`) is the fixed list — and holding one is a
property of a user or a group, edited as a checkbox list on those two screens (`admin-grants.ts`).
gated per route by `permission: "admin"`, rendering the core building blocks in `views/`.
## Layout
- `plugin.ts` — the manifest: the Admin nav fragment, the six permissions the plugin declares, and
the route table — one thin handler per method+path, gated via `permissionName(resource, actionForMethod(method))`
so a GET needs `:read` and a POST `:write`.
- `admin-grants.ts` — the permission picker and the grant diff, shared by the Users and Groups
screens: what a submitted checkbox set grants and revokes, against the host's declared catalog.
- `admin-users.ts` · `admin-groups.ts` · `admin-clients.ts` — each a set of pure
- `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"`.
- `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
screen's permission gate + the needed `ctx.system` clients once.
- `admin-shared.ts` — the permission naming (`permissionName` / `actionForMethod`), the shared gate
(`requirePermission`), CSRF form reader (`guardedForm`), confirm
admin gate + the needed `ctx.system` clients once.
- `admin-shared.ts` — the shared gate (`requireAdmin`), CSRF form reader (`guardedForm`), confirm
model, nav fragment, and the not-found / unavailable helpers.
- `views/` — the screens' EJS, plus the admin-specific body partials under `views/partials/`. They
`include()` the core building-block partials (shell, data-table, filter-bar, field, …).
The three screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders
The four screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders
unit-test as pure functions with no host; the HTTP routing/gate/CSRF is covered in
`src/http/app.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`.
+45 -58
View File
@@ -5,8 +5,8 @@
// 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 { can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
const DEFAULT_PAGE_SIZE = 25;
@@ -64,14 +64,14 @@ export function clientPayload(input: ClientInput): Record<string, unknown> {
};
}
export function validateClientInput(input: ClientInput, t: Translate = ADMIN_EN): string | null {
if (!input.name) return t("admin.clients.validation.name");
if (!input.redirectUris.length) return t("admin.clients.validation.redirectUris");
export function validateClientInput(input: ClientInput): string | null {
if (!input.name) return "Enter a name for the client.";
if (!input.redirectUris.length) return "Add at least one redirect URI.";
for (const uri of input.redirectUris) {
try {
new URL(uri); // must be an absolute URL — any scheme (public/native clients use custom ones)
} catch {
return t("admin.clients.validation.redirectUri", { uri });
return `"${uri}" is not a valid redirect URI — use an absolute URL like https://app.example.com/callback.`;
}
}
return null;
@@ -100,13 +100,10 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
}
export function buildClientsListModel(opts: {
canWrite?: boolean;
clients: OAuth2Client[];
csrfToken?: string;
t?: Translate;
url: URL | URLSearchParams | string;
}) {
const t = opts.t ?? ADMIN_EN;
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const needle = query.q.toLowerCase();
@@ -119,57 +116,56 @@ export function buildClientsListModel(opts: {
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q };
return {
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.nav.section") }, { label: t("admin.clients.title") }],
canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, t),
pagination: listPagination(state, page, t),
table: listTable(rows, t),
title: t("admin.clients.title"),
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "Admin" }, { label: "OAuth2 clients" }],
filterBar: listFilterBar(state),
pagination: listPagination(state, page),
table: listTable(rows),
title: "OAuth2 clients",
};
}
function listTable(rows: ClientView[], t: Translate) {
function listTable(rows: ClientView[]) {
return {
caption: t("admin.clients.title"),
columns: [{ label: t("admin.clients.column.name") }, { label: t("admin.clients.column.id") }, { label: t("admin.clients.column.type") }],
caption: "OAuth2 clients",
columns: [{ label: "Name" }, { label: "Client ID" }, { label: "Type" }],
rows: rows.map((c) => ({
cells: [
{ rowHeader: { href: detailHref(c.id), text: c.name } },
{ className: "cell-muted", text: c.id },
{ badge: { label: c.public ? t("admin.clients.public") : t("admin.clients.confidential"), tone: c.public ? "warn" : "info" } },
{ badge: { label: c.public ? "Public" : "Confidential", tone: c.public ? "warn" : "info" } },
],
name: c.name,
})),
};
}
function listFilterBar(state: ListState, t: Translate) {
function listFilterBar(state: ListState) {
const pills: { label: string; remove: string; value: string }[] = [];
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
return {
applyLabel: t("filter.apply"),
applyLabel: "Apply",
clearHref: ADMIN_CLIENTS_BASE,
label: t("admin.clients.filter"),
label: "Filter clients",
pills,
rows: [[
{ label: t("admin.clients.searchLabel"), name: "q", placeholder: t("admin.clients.searchPlaceholder"), type: "search", value: state.q },
{ label: "Search clients", name: "q", placeholder: "Search name or client ID…", type: "search", value: state.q },
{ type: "spacer" },
]],
};
}
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
return {
label: t("admin.clients.pagination"),
label: "Clients pagination",
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
pages: page.pages.map((p) =>
p.ellipsis ? { ellipsis: true }
: p.current ? { current: true, label: String(p.page) }
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
@@ -179,20 +175,18 @@ function listPagination(state: ListState, page: ReturnType<typeof paginate>, t:
export function buildClientFormModel(opts: {
csrfToken?: string;
error?: string;
t?: Translate;
values?: Partial<ClientInput>;
}) {
const t = opts.t ?? ADMIN_EN;
const v = opts.values;
const nameField: FieldConfig = {
autocomplete: "off", icon: "i-box", id: "name", label: t("admin.clients.field.name"), name: "name", required: true, value: v?.name ?? "",
autocomplete: "off", icon: "i-box", id: "name", label: "Name", name: "name", required: true, value: v?.name ?? "",
};
const scopeField: FieldConfig = {
hint: t("admin.clients.field.scopesHint"), id: "scope", label: t("admin.clients.field.scopes"), name: "scope",
hint: "Space-separated scopes the client may request.", id: "scope", label: "Scopes", name: "scope",
value: v?.scope ?? DEFAULT_SCOPE,
};
return {
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.clients.title") }, { label: t("admin.clients.register") }],
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: "Register" }],
error: opts.error,
form: {
action: ADMIN_CLIENTS_BASE,
@@ -203,31 +197,27 @@ export function buildClientFormModel(opts: {
public: v?.public ?? false,
redirectUris: (v?.redirectUris ?? []).join("\n"),
scopeField,
submitLabel: t("admin.clients.registerClient"),
submitLabel: "Register client",
},
title: t("admin.clients.registerTitle"),
title: "Register client",
};
}
export function buildClientDetailModel(opts: {
canWrite?: boolean;
client: ClientView;
created?: boolean; // just registered → success banner + the one-time secret (if any)
csrfToken?: string;
secret?: string; // one-time client_secret (confidential clients), shown once right after create
t?: Translate;
}) {
const t = opts.t ?? ADMIN_EN;
const base = detailHref(opts.client.id);
return {
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.clients.title") }, { label: opts.client.name }],
canWrite: opts.canWrite !== false,
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: opts.client.name }],
client: opts.client,
created: opts.created ?? false,
csrfToken: opts.csrfToken ?? "",
delete: { action: `${base}/delete` },
secret: opts.secret,
title: opts.created ? t("admin.clients.created") : opts.client.name,
title: opts.created ? "Client registered" : opts.client.name,
};
}
@@ -247,49 +237,47 @@ function readClientInput(form: URLSearchParams): ClientInput {
// Hydra capability (else a themed 503). Each route below is a thin handler over these.
interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; }
function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
const user = requirePermission(ctx, "oauth2-clients", action);
const user = requireAdmin(ctx);
const hydra = ctx.system?.hydra;
if (!hydra) return unavailable(ctx, ctx.t("admin.capability.hydra"));
if (!hydra) return unavailable(ctx, "Hydra OAuth2 admin");
return inner({ ctx, hydra, user });
};
}
// Same, plus the target client from ctx.params.id (unknown → themed 404).
function withClient(inner: (deps: ClientsDeps, client: OAuth2Client, id: string) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
function withClient(inner: (deps: ClientsDeps, client: OAuth2Client, id: string) => Promise<RouteResult>): RouteHandler {
return withClients(async (deps) => {
const id = deps.ctx.params["id"] ?? "";
const client = await deps.hydra.getClient(id);
if (!client) return notFound(deps.ctx);
return inner(deps, client, id);
}, action);
});
}
const clientFormResult = (ctx: RequestContext, extra: { error?: string; values?: Partial<ClientInput> }): RouteResult =>
({ data: { chrome: ctx.chrome, model: buildClientFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-form" });
const canWriteClients = (ctx: RequestContext): boolean => can(ctx, permissionName("oauth2-clients", "write"));
({ data: { chrome: ctx.chrome, model: buildClientFormModel({ csrfToken: ctx.chrome.csrfToken, ...extra }) }, view: "client-form" });
const clientDetailResult = (ctx: RequestContext, client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): RouteResult =>
({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ canWrite: canWriteClients(ctx), client: toClientView(client), csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-detail" });
({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ client: toClientView(client), csrfToken: ctx.chrome.csrfToken, ...extra }) }, view: "client-detail" });
// GET /admin/clients — the list.
export const clientsList = withClients(async ({ ctx, hydra }) => {
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
return { data: { chrome: ctx.chrome, model: buildClientsListModel({ canWrite: canWriteClients(ctx), clients, csrfToken: ctx.chrome.csrfToken, t: ctx.t, url: ctx.url }) }, view: "clients" };
return { data: { chrome: ctx.chrome, model: buildClientsListModel({ clients, csrfToken: ctx.chrome.csrfToken, url: ctx.url }) }, view: "clients" };
});
// POST /admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never
// returns it again). A Hydra 4xx (bad redirect/scope) re-renders the form (400); a 5xx rethrows → 500.
export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
const input = readClientInput((await guardedForm(ctx))!);
const error = validateClientInput(input, ctx.t);
const error = validateClientInput(input);
if (error) return { ...clientFormResult(ctx, { error, values: input }), status: 400 };
let created: OAuth2Client;
try {
created = await hydra.createClient(clientPayload(input));
} catch (err) {
if (err instanceof HydraError && err.status < 500) return { ...clientFormResult(ctx, { error: ctx.t("admin.clients.error.rejected"), values: input }), status: 400 };
if (err instanceof HydraError && err.status < 500) return { ...clientFormResult(ctx, { error: "Hydra rejected the client — check the redirect URIs and scopes.", values: input }), status: 400 };
throw err;
}
ctx.log.info("admin: oauth2 client registered", { actor: user.id, client: created.client_id ?? "" });
@@ -297,7 +285,7 @@ export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
});
// GET /admin/clients/new — the register form.
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})), "write");
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})));
// GET /admin/clients/:id — the detail (read-only; the secret is shown only once, at creation).
export const clientsDetail = withClient((deps, client) => Promise.resolve(clientDetailResult(deps.ctx, client)));
@@ -306,13 +294,12 @@ export const clientsDetail = withClient((deps, client) => Promise.resolve(client
export const clientsDeleteConfirm = withClient((deps, client, id) => {
const base = detailHref(id);
const name = toClientView(client).name;
const tt = deps.ctx.t;
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: tt("admin.clients.title") }, { href: base, label: name }, { label: tt("common.delete") }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.clients.delete"),
message: tt("admin.clients.deleteMessage", { name }), title: tt("admin.clients.delete"),
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete client",
message: `Delete client ${name}? Apps using it can no longer sign in through Plainpages.`, title: "Delete client",
}) }, view: "confirm" });
}, "write");
});
// POST /admin/clients/:id/delete — perform it.
export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => {
@@ -1,83 +0,0 @@
// The pure half of permission granting: what a submitted checkbox set changes, and the picker the
// two screens render from it. The Keto writes and the HTTP round trip are covered in app.test.ts.
import assert from "node:assert/strict";
import { test } from "node:test";
import type { PermissionDecl } from "#plugin-api";
import { buildPermissionPicker, grantDiff, grantTuple, groupSubject, userSubject } from "./admin-grants.ts";
const declared: PermissionDecl[] = [
{ description: "View users", name: "users:read" },
{ description: "Edit users", name: "users:write" },
{ name: "groups:read" },
];
test("grantTuple targets a user by subject_id and a group by subject_set", () => {
assert.deepEqual(grantTuple("users:read", userSubject("u1")), { namespace: "Permission", object: "users:read", relation: "granted", subject_id: "user:u1" });
assert.deepEqual(grantTuple("users:read", groupSubject("eng")), {
namespace: "Permission", object: "users:read", relation: "granted",
subject_set: { namespace: "Group", object: "eng", relation: "members" },
});
});
test("grantDiff: the submitted set is the desired state — tick grants, untick revokes, unchanged is a no-op", () => {
assert.deepEqual(grantDiff(declared, ["users:read"], ["users:read", "users:write"]), { grant: ["users:write"], revoke: [] });
assert.deepEqual(grantDiff(declared, ["users:read", "users:write"], ["users:read"]), { grant: [], revoke: ["users:write"] });
assert.deepEqual(grantDiff(declared, ["users:read"], ["users:read"]), { grant: [], revoke: [] });
assert.deepEqual(grantDiff(declared, ["users:read"], []), { grant: [], revoke: ["users:read"] }); // every box cleared
});
test("grantDiff ignores anything the plugins don't declare, in both directions", () => {
// A crafted POST can't grant a name no plugin gates on…
assert.deepEqual(grantDiff(declared, [], ["superuser:all"]), { grant: [], revoke: [] });
// …and a held name that is no longer declared (its plugin was uninstalled) is left alone rather
// than silently revoked by an unrelated save — this screen only speaks for what it offered.
assert.deepEqual(grantDiff(declared, ["legacy:thing"], ["users:read"]), { grant: ["users:read"], revoke: [] });
});
test("buildPermissionPicker ticks what is held and carries each declaration's description", () => {
const picker = buildPermissionPicker({ action: "/admin/users/u1/permissions", declared, direct: ["users:write"] });
assert.equal(picker.action, "/admin/users/u1/permissions");
assert.deepEqual(picker.choices.map((c) => c.name), ["users:read", "users:write", "groups:read"]);
assert.deepEqual(picker.choices.map((c) => c.checked), [false, true, false]);
assert.equal(picker.choices[0]?.description, "View users");
assert.equal(picker.choices[2]?.description, ""); // a declaration may omit one
assert.equal(picker.empty, undefined);
assert.equal(picker.readOnly, false);
assert.equal(picker.inheritedNote, undefined); // nothing is group-held here
});
// The failure this prevents: a permission held through a group used to render unticked, so the page
// said "not held" about a grant that reaches the JWT — and unticking it wrote nothing, which read as
// a successful revoke. Inherited rows are ticked, disabled, and never posted.
test("buildPermissionPicker distinguishes a direct grant from one inherited through a group", () => {
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:write"], effective: ["users:read", "users:write"] });
assert.deepEqual(picker.choices.map((c) => [c.name, c.checked, c.inherited]), [
["users:read", true, true], // effective but not direct → shown as held, not editable here
["users:write", true, false], // direct → editable
["groups:read", false, false],
]);
assert.ok(picker.inheritedNote, "the disabled row needs an explanation");
});
test("buildPermissionPicker in read-only mode still shows the state, and marks itself unwritable", () => {
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:read"], effective: ["users:read", "groups:read"], readOnly: true });
assert.equal(picker.readOnly, true);
assert.deepEqual(picker.choices.map((c) => c.checked), [true, false, true]); // a reader still sees who holds what
// Every row renders disabled for a reader, so the writable copy would be wrong twice over: "tick to
// grant" is false, and "greyed-out means group-held" would misattribute the direct grant.
assert.equal(picker.inheritedNote, undefined);
assert.notEqual(picker.hint, buildPermissionPicker({ action: "/x", declared, direct: [] }).hint);
});
test("buildPermissionPicker notes the transitive lag for a group, and stays quiet for a user", () => {
// A group's members inherit, so the change reaches them at their next re-mint; a user's own grant
// change revokes their live tokens, so there is nothing to warn about.
assert.ok(buildPermissionPicker({ action: "/x", declared, direct: [], transitive: true }).pending);
assert.equal(buildPermissionPicker({ action: "/x", declared, direct: [] }).pending, undefined);
});
test("buildPermissionPicker says so when no plugin declares a permission, rather than rendering an empty box", () => {
const picker = buildPermissionPicker({ action: "/x", declared: [], direct: [] });
assert.deepEqual(picker.choices, []);
assert.ok(picker.empty);
});
-123
View File
@@ -1,123 +0,0 @@
// Permission grants, shared by the Users and Groups screens. A permission is held by a user
// (`Permission:<name>#granted@user:<id>`) or by a whole group (`…@Group:<name>#members`), and Keto
// resolves a group's grant transitively at login.
//
// The set of permissions that *exist* is `ctx.declaredPermissions` — the host's catalog, built from
// what the installed plugins declare in code. Nothing here invents a name, which is why the old
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "#plugin-api";
const PERMISSION_NS = "Permission";
const GRANTED = "granted";
export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two forms post
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
export const userSubject = (id: string): GrantSubject => ({ subject_id: `user:${id}` });
export const groupSubject = (name: string): GrantSubject => ({ subject_set: { namespace: "Group", object: name, relation: "members" } });
export function grantTuple(permission: string, subject: GrantSubject): RelationTuple {
return { namespace: PERMISSION_NS, object: permission, relation: GRANTED, ...subject };
}
// The permissions this subject holds *directly* — one Keto read filtered by the subject, not one per
// declared name. This is the edge the picker edits; `effectivePermissions` adds what a group confers.
export async function heldPermissions(keto: KetoClient, subject: GrantSubject): Promise<string[]> {
const held = new Set<string>();
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ namespace: PERMISSION_NS, relation: GRANTED, ...subject, ...(pageToken ? { pageToken } : {}) });
for (const tuple of page.tuples) held.add(tuple.object);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
return [...held].sort();
}
// Every declared permission the subject effectively holds — direct grants *plus* anything reached
// through a group, which is what actually lands in their JWT. One Keto check per declared name;
// the catalog is small and this is an admin screen (login does the same walk).
export async function effectivePermissions(keto: KetoClient, subject: GrantSubject, declared: readonly PermissionDecl[]): Promise<string[]> {
const held = await Promise.all(declared.map((decl) => keto.check({ namespace: PERMISSION_NS, object: decl.name, relation: GRANTED, ...subject })));
return declared.filter((_, i) => held[i]).map((decl) => decl.name);
}
export interface PermissionChoice {
checked: boolean; // held directly — the only state this form can change
description: string;
// Effective through a group, not granted directly. Rendered ticked but disabled: the grant is real
// (it reaches the JWT), and it is removed by editing the group, not this subject.
inherited: boolean;
name: string;
}
export interface PermissionPicker {
action: string;
choices: PermissionChoice[];
empty: string | undefined; // set when no plugin declares a permission — the picker has nothing to offer
error?: string; // a rejected save (e.g. the self-revoke guard), rendered above the list
field: string;
hint: string;
inheritedNote: string | undefined; // set when at least one choice is group-held, to explain the disabled row
legend: string;
// Set for a group: its members hold these transitively, so a change reaches them at their next
// re-mint rather than at once. The user picker revokes live tokens, so it says nothing.
pending: string | undefined;
readOnly: boolean; // the viewer holds :read but not :write — show the state, offer no save
submit: string;
}
// The checkbox list: every declared permission, ticked where this subject holds it. A fixed list
// means the form is the whole truth — what it posts back *is* the desired set of *direct* grants
// (grantDiff). An inherited row is disabled, so it never posts and can never be diffed into a revoke.
export function buildPermissionPicker(opts: {
action: string;
declared: readonly PermissionDecl[];
direct: string[];
effective?: string[]; // omit when the caller can't resolve group-held grants; then only direct shows
readOnly?: boolean;
t?: Translate;
transitive?: boolean; // a group: its members inherit, so the change lands at their next re-mint
}): PermissionPicker {
const t = opts.t ?? ((k: string) => k);
const directSet = new Set(opts.direct);
const effectiveSet = new Set(opts.effective ?? opts.direct);
const choices = opts.declared.map((decl) => ({
checked: directSet.has(decl.name) || effectiveSet.has(decl.name),
description: decl.description ?? "",
inherited: !directSet.has(decl.name) && effectiveSet.has(decl.name),
name: decl.name,
}));
return {
action: opts.action,
choices,
empty: opts.declared.length === 0 ? t("admin.grants.none") : undefined,
field: PERMISSIONS_FIELD,
// A reader sees every row disabled, so "tick to grant" is false and "greyed-out means group-held"
// is worse than false — it would misattribute a *direct* grant to a group that doesn't hold it.
hint: t(opts.readOnly === true ? "admin.grants.hintReadOnly" : "admin.grants.hint"),
inheritedNote: opts.readOnly !== true && choices.some((c) => c.inherited) ? t("admin.grants.inherited") : undefined,
legend: t("admin.grants.legend"),
pending: opts.transitive === true ? t("admin.grants.pending") : undefined,
readOnly: opts.readOnly === true,
submit: t("admin.grants.save"),
};
}
// What a submitted set changes. Pure so the diff is testable without Keto: only declared names are
// considered, so a crafted POST cannot grant something no plugin gates on, and a held-but-undeclared
// name (left over from an uninstalled plugin) is never silently revoked by an unrelated save.
export function grantDiff(declared: readonly PermissionDecl[], held: string[], wanted: string[]): { grant: string[]; revoke: string[] } {
const offered = new Set(declared.map((d) => d.name));
const heldSet = new Set(held);
const wantedSet = new Set(wanted.filter((name) => offered.has(name)));
return {
grant: [...wantedSet].filter((name) => !heldSet.has(name)).sort(),
revoke: [...heldSet].filter((name) => offered.has(name) && !wantedSet.has(name)).sort(),
};
}
export async function applyGrants(keto: KetoClient, subject: GrantSubject, diff: { grant: string[]; revoke: string[] }): Promise<void> {
for (const name of diff.grant) await keto.writeTuple(grantTuple(name, subject));
for (const name of diff.revoke) await keto.deleteTuple(grantTuple(name, subject));
}
+45 -92
View File
@@ -6,9 +6,8 @@
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
// each returning a RouteResult.
import { can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "#plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
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 { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
const GROUP_NS = "Group";
@@ -91,8 +90,8 @@ const SORT: Record<string, (g: GroupView) => number | string> = {
name: (g) => g.name,
};
const COLUMNS = [
{ key: "name", label: "admin.groups.column.name" },
{ key: "members", label: "admin.groups.column.members" },
{ key: "name", label: "Group" },
{ key: "members", label: "Members" },
];
function detailHref(name: string): string {
@@ -111,13 +110,10 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
}
export function buildGroupsListModel(opts: {
canWrite?: boolean;
csrfToken?: string;
groups: GroupView[];
t?: Translate;
url: URL | URLSearchParams | string;
}) {
const t = opts.t ?? ADMIN_EN;
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
@@ -140,22 +136,21 @@ export function buildGroupsListModel(opts: {
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
return {
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }],
canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, t),
pagination: listPagination(state, page, t),
table: listTable(rows, state, sort, t),
title: t("admin.groups.title"),
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Admin" }, { label: "Groups" }],
filterBar: listFilterBar(state),
pagination: listPagination(state, page),
table: listTable(rows, state, sort),
title: "Groups",
};
}
function listTable(rows: GroupView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) {
function listTable(rows: GroupView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
return {
caption: t("admin.groups.title"),
caption: "Groups",
columns: COLUMNS.map((c) => {
const dir = sort && sort.field === c.key ? sort.dir : undefined;
const next = dir === "asc" ? `-${c.key}` : c.key;
return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
return { href: listHref(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true };
}),
rows: rows.map((g) => ({
cells: [{ rowHeader: { href: detailHref(g.name), text: g.name } }, String(g.memberCount)],
@@ -164,34 +159,34 @@ function listTable(rows: GroupView[], state: ListState, sort: { dir: "asc" | "de
};
}
function listFilterBar(state: ListState, t: Translate) {
function listFilterBar(state: ListState) {
const pills: { label: string; remove: string; value: string }[] = [];
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
return {
applyLabel: t("filter.apply"),
applyLabel: "Apply",
clearHref: ADMIN_GROUPS_BASE,
label: t("admin.groups.filter"),
label: "Filter groups",
pills,
rows: [[
{ label: t("admin.groups.searchLabel"), name: "q", placeholder: t("admin.groups.searchPlaceholder"), type: "search", value: state.q },
{ label: "Search groups", name: "q", placeholder: "Search group name…", type: "search", value: state.q },
{ type: "spacer" },
]],
};
}
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
if (state.sort) hidden.push({ name: "sort", value: state.sort });
return {
label: t("admin.groups.pagination"),
label: "Groups pagination",
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
pages: page.pages.map((p) =>
p.ellipsis ? { ellipsis: true }
: p.current ? { current: true, label: String(p.page) }
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
@@ -202,16 +197,14 @@ export function buildGroupFormModel(opts: {
csrfToken?: string;
error?: string;
memberOptions: MemberOption[];
t?: Translate;
values?: { member?: string; name?: string };
}) {
const t = opts.t ?? ADMIN_EN;
const nameField: FieldConfig = {
autocomplete: "off", hint: t("admin.groups.field.nameHint"), icon: "i-layers",
id: "name", label: t("admin.groups.field.name"), name: "name", required: true, value: opts.values?.name ?? "",
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-layers",
id: "name", label: "Group name", name: "name", required: true, value: opts.values?.name ?? "",
};
return {
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: t("common.new") }],
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: "New" }],
error: opts.error,
form: {
action: ADMIN_GROUPS_BASE,
@@ -220,46 +213,39 @@ export function buildGroupFormModel(opts: {
memberOptions: opts.memberOptions,
nameField,
selectedMember: opts.values?.member ?? "",
submitLabel: t("admin.groups.create"),
submitLabel: "Create group",
},
title: t("admin.groups.new"),
title: "New group",
};
}
export function buildGroupDetailModel(opts: {
canWrite?: boolean; // false ⇒ a `groups:read` holder: show the members, offer no edit
candidates: MemberOption[];
csrfToken?: string;
error?: string;
group: { name: string };
members: MemberView[];
permissions?: PermissionPicker;
t?: Translate;
}) {
const t = opts.t ?? ADMIN_EN;
const name = opts.group.name;
const base = detailHref(name);
const taken = new Set(opts.members.map((m) => m.subject));
const self = `group:${name}`; // a group can't be a member of itself
const options = opts.candidates.filter((c) => c.value !== self && !taken.has(c.value));
const canWrite = opts.canWrite !== false;
return {
add: { action: `${base}/members`, options },
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: name }],
canWrite, // the view drops add/remove/delete when false; the host already 403s those POSTs
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: name }],
csrfToken: opts.csrfToken ?? "",
delete: { action: `${base}/delete` },
error: opts.error,
group: { name },
members: { action: `${base}/members/delete`, rows: opts.members },
permissions: opts.permissions,
title: name,
};
}
// ---- request handler (imperative shell) ----
// Drain every page of a relation-tuple query.
// Drain every page of a relation-tuple query. (Reused by the Roles screen — same membership model.)
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
const out: RelationTuple[] = [];
let pageToken: string | undefined;
@@ -293,39 +279,38 @@ async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
return page.tuples.length > 0;
}
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate (`groups:read` on
// a GET, `groups:write` on a POST) + the Keto and Kratos capabilities (else a themed 503). Each route
// below is a thin handler over these.
// 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; }
function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
const user = requirePermission(ctx, "groups", action);
const user = requireAdmin(ctx);
const keto = ctx.system?.keto;
const kratosAdmin = ctx.system?.kratosAdmin;
if (!keto || !kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.keto"));
if (!keto || !kratosAdmin) return unavailable(ctx, "Keto and Kratos identity admin");
return inner({ ctx, keto, kratosAdmin, user });
};
}
// Same, plus the validated :name from ctx.params (an invalid group name → themed 404).
function withGroupName(inner: (deps: GroupsDeps, name: string) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
function withGroupName(inner: (deps: GroupsDeps, name: string) => Promise<RouteResult>): RouteHandler {
return withGroups((deps) => {
const name = deps.ctx.params["name"] ?? "";
if (!isValidGroupName(name)) return Promise.resolve(notFound(deps.ctx));
return inner(deps, name);
}, action);
});
}
const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
return { data: { chrome: deps.ctx.chrome, model: buildGroupFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, t: deps.ctx.t, ...extra }) }, view: "group-form" };
return { data: { chrome: deps.ctx.chrome, model: buildGroupFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "group-form" };
};
// GET /admin/groups — the list.
export const groupsList = withGroups(async ({ ctx, keto }) => {
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ canWrite: can(ctx, permissionName("groups", "write")), csrfToken: ctx.chrome.csrfToken, groups, t: ctx.t, url: ctx.url }) }, view: "groups" };
return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ csrfToken: ctx.chrome.csrfToken, groups, url: ctx.url }) }, view: "groups" };
});
// POST /admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
@@ -336,8 +321,8 @@ export const groupsCreate = withGroups(async (deps) => {
const member = (form.get("member") ?? "").trim();
const tuple = memberTuple(name, member);
const reject = async (error: string): Promise<RouteResult> => ({ ...(await groupFormResult(deps, { error, values: { member, name } })), status: 400 });
if (!isValidGroupName(name)) return reject(ctx.t("admin.groups.validation.name"));
if (!tuple) return reject(ctx.t("admin.groups.validation.member"));
if (!isValidGroupName(name)) return reject("Group names use lowercase letters, digits, dashes and underscores.");
if (!tuple) return reject("Pick a member to add as the group's first member.");
if (await groupExists(keto, name)) return reject("A group with that name already exists.");
await keto.writeTuple(tuple);
ctx.log.info("admin: group created", { actor: user.id, group: name });
@@ -345,38 +330,13 @@ export const groupsCreate = withGroups(async (deps) => {
});
// GET /admin/groups/new — the create form.
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}));
// GET /admin/groups/:name — the detail + membership page.
export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => {
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
const subject = groupSubject(name);
const [direct, effective] = await Promise.all([heldPermissions(keto, subject), effectivePermissions(keto, subject, ctx.declaredPermissions)]);
const permissions = buildPermissionPicker({
action: `${detailHref(name)}/permissions`,
declared: ctx.declaredPermissions,
direct,
effective, // a group nested in another group inherits its permissions too
readOnly: !can(ctx, permissionName("groups", "write")),
t: ctx.t,
transitive: true, // members inherit, so a change here lands at their next re-mint, not at once
});
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t }) }, view: "group-detail" };
});
// POST /admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold
// a group's permissions transitively, so the change reaches them at their next login or re-mint —
// the documented instant-revoke tradeoff for anything held through a group.
export const groupsPermissions = withGroupName(async ({ ctx, keto, user }, name) => {
const form = (await guardedForm(ctx))!;
const subject = groupSubject(name);
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
await applyGrants(keto, subject, diff);
if (diff.grant.length > 0 || diff.revoke.length > 0) {
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: diff.grant.join(","), group: name, revoked: diff.revoke.join(",") });
}
return { redirect: detailHref(name) };
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members }) }, view: "group-detail" };
});
// POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
@@ -390,25 +350,18 @@ export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
// GET /admin/groups/:name/delete — the deliberate confirm step.
export const groupsDeleteConfirm = withGroupName((deps, name) => {
const base = detailHref(name);
const tt = deps.ctx.t;
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: tt("admin.groups.title") }, { href: base, label: name }, { label: tt("common.delete") }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"),
message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"),
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete group",
message: `Delete group ${name}? This removes the group and all its memberships.`, title: "Delete group",
}) }, view: "confirm" });
}, "write");
});
// POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
export const groupsDelete = withGroupName(async ({ ctx, keto, user }, name) => {
await guardedForm(ctx); // CSRF-verify the POST
// Drop what the group *holds* before what it *contains*: a Keto set exists only through its
// tuples, so leaving the grants behind would resurrect every permission the moment someone
// re-created a group with the same name.
const subject = groupSubject(name);
const held = await heldPermissions(keto, subject);
for (const permission of held) await keto.deleteTuple(grantTuple(permission, subject));
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS });
ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") });
ctx.log.info("admin: group deleted", { actor: user.id, group: name });
return { redirect: ADMIN_GROUPS_BASE };
});
+106
View File
@@ -0,0 +1,106 @@
// Built-in Roles & permissions 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
// routing/gate/CSRF + live Keto/Kratos calls are exercised over HTTP in app.test.ts.
import assert from "node:assert/strict";
import { test } from "node:test";
import { memberView } from "./admin-groups.ts";
import {
buildRoleDetailModel,
buildRoleFormModel,
buildRolesListModel,
expandToEffectiveUsers,
isValidRoleName,
roleMemberTuple,
} from "./admin-roles.ts";
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)}` });
const groupTuple = (role: string, group: string): RelationTuple =>
({ namespace: "Role", object: role, relation: "members", subject_set: { namespace: "Group", object: group, relation: "members" } });
test("isValidRoleName + roleMemberTuple map the form value to a Role tuple over a user/group (else null)", () => {
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", "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);
});
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 tree: ExpandTree = {
children: [
leaf(1), // direct
{
children: [leaf(2), leaf(1)], // via group + dup
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Group", object: "eng", relation: "members" } }, // a member group, not a user
type: "union",
},
],
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Role", object: "admin", relation: "members" } },
type: "union",
};
assert.deepEqual(expandToEffectiveUsers(tree), [uid(1), uid(2)]);
assert.deepEqual(expandToEffectiveUsers(null), []);
assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty role
});
test("buildRolesListModel filters by search, sorts, paginates; the name links to the detail page", () => {
const roles = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `role-${String(i).padStart(2, "0")}` }));
const all = buildRolesListModel({ roles, url: "http://x/admin/roles" });
assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.title, "Roles");
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
assert.equal(first.rowHeader.text, "role-00");
assert.equal(first.rowHeader.href, "/admin/roles/role-00");
const one = buildRolesListModel({ roles, url: "http://x/admin/roles?q=role-07" });
assert.equal(one.pagination.summary.total, 1);
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
const desc = buildRolesListModel({ roles, url: "http://x/admin/roles?sort=-members" });
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "role-29");
});
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 m = buildRoleFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.title, "New role");
assert.equal(m.form.action, "/admin/roles");
assert.equal(m.form.submitLabel, "Create role");
assert.equal(m.form.csrfToken, "tok.sig");
assert.equal(m.form.nameField.required, true);
assert.deepEqual(m.form.memberOptions, options);
const err = buildRoleFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Admin" } });
assert.equal(err.error, "That name is taken.");
assert.equal(err.form.nameField.value, "Admin");
assert.equal(err.form.selectedMember, "group:eng");
});
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: "eng (group)", value: "group:eng" }, // already a member → excluded
{ label: "ops (group)", value: "group:ops" },
];
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
const m = buildRoleDetailModel({ candidates, effective, members, role: { name: "admin" } });
assert.equal(m.title, "admin");
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.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]);
assert.equal(m.delete.action, "/admin/roles/admin/delete");
});
+367
View File
@@ -0,0 +1,367 @@
// Roles & permissions 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)
// are reused from admin-groups. The role-specific piece is the **effective access** view:
// `keto.expand(Role:<name>#members)` flattened to the distinct users who hold the role directly or via
// a group — matching what login projects into the JWT (login.ts readRoles). Writes go only to Keto;
// 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 GroupView,
groupsFromTuples,
isValidGroupName,
memberCandidates,
type MemberOption,
type MemberView,
memberView,
pagedTuples,
parseSubject,
} from "./admin-groups.ts";
import type { FieldConfig } from "./admin-users.ts";
const ROLE_NS = "Role";
const MEMBERS = "members";
const DEFAULT_PAGE_SIZE = 25;
const PAGE_SIZES = [25, 50, 100];
// Expand far past any sane group-nesting depth so the effective-access view never silently
// under-reports the deepest members (Keto's own default is shallow).
const EXPAND_MAX_DEPTH = 50;
// A role and a group share the URL-safe name rule and the user|group membership model.
export type RoleView = GroupView;
export const isValidRoleName = isValidGroupName;
export const rolesFromTuples = groupsFromTuples;
export interface EffectiveUser {
label: string; // email (or the raw id when unresolved)
}
// The full membership tuple for assigning/revoking `value` to/from `role` (null if value is invalid).
export function roleMemberTuple(role: string, value: string): RelationTuple | null {
const subject = parseSubject(value);
return subject ? { namespace: ROLE_NS, object: role, relation: MEMBERS, ...subject } : null;
}
// Flatten a Keto `expand` tree → the sorted, distinct user ids that effectively hold the role
// (direct leaves + users reached through member groups, any depth). The subject rides on each
// node's `tuple`; subject-set nodes (the groups) contribute nothing directly — their members
// surface as leaves under them.
export function expandToEffectiveUsers(tree: ExpandTree | null | undefined): string[] {
const ids = new Set<string>();
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));
node.children?.forEach(walk);
};
walk(tree);
return [...ids].sort();
}
// ---- list view model ----
interface ListState {
page: number;
pageSize: number;
q: string;
sort: string | null;
}
const SORT: Record<string, (r: RoleView) => number | string> = {
members: (r) => r.memberCount,
name: (r) => r.name,
};
const COLUMNS = [
{ key: "name", label: "Role" },
{ key: "members", label: "Members" },
];
function detailHref(name: string): string {
return `${ADMIN_ROLES_BASE}/${encodeURIComponent(name)}`;
}
function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
const s = { ...state, ...overrides };
const p = new URLSearchParams();
if (s.q) p.set("q", s.q);
if (s.sort) p.set("sort", s.sort);
if (s.page > 1) p.set("page", String(s.page));
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
const qs = p.toString();
return qs ? `${ADMIN_ROLES_BASE}?${qs}` : ADMIN_ROLES_BASE;
}
export function buildRolesListModel(opts: {
csrfToken?: string;
roles: RoleView[];
url: URL | URLSearchParams | string;
}) {
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
const needle = query.q.toLowerCase();
let list = opts.roles.filter((r) => !needle || r.name.toLowerCase().includes(needle));
if (sort) {
const get = SORT[sort.field]!;
const dir = sort.dir === "desc" ? -1 : 1;
list = [...list].sort((a, b) => {
const av = get(a), bv = get(b);
const cmp = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
return cmp * dir;
});
}
const page = paginate(list.length, query.page, query.pageSize, { boundaries: 1, siblings: 1 });
const start = (page.page - 1) * page.pageSize;
const rows = list.slice(start, start + page.pageSize);
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
return {
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }],
filterBar: listFilterBar(state),
pagination: listPagination(state, page),
table: listTable(rows, state, sort),
title: "Roles",
};
}
function listTable(rows: RoleView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
return {
caption: "Roles",
columns: COLUMNS.map((c) => {
const dir = sort && sort.field === c.key ? sort.dir : undefined;
const next = dir === "asc" ? `-${c.key}` : c.key;
return { href: listHref(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true };
}),
rows: rows.map((r) => ({
cells: [{ rowHeader: { href: detailHref(r.name), text: r.name } }, String(r.memberCount)],
name: r.name,
})),
};
}
function listFilterBar(state: ListState) {
const pills: { label: string; remove: string; value: string }[] = [];
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
return {
applyLabel: "Apply",
clearHref: ADMIN_ROLES_BASE,
label: "Filter roles",
pills,
rows: [[
{ label: "Search roles", name: "q", placeholder: "Search role name…", type: "search", value: state.q },
{ type: "spacer" },
]],
};
}
function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
if (state.sort) hidden.push({ name: "sort", value: state.sort });
return {
label: "Roles pagination",
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
pages: page.pages.map((p) =>
p.ellipsis ? { ellipsis: true }
: p.current ? { current: true, label: String(p.page) }
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
// ---- create form + detail view models ----
export function buildRoleFormModel(opts: {
csrfToken?: string;
error?: string;
memberOptions: MemberOption[];
values?: { member?: string; name?: string };
}) {
const nameField: FieldConfig = {
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-shield",
id: "name", label: "Role name", name: "name", required: true, value: opts.values?.name ?? "",
};
return {
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }],
error: opts.error,
form: {
action: ADMIN_ROLES_BASE,
cancelHref: ADMIN_ROLES_BASE,
csrfToken: opts.csrfToken ?? "",
memberOptions: opts.memberOptions,
nameField,
selectedMember: opts.values?.member ?? "",
submitLabel: "Create role",
},
title: "New role",
};
}
export function buildRoleDetailModel(opts: {
candidates: MemberOption[];
csrfToken?: string;
effective: EffectiveUser[];
error?: string;
members: MemberView[];
role: { name: string };
}) {
const name = opts.role.name;
const base = detailHref(name);
const taken = new Set(opts.members.map((m) => m.subject));
const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the role itself
return {
add: { action: `${base}/members`, options },
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: name }],
csrfToken: opts.csrfToken ?? "",
delete: { action: `${base}/delete` },
effective: opts.effective,
error: opts.error,
members: { action: `${base}/members/delete`, rows: opts.members },
role: { name },
title: name,
};
}
// ---- request handler (imperative shell) ----
// instant-revoke: a role change for a `user:<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));
}
// A role exists exactly while it has ≥1 member (Keto has no create-object).
async function roleExists(keto: KetoClient, name: string): Promise<boolean> {
const page = await keto.listRelations({ namespace: ROLE_NS, object: name, relation: MEMBERS, pageSize: 1 });
return page.tuples.length > 0;
}
// The distinct users who effectively hold the role (expand → flatten → label by email). Skipped for
// an empty role (no member tuples) so we don't expand a non-existent Keto object.
async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolean, emailById: Map<string, string>): Promise<EffectiveUser[]> {
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}` }))
.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; }
function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
const user = requireAdmin(ctx);
const keto = ctx.system?.keto;
const kratosAdmin = ctx.system?.kratosAdmin;
if (!keto || !kratosAdmin) return unavailable(ctx, "Keto and Kratos identity admin");
return inner({ ctx, keto, kratosAdmin, revoke: ctx.system?.revoke, user });
};
}
// Same, plus the validated :name from ctx.params (an invalid role name → themed 404).
function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteResult>): RouteHandler {
return withRoles((deps) => {
const name = deps.ctx.params["name"] ?? "";
if (!isValidRoleName(name)) return Promise.resolve(notFound(deps.ctx));
return inner(deps, name);
});
}
const roleFormResult = async (deps: RolesDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
return { data: { chrome: deps.ctx.chrome, model: buildRoleFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "role-form" };
};
// The role detail (members + effective access). With `error` set it's a 400 (a rejected action).
const roleDetailResult = async (deps: RolesDeps, name: string, error?: string): Promise<RouteResult> => {
const { emailById, options } = await memberCandidates(deps.keto, deps.kratosAdmin);
const tuples = await pagedTuples(deps.keto, { namespace: ROLE_NS, object: name, relation: MEMBERS });
const members = tuples.map((t) => memberView(t, emailById));
const effective = await effectiveUsers(deps.keto, name, tuples.length > 0, emailById);
const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildRoleDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, role: { name }, ...(error ? { error } : {}) }) }, view: "role-detail" };
return error ? { ...result, status: 400 } : result;
};
// GET /admin/roles — the list.
export const rolesList = withRoles(async ({ ctx, keto }) => {
const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS }));
return { data: { chrome: ctx.chrome, model: buildRolesListModel({ csrfToken: ctx.chrome.csrfToken, roles, url: ctx.url }) }, view: "roles" };
});
// POST /admin/roles — create + assign the first member (a *user* grant revokes their live tokens).
export const rolesCreate = withRoles(async (deps) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
const name = (form.get("name") ?? "").trim();
const member = (form.get("member") ?? "").trim();
const tuple = roleMemberTuple(name, member);
const reject = async (error: string): Promise<RouteResult> => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 });
if (!isValidRoleName(name)) return reject("Role names use lowercase letters, digits, dashes and underscores.");
if (!tuple) return reject("Pick a user or group to assign the role to.");
if (await roleExists(keto, name)) return reject("A role with that name already exists.");
await keto.writeTuple(tuple);
revokeUserMember(revoke, member);
ctx.log.info("admin: role created + first member assigned", { actor: user.id, member, role: name });
return { redirect: detailHref(name) };
});
// GET /admin/roles/new — the create form.
export const rolesNewForm = withRoles((deps) => roleFormResult(deps, {}));
// GET /admin/roles/:name — the detail (members + effective access via Keto expand).
export const rolesDetail = withRoleName((deps, name) => roleDetailResult(deps, name));
// POST /admin/roles/:name/members — assign a user/group; a *user* grant revokes their live tokens.
export const rolesAddMember = withRoleName(async (deps, name) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
const member = (form.get("member") ?? "").trim();
const tuple = roleMemberTuple(name, member); // the picker only offers real users/groups
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role assigned", { actor: user.id, member, role: name }); }
return { redirect: detailHref(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.");
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" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete role",
message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, title: "Delete role",
}) }, view: "confirm" });
});
// POST /admin/roles/:name/delete — remove every member tuple (a whole-role delete lags per the
// documented instant-revoke tradeoff; the admin role is protected).
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.");
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 };
});
// POST /admin/roles/:name/members/delete — unassign; a *user* unassign revokes their live tokens.
// Self-protection: an admin can't revoke their own *direct* admin grant (a group-held admin isn't
// covered — the robust "last effective admin" check is deferred).
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.");
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) };
});
+16 -48
View File
@@ -1,17 +1,16 @@
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
// (requirePermission/guardedForm gate every admin write) and reused across all three screens, so pin the
// (requireAdmin/guardedForm gate every admin write) and reused across all four screens, so pin the
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts.
// Import only from the #plugin-api barrel — the same contract boundary the plugin code uses.
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, isValidPermissionName, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api";
import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, permissionName, requirePermission } from "./admin-shared.ts";
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";
const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] };
const writer: User = { email: "cy@x.io", id: "u3", permissions: ["users:read", "users:write"] };
const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:read"] };
const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] };
const member: User = { 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 {
@@ -19,59 +18,28 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
req.method = opts.method ?? "GET";
return {
chrome: CHROME, declaredPermissions: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url,
verifyCsrf: opts.verifyCsrf ?? (() => true),
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),
};
}
// ---- nav fragment ----
test("ADMIN_NAV: an ungated Admin header whose three screens each gate on their own read permission", () => {
test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => {
assert.equal(ADMIN_NAV.id, "admin");
// No gate on the header: a user may hold one screen's permission and not another's. composeNav
// drops a header left with no visible children, so holding none of the three hides the section.
// Both halves matter — give the header an `href` and it survives the filter as a visible leaf,
// ungated, for anonymous visitors included.
assert.equal(ADMIN_NAV.permission, undefined);
assert.equal(ADMIN_NAV.href, undefined);
assert.equal(ADMIN_NAV.permission, ADMIN_PERMISSION); // 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/clients"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read"]);
// Labels are catalog keys; the host translates them with this plugin's catalog when it composes
// the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys.
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.clients"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients"]);
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined));
});
// ---- permission naming ----
test("permissionName builds <resource>:<action>, and the host agrees the result is well-formed", () => {
assert.equal(permissionName("users", "read"), "users:read");
assert.equal(permissionName("oauth2-clients", "write"), "oauth2-clients:write");
assert.ok(isValidPermissionName(permissionName("oauth2-clients", "write"))); // the rule discovery enforces
});
test("actionForMethod: read for GET/HEAD, write for every mutation", () => {
assert.equal(actionForMethod("GET"), "read");
assert.equal(actionForMethod("HEAD"), "read"); // a GET route also answers HEAD
assert.equal(actionForMethod("POST"), "write");
assert.equal(actionForMethod("DELETE"), "write"); // anything that isn't a read is a write
assert.equal(actionForMethod("get"), "read"); // method case is the caller's
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
});
// ---- auth gates ----
test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => {
assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page
assert.throws(() => requirePermission(fakeCtx({ user: member }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(requirePermission(fakeCtx({ user: reader }), "users"), reader);
// The whole point of the split: users:read opens the list but not the create/delete POSTs.
assert.throws(() => requirePermission(fakeCtx({ method: "POST", user: reader }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(requirePermission(fakeCtx({ method: "POST", user: writer }), "users"), writer);
// Resources don't leak into each other: a users holder is not a groups holder.
assert.throws(() => requirePermission(fakeCtx({ user: writer }), "groups"), (e: unknown) => e instanceof GuardError && e.status === 403);
test("requireAdmin: anonymous → 401→/login, signed-in non-admin → 403, admin → the user", () => {
assert.throws(() => requireAdmin(fakeCtx({ user: null })), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page
assert.throws(() => requireAdmin(fakeCtx({ user: member })), (e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(requireAdmin(fakeCtx({ user: admin })), admin);
});
test("guardedForm: valid double-submit → the parsed body, bad token → 403, non-POST → undefined", async () => {
+20 -50
View File
@@ -3,68 +3,38 @@
// (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, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "#plugin-api";
import enUS from "./i18n/en-US.ts";
// This plugin's English (its catalog, then the host's — the screens reuse core words like Cancel and
// Search), for a view model built outside a request: its unit tests. At runtime the handlers pass
// ctx.t, which reads this catalog in the visitor's locale first, then the host's.
export const ADMIN_EN: Translate = englishTranslator(enUS);
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type User } from "#plugin-api";
export const ADMIN_PERMISSION = "admin"; // role token gating the whole admin section
export const ADMIN_USERS_BASE = "/admin/users";
export const ADMIN_GROUPS_BASE = "/admin/groups";
export const ADMIN_ROLES_BASE = "/admin/roles";
export const ADMIN_CLIENTS_BASE = "/admin/clients";
// One resource per screen — the `<resource>` half of every permission this plugin gates on.
// `oauth2-clients` rather than `clients` because permission names are one global namespace.
// There is no `permissions` resource: permissions are declared in plugin code, not created here, so
// holding a grant is a property of a user or a group and is edited on those two screens.
export type AdminResource = "groups" | "oauth2-clients" | "users";
export type AdminScreen = "clients" | "groups" | "roles" | "users";
export type AdminAction = "read" | "write";
// `<resource>:<action>` (README → Naming a permission).
export function permissionName(resource: AdminResource, action: AdminAction): string {
return `${resource}:${action}`;
}
// This plugin's mapping from method to action: every screen reads on GET/HEAD and mutates on POST.
// The manifest's route table and the in-handler guard both go through it rather than each spelling
// the permission out, so they cannot drift into gating on different names. Deliberately local — as
// a general mechanism it would make authorization a function of the transport verb, and a route
// table should answer "what does this need?" on its own (AGENTS.md).
export function actionForMethod(method: string): AdminAction {
const verb = method.toUpperCase();
return verb === "GET" || verb === "HEAD" ? "read" : "write";
}
// The plugin's nav fragment: an ungated "Admin" header + its three screens, each gated on its own
// read permission. The header carries no `permission` because a user may hold one screen's and not
// another's; composeNav drops a header left with no visible children, so a user holding none of the
// three never sees the section. The host current-marks the active item — no `current`/`open` here.
// 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
// non-admin), and current-marks the active item — so there is no `current`/`open` state here.
export const ADMIN_NAV: NavNode = {
children: [
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") },
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") },
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") },
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
{ href: ADMIN_ROLES_BASE, icon: "i-shield", id: "roles", label: "Roles" },
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" },
],
icon: "i-shield",
id: "admin",
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
label: "Admin",
permission: ADMIN_PERMISSION,
};
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
// declares the same permission, so the host enforces it 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.
// `action` defaults to the method's, and is passed explicitly by a *write-intent GET* — a create form
// or a delete-confirm page, whose only purpose is to start a write. Those refuse a reader honestly
// instead of rendering a form whose submit would 403; the route table declares the same override, so
// the two still cannot disagree.
export function requirePermission(ctx: RequestContext, resource: AdminResource, action?: AdminAction): User {
// The admin gate: a signed-in admin only. Each route already declares `permission: "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 {
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
const permission = permissionName(resource, action ?? actionForMethod(ctx.req.method ?? "GET"));
if (!can(ctx, permission)) throw new GuardError(403, `${permission} required`);
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin role required");
return user;
}
@@ -79,13 +49,13 @@ export async function guardedForm(ctx: RequestContext): Promise<URLSearchParams
// A themed "not found" (bad id/name in the path) rendered in the admin shell — 404, never a 500.
export function notFound(ctx: RequestContext): RouteResult {
return { data: { chrome: ctx.chrome, message: ctx.t("admin.notFound.message"), title: ctx.t("admin.notFound.title") }, status: 404, view: "notice" };
return { data: { chrome: ctx.chrome, message: "That item doesn't exist.", title: "Not found" }, status: 404, view: "notice" };
}
// A capability the plugin needs isn't on ctx.system (Ory not wired). Login already requires these in
// a real deployment, so this is the honest 503 fallback for a misconfigured host, not a crash.
export function unavailable(ctx: RequestContext, what: string): RouteResult {
return { data: { chrome: ctx.chrome, message: ctx.t("admin.unavailable.message", { what }), title: ctx.t("admin.unavailable.title") }, status: 503, view: "notice" };
return { data: { chrome: ctx.chrome, message: `${what} is not configured on this deployment.`, title: "Admin unavailable" }, status: 503, view: "notice" };
}
// Model for the shared destructive-confirm page (views/confirm.ejs). The view reads the shell fields
+69 -145
View File
@@ -4,9 +4,8 @@
// 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 { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type User } 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
const DEFAULT_PAGE_SIZE = 25;
@@ -31,6 +30,8 @@ export interface UserInput {
password: string;
}
const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
function nameParts(identity: Identity): { first: string; last: string } {
const nm = ((identity.traits?.name ?? {}) as { first?: unknown; last?: unknown });
return {
@@ -87,9 +88,9 @@ const SORT: Record<string, (u: UserView) => string> = {
status: (u) => u.state,
};
const COLUMNS = [
{ key: "name", label: "admin.users.column.name" },
{ key: "email", label: "admin.users.column.email" },
{ key: "status", label: "admin.users.column.status" },
{ key: "name", label: "Name" },
{ key: "email", label: "Email" },
{ key: "status", label: "Status" },
];
// Canonical list URL from the current state + per-link overrides; omits defaults so links stay tidy.
@@ -106,13 +107,10 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
}
export function buildUsersListModel(opts: {
canWrite?: boolean;
csrfToken?: string;
identities: Identity[];
t?: Translate;
url: URL | URLSearchParams | string;
}) {
const t = opts.t ?? ADMIN_EN;
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const status = query.filters.status?.[0] ?? "all";
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
@@ -135,71 +133,70 @@ export function buildUsersListModel(opts: {
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken, status };
return {
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.nav.section") }, { label: t("admin.users.title") }],
canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, all.length, t),
pagination: listPagination(state, page, t),
table: listTable(rows, state, sort, t),
title: t("admin.users.title"),
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Admin" }, { label: "Users" }],
filterBar: listFilterBar(state, all.length),
pagination: listPagination(state, page),
table: listTable(rows, state, sort),
title: "Users",
};
}
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) {
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
return {
actions: true,
caption: t("admin.users.title"),
caption: "Users",
columns: COLUMNS.map((c) => {
const dir = sort && sort.field === c.key ? sort.dir : undefined;
const next = dir === "asc" ? `-${c.key}` : c.key; // asc→desc, else→asc
return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
return { href: listHref(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true };
}),
rows: rows.map((u) => ({
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: "i-edit", label: t("common.edit") }],
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: "i-edit", label: "Edit" }],
cells: [
{ user: { initials: u.initials, name: u.name } },
u.email,
{ badge: { label: t(`admin.users.status.${u.state}`), tone: STATE_TONE[u.state] ?? "info" } },
{ badge: { label: cap(u.state), tone: STATE_TONE[u.state] ?? "info" } },
],
name: u.name,
})),
};
}
function listFilterBar(state: ListState, total: number, t: Translate) {
function listFilterBar(state: ListState, total: number) {
const pills: { label: string; remove: string; value: string }[] = [];
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
if (state.status !== "all") pills.push({ label: t("admin.users.status.label"), remove: listHref(state, { page: 1, status: "all" }), value: t(`admin.users.status.${state.status}`) });
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
if (state.status !== "all") pills.push({ label: "Status", remove: listHref(state, { page: 1, status: "all" }), value: cap(state.status) });
return {
applyLabel: t("filter.apply"), // an untranslated core key still resolves: the host catalog is the fallback
applyLabel: "Apply filters",
clearHref: ADMIN_USERS_BASE,
label: t("admin.users.filter"),
label: "Filter users",
pills,
rows: [[
{ label: t("admin.users.searchLabel"), name: "q", placeholder: t("admin.users.searchPlaceholder"), type: "search", value: state.q },
{ legend: t("admin.users.status.label"), name: "status", options: [
{ count: total, label: t("admin.users.status.all"), value: "all" },
{ label: t("admin.users.status.active"), value: "active" },
{ label: t("admin.users.status.inactive"), value: "inactive" },
{ label: "Search users", name: "q", placeholder: "Search name or email…", type: "search", value: state.q },
{ legend: "Status", name: "status", options: [
{ count: total, label: "All", value: "all" },
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
], type: "segmented", value: state.status },
{ type: "spacer" },
]],
};
}
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
if (state.status !== "all") hidden.push({ name: "status", value: state.status });
if (state.sort) hidden.push({ name: "sort", value: state.sort });
return {
label: t("admin.users.pagination"),
label: "Users pagination",
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
pages: page.pages.map((p) =>
p.ellipsis ? { ellipsis: true }
: p.current ? { current: true, label: String(p.page) }
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
@@ -219,16 +216,12 @@ export interface FieldConfig {
}
export function buildUserFormModel(opts: {
canWrite?: boolean; // false ⇒ a `users:read` holder: show the state, render no write affordance
csrfToken?: string;
error?: string;
identity?: Identity | null;
permissions?: PermissionPicker; // editing only — a user that doesn't exist yet can hold nothing
recovery?: RecoveryCode;
t?: Translate;
values?: Partial<UserInput>;
}) {
const t = opts.t ?? ADMIN_EN;
const editing = opts.identity != null;
const view = editing ? toUserView(opts.identity!) : null;
const np = editing ? nameParts(opts.identity!) : { first: opts.values?.first ?? "", last: opts.values?.last ?? "" };
@@ -236,30 +229,27 @@ export function buildUserFormModel(opts: {
const idPath = editing ? `${ADMIN_USERS_BASE}/${encodeURIComponent(view!.id)}` : ADMIN_USERS_BASE;
const fields: FieldConfig[] = [
{ autocomplete: "email", icon: "i-mail", id: "email", label: t("admin.users.field.email"), name: "email", required: !editing, type: "email", value: email,
...(editing ? { hint: t("admin.users.field.emailHint"), readonly: true } : {}) },
{ id: "first", label: t("admin.users.field.first"), name: "first", optional: true, value: np.first },
{ id: "last", label: t("admin.users.field.last"), name: "last", optional: true, value: np.last },
{ autocomplete: "email", icon: "i-mail", id: "email", label: "Email", name: "email", required: !editing, type: "email", value: email,
...(editing ? { hint: "The login identifier — can't be changed here.", readonly: true } : {}) },
{ id: "first", label: "First name", name: "first", optional: true, value: np.first },
{ id: "last", label: "Last name", name: "last", optional: true, value: np.last },
];
if (!editing) fields.push({ autocomplete: "new-password", hint: t("admin.users.field.passwordHint"), icon: "i-lock", id: "password", label: t("admin.users.field.password"), name: "password", optional: true, type: "password" });
if (!editing) fields.push({ autocomplete: "new-password", hint: "Optional — leave blank to have the user set one via a recovery code.", icon: "i-lock", id: "password", label: "Password", name: "password", optional: true, type: "password" });
const canWrite = opts.canWrite !== false;
return {
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: editing ? t("common.edit") : t("common.new") }],
canWrite, // the view drops every write affordance when false; the host already 403s the POSTs
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: editing ? "Edit" : "New" }],
edit: editing ? {
deleteAction: `${idPath}/delete`,
id: view!.id,
nextLabel: view!.state === "inactive" ? t("admin.users.reactivate") : t("admin.users.deactivate"),
nextLabel: view!.state === "inactive" ? "Reactivate" : "Deactivate",
recoveryAction: `${idPath}/recovery`,
state: view!.state,
stateAction: `${idPath}/state`,
} : undefined,
error: opts.error,
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? t("admin.users.save") : t("admin.users.create") },
permissions: editing ? opts.permissions : undefined,
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? "Save changes" : "Create user" },
recovery: opts.recovery,
title: editing ? t("admin.users.edit") : t("admin.users.new"),
title: editing ? "Edit user" : "New user",
};
}
@@ -274,42 +264,39 @@ function readUserInput(form: URLSearchParams): UserInput {
};
}
// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (`users:read` on a
// GET, `users:write` on a POST) and the Kratos capability (else a themed 503). Each route below is a
// thin handler over these.
// `keto` is optional the way every other capability here is: without it the page still lists and
// edits users, it just can't show the permission picker.
interface UsersDeps { ctx: RequestContext; keto: KetoClient | undefined; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
// 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; }
// Resolve the shared deps, then run `inner`. The route's own `permission` already gated at the host;
// `requirePermission` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
// Resolve the shared deps, then run `inner`. The route's `permission: "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) => {
const user = requirePermission(ctx, "users", action);
const user = requireAdmin(ctx);
const kratosAdmin = ctx.system?.kratosAdmin;
if (!kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.kratos"));
return inner({ ctx, keto: ctx.system?.keto, kratosAdmin, revoke: ctx.system?.revoke, user });
if (!kratosAdmin) return unavailable(ctx, "Kratos identity admin");
return inner({ ctx, kratosAdmin, revoke: ctx.system?.revoke, user });
};
}
// Same, plus the target identity from ctx.params.id (unknown id → themed 404). The router already
// decoded the id and 404s malformed %-encoding, so no manual decode is needed here.
function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) => Promise<RouteResult>): RouteHandler {
return withUser(async (deps) => {
const id = deps.ctx.params["id"] ?? "";
const identity = await deps.kratosAdmin.getIdentity(id);
if (!identity) return notFound(deps.ctx);
return inner(deps, identity, id);
}, action);
});
}
const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult =>
({ data: { chrome: ctx.chrome, model: buildUserFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "user-form" });
({ data: { chrome: ctx.chrome, model: buildUserFormModel({ csrfToken: ctx.chrome.csrfToken, ...extra }) }, view: "user-form" });
// GET /admin/users — the filtered/sorted/paged list.
export const usersList = withUser(async ({ ctx, kratosAdmin }) => {
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
return { data: { chrome: ctx.chrome, model: buildUsersListModel({ canWrite: canWriteUsers(ctx), csrfToken: ctx.chrome.csrfToken, identities, t: ctx.t, url: ctx.url }) }, view: "users" };
return { data: { chrome: ctx.chrome, model: buildUsersListModel({ csrfToken: ctx.chrome.csrfToken, identities, url: ctx.url }) }, view: "users" };
});
// POST /admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input.
@@ -318,7 +305,7 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
try {
await kratosAdmin.createIdentity(createIdentityPayload(input));
} catch (err) {
if (err instanceof KratosError) return { ...formResult(ctx, { error: createError(err, ctx.t), values: input }), status: 400 };
if (err instanceof KratosError) return { ...formResult(ctx, { error: createError(err), values: input }), status: 400 };
throw err;
}
ctx.log.info("admin: user created", { actor: user.id, email: input.email });
@@ -326,73 +313,18 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
});
// GET /admin/users/new — the empty create form.
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})), "write");
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})));
// GET /admin/users/:id — the edit form, prefilled.
export const usersEditForm = withTarget(async (deps, identity, id) => {
const permissions = await userPermissionPicker(deps, id);
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
});
const canWriteUsers = (ctx: RequestContext): boolean => can(ctx, permissionName("users", "write"));
// The checkbox list of declared permissions: ticked where this user holds one, and disabled where
// the grant comes from a group (real, but removed on that group). Undefined when Keto isn't wired —
// the rest of the edit page still works.
async function userPermissionPicker(deps: UsersDeps, id: string, error?: string): Promise<PermissionPicker | undefined> {
if (!deps.keto) return undefined;
const subject = userSubject(id);
const [direct, effective] = await Promise.all([
heldPermissions(deps.keto, subject),
effectivePermissions(deps.keto, subject, deps.ctx.declaredPermissions),
]);
return {
...buildPermissionPicker({
action: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}/permissions`,
declared: deps.ctx.declaredPermissions,
direct,
effective,
readOnly: !canWriteUsers(deps.ctx),
t: deps.ctx.t,
}),
...(error ? { error } : {}),
};
}
// POST /admin/users/:id/permissions — the submitted checkboxes are the desired set of *direct*
// grants; grant what's newly ticked, revoke what's newly unticked. A change to a user's own grants
// revokes their live tokens so it lands now rather than at the next re-mint.
export const usersPermissions = withTarget(async (deps, identity, id) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto"));
const subject = userSubject(id);
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
// Self-lockout guard, matching the self-deactivate/self-delete ones: revoking your own grants can
// remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very
// next request. Recovery would be a curl against Keto — not something the operator persona can do.
if (id === user.id && diff.revoke.length > 0) {
ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") });
const permissions = await userPermissionPicker(deps, id, ctx.t("admin.grants.selfRevoke"));
return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), identity, ...(permissions ? { permissions } : {}) }), status: 400 };
}
await applyGrants(keto, subject, diff);
if (diff.grant.length > 0 || diff.revoke.length > 0) {
revoke?.(id);
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: diff.grant.join(","), revoked: diff.revoke.join(","), target: id });
}
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
});
export const usersEditForm = withTarget((deps, identity) => Promise.resolve(formResult(deps.ctx, { identity })));
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
export const usersUpdate = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin } = deps;
export const usersUpdate = withTarget(async ({ ctx, kratosAdmin }, identity, id) => {
const input = readUserInput((await guardedForm(ctx))!);
try {
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
} catch (err) {
// Re-render with the picker, or the permissions section vanishes off the page on a failed save.
if (err instanceof KratosError) return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), error: ctx.t("admin.users.error.save"), identity, ...(await pickerOrNothing(deps, id)) }), status: 400 };
if (err instanceof KratosError) return { ...formResult(ctx, { error: "Could not save changes — check the fields and try again.", identity }), status: 400 };
throw err;
}
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
@@ -402,7 +334,7 @@ export const usersUpdate = withTarget(async (deps, identity, id) => {
// tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account.
export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
await guardedForm(ctx); // CSRF-verify the POST (no fields read)
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDeactivate"), identity }), status: 400 };
if (id === user.id) return { ...formResult(ctx, { error: "You can't deactivate your own account.", identity }), status: 400 };
const nextState = identity.state === "inactive" ? "active" : "inactive";
await kratosAdmin.updateIdentity(id, setStatePayload(identity, nextState));
if (nextState === "inactive") revoke?.(id);
@@ -412,21 +344,20 @@ export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user },
// GET /admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete.
export const usersDeleteConfirm = withTarget((deps, identity, id) => {
if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: deps.ctx.t("admin.users.error.selfDelete"), identity }), status: 400 });
if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: "You can't delete your own account.", identity }), status: 400 });
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`;
const view = toUserView(identity);
const tt = deps.ctx.t;
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: tt("admin.users.title") }, { href: back, label: view.name }, { label: tt("common.delete") }],
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: tt("admin.users.delete"),
message: tt("admin.users.deleteMessage", { email: view.email }), title: tt("admin.users.delete"),
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { href: back, label: view.name }, { label: "Delete" }],
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: "Delete user",
message: `Delete ${view.email}? This permanently removes the account and can't be undone.`, title: "Delete user",
}) }, view: "confirm" });
}, "write");
});
// POST /admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
await guardedForm(ctx); // CSRF-verify the POST
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDelete"), identity }), status: 400 };
if (id === user.id) return { ...formResult(ctx, { error: "You can't delete your own account.", identity }), status: 400 };
await kratosAdmin.deleteIdentity(id);
revoke?.(id);
ctx.log.info("admin: user deleted", { actor: user.id, target: id });
@@ -434,21 +365,14 @@ export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user },
});
// POST /admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
export const usersRecovery = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin } = deps;
export const usersRecovery = withTarget(async ({ ctx, kratosAdmin }, identity, id) => {
await guardedForm(ctx); // CSRF-verify the POST
const recovery = await kratosAdmin.createRecoveryCode(id);
return formResult(ctx, { canWrite: canWriteUsers(ctx), identity, recovery, ...(await pickerOrNothing(deps, id)) });
return formResult(ctx, { identity, recovery });
});
// The picker as a spreadable fragment, so a re-render never silently drops the section.
async function pickerOrNothing(deps: UsersDeps, id: string): Promise<{ permissions?: PermissionPicker }> {
const permissions = await userPermissionPicker(deps, id);
return permissions ? { permissions } : {};
}
function createError(err: KratosError, t: Translate): string {
function createError(err: KratosError): string {
return err.status === 409
? t("admin.users.error.duplicate")
: t("admin.users.error.create");
? "A user with that email already exists."
: "Could not create the user — check the email and try again.";
}
-136
View File
@@ -1,136 +0,0 @@
// The admin plugin's own catalog — the baseline its other locales are written against. Its keys
// are looked up before the host's, so this plugin owns its words without prefixing them.
const messages = {
"admin.capability.hydra": "Hydra OAuth2 admin",
"admin.capability.keto": "Keto and Kratos identity admin",
"admin.capability.kratos": "Kratos identity admin",
"admin.clients.column.id": "Client ID",
"admin.clients.column.name": "Name",
"admin.clients.column.type": "Type",
"admin.clients.confidential": "Confidential",
"admin.clients.consent.firstParty": "First-party (auto-granted)",
"admin.clients.consent.label": "Consent",
"admin.clients.consent.screen": "Shows the consent screen",
"admin.clients.created": "Client registered",
"admin.clients.createdNotice": "Client registered.",
"admin.clients.delete": "Delete client",
"admin.clients.deleteMessage": "Delete client {{name}}? Apps using it can no longer sign in through Plainpages.",
"admin.clients.error.rejected": "Hydra rejected the client — check the redirect URIs and scopes.",
"admin.clients.field.name": "Name",
"admin.clients.field.redirectUris": "Redirect URIs",
"admin.clients.field.redirectUrisHint": "One per line — where the app is sent back after sign-in.",
"admin.clients.field.scopes": "Scopes",
"admin.clients.field.scopesHint": "Space-separated scopes the client may request.",
"admin.clients.field.typeHint":
"Browser and mobile apps can't keep a secret — choose Public. Server-side apps that can store one — leave it Confidential.",
"admin.clients.filter": "Filter clients",
"admin.clients.pagination": "Clients pagination",
"admin.clients.public": "Public",
"admin.clients.publicPkce": "Public (PKCE)",
"admin.clients.register": "Register",
"admin.clients.registerClient": "Register client",
"admin.clients.registerTitle": "Register client",
"admin.clients.rereg": "To change a client, delete and re-register — this issues a new client ID and secret. The secret is shown only once, at registration.",
"admin.clients.searchLabel": "Search clients",
"admin.clients.searchPlaceholder": "Search name or client ID…",
"admin.clients.secret": "Client secret",
"admin.clients.secretHint": "Copy these now — the secret can't be shown again. Store them where the app reads its credentials.",
"admin.clients.title": "OAuth2 clients",
"admin.clients.validation.name": "Enter a name for the client.",
"admin.clients.validation.redirectUri": "\"{{uri}}\" is not a valid redirect URI — use an absolute URL like https://app.example.com/callback.",
"admin.clients.validation.redirectUris": "Add at least one redirect URI.",
"admin.common.chooseMember": "Choose a user or group…",
"admin.common.group": "Group",
"admin.common.member": "Member",
"admin.common.type": "Type",
"admin.common.user": "User",
"admin.grants.hint": "Which permissions exist is set by the plugins installed on this system. Tick to grant, untick to revoke.",
"admin.grants.hintReadOnly": "Which permissions exist is set by the plugins installed on this system. You can see these, but not change them.",
"admin.grants.inherited": "Greyed-out permissions come from a group. Change them on that group.",
"admin.grants.legend": "Permissions",
"admin.grants.none": "No installed plugin declares a permission, so there is nothing to grant.",
"admin.grants.pending": "Members get this at their next sign-in (up to 10 minutes).",
"admin.grants.save": "Save permissions",
"admin.grants.selfRevoke": "You can't revoke your own permissions — ask another administrator, so you can't lock yourself out.",
"admin.groups.actions": "Group actions",
"admin.groups.addMember": "Add a member",
"admin.groups.allMembers": "All users and groups are already members.",
"admin.groups.column.members": "Members",
"admin.groups.column.name": "Group",
"admin.groups.create": "Create group",
"admin.groups.delete": "Delete group",
"admin.groups.deleteMessage": "Delete group {{name}}? This can't be undone.",
"admin.groups.field.name": "Group name",
"admin.groups.field.nameHint": "Lowercase letters, digits, dashes and underscores.",
"admin.groups.filter": "Filter groups",
"admin.groups.firstMember": "First member",
"admin.groups.firstMemberHint": "A group exists once it has a member; add more after creating it.",
"admin.groups.members": "Members",
"admin.groups.membersOf": "Members of {{name}}",
"admin.groups.new": "New group",
"admin.groups.noMembers": "No members yet.",
"admin.groups.pagination": "Groups pagination",
"admin.groups.searchLabel": "Search groups",
"admin.groups.searchPlaceholder": "Search group name…",
"admin.groups.title": "Groups",
"admin.groups.validation.member": "Pick a member to add as the group's first member.",
"admin.groups.validation.name": "Group names use lowercase letters, digits, dashes and underscores.",
"admin.nav.clients": "OAuth2 clients",
"admin.nav.groups": "Groups",
"admin.nav.section": "Admin",
"admin.nav.users": "Users",
"admin.notFound.message": "That item doesn't exist.",
"admin.notFound.title": "Not found",
"admin.unavailable.message": "{{what}} is not configured on this deployment.",
"admin.unavailable.title": "Admin unavailable",
"admin.users.actions": "Account actions",
"admin.users.column.email": "Email",
"admin.users.column.name": "Name",
"admin.users.column.status": "Status",
"admin.users.confirm": "Confirm action",
"admin.users.create": "Create user",
"admin.users.deactivate": "Deactivate",
"admin.users.delete": "Delete user",
"admin.users.deleteMessage": "Delete {{email}}? This permanently removes the account and can't be undone.",
"admin.users.edit": "Edit user",
"admin.users.error.create": "Could not create the user — check the email and try again.",
"admin.users.error.duplicate": "A user with that email already exists.",
"admin.users.error.save": "Could not save changes — check the fields and try again.",
"admin.users.error.selfDeactivate": "You can't deactivate your own account.",
"admin.users.error.selfDelete": "You can't delete your own account.",
"admin.users.field.email": "Email",
"admin.users.field.emailHint": "The sign-in identifier — can't be changed here.",
"admin.users.field.first": "First name",
"admin.users.field.last": "Last name",
"admin.users.field.password": "Password",
"admin.users.field.passwordHint": "Optional — leave blank to have the user set one via a recovery code.",
"admin.users.filter": "Filter users",
"admin.users.new": "New user",
"admin.users.pagination": "Users pagination",
"admin.users.reactivate": "Reactivate",
"admin.users.recovery.body": "Give it to the user — they enter it to set a new password (generate a fresh one if it has expired):",
"admin.users.recovery.link": "the password-reset screen",
"admin.users.recovery.generate": "Generate recovery code",
"admin.users.recovery.title": "Recovery code generated",
"admin.users.save": "Save changes",
"admin.users.searchLabel": "Search users",
"admin.users.searchPlaceholder": "Search name or email…",
"admin.users.status.active": "Active",
"admin.users.status.all": "All",
"admin.users.status.inactive": "Inactive",
"admin.users.status.label": "Status",
"admin.users.title": "Users",
};
export type AdminMessages = typeof messages;
export default messages;
-134
View File
@@ -1,134 +0,0 @@
import type { AdminMessages } from "./en-US.ts";
const messages: AdminMessages = {
"admin.capability.hydra": "Hydra OAuth2-administration",
"admin.capability.keto": "Keto- och Kratos-identitetsadministration",
"admin.capability.kratos": "Kratos identitetsadministration",
"admin.clients.column.id": "Klient-ID",
"admin.clients.column.name": "Namn",
"admin.clients.column.type": "Typ",
"admin.clients.confidential": "Konfidentiell",
"admin.clients.consent.firstParty": "Förstapart (godkänns automatiskt)",
"admin.clients.consent.label": "Godkännande",
"admin.clients.consent.screen": "Visar godkännandesidan",
"admin.clients.created": "Klienten är registrerad",
"admin.clients.createdNotice": "Klienten är registrerad.",
"admin.clients.delete": "Radera klient",
"admin.clients.deleteMessage": "Ta bort klienten {{name}}? Appar som använder den kan inte längre logga in via Plainpages.",
"admin.clients.error.rejected": "Hydra nekade klienten — kontrollera omdirigerings-URI:erna och scopen.",
"admin.clients.field.name": "Namn",
"admin.clients.field.redirectUris": "Omdirigerings-URI:er",
"admin.clients.field.redirectUrisHint": "En per rad — dit appen skickas tillbaka efter inloggning.",
"admin.clients.field.scopes": "Scope",
"admin.clients.field.scopesHint": "Mellanslagsseparerade scope som klienten får begära.",
"admin.clients.field.typeHint":
"Webbläsar- och mobilappar kan inte hålla en hemlighet — välj Publik. Serverappar som kan lagra en — låt stå som Konfidentiell.",
"admin.clients.filter": "Filtrera klienter",
"admin.clients.pagination": "Sidnavigering för klienter",
"admin.clients.public": "Publik",
"admin.clients.publicPkce": "Publik (PKCE)",
"admin.clients.register": "Registrera",
"admin.clients.registerClient": "Registrera klient",
"admin.clients.registerTitle": "Registrera klient",
"admin.clients.rereg":
"För att ändra en klient: ta bort den och registrera på nytt — det ger ett nytt klient-ID och en ny hemlighet. Hemligheten visas bara en gång, vid registreringen.",
"admin.clients.searchLabel": "Sök klienter",
"admin.clients.searchPlaceholder": "Sök på namn eller klient-ID…",
"admin.clients.secret": "Klienthemlighet",
"admin.clients.secretHint": "Kopiera nu — hemligheten kan inte visas igen. Spara uppgifterna där appen läser dem.",
"admin.clients.title": "OAuth2-klienter",
"admin.clients.validation.name": "Ange ett namn för klienten.",
"admin.clients.validation.redirectUri": "\"{{uri}}\" är inte en giltig omdirigerings-URI — använd en absolut URL som https://app.example.com/callback.",
"admin.clients.validation.redirectUris": "Lägg till minst en omdirigerings-URI.",
"admin.common.chooseMember": "Välj en användare eller grupp…",
"admin.common.group": "Grupp",
"admin.common.member": "Medlem",
"admin.common.type": "Typ",
"admin.common.user": "Användare",
"admin.grants.hint": "Vilka behörigheter som finns bestäms av de plugins som är installerade. Kryssa i för att tilldela, ur för att återkalla.",
"admin.grants.hintReadOnly": "Vilka behörigheter som finns bestäms av de plugins som är installerade. Du kan se dem, men inte ändra dem.",
"admin.grants.inherited": "Gråmarkerade behörigheter kommer från en grupp. Ändra dem på gruppen.",
"admin.grants.legend": "Behörigheter",
"admin.grants.none": "Ingen installerad plugin deklarerar någon behörighet, så det finns inget att tilldela.",
"admin.grants.pending": "Medlemmar får detta vid nästa inloggning (upp till 10 minuter).",
"admin.grants.save": "Spara behörigheter",
"admin.grants.selfRevoke": "Du kan inte återkalla dina egna behörigheter — be en annan administratör, så att du inte låser ute dig själv.",
"admin.groups.actions": "Gruppåtgärder",
"admin.groups.addMember": "Lägg till en medlem",
"admin.groups.allMembers": "Alla användare och grupper är redan medlemmar.",
"admin.groups.column.members": "Medlemmar",
"admin.groups.column.name": "Grupp",
"admin.groups.create": "Skapa grupp",
"admin.groups.delete": "Radera grupp",
"admin.groups.deleteMessage": "Ta bort gruppen {{name}}? Det går inte att ångra.",
"admin.groups.field.name": "Gruppnamn",
"admin.groups.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
"admin.groups.filter": "Filtrera grupper",
"admin.groups.firstMember": "Första medlem",
"admin.groups.firstMemberHint": "En grupp finns så snart den har en medlem; lägg till fler efteråt.",
"admin.groups.members": "Medlemmar",
"admin.groups.membersOf": "Medlemmar i {{name}}",
"admin.groups.new": "Ny grupp",
"admin.groups.noMembers": "Inga medlemmar ännu.",
"admin.groups.pagination": "Sidnavigering för grupper",
"admin.groups.searchLabel": "Sök grupper",
"admin.groups.searchPlaceholder": "Sök på gruppnamn…",
"admin.groups.title": "Grupper",
"admin.groups.validation.member": "Välj en medlem som gruppens första medlem.",
"admin.groups.validation.name": "Gruppnamn använder små bokstäver, siffror, bindestreck och understreck.",
"admin.nav.clients": "OAuth2-klienter",
"admin.nav.groups": "Grupper",
"admin.nav.section": "Administration",
"admin.nav.users": "Användare",
"admin.notFound.message": "Objektet finns inte.",
"admin.notFound.title": "Hittades inte",
"admin.unavailable.message": "{{what}} är inte konfigurerat i den här installationen.",
"admin.unavailable.title": "Administrationen är otillgänglig",
"admin.users.actions": "Kontoåtgärder",
"admin.users.column.email": "E-postadress",
"admin.users.column.name": "Namn",
"admin.users.column.status": "Status",
"admin.users.confirm": "Bekräfta åtgärden",
"admin.users.create": "Skapa användare",
"admin.users.deactivate": "Inaktivera",
"admin.users.delete": "Radera användare",
"admin.users.deleteMessage": "Ta bort {{email}}? Kontot tas bort permanent och det går inte att ångra.",
"admin.users.edit": "Redigera användare",
"admin.users.error.create": "Användaren kunde inte skapas — kontrollera e-postadressen och försök igen.",
"admin.users.error.duplicate": "Det finns redan en användare med den e-postadressen.",
"admin.users.error.save": "Ändringarna kunde inte sparas — kontrollera fälten och försök igen.",
"admin.users.error.selfDeactivate": "Du kan inte inaktivera ditt eget konto.",
"admin.users.error.selfDelete": "Du kan inte ta bort ditt eget konto.",
"admin.users.field.email": "E-postadress",
"admin.users.field.emailHint": "Inloggningsidentiteten — den kan inte ändras här.",
"admin.users.field.first": "Förnamn",
"admin.users.field.last": "Efternamn",
"admin.users.field.password": "Lösenord",
"admin.users.field.passwordHint": "Frivilligt — lämna tomt så får användaren sätta det själv via en återställningskod.",
"admin.users.filter": "Filtrera användare",
"admin.users.new": "Ny användare",
"admin.users.pagination": "Sidnavigering för användare",
"admin.users.reactivate": "Aktivera igen",
"admin.users.recovery.body": "Ge den till användaren — koden anges för att sätta ett nytt lösenord (skapa en ny om den hunnit gå ut):",
"admin.users.recovery.link": "sidan för lösenordsåterställning",
"admin.users.recovery.generate": "Skapa återställningskod",
"admin.users.recovery.title": "Återställningskod skapad",
"admin.users.save": "Spara ändringar",
"admin.users.searchLabel": "Sök användare",
"admin.users.searchPlaceholder": "Sök på namn eller e-postadress…",
"admin.users.status.active": "Aktiv",
"admin.users.status.all": "Alla",
"admin.users.status.inactive": "Inaktiv",
"admin.users.status.label": "Status",
"admin.users.title": "Användare",
};
export default messages;
-62
View File
@@ -1,62 +0,0 @@
// The manifest's own invariants. A route gating on a permission the manifest doesn't declare is
// silent: bootstrap seeds only declared names, so the demo admin would simply 403 on that screen
// with nothing in the logs to explain it. Pin the two halves against each other here.
import assert from "node:assert/strict";
import { test } from "node:test";
import { isValidPermissionName } from "#plugin-api";
import manifest from "./plugin.ts";
const routes = manifest.routes ?? [];
const declared = (manifest.permissions ?? []).map((p) => p.name);
test("every route is gated, and gates on a permission the manifest declares", () => {
assert.ok(routes.length > 0);
for (const route of routes) {
assert.equal(route.public, undefined, `${route.method} ${route.path} must not be public`);
assert.ok(route.permission, `${route.method} ${route.path} has no permission`);
assert.ok(declared.includes(route.permission!), `${route.method} ${route.path} gates on undeclared ${route.permission}`);
}
});
test("the manifest declares no permission it never gates on", () => {
const gated = new Set(routes.map((r) => r.permission));
for (const name of declared) assert.ok(gated.has(name), `declared but unused: ${name}`);
});
// A nav permission is a plain string the host matches against the JWT claim: a typo ("user:read")
// passes discovery's shape check and silently hides that menu item forever. Same silent-failure
// class the route checks above close, so close it on the nav side too.
test("every nav permission is one the manifest declares", () => {
const navPermissions: string[] = [];
const walk = (nodes: typeof manifest.nav): void => {
for (const node of nodes ?? []) {
if (node.permission != null) navPermissions.push(node.permission);
walk(node.children);
}
};
walk(manifest.nav);
assert.equal(navPermissions.length, 3);
for (const name of navPermissions) assert.ok(declared.includes(name), `nav gates on undeclared ${name}`);
});
test("every declared permission is <resource>:<action>, and reads and writes are split per resource", () => {
for (const name of declared) assert.ok(isValidPermissionName(name), name); // the host's rule, not a copy of it
// Three screens × read/write. There is deliberately no `permissions:` pair: permissions are
// declared in plugin code, so holding one is edited on the user or group that holds it.
assert.deepEqual([...declared].sort(), [
"groups:read", "groups:write",
"oauth2-clients:read", "oauth2-clients:write",
"users:read", "users:write",
]);
});
test("GET routes gate on read and mutations on write, so a reader can open a screen but not change it", () => {
// …except a write-intent GET — a create form or a delete-confirm page, which exists only to start a
// write. Those gate on `:write` so a reader is refused there rather than at the submit.
const writeIntent = (path: string): boolean => path.endsWith("/new") || path.endsWith("/delete");
for (const route of routes) {
const action = route.method === "GET" && !writeIntent(route.path) ? "read" : "write";
assert.ok(route.permission?.endsWith(`:${action}`), `${route.method} ${route.path}${route.permission}`);
}
assert.equal(routes.filter((r) => r.method === "GET" && writeIntent(r.path)).length, 6); // 2 per screen
});
+42 -51
View File
@@ -1,4 +1,4 @@
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system.
// Admin example plugin: the Users / Groups / Roles / OAuth2-clients screens for running the system.
// These used to ship inside the core; they were extracted here so a fresh clone has no built-in admin
// GUI. Copy this folder to plugins/admin (then restart) to enable it — see README → Quick start.
//
@@ -8,67 +8,58 @@
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts";
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts";
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-roles.ts";
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
// One route factory per screen: a GET gates on `<resource>:read` and a POST on `<resource>:write`,
// derived through the same two helpers the in-handler guard uses, so the table below cannot drift
// from it. The host redirects an anonymous visitor to /login, gives a signed-in user missing the
// permission 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.
// `action` overrides the method's default for a *write-intent GET* — a create form or a
// delete-confirm page, which exists only to start a write and so refuses a reader rather than
// rendering a form whose submit would 403. The handler's own guard takes the same override.
const on = (resource: AdminResource) => (method: HttpMethod, path: string, handler: RouteHandler, action?: AdminAction): Route =>
({ handler, method, path, permission: permissionName(resource, action ?? actionForMethod(method)) });
const users = on("users");
const groups = on("groups");
const clients = on("oauth2-clients");
// Every admin route is gated by the one `admin` permission — 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 });
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: "View users and the permissions they hold", name: "users:read" },
{ description: "Create, edit and delete users, and grant them permissions", name: "users:write" },
{ description: "View groups, their members and the permissions they hold", name: "groups:read" },
{ description: "Create and delete groups, and change their members and permissions", name: "groups:write" },
{ description: "View OAuth2 clients", name: "oauth2-clients:read" },
{ description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" },
],
permissions: [{ description: "Administer users, groups, roles, and OAuth2 clients", token: ADMIN_PERMISSION }],
routes: [
// Users
users("GET", "/users", usersList),
users("POST", "/users", usersCreate),
users("GET", "/users/new", usersNewForm, "write"),
users("GET", "/users/:id", usersEditForm),
users("POST", "/users/:id", usersUpdate),
users("POST", "/users/:id/state", usersState),
users("GET", "/users/:id/delete", usersDeleteConfirm, "write"),
users("POST", "/users/:id/delete", usersDelete),
users("POST", "/users/:id/recovery", usersRecovery),
users("POST", "/users/:id/permissions", usersPermissions),
r("GET", "/users", usersList),
r("POST", "/users", usersCreate),
r("GET", "/users/new", usersNewForm),
r("GET", "/users/:id", usersEditForm),
r("POST", "/users/:id", usersUpdate),
r("POST", "/users/:id/state", usersState),
r("GET", "/users/:id/delete", usersDeleteConfirm),
r("POST", "/users/:id/delete", usersDelete),
r("POST", "/users/:id/recovery", usersRecovery),
// Groups
groups("GET", "/groups", groupsList),
groups("POST", "/groups", groupsCreate),
groups("GET", "/groups/new", groupsNewForm, "write"),
groups("GET", "/groups/:name", groupsDetail),
groups("POST", "/groups/:name/members", groupsAddMember),
groups("GET", "/groups/:name/delete", groupsDeleteConfirm, "write"),
groups("POST", "/groups/:name/delete", groupsDelete),
groups("POST", "/groups/:name/members/delete", groupsRemoveMember),
groups("POST", "/groups/:name/permissions", groupsPermissions),
r("GET", "/groups", groupsList),
r("POST", "/groups", groupsCreate),
r("GET", "/groups/new", groupsNewForm),
r("GET", "/groups/:name", groupsDetail),
r("POST", "/groups/:name/members", groupsAddMember),
r("GET", "/groups/:name/delete", groupsDeleteConfirm),
r("POST", "/groups/:name/delete", groupsDelete),
r("POST", "/groups/:name/members/delete", groupsRemoveMember),
// Roles
r("GET", "/roles", rolesList),
r("POST", "/roles", rolesCreate),
r("GET", "/roles/new", rolesNewForm),
r("GET", "/roles/:name", rolesDetail),
r("POST", "/roles/:name/members", rolesAddMember),
r("GET", "/roles/:name/delete", rolesDeleteConfirm),
r("POST", "/roles/:name/delete", rolesDelete),
r("POST", "/roles/:name/members/delete", rolesRemoveMember),
// OAuth2 clients
clients("GET", "/clients", clientsList),
clients("POST", "/clients", clientsCreate),
clients("GET", "/clients/new", clientsNewForm, "write"),
clients("GET", "/clients/:id", clientsDetail),
clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"),
clients("POST", "/clients/:id/delete", clientsDelete),
r("GET", "/clients", clientsList),
r("POST", "/clients", clientsCreate),
r("GET", "/clients/new", clientsNewForm),
r("GET", "/clients/:id", clientsDetail),
r("GET", "/clients/:id/delete", clientsDeleteConfirm),
r("POST", "/clients/:id/delete", clientsDelete),
],
});
@@ -3,7 +3,7 @@
shell. Doubles as the post-register page when `created`/`secret` are set.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/client-detail-body", { canWrite: model.canWrite, client: model.client, created: model.created, csrfToken: chrome.csrfToken, del: model.delete, secret: model.secret });
const body = include("partials/client-detail-body", { client: model.client, created: model.created, csrfToken: chrome.csrfToken, del: model.delete, secret: model.secret });
-%>
<%- include("partials/shell", {
body,
+2 -3
View File
@@ -1,13 +1,12 @@
<%#
OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as
the Groups screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
the Roles screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination);
// Only offer "Register client" to an oauth2-clients:write holder — a :read one would get the 403 page.
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/clients/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.clients.registerClient") + '</a>';
const actions = '<a class="btn btn-primary" href="/admin/clients/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Register client</a>';
-%>
<%- include("partials/shell", {
actions,
@@ -2,7 +2,7 @@
Group admin detail / membership page: the group-detail body in the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/group-detail-body", { add: model.add, canWrite: model.canWrite, csrfToken: model.csrfToken, del: model.delete, error: model.error, group: model.group, members: model.members, permissions: model.permissions });
const body = include("partials/group-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, error: model.error, group: model.group, members: model.members });
-%>
<%- include("partials/shell", {
body,
+1 -2
View File
@@ -6,8 +6,7 @@
const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination);
// Only offer "New group" to a groups:write holder — a groups:read one would get the 403 page.
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>';
const actions = '<a class="btn btn-primary" href="/admin/groups/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add group</a>';
-%>
<%- include("partials/shell", {
actions,
@@ -11,30 +11,28 @@
-%>
<div class="form-page">
<% if (locals.created) { -%>
<%- include("partials/alert", { text: t("admin.clients.createdNotice"), tone: "pos" }) %>
<%- include("partials/alert", { text: "Client registered.", tone: "pos" }) %>
<% } -%>
<% if (locals.secret) { -%>
<section class="form-card" aria-labelledby="secret-h">
<h2 class="card-title" id="secret-h"><%= t("admin.clients.secret") %></h2>
<p class="field-hint"><%= t("admin.clients.secretHint") %></p>
<div class="field"><label for="cid"><%= t("admin.clients.column.id") %></label><input class="input" id="cid" type="text" value="<%= c.id %>" readonly></div>
<div class="field"><label for="csecret"><%= t("admin.clients.secret") %></label><input class="input" id="csecret" type="text" value="<%= locals.secret %>" readonly></div>
<h2 class="card-title" id="secret-h">Client secret</h2>
<p class="field-hint">Copy these now — the secret can't be shown again. Store them where the app reads its credentials.</p>
<div class="field"><label for="cid">Client ID</label><input class="input" id="cid" type="text" value="<%= c.id %>" readonly></div>
<div class="field"><label for="csecret">Client secret</label><input class="input" id="csecret" type="text" value="<%= locals.secret %>" readonly></div>
</section>
<% } -%>
<section class="form-card" aria-labelledby="client-h">
<h2 class="card-title" id="client-h"><%= c.name %></h2>
<dl class="detail-list">
<dt><%= t("admin.clients.column.id") %></dt><dd><%= c.id %></dd>
<dt><%= t("admin.clients.column.type") %></dt><dd><%= c.public ? t("admin.clients.publicPkce") : t("admin.clients.confidential") %></dd>
<dt><%= t("admin.clients.consent.label") %></dt><dd><%= c.firstParty ? t("admin.clients.consent.firstParty") : t("admin.clients.consent.screen") %></dd>
<dt><%= t("admin.clients.field.scopes") %></dt><dd><%= c.scopes.length ? c.scopes.join(" ") : "—" %></dd>
<dt><%= t("admin.clients.field.redirectUris") %></dt><dd><% if (c.redirectUris.length) { %><ul class="plain-list"><% c.redirectUris.forEach((u) => { %><li><%= u %></li><% }) %></ul><% } else { %>—<% } %></dd>
<dt>Client ID</dt><dd><%= c.id %></dd>
<dt>Type</dt><dd><%= c.public ? "Public (PKCE)" : "Confidential" %></dd>
<dt>Consent</dt><dd><%= c.firstParty ? "First-party (auto-granted)" : "Shows the consent screen" %></dd>
<dt>Scopes</dt><dd><%= c.scopes.length ? c.scopes.join(" ") : "—" %></dd>
<dt>Redirect URIs</dt><dd><% if (c.redirectUris.length) { %><ul class="plain-list"><% c.redirectUris.forEach((u) => { %><li><%= u %></li><% }) %></ul><% } else { %>—<% } %></dd>
</dl>
</section>
<% if (locals.canWrite !== false) { -%>
<section class="form-card admin-actions" aria-label="<%= t("admin.clients.title") %>">
<p class="field-hint"><%= t("admin.clients.rereg") %></p>
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.clients.delete") %></a>
<section class="form-card admin-actions" aria-label="Client actions">
<p class="field-hint">To change a client, delete and re-register — this issues a new client ID and secret. The secret is shown only once, at registration.</p>
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete client</a>
</section>
<% } -%>
</div>
@@ -10,20 +10,20 @@
<% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<form class="form-card" method="post" action="<%= form.action %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("partials/field", form.nameField) %>
<div class="field">
<label for="redirectUris"><%= t("admin.clients.field.redirectUris") %></label>
<label for="redirectUris">Redirect URIs</label>
<textarea class="input" id="redirectUris" name="redirectUris" rows="3" placeholder="https://app.example.com/callback"><%= form.redirectUris %></textarea>
<span class="field-hint"><%= t("admin.clients.field.redirectUrisHint") %></span>
<span class="field-hint">One per line — where the app is sent back after sign-in.</span>
</div>
<%- include("partials/field", form.scopeField) %>
<label class="check"><input type="checkbox" name="public"<% if (form.public) { %> checked<% } %>> Public client (SPA / native app, PKCE — no secret)</label>
<span class="field-hint"><%= t("admin.clients.field.typeHint") %></span>
<span class="field-hint">Browser and mobile apps can't keep a secret — choose Public. Server-side apps that can store one — leave it Confidential.</span>
<label class="check"><input type="checkbox" name="firstParty"<% if (form.firstParty) { %> checked<% } %>> First-party (auto-grant consent — skip the consent screen)</label>
<div class="form-actions">
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
@@ -7,11 +7,11 @@
csrfToken
%>
<div class="form-page">
<section class="form-card admin-actions" aria-label="<%= t("admin.users.confirm") %>">
<section class="form-card admin-actions" aria-label="Confirm action">
<p><%= locals.message %></p>
<div class="form-actions">
<a class="btn" href="<%= localeHref(locals.cancelHref) %>"><%= t("common.cancel") %></a>
<form method="post" action="<%= localeHref(locals.confirm.action) %>"><input type="hidden" name="_csrf" value="<%= locals.csrfToken %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= locals.confirm.label %></button></form>
<a class="btn" href="<%= locals.cancelHref %>">Cancel</a>
<form method="post" action="<%= locals.confirm.action %>"><input type="hidden" name="_csrf" value="<%= locals.csrfToken %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= locals.confirm.label %></button></form>
</div>
</section>
</div>
@@ -1,7 +1,7 @@
<%#
Admin group membership body, captured into the shell content slot. Config:
group { name }
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = remove-member endpoint
members { action, rows: { kind:"group"|"user", label, subject }[] } action = remove-member endpoint
add { action, options: {label,value}[] } action = add-member endpoint
del { action } delete the whole group
csrfToken, error?
@@ -17,33 +17,26 @@
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<section class="form-card" aria-labelledby="members-h">
<h2 class="card-title" id="members-h"><%= t("admin.groups.members") %></h2>
<h2 class="card-title" id="members-h">Members</h2>
<% if (members.rows.length) { -%>
<div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: group.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody>
<div class="table-wrap"><table class="table"><caption class="sr-only">Members of <%= group.name %></caption><thead><tr><th scope="col">Member</th><th scope="col">Type</th><th class="col-actions" scope="col"><span class="sr-only">Actions</span></th></tr></thead><tbody>
<% members.rows.forEach((m) => { -%>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><% if (locals.canWrite !== false) { %><form method="post" action="<%= localeHref(members.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("common.remove") %></button></form><% } %></td></tr>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? "Group" : "User" %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg>Remove</button></form></td></tr>
<% }) -%>
</tbody></table></div>
<% } else { -%>
<p class="cell-muted"><%= t("admin.groups.noMembers") %></p>
<p class="cell-muted">No members yet.</p>
<% } -%>
</section>
<% if (locals.canWrite !== false) { -%>
<section class="form-card" aria-labelledby="add-h">
<h2 class="card-title" id="add-h"><%= t("admin.groups.addMember") %></h2>
<h2 class="card-title" id="add-h">Add a member</h2>
<% if (add.options.length) { -%>
<form class="inline-form" method="post" action="<%= localeHref(add.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("common.add") %></button></form>
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add</button></form>
<% } else { -%>
<p class="cell-muted"><%= t("admin.groups.allMembers") %></p>
<p class="cell-muted">All users and groups are already members.</p>
<% } -%>
</section>
<% } -%>
<% if (locals.permissions) { -%>
<%- include("partials/permission-picker", { csrfToken: csrf, permissions: locals.permissions }) %>
<% } -%>
<% if (locals.canWrite !== false) { -%>
<section class="form-card admin-actions" aria-label="<%= t("admin.groups.actions") %>">
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a>
<section class="form-card admin-actions" aria-label="Group actions">
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete group</a>
</section>
<% } -%>
</div>
@@ -10,16 +10,16 @@
<% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<form class="form-card" method="post" action="<%= form.action %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("partials/field", form.nameField) %>
<div class="field">
<label for="member"><%= t("admin.groups.firstMember") %></label>
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>><%= t("admin.common.chooseMember") %></option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
<span class="field-hint"><%= t("admin.groups.firstMemberHint") %></span>
<label for="member">First member</label>
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a member</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
<span class="field-hint">A group exists once it has a member; add more after creating it.</span>
</div>
<div class="form-actions">
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
@@ -1,49 +0,0 @@
<%#
The permission picker, shared by the user-edit and group-detail pages. A fieldset of checkboxes —
one per permission the installed plugins declare — ticked where this user/group holds it. The whole
set posts back, so what is submitted IS the desired set of *direct* grants (see admin-grants.ts).
Two rows never post, by design: an `inherited` one (the grant comes from a group, so it is changed
there) and every row when `readOnly` (the viewer holds :read but not :write). Neither can be diffed
into an accidental revoke, because grantDiff compares against the direct grants only.
Locals: csrfToken, permissions ({ action, choices, empty, error, field, hint, inheritedNote, legend, pending, readOnly, submit }).
%>
<section class="form-card" aria-labelledby="permissions-h">
<h2 class="card-title" id="permissions-h"><%= permissions.legend %></h2>
<% if (permissions.error) { -%>
<%- include("partials/alert", { text: permissions.error, tone: "neg" }) %>
<% } -%>
<% if (permissions.empty) { -%>
<p class="cell-muted"><%= permissions.empty %></p>
<% } else { -%>
<p class="cell-muted"><%= permissions.hint %></p>
<% if (permissions.readOnly) { -%>
<fieldset class="check-group">
<legend class="sr-only"><%= permissions.legend %></legend>
<% permissions.choices.forEach((c) => { -%>
<label class="check"><input type="checkbox"<%= c.checked ? " checked" : "" %> disabled><span><%= c.description || c.name %></span><span class="cell-muted"><%= c.name %></span></label>
<% }) -%>
</fieldset>
<% } else { -%>
<form method="post" action="<%= localeHref(permissions.action) %>">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<fieldset class="check-group">
<legend class="sr-only"><%= permissions.legend %></legend>
<% permissions.choices.forEach((c) => { -%>
<label class="check"><input type="checkbox" name="<%= permissions.field %>" value="<%= c.name %>"<%= c.checked ? " checked" : "" %><%= c.inherited ? " disabled" : "" %>><span><%= c.description || c.name %></span><span class="cell-muted"><%= c.name %></span></label>
<% }) -%>
</fieldset>
<div class="form-actions">
<button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><%= permissions.submit %></button>
</div>
</form>
<% } -%>
<% if (permissions.inheritedNote) { -%>
<p class="cell-muted"><%= permissions.inheritedNote %></p>
<% } -%>
<% if (permissions.pending) { -%>
<p class="cell-muted"><%= permissions.pending %></p>
<% } -%>
<% } -%>
</section>
@@ -0,0 +1,57 @@
<%#
Admin role detail body, captured into the shell content slot. Config:
role { name }
members { action, rows: { kind:"group"|"user", 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
csrfToken, error?
%><%
const role = locals.role;
const members = locals.members;
const effective = locals.effective;
const add = locals.add;
const del = locals.del;
const csrf = locals.csrfToken;
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<section class="form-card" aria-labelledby="members-h">
<h2 class="card-title" id="members-h">Assigned to</h2>
<% if (members.rows.length) { -%>
<div class="table-wrap"><table class="table"><caption class="sr-only">Members of <%= role.name %></caption><thead><tr><th scope="col">Member</th><th scope="col">Type</th><th class="col-actions" scope="col"><span class="sr-only">Actions</span></th></tr></thead><tbody>
<% members.rows.forEach((m) => { -%>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? "Group" : "User" %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg>Revoke</button></form></td></tr>
<% }) -%>
</tbody></table></div>
<% } else { -%>
<p class="cell-muted">Not assigned to anyone yet.</p>
<% } -%>
</section>
<section class="form-card" aria-labelledby="effective-h">
<h2 class="card-title" id="effective-h">Effective access</h2>
<p class="field-hint">Everyone who holds this role — directly or through a group (resolved by Keto).</p>
<% if (effective.length) { -%>
<ul class="plain-list">
<% effective.forEach((u) => { -%>
<li><span class="cell-strong"><%= u.label %></span></li>
<% }) -%>
</ul>
<% } else { -%>
<p class="cell-muted">No users hold this role yet.</p>
<% } -%>
</section>
<section class="form-card" aria-labelledby="add-h">
<h2 class="card-title" id="add-h">Assign the role</h2>
<% if (add.options.length) { -%>
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Assign</button></form>
<% } else { -%>
<p class="cell-muted">All users and groups already have this role.</p>
<% } -%>
</section>
<section class="form-card admin-actions" aria-label="Role actions">
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete role</a>
</section>
</div>
@@ -0,0 +1,26 @@
<%#
Admin role create form body, captured into the shell content slot. Config:
form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config,
memberOptions: {label,value}[], selectedMember }
error? string shown when a write was rejected
%><%
const form = locals.form;
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<form class="form-card" method="post" action="<%= form.action %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("partials/field", form.nameField) %>
<div class="field">
<label for="member">Assign to</label>
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a user or group…</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
<span class="field-hint">A role exists once assigned; add more users or groups after creating it.</span>
</div>
<div class="form-actions">
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
</div>
@@ -14,28 +14,23 @@
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<% if (recovery) { -%>
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong><%= t("admin.users.recovery.title") %></strong><span><%= t("admin.users.recovery.body") %> <a href="<%= localeHref("/recovery") %>"><%= t("admin.users.recovery.link") %></a></span><% if (recovery.code) { %><span class="recovery-code"><code><%= recovery.code %></code></span><% } %></div></div>
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong>Recovery code generated</strong><span>Give it to the user — they enter it on the <a href="/recovery">password-reset screen</a> to set a new password (generate a fresh one if it has expired).</span><% if (recovery.code) { %><span class="recovery-code"><code><%= recovery.code %></code></span><% } %></div></div>
<% } -%>
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<form class="form-card" method="post" action="<%= form.action %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<% form.fields.forEach((field) => { -%>
<%- include("partials/field", field) %>
<% }) -%>
<div class="form-actions">
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
<% if (locals.canWrite !== false) { -%>
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
<% } -%>
</div>
</form>
<% if (edit && locals.permissions) { -%>
<%- include("partials/permission-picker", { csrfToken: form.csrfToken, permissions: locals.permissions }) %>
<% } -%>
<% if (edit && locals.canWrite !== false) { -%>
<section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>">
<form method="post" action="<%= localeHref(edit.recoveryAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form>
<form method="post" action="<%= localeHref(edit.stateAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
<a class="btn btn-danger" href="<%= localeHref(edit.deleteAction) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.users.delete") %></a>
<% if (edit) { -%>
<section class="form-card admin-actions" aria-label="Account actions">
<form method="post" action="<%= edit.recoveryAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg>Generate recovery code</button></form>
<form method="post" action="<%= edit.stateAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
<a class="btn btn-danger" href="<%= edit.deleteAction %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete user</a>
</section>
<% } -%>
</div>
@@ -0,0 +1,16 @@
<%#
Role admin detail page: the role-detail body (members · effective access) in the shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/role-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, role: model.role });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -0,0 +1,16 @@
<%#
Role admin create page: the role-form body captured into the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/role-form-body", { error: model.error, form: model.form });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+21
View File
@@ -0,0 +1,21 @@
<%#
Roles admin list: the same building blocks as the Groups screen, around the shell, backed
by live Keto Role subject sets (admin-roles.ts). Filter/sort/page round-trip the URL.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination);
const actions = '<a class="btn btn-primary" href="/admin/roles/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add role</a>';
-%>
<%- include("partials/shell", {
actions,
body: filters + table + pager,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+1 -1
View File
@@ -2,7 +2,7 @@
Users admin create/edit page: the user-form body captured into the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/user-form-body", { canWrite: model.canWrite, edit: model.edit, error: model.error, form: model.form, permissions: model.permissions, recovery: model.recovery });
const body = include("partials/user-form-body", { edit: model.edit, error: model.error, form: model.form, recovery: model.recovery });
-%>
<%- include("partials/shell", {
body,
+1 -2
View File
@@ -6,8 +6,7 @@
const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination);
// Only offer "New user" to a users:write holder — a users:read one would get the 403 page.
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>';
const actions = '<a class="btn btn-primary" href="/admin/users/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add user</a>';
-%>
<%- include("partials/shell", {
actions,
+1 -5
View File
@@ -17,10 +17,6 @@ What it demonstrates:
reusing the core `field` partial.
- **Permission-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.
- **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including
the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and
the views carry the visitor's language onto their links with `localeHref()`.
(README → [Languages](../../../README.md#languages-i18n).)
The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and
`fetch` is injectable, so they unit-test as pure functions (`shifts.test.ts`).
@@ -50,6 +46,6 @@ cosmetically) — normalise to your backend's format there if it matters.
## Granting access
A user sees Scheduling once they hold the `scheduling:read` permission in Keto (and `scheduling:write`
A user sees Scheduling once they hold the `scheduling:read` role in Keto (and `scheduling:write`
to create). The one-command bootstrap grants both to the demo admin, so the seeded
`admin@plainpages.local` can use it immediately.
-40
View File
@@ -1,40 +0,0 @@
// This plugin's own catalog, and the baseline its other locales are written against. Keys are
// looked up here first and fall back to the host's, so a plugin owns its words without prefixing
// them, and `shifts.count` shows the plural form (host: README → Translating).
import type { PluralMessage } from "#plugin-api";
const messages = {
"scheduling.field.assignee": "Assignee",
"scheduling.field.end": "End",
"scheduling.field.start": "Start",
"scheduling.field.title": "Shift title",
"scheduling.filter.label": "Filter shifts",
"scheduling.filter.searchLabel": "Search shifts",
"scheduling.filter.searchPlaceholder": "Search title or assignee…",
"scheduling.form.submit": "Create shift",
"scheduling.nav.overview": "Overview",
"scheduling.nav.section": "Scheduling",
"scheduling.nav.shifts": "Shifts",
"scheduling.new.title": "New shift",
"scheduling.overview.lead":
"Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.",
"scheduling.overview.signIn": "Sign in to view shifts",
"scheduling.overview.title": "Scheduling",
"scheduling.overview.view": "View shifts",
"scheduling.shifts.count": { one: "{{count}} shift", other: "{{count}} shifts" } as PluralMessage,
"scheduling.shifts.new": "New shift",
"scheduling.shifts.title": "Shifts",
"scheduling.table.assignee": "Assignee",
"scheduling.table.end": "End",
"scheduling.table.shift": "Shift",
"scheduling.table.start": "Start",
"scheduling.upstream.create": "Couldn't save the shift — the scheduling service is unavailable.",
"scheduling.upstream.list": "Couldn't reach the scheduling service — try again shortly.",
"scheduling.validation.assignee": "Assign the shift to someone.",
"scheduling.validation.title": "A shift needs a title.",
};
export type SchedulingMessages = typeof messages;
export default messages;
-34
View File
@@ -1,34 +0,0 @@
import type { SchedulingMessages } from "./en-US.ts";
const messages: SchedulingMessages = {
"scheduling.field.assignee": "Tilldelad",
"scheduling.field.end": "Slut",
"scheduling.field.start": "Start",
"scheduling.field.title": "Passets namn",
"scheduling.filter.label": "Filtrera pass",
"scheduling.filter.searchLabel": "Sök pass",
"scheduling.filter.searchPlaceholder": "Sök på namn eller person…",
"scheduling.form.submit": "Skapa pass",
"scheduling.nav.overview": "Översikt",
"scheduling.nav.section": "Schemaläggning",
"scheduling.nav.shifts": "Pass",
"scheduling.new.title": "Nytt pass",
"scheduling.overview.lead":
"Schemaläggningen samordnar teamets pass. Alla kan läsa den här översikten; själva passlistan kräver behörigheten <code>scheduling:read</code>.",
"scheduling.overview.signIn": "Logga in för att se passen",
"scheduling.overview.title": "Schemaläggning",
"scheduling.overview.view": "Visa pass",
"scheduling.shifts.count": { one: "{{count}} pass", other: "{{count}} pass" },
"scheduling.shifts.new": "Nytt pass",
"scheduling.shifts.title": "Pass",
"scheduling.table.assignee": "Tilldelad",
"scheduling.table.end": "Slut",
"scheduling.table.shift": "Pass",
"scheduling.table.start": "Start",
"scheduling.upstream.create": "Passet kunde inte sparas — schemaläggningstjänsten är otillgänglig.",
"scheduling.upstream.list": "Vi når inte schemaläggningstjänsten — försök igen om en stund.",
"scheduling.validation.assignee": "Passet måste tilldelas någon.",
"scheduling.validation.title": "Passet behöver ett namn.",
};
export default messages;
+8 -9
View File
@@ -17,28 +17,27 @@ export default definePlugin({
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") },
// Merged into the global menu + filtered per user. Labels are keys in this plugin's own catalog
// (i18n/<locale>.ts) — a plain string works too, it just isn't translated. "Overview" is `public`, so the "Scheduling"
// Merged into the global menu + filtered per user. "Overview" is `public`, so the "Scheduling"
// header shows for everyone (even signed out); "Shifts" needs `scheduling:read`, so the gated data
// stays hidden until a reader signs in (a plugin may make a page + its menu option public).
nav: [{
children: [
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "scheduling.nav.overview", public: true },
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ },
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true },
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ },
],
icon: "i-cal",
id: "scheduling",
label: "scheduling.nav.section",
label: "Scheduling",
}],
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
// Tokens this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
permissions: [
{ description: "View shifts", name: READ },
{ description: "Create and edit shifts", name: WRITE },
{ description: "View shifts", token: READ },
{ description: "Create and edit shifts", token: WRITE },
],
// Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public`
// (anyone may reach /scheduling, signed in or not); the rest need a permission.
// (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 },
+9 -12
View File
@@ -4,23 +4,20 @@ import { Readable } from "node:stream";
import test from "node:test";
// Import only from the #plugin-api barrel — the same contract boundary shifts.ts uses (the host may
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api";
import enUS from "./i18n/en-US.ts";
import { GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api";
import {
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
} from "./shifts.ts";
const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
function fakeCtx(opts: { body?: string; roles?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
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, declaredPermissions: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
verifyCsrf: opts.verifyCsrf ?? (() => true),
chrome: CHROME, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
roles: opts.roles ?? [], url, user: null, verifyCsrf: opts.verifyCsrf ?? (() => true),
};
}
@@ -96,8 +93,8 @@ test("readInput trims; validate requires title + assignee", () => {
// ---- list handler ----
test("listShifts renders the upstream rows; q filters; canWrite reflects the permission", async () => {
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ permissions: ["scheduling:write"] })));
test("listShifts renders the upstream rows; q filters; canWrite reflects the role", async () => {
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ roles: ["scheduling:write"] })));
assert.equal(r.view, "shifts");
const table = r.data["table"] as { rows: { name: string }[] };
assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]);
@@ -115,15 +112,15 @@ test("listShifts degrades to a recoverable error page when the upstream is down
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
});
// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
// ---- public overview handler (a page anyone can reach, gated data stays behind the role) ----
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
const anon = asView(await overview()(fakeCtx())); // user null, no permissions
const anon = asView(await overview()(fakeCtx())); // user null, no roles
assert.equal(anon.view, "overview");
assert.equal(anon.data["chrome"], CHROME);
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] })));
const reader = asView(await overview()(fakeCtx({ roles: ["scheduling:read"] })));
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
});
+31 -49
View File
@@ -6,18 +6,12 @@
// pure functions against a mock upstream with no network (README.md → Local dev & test story).
// One import from the host's #plugin-api barrel — the stable author surface (see README.md → Building plugins).
import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "#plugin-api";
import enUS from "./i18n/en-US.ts";
// The plugin's own English (its catalog, then the host's), for a view model built outside a request:
// its unit tests. At runtime a handler passes ctx.t, which reads this catalog in the visitor's
// locale first, then the host's.
const EN: Translate = englishTranslator(enUS);
import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, tracedFetch } from "#plugin-api";
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
export const SHIFTS_PATH = "/scheduling/shifts";
export const READ = "scheduling:read"; // the permission gating the list + nav
export const WRITE = "scheduling:write"; // the permission gating create
export const READ = "scheduling:read"; // permission token gating the list + nav
export const WRITE = "scheduling:write"; // permission token gating create
export interface Shift {
id: string;
@@ -93,63 +87,58 @@ function toShift(raw: unknown): Shift {
// ---- view models (pure; the EJS views read these) -----------------------------------
export function buildListModel(opts: { canWrite: boolean; chrome: PageChrome; error?: string; q: string; shifts: Shift[]; t?: Translate }) {
const t = opts.t ?? EN;
export function buildListModel(opts: { canWrite: boolean; chrome: PageChrome; error?: string; q: string; shifts: Shift[] }) {
return {
breadcrumbs: [{ label: t("scheduling.shifts.title") }], // SHIFTS_PATH is the list itself; the form links back to it
breadcrumbs: [{ label: "Shifts" }], // SHIFTS_PATH is the list itself; the form links back to it as "Shifts"
canWrite: opts.canWrite,
chrome: opts.chrome,
// A plural message: one catalog key, the right form per locale and count (Intl.PluralRules).
count: t("scheduling.shifts.count", { count: opts.shifts.length }),
...(opts.error ? { error: opts.error } : {}),
filterBar: {
applyLabel: t("filter.search"),
applyLabel: "Search",
clearHref: SHIFTS_PATH,
label: t("scheduling.filter.label"),
pills: opts.q ? [{ label: t("filter.search"), remove: SHIFTS_PATH, value: opts.q }] : [],
label: "Filter shifts",
pills: opts.q ? [{ label: "Search", remove: SHIFTS_PATH, value: opts.q }] : [],
rows: [[
{ label: t("scheduling.filter.searchLabel"), name: "q", placeholder: t("scheduling.filter.searchPlaceholder"), type: "search", value: opts.q },
{ label: "Search shifts", name: "q", placeholder: "Search title or assignee…", type: "search", value: opts.q },
{ type: "spacer" },
]],
},
newHref: `${SHIFTS_PATH}/new`,
table: {
caption: t("scheduling.shifts.title"),
columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.assignee") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }],
caption: "Shifts",
columns: [{ label: "Shift" }, { label: "Assignee" }, { label: "Start" }, { label: "End" }],
rows: opts.shifts.map((s) => ({
cells: [{ rowHeader: { text: s.title } }, s.assignee, s.start, s.end],
name: s.title,
})),
},
title: t("scheduling.shifts.title"),
title: "Shifts",
};
}
export function buildFormModel(opts: { chrome: PageChrome; errors?: Record<string, string>; formError?: string; t?: Translate; values?: Partial<ShiftInput> }) {
const t = opts.t ?? EN;
export function buildFormModel(opts: { chrome: PageChrome; errors?: Record<string, string>; formError?: string; values?: Partial<ShiftInput> }) {
const v = opts.values ?? {};
const e = opts.errors ?? {};
const field = (cfg: { icon?: string; id: string; label: string; type?: string; value: string }) => ({
...cfg, name: cfg.id, ...(e[cfg.id] ? { error: e[cfg.id] } : {}), ...(cfg.id === "title" || cfg.id === "assignee" ? { required: true } : {}),
});
return {
breadcrumbs: [{ href: SHIFTS_PATH, label: t("scheduling.shifts.title") }, { label: t("scheduling.new.title") }],
breadcrumbs: [{ href: SHIFTS_PATH, label: "Shifts" }, { label: "New shift" }],
chrome: opts.chrome,
...(opts.formError ? { formError: opts.formError } : {}),
form: {
action: SHIFTS_PATH,
cancelHref: SHIFTS_PATH,
csrfToken: opts.chrome.csrfToken,
cancelLabel: t("common.cancel"),
fields: [
field({ icon: "i-cal", id: "title", label: t("scheduling.field.title"), value: v.title ?? "" }),
field({ icon: "i-user", id: "assignee", label: t("scheduling.field.assignee"), value: v.assignee ?? "" }),
field({ id: "start", label: t("scheduling.field.start"), type: "datetime-local", value: v.start ?? "" }),
field({ id: "end", label: t("scheduling.field.end"), type: "datetime-local", value: v.end ?? "" }),
field({ icon: "i-cal", id: "title", label: "Shift title", value: v.title ?? "" }),
field({ icon: "i-user", id: "assignee", label: "Assignee", value: v.assignee ?? "" }),
field({ id: "start", label: "Start", type: "datetime-local", value: v.start ?? "" }),
field({ id: "end", label: "End", type: "datetime-local", value: v.end ?? "" }),
],
submitLabel: t("scheduling.form.submit"),
submitLabel: "Create shift",
},
title: t("scheduling.new.title"),
title: "New shift",
};
}
@@ -166,10 +155,10 @@ export function readInput(form: URLSearchParams): ShiftInput {
// Required-field validation → { field: message } or null. Kept deliberately small; the upstream
// owns the real domain rules (overlap, capacity, …) and rejects with a 4xx the handler surfaces.
export function validate(input: ShiftInput, t: Translate = EN): Record<string, string> | null {
export function validate(input: ShiftInput): Record<string, string> | null {
const errors: Record<string, string> = {};
if (!input.title) errors["title"] = t("scheduling.validation.title");
if (!input.assignee) errors["assignee"] = t("scheduling.validation.assignee");
if (!input.title) errors["title"] = "A shift needs a title.";
if (!input.assignee) errors["assignee"] = "Assign the shift to someone.";
return Object.keys(errors).length ? errors : null;
}
@@ -184,32 +173,25 @@ export function listShifts(upstream: ShiftsUpstream): RouteHandler {
shifts = await upstream.list();
} catch (err) {
ctx.log.warn("scheduling upstream unreachable", { error: String(err) }); // plugin logging via ctx.log
error = ctx.t("scheduling.upstream.list");
error = "Couldn't reach the scheduling service — try again shortly.";
}
const needle = q.toLowerCase();
const rows = needle ? shifts.filter((s) => s.title.toLowerCase().includes(needle) || s.assignee.toLowerCase().includes(needle)) : shifts;
return { data: buildListModel({ canWrite: can(ctx, WRITE), chrome: ctx.chrome, ...(error ? { error } : {}), q, shifts: rows, t: ctx.t }), view: "shifts" };
return { data: buildListModel({ canWrite: can(ctx, WRITE), chrome: ctx.chrome, ...(error ? { error } : {}), q, shifts: rows }), view: "shifts" };
};
}
export function newShiftForm(): RouteHandler {
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" });
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome }), view: "shift-new" });
}
// 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 permission via can() (zero I/O).
// else a prompt to sign in. ctx.user may be null here, so read the role via can() (zero I/O).
export function overview(): RouteHandler {
return (ctx) => ({
data: {
breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }],
canRead: can(ctx, READ),
chrome: ctx.chrome,
shiftsHref: ctx.localeHref(SHIFTS_PATH), // a plugin carries the visitor's locale onto its own links
signInHref: ctx.localeHref(`/login?return_to=${encodeURIComponent(ctx.localeHref(SHIFTS_PATH))}`),
title: ctx.t("scheduling.overview.title"),
},
data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" },
view: "overview",
});
}
@@ -220,13 +202,13 @@ export function createShift(upstream: ShiftsUpstream): RouteHandler {
// A write is a first-party form, so guard it with the host's double-submit token (ctx.verifyCsrf).
if (!ctx.verifyCsrf(form.get(CSRF_FIELD))) throw new GuardError(403, "invalid CSRF token");
const input = readInput(form);
const errors = validate(input, ctx.t);
if (errors) return { data: buildFormModel({ chrome: ctx.chrome, errors, t: ctx.t, values: input }), status: 400, view: "shift-new" };
const errors = validate(input);
if (errors) return { data: buildFormModel({ chrome: ctx.chrome, errors, values: input }), status: 400, view: "shift-new" };
try {
await upstream.create(input);
} catch (err) {
ctx.log.warn("scheduling shift create failed (upstream)", { error: String(err) });
return { data: buildFormModel({ chrome: ctx.chrome, formError: ctx.t("scheduling.upstream.create"), t: ctx.t, values: input }), status: 502, view: "shift-new" };
return { data: buildFormModel({ chrome: ctx.chrome, formError: "Couldn't save the shift — the scheduling service is unavailable.", values: input }), status: 502, view: "shift-new" };
}
ctx.log.info("scheduling shift created", { assignee: input.assignee, title: input.title });
return { redirect: SHIFTS_PATH }; // POST-redirect-GET
@@ -3,16 +3,16 @@
nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for
everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
Data: chrome, title, breadcrumbs, canRead, shiftsHref, signInHref
Data: chrome, title, breadcrumbs, canRead, shiftsHref
%><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
const cta = canRead
? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>'
: '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>';
? '<a class="btn btn-primary" href="' + shiftsHref + '">View shifts</a>'
: '<a class="btn btn-primary" href="/login?return_to=' + encodeURIComponent(shiftsHref) + '">Sign in to view shifts</a>';
-%>
<%- include("partials/shell", {
actions: "",
body: '<div class="scheduling-page"><p>' + t("scheduling.overview.lead") + '</p>' + cta + '</div>',
body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> role.</p>' + cta + '</div>',
brand: chrome.brand,
breadcrumbs,
csrfToken: chrome.csrfToken,
@@ -1,7 +1,7 @@
<%#
A plugin's own partial (resolved before the core ones). The new-shift form body, reusing the core
`partials/field` + `partials/alert`. Config: form { action, csrfToken, submitLabel, cancelHref,
cancelLabel, fields: field.ejs config[] }, formError?
fields: field.ejs config[] }, formError?
%><%
const form = locals.form;
-%>
@@ -9,13 +9,13 @@
<% if (locals.formError) { -%>
<%- include("partials/alert", { text: locals.formError, tone: "neg" }) %>
<% } -%>
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<form class="form-card" method="post" action="<%= form.action %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<% form.fields.forEach((field) => { -%>
<%- include("partials/field", field) %>
<% }) -%>
<div class="form-actions">
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= form.cancelLabel %></a>
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
+3 -3
View File
@@ -3,19 +3,19 @@
service; this view renders them with the core building blocks inside the native app shell
(ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table,
alert) — see docs/plugin-contract.md. Zero-JS: search round-trips the URL.
Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error?
Data: chrome, title, breadcrumbs, filterBar, table, canWrite, newHref, error?
%><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
const filtersHtml = include("partials/filter-bar", filterBar);
const tableHtml = include("partials/data-table", table);
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
const actions = canWrite
? '<a class="btn btn-primary" href="' + localeHref(newHref) + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("scheduling.shifts.new") + '</a>'
? '<a class="btn btn-primary" href="' + newHref + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>New shift</a>'
: "";
-%>
<%- include("partials/shell", {
actions,
body: '<div class="scheduling-page">' + alertHtml + filtersHtml + '<p class="shift-count">' + count + '</p>' + tableHtml + '</div>',
body: '<div class="scheduling-page">' + alertHtml + filtersHtml + tableHtml + '</div>',
brand: chrome.brand,
breadcrumbs,
csrfToken: chrome.csrfToken,
View File
+1 -1
View File
@@ -1,4 +1,4 @@
# Ory Keto — authorization (ReBAC), the source of truth for permissions/groups and the rare
# Ory Keto — authorization (ReBAC), the source of truth for roles/groups and the rare
# fine-grained check (README: three tiers of "may I?"). The permission model lives in
# namespaces.keto.ts (OPL); DSN comes from the env (the per-service keto DB). The web
# app never connects directly — it calls the read (4466) / write (4467) APIs, the ports
+9 -11
View File
@@ -4,30 +4,28 @@
// identity ids (== the JWT `sub`).
import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types"
// A person. Ory calls this an "identity" (Kratos owns the record); Plainpages says "user"
// throughout. Subjects are written as `user:<kratos-identity-id>`.
// A human identity. Subjects are written as `user:<kratos-identity-id>`.
class User implements Namespace {}
// A named set of users (and nested groups), resolved transitively. The admin "Groups"
// screen manages membership; checks expand it automatically.
// 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">)[]
}
}
// A coarse permission — an operation a route or menu item gates on, and the source of truth
// for the JWT `permissions` claim. At login the app reads `Permission:<name>#granted@user:<id>`
// from Keto and projects the result into the token (README: Login → session JWT). A group can
// hold a permission, so grants go to a user or to a whole group.
class Permission implements Namespace {
// 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
// (README: Login → session JWT). A group can hold a role, so members can be users or groups.
class Role implements Namespace {
related: {
granted: (User | SubjectSet<Group, "members">)[]
members: (User | SubjectSet<Group, "members">)[]
}
}
// A fine-grained, relationship-checked resource — README's third "may I?" tier, the rare
// live Keto check (e.g. sharing/delegation). Permits nest: owner ⊇ editor ⊇ viewer.
// live Keto check (e.g. sharing/delegation). Permissions nest: owner ⊇ editor ⊇ viewer.
// Grants accept a user directly or any member of a group.
class Resource implements Namespace {
related: {
+2 -2
View File
@@ -41,7 +41,7 @@ selfservice:
ui_url: http://localhost:3000/login
after:
# After authenticating, land on our completion route — it mints the session JWT
# (permissions from Keto → metadata_public projection → tokenize) and sets our cookie.
# (roles from Keto → metadata_public projection → tokenize) and sets our cookie.
default_browser_return_url: http://localhost:3000/auth/complete
registration:
ui_url: http://localhost:3000/registration
@@ -94,7 +94,7 @@ session:
same_site: Lax
# Session→JWT tokenizer: whoami(tokenize_as: plainpages) mints a short-lived,
# locally-verifiable JWT so the hot path never calls Ory. Claims come from the
# committed Jsonnet mapper (sub = identity id, email from traits, permissions from the
# committed Jsonnet mapper (sub = identity id, email from traits, roles from the
# metadata_public projection); signed with tokenizer/jwks.json.
whoami:
tokenizer:
+3 -3
View File
@@ -1,7 +1,7 @@
// Session→JWT claims mapper for the `plainpages` tokenizer. Kratos exposes the
// session as `session`; `sub` is set from the identity id (subject_source: id) and
// can't be overridden here. permissions come from metadata_public — the per-login projection
// of Keto permissions the app refreshes at login (metadata_admin is NOT carried in the session
// can't be overridden here. roles come from metadata_public — the per-login projection
// of Keto roles the app refreshes at login (metadata_admin is NOT carried in the session
// the tokenizer sees; metadata_public is). Absent on a fresh identity ⇒ empty list.
local session = std.extVar('session');
local meta =
@@ -12,6 +12,6 @@ local meta =
{
claims: {
email: session.identity.traits.email,
permissions: if std.objectHas(meta, 'permissions') then meta.permissions else [],
roles: if std.objectHas(meta, 'roles') then meta.roles else [],
},
}
+15 -42
View File
@@ -126,7 +126,7 @@ summary { list-style: none; cursor: pointer; }
border-radius: 3px;
}
@media (prefers-reduced-motion: no-preference) {
.sidebar, .scrim, summary, .nav-item, .btn, .chip, .profile, .kebab { transition: .15s ease; }
.sidebar, .scrim, summary, .nav-item, .btn, .chip { transition: .15s ease; }
}
.sr-only {
@@ -282,6 +282,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
outline: 2px solid var(--focus); outline-offset: 1px;
}
/* profile / settings row */
.footer-actions { display: flex; align-items: center; gap: 4px; }
.profile {
display: flex; align-items: center; gap: 9px; flex: 1 1 auto;
@@ -474,44 +475,20 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.check input, .radio input { width: 15px; height: 15px; accent-color: var(--accent);
margin: 0; cursor: pointer; }
.check:hover, .radio:hover { color: var(--text); }
/* A stacked group of .check rows in a <fieldset> — the right element for related checkboxes, but the
UA gives it a groove border, so reset it like .filter-field and .menu-field do. A disabled row is
still readable: it states a fact (a permission held through a group) rather than offering an edit. */
.check-group { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.check-group .check { align-items: baseline; }
.check-group .check input:disabled { cursor: default; }
.check-group .check:has(input:disabled) { opacity: .7; cursor: default; }
.check-group .check .cell-muted { margin-left: auto; padding-left: 12px; font-size: var(--fz-xs); }
/* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover]
panel, wrapped so the pair is one element in any layout. The browser owns open/close, and the top
layer keeps the panel clear of a scrolling ancestor's overflow. `position-anchor: auto` binds it to
the button that opened it; a bare anchor() without it resolves to nothing in every engine. `color`
is set because the UA sheet gives [popover] a CanvasText of its own, which would ignore the theme. */
.menu { display: inline-flex; }
/* popover menu (column settings, kebab, etc.) — pure <details> */
.menu { position: relative; display: inline-flex; }
.menu > summary { display: inline-flex; }
.menu > summary::after { content: none; }
.menu-pop {
position: absolute; inset: auto; margin: 6px 0 0;
position-anchor: auto;
position-try-fallbacks: flip-block, flip-inline;
top: anchor(bottom); right: anchor(right);
position: absolute; top: calc(100% + 6px); right: 0; z-index: 40;
min-width: 210px; padding: 6px;
background: var(--surface); color: var(--text);
border: 1px solid var(--border-2); border-radius: var(--radius);
background: var(--surface); border: 1px solid var(--border-2);
border-radius: var(--radius);
box-shadow: 0 8px 28px rgba(0,0,0,.16);
}
.menu-pop.left { right: auto; left: anchor(left); }
.menu-pop.up { top: auto; bottom: anchor(top); margin: 0 0 6px; }
/* A browser too old for the Popover API leaves the trigger inert, so let each panel flow under its
trigger rather than stranding Sign out and the language picker behind a button that does nothing.
The wrapper turns block so the pair stacks instead of sitting side by side, and min-width goes —
it would otherwise push the sidebar and a 44px action cell far past their width. Cramped but
reachable, and unreachable by any test: a browser with both features cannot render this path.
Both are guarded — an engine with popover but no anchor positioning would otherwise leave the
panel absolutely positioned with every anchor() dropped, detached from its trigger. */
@supports not (selector([popover]:popover-open) and (position-anchor: auto)) {
.menu { display: block; }
.menu-pop, .menu-pop.up { position: static; min-width: 0; margin: 6px 0 0; }
}
.menu-pop.left { right: auto; left: 0; }
.menu-pop.up { top: auto; bottom: calc(100% + 6px); }
.menu-head { font-size: var(--fz-xs); text-transform: uppercase;
letter-spacing: .05em; color: var(--text-faint); font-weight: 600;
padding: 5px 8px; }
@@ -523,7 +500,6 @@ span.nav-self { cursor: default; } /* static / non-clickable */
}
.menu-item-form { display: contents; } /* form wraps the Sign-out button without changing layout */
.menu-item:hover { background: var(--surface-2); }
.menu-item[aria-current] { font-weight: 600; color: var(--text); } /* the language you are in */
.menu-item.danger { color: var(--neg); }
.menu-item .ico { color: var(--text-faint); }
.menu-item.danger .ico { color: var(--neg); }
@@ -634,13 +610,10 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
/* row kebab */
.col-actions { width: 44px; text-align: center; }
.kebab { width: 26px; height: 26px; border-radius: var(--radius);
display: grid; place-items: center; color: var(--text-faint); margin: 0 auto;
background: transparent; border: 0; padding: 0; cursor: pointer; }
.kebab:hover { background: var(--surface-2); color: var(--text); }
/* Held open — a trigger's panel is its next sibling. Its own rule: :has() is non-forgiving, so an
engine that doesn't know :popover-open would drop the hover style along with it. */
.kebab:has(+ .menu-pop:popover-open) { background: var(--surface-2); color: var(--text); }
.kebab summary { width: 26px; height: 26px; border-radius: var(--radius);
display: grid; place-items: center; color: var(--text-faint); margin: 0 auto; }
.kebab summary:hover { background: var(--surface-2); color: var(--text); }
.kebab[open] summary { background: var(--surface-2); color: var(--text); }
/* ---------- 10. PAGINATION --------------------------------- */
.pager {
+25 -33
View File
@@ -1,11 +1,11 @@
// One-command bootstrap: idempotent first-boot seeding. Guards the pure payload
// builders (Kratos create-identity body + Keto permission tuple), the idempotent seedAdmin
// builders (Kratos create-identity body + Keto role tuple), the idempotent seedAdmin
// orchestration (fresh 201 vs existing 409 → reuse id), and the JWKS generate-if-absent
// safety net. Live boot is verified by running the stack; these catch contract drift.
import { test } from "node:test";
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, seedAdmin, seedPermissions } from "./bootstrap.ts";
import { ensureJwks, firstRunBanner, identityPayload, roleTuple, seedAdmin, seedRoles } from "./bootstrap.ts";
const json = (status: number, body?: unknown) =>
new Response(body === undefined ? null : JSON.stringify(body), {
@@ -20,35 +20,27 @@ test("identityPayload is a valid Kratos create-identity body with a password cre
assert.equal(body.credentials.password.config.password, "admin");
});
test("permissionTuple grants a permission to user:<id> in the Permission namespace", () => {
test("roleTuple grants a role to user:<id> in the Role namespace", () => {
const id = randomUUID();
assert.deepEqual(permissionTuple(id, "admin"), {
namespace: "Permission",
assert.deepEqual(roleTuple(id, "admin"), {
namespace: "Role",
object: "admin",
relation: "granted",
relation: "members",
subject_id: `user:${id}`,
});
});
test("seedPermissions unions ADMIN_PERMISSIONS (empty by default) with the discovered plugins' declared permissions", () => {
// Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two names → the demo admin
// holds exactly what the installed plugins gate on, derived from discovery, not hardcoded here.
assert.deepEqual(seedPermissions(undefined, ["scheduling:read", "scheduling:write"]), ["scheduling:read", "scheduling:write"]);
// No plugins → nothing to grant. A host-invented base would be a permission that gates nothing.
assert.deepEqual(seedPermissions(undefined, []), []);
assert.deepEqual(seedPermissions("ops:read, ops:write ", ["inventory:read"]), ["ops:read", "ops:write", "inventory:read"]); // env trimmed + extended
assert.deepEqual(seedPermissions("scheduling:read", ["scheduling:read"]), ["scheduling:read"]); // dedup, no double grant
assert.deepEqual(seedPermissions(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides)
test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared tokens", () => {
// 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"]);
assert.deepEqual(seedRoles(undefined, []), ["admin"]); // no plugins → just the base admin role
assert.deepEqual(seedRoles("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended
assert.deepEqual(seedRoles("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant
assert.deepEqual(seedRoles("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides)
});
test("seedPermissions refuses an ADMIN_PERMISSIONS name that isn't <resource>:<action>", () => {
// The operator's env is the one remaining hand-typed path; a manifest's names were checked at
// discovery. `admin` would otherwise write a tuple that gates nothing, with no error anywhere.
assert.throws(() => seedPermissions("admin", []), /ADMIN_PERMISSIONS.*<resource>:<action>.*admin/s);
assert.throws(() => seedPermissions("users:read,Bad Name", []), /Bad Name/);
});
test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => {
test("seedAdmin on a fresh stack creates the identity and grants every role (one tuple each)", async () => {
const id = randomUUID();
const calls: { method: string; url: string; body?: unknown }[] = [];
const fetchImpl = (async (url, init) => {
@@ -65,20 +57,20 @@ test("seedAdmin on a fresh stack creates the identity and grants every permissio
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
password: "admin",
permissions: ["admin", "scheduling:read"],
roles: ["admin", "scheduling:read"],
});
assert.deepEqual(result, { created: true, id, permissions: ["admin", "scheduling:read"] });
assert.deepEqual(result, { created: true, id, roles: ["admin", "scheduling:read"] });
const puts = calls.filter((c) => c.url.includes("relation-tuples"));
assert.equal(puts.length, 2); // one grant per permission
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: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` },
{ namespace: "Permission", object: "scheduling:read", relation: "granted", subject_id: `user:${id}` },
{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` },
{ namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `user:${id}` },
]);
});
test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the permission", async () => {
test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the role", async () => {
const id = randomUUID();
let granted: unknown;
const fetchImpl = (async (url, init) => {
@@ -98,11 +90,11 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
password: "admin",
permissions: ["admin"],
roles: ["admin"],
});
assert.deepEqual(result, { created: false, id, permissions: ["admin"] });
assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` });
assert.deepEqual(result, { created: false, id, roles: ["admin"] });
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` });
});
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
@@ -114,7 +106,7 @@ test("seedAdmin fails loud on an unexpected Kratos error", async () => {
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
password: "admin",
permissions: ["admin"],
roles: ["admin"],
}),
/Kratos/,
);
+26 -35
View File
@@ -2,14 +2,13 @@
// 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 permissions in Keto so menu/permission checks resolve out of the box — every
// discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so a dropped-in
// plugin is usable by the demo admin with no host config edit (the host stays plugin-agnostic).
// 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
// 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";
import { fileURLToPath } from "node:url";
import { discoverPlugins } from "../plugin-host/discovery.ts";
import { declaredPermissions, isValidPermissionName } from "../plugin-host/plugin.ts";
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
import { createLogger, runWithLog, tracedFetch } from "../logger.ts";
@@ -23,27 +22,19 @@ export function identityPayload(email: string, password: string) {
};
}
// Coarse-permission grant: `Permission:<permission>#members@user:<id>`. Subject ids are `user:<kratos-id>`
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT permissions.
export function permissionTuple(userId: string, permission: string) {
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
// Coarse-role grant: `Role:<role>#members@user:<id>`. Subject ids are `user:<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}` };
}
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, empty by default)
// unioned with every discovered plugin's declared permission names (a route/nav `permission` is a
// coarse permission — granted as a Keto `Permission:<name>#granted` tuple). So the host names no plugin, yet a
// dropped-in plugin's permissions are seeded out of the box. Deduped, order-stable, blanks dropped.
// The base is empty because permissions are `<resource>:<action>` and every one of them is owned by
// the plugin that gates on it — a host-invented default would gate nothing.
// ADMIN_PERMISSIONS is the one place an operator names a permission by hand, so it is held to the
// same `<resource>:<action>` rule discovery applies to a manifest — fail loud rather than write a
// tuple that gates nothing. A declared name has already passed that check at discovery.
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredNames: string[]): string[] {
// 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
// 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[] {
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
const configured = clean((adminPermissionsEnv ?? "").split(","));
const bad = configured.filter((name) => !isValidPermissionName(name));
if (bad.length > 0) throw new Error(`bootstrap: ADMIN_PERMISSIONS must be <resource>:<action> names, e.g. "things:read"; got ${bad.join(", ")}`);
return [...new Set([...configured, ...clean(declaredNames)])];
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredTokens)])];
}
// --- JWKS safety net -----------------------------------------------------------------
@@ -72,13 +63,13 @@ export interface SeedOptions {
ketoWriteUrl: string;
kratosAdminUrl: string;
password: string;
permissions: string[];
roles: string[];
}
export interface SeedResult {
created: boolean;
id: string;
permissions: string[];
roles: string[];
}
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
@@ -102,17 +93,17 @@ export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
throw new Error(`bootstrap: Kratos create identity failed (${res.status}): ${await res.text()}`);
}
// Grant each permission in Keto. PUT is idempotent — re-running just re-asserts the tuple.
for (const permission of opts.permissions) {
// Grant each role in Keto. PUT is idempotent — re-running just re-asserts the tuple.
for (const role of opts.roles) {
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, {
body: JSON.stringify(permissionTuple(id, permission)),
body: JSON.stringify(roleTuple(id, role)),
headers: { "content-type": "application/json" },
method: "PUT",
});
if (!grant.ok) throw new Error(`bootstrap: Keto grant permission "${permission}" failed (${grant.status}): ${await grant.text()}`);
if (!grant.ok) throw new Error(`bootstrap: Keto grant role "${role}" failed (${grant.status}): ${await grant.text()}`);
}
return { created, id, permissions: opts.permissions };
return { created, id, roles: opts.roles };
}
async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
@@ -131,7 +122,7 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
const rule = "─".repeat(58);
return [
`${rule}`,
`│ Plainpages is ready — sign in at ${opts.appUrl}`,
`│ Plainpages is ready — log in at ${opts.appUrl}`,
`│ email: ${opts.email}`,
`│ password: ${opts.password}`,
`│ ⚠ Demo admin credentials — change them before production.`,
@@ -152,10 +143,10 @@ 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 every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the
// Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared permission tokens, so the
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
const declared = declaredPermissions(await discoverPlugins()).map((decl) => decl.name);
const permissions = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.token));
const roles = seedRoles(env["ADMIN_ROLES"], declared);
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin";
const result = await seedAdmin({
@@ -164,9 +155,9 @@ async function main() {
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
password,
permissions,
roles,
});
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") });
log.info("admin seeded", { created: result.created, id: result.id, roles: result.roles.join(", ") });
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
});
+3 -3
View File
@@ -1,12 +1,12 @@
// Optional revocation denylist: instant permission/session revoke without putting Keto
// Optional revocation denylist: instant role/session revoke without putting Keto
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
//
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked permission or a
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked role or a
// killed session only takes effect when the token is next minted (re-login / TTL refresh) —
// up to one token TTL of lag. For security-critical revoke (offboarding, a compromised
// account) that lag is too long. An admin action records the subject as revoked-now and the
// hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which
// re-reads permissions from Keto, or clears a now-dead session).
// re-reads roles from Keto, or clears a now-dead session).
//
// Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it
// stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
+7 -11
View File
@@ -34,11 +34,9 @@ test("maps a password login flow: csrf hidden, themed email/password fields, a s
assert.equal(view.method, "post");
assert.deepEqual(view.hidden, [{ name: "csrf_token", value: "tok123" }]);
// Visible fields carry label, type, required, autocomplete + a themed input icon. The label is
// ours (auth.field.identifier) rather than Kratos' wording — Kratos' generic trait-label id is
// ambiguous, so field labels are keyed on the input name.
// Visible fields carry label, type, required, autocomplete + a themed input icon.
assert.equal(view.fields.length, 2);
assert.deepEqual(view.fields[0], { autocomplete: "username", icon: "i-mail", id: "field-identifier", label: "Email", name: "identifier", required: true, type: "email" });
assert.deepEqual(view.fields[0], { autocomplete: "username", icon: "i-mail", id: "field-identifier", label: "E-Mail", name: "identifier", required: true, type: "email" });
assert.equal(view.fields[1]?.icon, "i-lock");
assert.equal(view.fields[1]?.type, "password");
@@ -55,7 +53,7 @@ test("maps a password login flow: csrf hidden, themed email/password fields, a s
assert.equal(view.messages.length, 0);
});
test("maps field errors and flow-level messages by tone, translating the ids we cover", () => {
test("maps field errors and flow-level messages by tone", () => {
const view = buildFlowView(
flow(
[
@@ -67,15 +65,13 @@ test("maps field errors and flow-level messages by tone, translating the ids we
"login",
);
// Submitted value is preserved; the node's error rides on the field — with our wording for the
// id (4000002), since Kratos writes "Property password is missing." for every required field.
// Submitted value is preserved; the node's error rides on the field.
assert.equal(view.fields[0]?.value, "taken@example.com");
assert.deepEqual(view.fields[0]?.error, { text: "This field is required." });
assert.deepEqual(view.fields[0]?.error, { text: "This email is already in use." });
// Flow messages map error→neg, info→info (success→pos covered by the tone map). A mapped id
// (4000006) is replaced; an id we hold no key for keeps Kratos' own text.
// Flow messages map error→neg, info→info (success→pos covered by the tone map).
assert.deepEqual(view.messages, [
{ text: "The credentials are invalid. Check for typos in your email address or password.", tone: "neg" },
{ text: "The provided credentials are invalid.", tone: "neg" },
{ text: "Check your email.", tone: "info" },
]);
});
+14 -44
View File
@@ -4,8 +4,6 @@
// configured `oidc` provider. The form posts straight back to `flow.ui.action`, so Kratos
// owns its CSRF; we only render and map errors. No providers configured ⇒ no SSO buttons.
import { ENGLISH } from "../i18n/english.ts";
import type { Translate } from "../i18n/translate.ts";
import type { Flow, FlowType, UiNode } from "./kratos-public.ts";
export interface FlowField {
@@ -68,39 +66,14 @@ export const AUTH_FLOWS: Record<string, FlowType> = {
"/verification": "verification",
};
// Where each flow's card links; its words come from the catalog under `auth.<flow>.*`.
const LINKS: Record<FlowType, { alt?: string; back?: boolean }> = {
login: { alt: "/registration" },
recovery: { alt: "/login", back: true },
registration: { alt: "/login" },
settings: {},
verification: { back: true },
const CHROME: Record<FlowType, FlowChrome> = {
login: { alt: { href: "/registration", label: "Create one", text: "Don't have an account?" }, sub: "Welcome back. Enter your details to continue.", title: "Sign in" },
recovery: { alt: { href: "/login", label: "Sign in", text: "Remembered it?" }, back: { href: "/login", label: "Back to sign in" }, sub: "Enter your email and we'll send you a recovery code.", title: "Reset password" },
registration: { alt: { href: "/login", label: "Sign in", text: "Already have an account?" }, sub: "Get started — it only takes a minute.", title: "Create account" },
settings: { sub: "Update your account details.", title: "Account settings" },
verification: { back: { href: "/login", label: "Back to sign in" }, sub: "Enter the code we sent you.", title: "Verify your email" },
};
function chromeFor(type: FlowType, t: Translate): FlowChrome {
const links = LINKS[type];
return {
...(links.alt ? { alt: { href: links.alt, label: t(`auth.${type}.altLabel`), text: t(`auth.${type}.altText`) } } : {}),
...(links.back ? { back: { href: "/login", label: t(`auth.${type}.back`) } } : {}),
sub: t(`auth.${type}.sub`),
title: t(`auth.${type}.title`),
};
}
// A string Kratos authored (a field label, a button, a validation message). Kratos writes English
// and tags it with a stable numeric id, so the first key we hold a translation for wins and
// anything unmapped keeps Kratos' own words — never a bare key on screen.
function kratosText(t: Translate, fallback: string, ...keys: (string | undefined)[]): string {
for (const key of keys) {
if (key === undefined) continue;
const text = t(key);
if (text !== key) return text;
}
return fallback;
}
const idKey = (id: number | undefined): string | undefined => (id === undefined ? undefined : `kratos.${id}`);
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
// Themed input icon by field semantics; undefined ⇒ no icon.
@@ -120,7 +93,7 @@ function tone(type: string): FlowMessage["tone"] {
const ssoLogo = (value: string): string => (value.charAt(0) || "?").toUpperCase();
function toField(node: UiNode, name: string, type: string, t: Translate): FlowField {
function toField(node: UiNode, name: string, type: string): FlowField {
const value = str(node.attributes["value"]);
// The recovery/verification one-time code: numeric, and Kratos doesn't trim it, so a stray pasted
// space makes it reject the code as "invalid". A digits-only pattern + numeric keypad block that in
@@ -131,13 +104,11 @@ function toField(node: UiNode, name: string, type: string, t: Translate): FlowFi
const errorMsg = node.messages.find((m) => m.type === "error");
return {
id: "field-" + name.replace(/[^a-z0-9]+/gi, "-"),
// Kratos' generic trait label (id 1070002) is "Email" here and "First name" on a schema with
// that trait, so a field falls back to its input name — the one thing that is unambiguous.
label: kratosText(t, node.meta.label?.text ?? name, idKey(node.meta.label?.id), `auth.field.${name}`),
label: node.meta.label?.text ?? name,
name,
type,
...(autocomplete ? { autocomplete } : {}),
...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}),
...(errorMsg ? { error: { text: errorMsg.text } } : {}),
...(icon ? { icon } : {}),
...(isCode ? { inputmode: "numeric", pattern: "[0-9]*" } : {}),
...(node.attributes["required"] === true ? { required: true } : {}),
@@ -145,7 +116,7 @@ function toField(node: UiNode, name: string, type: string, t: Translate): FlowFi
};
}
export function buildFlowView(flow: Flow, type: FlowType, t: Translate = ENGLISH): FlowView {
export function buildFlowView(flow: Flow, type: FlowType): FlowView {
const hidden: { name: string; value: string }[] = [];
const fields: FlowField[] = [];
const buttons: FlowButton[] = [];
@@ -165,10 +136,9 @@ export function buildFlowView(flow: Flow, type: FlowType, t: Translate = ENGLISH
hidden.push({ name, value: str(node.attributes["value"]) ?? "" });
} else if (inputType === "submit" || inputType === "button") {
const value = str(node.attributes["value"]);
const label = kratosText(t, node.meta.label?.text ?? t("auth.continue"), idKey(node.meta.label?.id));
buttons.push({ label, ...(name ? { name } : {}), ...(value != null ? { value } : {}) });
buttons.push({ label: node.meta.label?.text ?? "Continue", ...(name ? { name } : {}), ...(value != null ? { value } : {}) });
} else {
fields.push(toField(node, name, inputType, t));
fields.push(toField(node, name, inputType));
}
}
@@ -177,10 +147,10 @@ export function buildFlowView(flow: Flow, type: FlowType, t: Translate = ENGLISH
buttons,
fields,
hidden,
messages: (flow.ui.messages ?? []).map((m) => ({ text: kratosText(t, m.text, idKey(m.id)), tone: tone(m.type) })),
messages: (flow.ui.messages ?? []).map((m) => ({ text: m.text, tone: tone(m.type) })),
method: flow.ui.method || "post",
sso,
...(type === "login" ? { recoverHref: "/recovery" } : {}),
...chromeFor(type, t),
...CHROME[type],
};
}
+1 -1
View File
@@ -48,7 +48,7 @@ test("rotateJwks --prune keeps only the newest (first) key, dropping superseded
test("a JWS signed with a generated key verifies via our own verifier (reads what Kratos signs)", () => {
const key = generateJwks().keys[0]!;
const head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid }));
const body = b64url(JSON.stringify({ email: "a@b.c", permissions: [], sub: key.kid }));
const body = b64url(JSON.stringify({ email: "a@b.c", roles: [], sub: key.kid }));
const sig = sign("SHA256", Buffer.from(`${head}.${body}`), { dsaEncoding: "ieee-p1363", key: createPrivateKey({ key: key as unknown as JsonWebKey, format: "jwk" }) });
const token = `${head}.${body}.${sig.toString("base64url")}`;
+2 -2
View File
@@ -12,7 +12,7 @@ function ctxFor(user: User | null, url = "/"): RequestContext {
return buildContext(req, new ServerResponse(req), { user });
}
const alice: User = { email: "a@b.c", id: "u1", permissions: ["admin", "scheduling:read"] };
const alice: User = { 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);
@@ -30,7 +30,7 @@ test("requireSession returns the user, or throws GuardError(401)→/login (prese
err instanceof GuardError && err.location === "/login?return_to=%2Fscheduling%2Fshifts%3Fq%3D1");
});
test("can reads a coarse permission from the JWT claims; anonymous has none", () => {
test("can reads a coarse role from the JWT claims; anonymous has none", () => {
assert.equal(can(ctxFor(alice), "admin"), true);
assert.equal(can(ctxFor(alice), "billing:write"), false);
assert.equal(can(ctxFor(null), "admin"), false);
+3 -3
View File
@@ -37,9 +37,9 @@ export function requireSession(ctx: RequestContext): User {
return ctx.user;
}
// Coarse permission check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
export function can(ctx: RequestContext, permission: string): boolean {
return ctx.permissions.includes(permission);
// Coarse role check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
export function can(ctx: RequestContext, role: string): boolean {
return ctx.roles.includes(role);
}
// Live Keto relationship check at the point of action. The subject is the current user;
+7 -7
View File
@@ -22,11 +22,11 @@ const jwk2: JsonWebKey = { ...(k2.publicKey.export({ format: "jwk" }) as JsonWeb
const jwks = staticJwks([jwk1, jwk2]); // rotated set: two live keys
const NOW = 1_700_000_000; // fixed clock for deterministic exp/nbf checks
const valid = { email: "a@b.c", exp: NOW + 600, permissions: ["admin"], sub: "u1" };
const valid = { email: "a@b.c", exp: NOW + 600, roles: ["admin"], sub: "u1" };
test("verifyToken: a valid token → User, selecting the verify key by kid across a rotated set", async () => {
const user = await verifyToken(mint(k2.privateKey, "k2", valid), jwks, { now: NOW });
assert.deepEqual(user, { email: "a@b.c", id: "u1", permissions: ["admin"] });
assert.deepEqual(user, { email: "a@b.c", id: "u1", roles: ["admin"] });
});
test("verifyToken requires exp, rejects expiry and future nbf, with clock-skew leeway", async () => {
@@ -59,18 +59,18 @@ 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 permissions to [], keeps only string permissions", () => {
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" }).permissions, []); // permissions absent
assert.deepEqual(claimsToUser({ email: "a@b.c", permissions: ["a", 1, "b"], sub: "u" }).permissions, ["a", "b"]);
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("resolveSession classifies the cookie; authenticate is its fail-closed user 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", permissions: ["admin"] };
const user = { 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 });
@@ -96,6 +96,6 @@ test("verifyToken honours an optional denylist: a revoked subject's token reject
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 });
// 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", permissions: ["admin"] });
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 });
});
+4 -4
View File
@@ -59,15 +59,15 @@ 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); permissions defaults to [] and
// 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 {
const sub = payload["sub"];
if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub");
const email = payload["email"];
if (typeof email !== "string" || email === "") throw new TokenError("token missing email");
const permissions = payload["permissions"];
return { email, id: sub, permissions: Array.isArray(permissions) ? permissions.filter((r): r is string => typeof r === "string") : [] };
const roles = payload["roles"];
return { email, id: sub, roles: Array.isArray(roles) ? roles.filter((r): r is string => typeof r === "string") : [] };
}
// Verify a session JWT end-to-end: select the key by `kid`, check the signature, validate
@@ -80,7 +80,7 @@ export async function verifyToken(token: string, jwks: JwksProvider, options: Ve
validateClaims(verified.payload, options);
const user = claimsToUser(verified.payload);
// Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so
// resolveSession routes it through the re-mint (fresh permissions from Keto, or a cleared session).
// 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);
return user;
}
+4 -4
View File
@@ -22,10 +22,10 @@ const rsaJwk = rsa.publicKey.export({ format: "jwk" }) as JsonWebKey;
const ecJwk = ec.publicKey.export({ format: "jwk" }) as JsonWebKey;
test("verifies an RS256 token, returning the decoded header + payload", () => {
const token = makeJws("RS256", rsa.privateKey, { permissions: ["admin"], sub: "u" });
const token = makeJws("RS256", rsa.privateKey, { roles: ["admin"], sub: "u" });
const verified = verifyJws(token, rsaJwk);
assert.equal(verified.header.alg, "RS256");
assert.deepEqual(verified.payload, { permissions: ["admin"], sub: "u" });
assert.deepEqual(verified.payload, { roles: ["admin"], sub: "u" });
});
test("verifies an ES256 token (raw r‖s signature)", () => {
@@ -35,10 +35,10 @@ test("verifies an ES256 token (raw r‖s signature)", () => {
// All three reach and fail the signature check itself, not an earlier structural guard.
test("rejects a signature that fails verification (tampered payload, wrong key, empty)", () => {
const token = makeJws("RS256", rsa.privateKey, { permissions: ["user"], sub: "u" });
const token = makeJws("RS256", rsa.privateKey, { roles: ["user"], sub: "u" });
const [header, payload, signature] = token.split(".");
const forged = `${header}.${b64url(JSON.stringify({ permissions: ["admin"], sub: "u" }))}.${signature}`;
const forged = `${header}.${b64url(JSON.stringify({ roles: ["admin"], sub: "u" }))}.${signature}`;
assert.throws(() => verifyJws(forged, rsaJwk), /invalid signature/);
const otherJwk = generateKeyPairSync("rsa", { modulusLength: 2048 }).publicKey.export({ format: "jwk" }) as JsonWebKey;
+14 -14
View File
@@ -29,13 +29,13 @@ const keto = (fetchImpl: typeof fetch) => createKetoClient({ fetchImpl, readUrl:
test("check GETs the read API and returns the allowed boolean (true and false)", async () => {
const allow = recorder(() => res(200, { allowed: true }));
assert.equal(await keto(allow.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }), true);
assert.equal(await keto(allow.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }), true);
assert.match(allow.calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/check\?/);
assert.match(allow.calls[0]!.url, /namespace=Permission&object=admin&relation=granted/);
assert.match(allow.calls[0]!.url, /namespace=Role&object=admin&relation=members/);
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: "Permission", object: "admin", relation: "granted", subject_id: "user:nobody" }), false);
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: "user:nobody" }), false);
});
test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => {
@@ -51,20 +51,20 @@ test("check on a subject_set builds subject_set.* params and forwards max-depth"
test("check throws a KetoError carrying the status on an unexpected response", async () => {
await assert.rejects(
keto((async () => res(400, { error: "bad" })) as typeof fetch).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }),
keto((async () => res(400, { error: "bad" })) as typeof fetch).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }),
(e: unknown) => e instanceof KetoError && e.status === 400,
);
});
test("listRelations builds the filter query + pagination and parses next_page_token", async () => {
const tuples = [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }];
const tuples = [{ namespace: "Role", object: "admin", relation: "members", subject_id: USER }];
const { calls, fetchImpl } = recorder(() => res(200, { next_page_token: "NEXT", relation_tuples: tuples }));
const out = await keto(fetchImpl).listRelations({ namespace: "Permission", object: "admin", pageSize: 10, pageToken: "CUR", relation: "granted" });
const out = await keto(fetchImpl).listRelations({ namespace: "Role", object: "admin", pageSize: 10, pageToken: "CUR", relation: "members" });
assert.deepEqual(out.tuples, tuples);
assert.equal(out.nextPageToken, "NEXT");
const url = calls[0]!.url;
assert.match(url, /^http:\/\/keto:4466\/relation-tuples\?/);
assert.match(url, /namespace=Permission&object=admin&relation=granted/);
assert.match(url, /namespace=Role&object=admin&relation=members/);
assert.match(url, /page_size=10&page_token=CUR/);
// No Link header / token in the body ⇒ null, empty list ⇒ [].
const empty = await keto((async () => res(200, {})) as typeof fetch).listRelations();
@@ -72,16 +72,16 @@ test("listRelations builds the filter query + pagination and parses next_page_to
});
test("expand GETs the read API for a subject set and returns the tree (with max-depth)", async () => {
const tree = { children: [{ tuple: { namespace: "", object: "", relation: "", subject_id: USER }, type: "leaf" }], tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Permission", object: "admin", relation: "granted" } }, type: "union" };
const tree = { children: [{ tuple: { namespace: "", object: "", relation: "", subject_id: USER }, type: "leaf" }], tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Role", object: "admin", relation: "members" } }, type: "union" };
const { calls, fetchImpl } = recorder(() => res(200, tree));
const out = await keto(fetchImpl).expand({ namespace: "Permission", object: "admin", relation: "granted" }, { maxDepth: 3 });
const out = await keto(fetchImpl).expand({ namespace: "Role", object: "admin", relation: "members" }, { maxDepth: 3 });
assert.deepEqual(out, tree);
assert.match(calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/expand\?/);
assert.match(calls[0]!.url, /namespace=Permission&object=admin&relation=granted&max-depth=3/);
assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members&max-depth=3/);
});
test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx throws)", async () => {
const tuple = { namespace: "Permission", object: "admin", relation: "granted", subject_id: USER };
const tuple = { namespace: "Role", object: "admin", relation: "members", subject_id: USER };
const { calls, fetchImpl } = recorder(() => res(201, tuple));
await keto(fetchImpl).writeTuple(tuple);
assert.equal(calls[0]!.method, "PUT");
@@ -95,12 +95,12 @@ test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx th
test("deleteTuple DELETEs the write API by query params (204 resolves; non-204 throws)", async () => {
const { calls, fetchImpl } = recorder(() => res(204));
await keto(fetchImpl).deleteTuple({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER });
await keto(fetchImpl).deleteTuple({ namespace: "Role", object: "admin", relation: "members", subject_id: USER });
assert.equal(calls[0]!.method, "DELETE");
assert.match(calls[0]!.url, /^http:\/\/keto:4467\/admin\/relation-tuples\?/);
assert.match(calls[0]!.url, /namespace=Permission&object=admin&relation=granted/);
assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members/);
await assert.rejects(
keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Permission", object: "x", relation: "granted", subject_id: USER }),
keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Role", object: "x", relation: "members", subject_id: USER }),
(e: unknown) => e instanceof KetoError && e.status === 404,
);
});
+1 -1
View File
@@ -13,7 +13,7 @@ export interface SubjectSet {
}
// A relationship tuple — the wire shape for writes and the filter shape for reads. Subject
// is `subject_id` xor `subject_set` (never both). Mirrors bootstrap.ts's permissionTuple.
// is `subject_id` xor `subject_set` (never both). Mirrors bootstrap.ts's roleTuple.
export interface RelationTuple {
namespace: string;
object: string;
+4 -4
View File
@@ -1,5 +1,5 @@
// Kratos admin-API client: typed fetch wrappers over Ory Kratos' admin endpoints —
// identity CRUD + the surgical metadata_public update the login flow projects permissions into.
// identity CRUD + the surgical metadata_public update the login flow projects roles into.
// Guards the request contracts (URLs, method, JSON-Patch body, query/pagination) and the
// result mapping (201/200/404/4xx). Live wiring is verified by login completion.
import { test } from "node:test";
@@ -90,13 +90,13 @@ test("updateIdentity PUTs the full body to /admin/identities/<id> and returns th
});
test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it never clobbers traits", async () => {
const identity = { id: ID, metadata_public: { permissions: ["admin"] } };
const identity = { id: ID, metadata_public: { roles: ["admin"] } };
const { calls, fetchImpl } = recorder(() => res(200, identity));
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { permissions: ["admin"] });
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { roles: ["admin"] });
assert.deepEqual(out, identity);
assert.equal(calls[0]!.method, "PATCH");
assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`));
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { permissions: ["admin"] } }]);
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { roles: ["admin"] } }]);
});
test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => {
+5 -5
View File
@@ -1,6 +1,6 @@
// Kratos admin-API client: typed `fetch` wrappers over Ory Kratos' admin endpoints
// (internal-only admin port) — identity CRUD + the surgical `metadata_public` update login
// completion projects Keto permissions into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
// completion projects Keto roles into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
// `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
import { KratosError } from "./kratos-public.ts";
@@ -33,7 +33,7 @@ export interface RecoveryCode {
export interface KratosAdmin {
createIdentity(payload: unknown): Promise<Identity>;
createRecoveryCode(userId: string, opts?: { expiresIn?: string }): Promise<RecoveryCode>;
createRecoveryCode(identityId: string, opts?: { expiresIn?: string }): Promise<RecoveryCode>;
deleteIdentity(id: string): Promise<void>;
getIdentity(id: string): Promise<Identity | null>;
listIdentities(opts?: ListOptions): Promise<IdentityList>;
@@ -67,8 +67,8 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
// Mint a recovery code for an identity (admin "trigger recovery") — the link is mailed to the
// user by Kratos; the code/link are also returned so an operator can hand them over directly.
async createRecoveryCode(userId, opts = {}) {
const body: Record<string, unknown> = { identity_id: userId };
async createRecoveryCode(identityId, opts = {}) {
const body: Record<string, unknown> = { identity_id: identityId };
if (opts.expiresIn) body.expires_in = opts.expiresIn;
const res = await http(`${base}/admin/recovery/code`, { body: JSON.stringify(body), headers: json, method: "POST" });
if (res.status !== 200 && res.status !== 201) return fail("create recovery code", res);
@@ -106,7 +106,7 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
},
// JSON Patch `add` sets metadata_public whether it's currently absent, null, or set, and
// touches nothing else — so the login permission projection never clobbers traits/state.
// touches nothing else — so the login role projection never clobbers traits/state.
// (metadata_public, not _admin: the session the tokenizer sees carries only public metadata.)
async updateMetadataPublic(id, metadata) {
const patch = [{ op: "add", path: "/metadata_public", value: metadata }];
+20 -20
View File
@@ -1,4 +1,4 @@
// Login completion: turn a Kratos session into our session JWT — read permissions from Keto,
// Login completion: turn a Kratos session into our session JWT — read roles from Keto,
// project them onto the identity, tokenize, build the cookie. Fakes the three Ory clients;
// the live, full-stack login is verified by the Playwright E2E.
import { test } from "node:test";
@@ -6,10 +6,10 @@ import assert from "node:assert/strict";
import type { KetoClient, RelationTuple } from "./keto-client.ts";
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
import type { KratosPublic, Session } from "./kratos-public.ts";
import { completeLogin, readPermissions, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
import { completeLogin, readRoles, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
const permissionTuple = (object: string): RelationTuple => ({ namespace: "Permission", object, relation: "granted", subject_id: `user:${ID}` });
const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `user:${ID}` });
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
check: async () => false,
@@ -40,32 +40,32 @@ const publicStub = (over: Partial<KratosPublic> = {}): KratosPublic => ({
...over,
});
test("readPermissions returns permissions held directly OR transitively (enumerate defined permissions → Keto-check each)", async () => {
test("readRoles returns roles held directly OR transitively (enumerate defined roles → Keto-check each)", async () => {
const listQ: unknown[] = [];
const checked: string[] = [];
const permission = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Permission", object, relation: "granted", ...subject });
const role = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Role", object, relation: "members", ...subject });
const keto = ketoStub({
// Enumerate every Permission tuple (paged, no subject filter) to find the distinct permission names —
// Enumerate every Role tuple (paged, no subject filter) to find the distinct role names —
// 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: [permission("editor", { subject_id: "user:other" })] };
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [role("editor", { subject_id: "user:other" })] };
return { nextPageToken: "p2", tuples: [
permission("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
permission("admin", { subject_id: `user:${ID}` }),
permission("viewer", { subject_id: "user:stranger" }),
role("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
role("admin", { subject_id: `user:${ID}` }),
role("viewer", { subject_id: "user:stranger" }),
] };
},
// Keto resolves transitively: the user holds editor (via a group) + admin (direct), not viewer.
check: async (t) => { checked.push(t.object); return t.object === "admin" || t.object === "editor"; },
});
assert.deepEqual(await readPermissions(keto, ID), ["admin", "editor"]);
assert.deepEqual(listQ[0], { namespace: "Permission", relation: "granted" }); // enumerate, not subject-filtered
assert.deepEqual(await readRoles(keto, ID), ["admin", "editor"]);
assert.deepEqual(listQ[0], { namespace: "Role", relation: "members" }); // enumerate, not subject-filtered
assert.equal((listQ[1] as { pageToken?: string }).pageToken, "p2"); // second page follows the cursor
assert.deepEqual(checked.sort(), ["admin", "editor", "viewer"]); // every distinct permission checked for the user
assert.deepEqual(checked.sort(), ["admin", "editor", "viewer"]); // every distinct role checked for the user
});
test("completeLogin: read permissions → project onto metadata_public → tokenize → JWT (in that order)", async () => {
test("completeLogin: read roles → project onto metadata_public → tokenize → JWT (in that order)", async () => {
const events: string[] = [];
let projected: unknown;
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
@@ -76,11 +76,11 @@ test("completeLogin: read permissions → project onto metadata_public → token
},
});
const kratosAdmin = adminStub({ updateMetadataPublic: async (_id, meta) => { events.push("project"); projected = meta; return identity; } });
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [permissionTuple("admin")] }) });
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
const out = await completeLogin({ keto, kratosAdmin, kratosPublic }, "plainpages_session=s");
assert.deepEqual(out, { email: "admin@plainpages.local", userId: ID, jwt: "h.p.s", permissions: ["admin"] });
assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions, projected for the tokenizer
assert.deepEqual(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", roles: ["admin"] });
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles, projected for the tokenizer
assert.deepEqual(events, ["whoami", "project", "tokenize"]); // projection MUST precede tokenize
});
@@ -101,11 +101,11 @@ test("completeLogin maps a missing email trait to null and throws if the tokeniz
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [permissionTuple("admin")] }) });
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
// TTL lapsed but the Kratos session lives → re-read permissions from Keto, re-tokenize, fresh cookie.
// 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, permissions: ["admin"] });
assert.deepEqual(live.user, { 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.
+21 -21
View File
@@ -1,9 +1,9 @@
// Login completion: turn a fresh Kratos session into our locally-verifiable
// session JWT — the one moment Ory is on the path (README: Login → session JWT):
// 1. whoami(cookie) → the identity (id, email); no active session ⇒ null
// 2. read permissions from Keto → the source of truth for the `permissions` claim
// 2. read roles from Keto → the source of truth for the `roles` claim
// 3. project onto metadata_public (admin API) so the tokenizer's mapper can read them
// 4. whoami(tokenize_as) → the signed JWT { sub, email, permissions }, stored as our cookie
// 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";
@@ -32,46 +32,46 @@ export interface LoginDeps {
export interface CompletedLogin {
email: string | null;
userId: string;
identityId: string;
jwt: string;
permissions: string[];
roles: string[];
}
// The coarse permissions a user holds — directly (`Permission:<name>#members@user:<id>`) or transitively via a
// group that is a member of the permission. Enumerates the defined permissions (the distinct objects in the Permission
// namespace) and asks Keto to resolve each membership, so a permission granted to a group reaches the JWT —
// The coarse roles a user holds — directly (`Role:<name>#members@user:<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; permission count is small, so the per-permission checks are cheap and run in parallel.
export async function readPermissions(keto: KetoClient, userId: string): Promise<string[]> {
const subject_id = `user:${userId}`;
// 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 names = new Set<string>();
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ namespace: "Permission", relation: "granted", ...(pageToken ? { pageToken } : {}) });
const page = await keto.listRelations({ namespace: "Role", relation: "members", ...(pageToken ? { pageToken } : {}) });
for (const t of page.tuples) names.add(t.object);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
const permissions = [...names];
const held = await Promise.all(permissions.map((object) => keto.check({ namespace: "Permission", object, relation: "granted", subject_id })));
return permissions.filter((_, i) => held[i]).sort();
const roles = [...names];
const held = await Promise.all(roles.map((object) => keto.check({ namespace: "Role", object, relation: "members", subject_id })));
return roles.filter((_, i) => held[i]).sort();
}
export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> {
const session = await deps.kratosPublic.whoami(cookie ? { cookie } : {});
if (!session?.identity) return null;
const userId = session.identity.id;
const identityId = session.identity.id;
const emailTrait = session.identity.traits?.["email"];
const email = typeof emailTrait === "string" ? emailTrait : null;
const permissions = await readPermissions(deps.keto, userId);
await deps.kratosAdmin.updateMetadataPublic(userId, { permissions });
const roles = await readRoles(deps.keto, identityId);
await deps.kratosAdmin.updateMetadataPublic(identityId, { roles });
const tokenized = await deps.kratosPublic.whoami({ ...(cookie ? { cookie } : {}), tokenizeAs: TOKENIZE_AS });
const jwt = tokenized?.tokenized;
if (!jwt) throw new Error("login completion: Kratos tokenizer returned no JWT");
currentLog()?.info("session minted", { permissions: permissions.join(","), sub: userId }); // login or TTL re-mint
return { email, userId, jwt, permissions };
currentLog()?.info("session minted", { roles: roles.join(","), sub: identityId }); // login or TTL re-mint
return { email, identityId, jwt, roles };
}
export interface Reminted {
@@ -80,14 +80,14 @@ export interface Reminted {
}
// Re-mint the session JWT on TTL expiry — "stay signed in" (README): the ~10m token lapsed but
// the long-lived Kratos session may still be live. A live session ⇒ re-read permissions from Keto,
// the long-lived Kratos session may still be live. A live session ⇒ re-read roles from Keto,
// re-tokenize, fresh cookie + the refreshed user (the one moment authz recomputes). A dead
// session ⇒ a cookie that *clears* the stale JWT, so later requests fall straight through to
// 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.userId, permissions: completed.permissions } };
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } };
}
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
+1 -1
View File
@@ -2,7 +2,7 @@
// /oauth2/consent?consent_challenge=… (hydra.yml urls.consent). A first-party client (or one
// Hydra already skipped) is auto-granted the requested scopes; a third-party client shows the
// themed consent screen, then accept (allow) / reject (deny). id_token claims (email/name) come
// from the Kratos identity. OAuth2-provider permission only — no first-party page needs this (README).
// from the Kratos identity. OAuth2-provider role only — no first-party page needs this (README).
import type { AcceptConsent, ConsentRequest, HydraAdmin, OAuth2Client } from "./hydra-admin.ts";
import type { KratosPublic } from "./kratos-public.ts";
+1 -1
View File
@@ -2,7 +2,7 @@
// Hydra hands the browser to /oauth2/login?login_challenge=… (hydra.yml urls.login). We
// authenticate the user with their existing Kratos session and accept the request; Hydra then
// proceeds to consent and mints the tokens. No first-party page needs this — it's the OAuth2
// provider permission only (README).
// provider role only (README).
import type { HydraAdmin } from "./hydra-admin.ts";
import type { KratosPublic } from "./kratos-public.ts";
+13 -20
View File
@@ -7,7 +7,6 @@ import { readFormBody } from "../http/body.ts";
import type { BuiltinRoute, RequestCsrf } from "../http/builtin-routes.ts";
import type { RequestContext } from "../http/context.ts";
import { CSRF_FIELD } from "./csrf.ts";
import { chosenLocale } from "../i18n/locale.ts";
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
import { HydraError, type HydraAdmin } from "./hydra-admin.ts";
import type { KetoClient } from "./keto-client.ts";
@@ -30,7 +29,7 @@ export interface AuthRouteDeps {
}
const TEXT_PLAIN = { "content-type": "text/plain; charset=utf-8" };
const FORBIDDEN: RouteResult = { status: 403, view: "403" };
const FORBIDDEN: RouteResult = { data: { title: "Forbidden" }, status: 403, view: "403" };
// Scheme + host for a self-referencing absolute URL (Kratos/Hydra return targets). Host reflects
// what the browser used (so it matches the allow-lists); scheme follows SECURE_COOKIES. A spoofed
@@ -61,16 +60,10 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
// as-is — Kratos allow-lists it. localPath rejects an off-origin "//evil.com".
const raw = ctx.url.searchParams.get("return_to");
const local = localPath(raw);
const chosen = chosenLocale(ctx);
let returnTo: string | undefined;
if (local || chosen) {
// The flow's return target is the host's, not Kratos' — so the language the visitor picked
// on the sign-in page survives the round-trip through Kratos and lands on the page after
// it. Without this the most-travelled path in the product (pick Swedish → sign in) drops
// straight back to Accept-Language.
if (local) {
const complete = new URL(`${selfOrigin(ctx, secureCookies)}/auth/complete`);
if (local) complete.searchParams.set("return_to", ctx.localeHref(local));
if (chosen) complete.searchParams.set("locale", chosen);
complete.searchParams.set("return_to", local);
returnTo = complete.toString();
} else if (raw) returnTo = raw;
const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) });
@@ -94,14 +87,14 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
// documented, so render an honest 503 rather than the catch-all "error on our end" 500.
if (!(err instanceof KratosError) || err.status >= 500) {
ctx.log.warn("auth flow failed (Ory unreachable?)", { error: String(err), path: pathname });
return { status: 503, view: "503" };
return { data: { title: "Sign-in unavailable" }, status: 503, view: "503" };
}
throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected)
}
// Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the
// shell's Sign-out form (shown on /settings, where the user is signed in) needs the token.
csrf.setCookie();
return { data: { chrome: ctx.chrome, flow: buildFlowView(flow, flowType, ctx.t) }, view: "auth" };
return { data: { chrome: ctx.chrome, flow: buildFlowView(flow, flowType) }, view: "auth" };
};
}
@@ -121,7 +114,7 @@ function oauthLogin(deps: { hydra: HydraAdmin; kratos: KratosPublic }, secureCoo
// A stale/invalid/consumed challenge (Hydra 4xx — back button, slow login, re-used URL) is
// user-reachable: tell them to restart rather than 500. A 5xx (Hydra down) rethrows → 500.
if (err instanceof HydraError && err.status < 500) {
return { headers: TEXT_PLAIN, html: ctx.t("oauth.loginExpired"), status: 400 };
return { headers: TEXT_PLAIN, html: "This sign-in request has expired. Please start again from the application you were signing in to.", status: 400 };
}
throw err;
}
@@ -129,9 +122,9 @@ function oauthLogin(deps: { hydra: HydraAdmin; kratos: KratosPublic }, secureCoo
}
// Stale/consumed challenge (Hydra 4xx) → recoverable 400; a genuine outage (5xx) → 500 (as /oauth2/login).
function consentError(err: unknown, ctx: RequestContext): RouteResult {
function consentError(err: unknown): RouteResult {
if (err instanceof HydraError && err.status < 500) {
return { headers: TEXT_PLAIN, html: ctx.t("oauth.consentExpired"), status: 400 };
return { headers: TEXT_PLAIN, html: "This authorization request has expired. Please start again from the application you were signing in to.", status: 400 };
}
throw err;
}
@@ -150,7 +143,7 @@ function consentScreen(deps: { hydra: HydraAdmin; kratos: KratosPublic }, brand:
csrf.setCookie();
return { data: { brand, consent: view, csrfField: CSRF_FIELD, csrfToken: csrf.token }, view: "oauth-consent" };
} catch (err) {
return consentError(err, ctx);
return consentError(err);
}
};
}
@@ -171,7 +164,7 @@ function consentDecision(deps: { hydra: HydraAdmin; kratos: KratosPublic }): Bui
: await rejectConsent(deps, challenge);
return { redirect };
} catch (err) {
return consentError(err, ctx);
return consentError(err);
}
};
}
@@ -191,7 +184,7 @@ function oauthLogout(hydra: HydraAdmin): BuiltinRoute["handler"] {
} catch (err) {
// Stale/consumed challenge (Hydra 4xx) → recoverable 400; a genuine outage (5xx) → 500.
if (err instanceof HydraError && err.status < 500) {
return { headers: TEXT_PLAIN, html: ctx.t("oauth.logoutExpired"), status: 400 };
return { headers: TEXT_PLAIN, html: "This logout request has expired. Please start again from the application you were signing out of.", status: 400 };
}
throw err;
}
@@ -199,7 +192,7 @@ function oauthLogout(hydra: HydraAdmin): BuiltinRoute["handler"] {
}
// Login completion: where Kratos lands the browser after authenticating (kratos.yml). Mint our
// session JWT — read permissions from Keto, project onto the identity, tokenize — and store it as the
// session JWT — read roles from Keto, project onto the identity, tokenize — and store it as the
// cookie; no active session bounces back to sign in.
function completeAuth(deps: { keto: KetoClient; kratosAdmin: KratosAdmin; kratosPublic: KratosPublic }, secureCookies: boolean): BuiltinRoute["handler"] {
return async (ctx: RequestContext): Promise<RouteResult> => {
@@ -236,7 +229,7 @@ function logout(kratos: KratosPublic, secureCookies: boolean): BuiltinRoute["han
// canonical-host redirect prevents the common cause (a lost cross-host CSRF cookie); this is the
// honest fallback for any genuine flow error. The id is shown only for support reference.
const errorSink = (ctx: RequestContext): RouteResult =>
({ data: { id: ctx.url.searchParams.get("id") }, view: "error" });
({ data: { id: ctx.url.searchParams.get("id"), title: "Sign-in problem" }, view: "error" });
export function buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }: AuthRouteDeps): BuiltinRoute[] {
const routes: BuiltinRoute[] = [];
+3 -5
View File
@@ -8,8 +8,7 @@ import { readFileSync } from "node:fs";
const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8");
const workflow = read(".gitea/workflows/ci.yml");
// Comments stripped: the flags below must be asserted against the code, not against prose naming them.
const gate = read("ci.sh").split("\n").filter((l) => !l.trimStart().startsWith("#")).join("\n");
const gate = read("ci.sh");
const step = (needle: string) => {
const found = workflow.split("\n - ").slice(1).filter((s) => s.includes(needle));
assert.equal(found.length, 1, `exactly one workflow step contains ${needle}`);
@@ -29,8 +28,7 @@ test("the commit-hash image is pushed even when the gate no-ops", () => {
assert.doesNotMatch(step("docker push"), /^\s*if:/m);
});
test("only *.md counts as docs; a dirty tree and a rename both count as changed", () => {
test("only *.md counts as docs, and a dirty working tree counts as changed", () => {
assert.ok(gate.includes("\\.md$"), "the non-docs match is a *.md suffix test");
assert.match(gate, /git status --porcelain --no-renames/, "uncommitted code and a staged rename can never be skipped over");
assert.match(gate, /git diff --name-only --no-renames/, "a rename must list both of its paths");
assert.match(gate, /git status --porcelain/, "uncommitted code can never be skipped over");
});
+2 -2
View File
@@ -31,7 +31,7 @@ export interface Config {
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
port: number;
revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist
revocationDenylist: boolean; // enable the optional instant role/session revoke denylist
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
secureCookies: boolean;
serviceName: string; // OTLP service.name — an implementer brands their own logs/traces
@@ -157,7 +157,7 @@ export function loadConfig(env: Env = process.env): Config {
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
port: readPort(env),
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or role
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
// tokenizer TTL + skew, so it outlasts any pre-revoke token).
revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false),
-35
View File
@@ -1,35 +0,0 @@
// Guards the E2E console guard: a spec that imports `test` straight from Playwright runs unwatched,
// and a browser warning nobody looks at is exactly what the guard exists to catch — so the wiring is
// asserted here, in the fast unit gate, rather than discovered by a silent gap in an E2E run. A text
// guard: @playwright/test is installed in the e2e-tests image, not in the one running these tests.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs";
const read = (p: string) => readFileSync(new URL(`../e2e-tests/${p}`, import.meta.url), "utf8");
const specs = readdirSync(new URL("../e2e-tests/", import.meta.url)).filter((f) => f.endsWith(".spec.ts"));
test("every spec takes its `test` from the console guard, never straight from Playwright", () => {
assert.ok(specs.length >= 5, "scans the E2E specs");
for (const spec of specs) {
const source = read(spec);
assert.match(source, /^import \{[^}]*\btest\b[^}]*\} from "\.\/console-guard\.ts";$/m, `${spec} imports test from the guard`);
assert.doesNotMatch(source, /^import \{(?![^}]*\btype\b)[^}]*\} from "@playwright\/test";$/m, `${spec} imports no value from @playwright/test`);
assert.doesNotMatch(source, /\.newPage\(/, `${spec} takes its page from the fixture or watchedPage(), never a raw newPage()`);
}
});
test("the guard reads console errors, warnings and uncaught page errors, and fails on what it kept", () => {
const guard = read("console-guard.ts");
assert.match(guard, /type === "error" \|\| type === "warning"/);
assert.match(guard, /page\.on\("pageerror"/);
assert.match(guard, /expect\(unexpected, .*\)\.toEqual\(\[\]\)/);
});
test("the Ory-free specs run in all three engines, so each engine's console is read", () => {
const config = read("playwright.config.ts");
for (const engine of ["firefox", "webkit"]) {
assert.match(config, new RegExp(`name: "${engine}", testMatch: ORY_FREE`), `${engine} runs the Ory-free specs`);
}
assert.match(config, /const ORY_FREE = \/\\\/\(visual\|language\)\\\.spec\\\.ts\$\//);
});
+113 -302
View File
@@ -15,16 +15,13 @@ import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
import { can, check, GuardError, requireSession } from "../auth/guards.ts";
import { HydraError, type HydraAdmin, type OAuth2Client } from "../auth/hydra-admin.ts";
import { staticJwks } from "../auth/jwks.ts";
import type { KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
import type { Identity, KratosAdmin } from "../auth/kratos-admin.ts";
import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts";
import { SESSION_COOKIE } from "../auth/login.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
import adminManifest from "../../examples/plugins/admin/plugin.ts";
import { createI18n } from "../i18n/runtime.ts";
import type { MenuConfig } from "../ui/menu-config.ts";
import { loadI18n } from "../i18n/load.ts";
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
// The admin screens ship as a drop-in example plugin; the HTTP-level admin tests mount it via
@@ -43,9 +40,9 @@ function mintJwt(payload: Record<string, unknown>): string {
const input = `${b64url(JSON.stringify({ alg: "ES256", kid: "test-kid", typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`;
return `${input}.${b64url(sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key: ec.privateKey }))}`;
}
// A session cookie carrying `permissions`, valid for 10 min — the auth most tests need to reach a gated page.
const session = (permissions: string[] = []): string =>
`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions, sub: "u1" })}`;
// A session cookie carrying `roles`, valid for 10 min — the auth most tests need to reach a gated page.
const session = (roles: string[] = []): string =>
`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, roles, sub: "u1" })}`;
const server = createApp({ jwks: staticJwks([ecJwk]) });
let base = "";
@@ -86,7 +83,7 @@ test("/ is the public landing: anonymous → 200 with intro + sign-in/register l
const html = await res.text();
assert.match(html, /href="\/login"/); // a prominent path to sign in
assert.match(html, /href="\/registration"/); // and to register
// the same app shell every page renders — the menu shows even when signed out (permission-filtered).
// the same app shell every page renders — the menu shows even when signed out (role-filtered).
assert.match(html, /<aside class="sidebar"/);
assert.match(html, /class="landing-title"/); // the landing hero owns the page's single <h1>
});
@@ -372,8 +369,8 @@ test("/error renders a themed sign-in error page (Kratos' flow error sink), not
test("renders the 500 HTML page when a handler throws", async () => {
const dir = mkdtempSync(join(tmpdir(), "pp-views-"));
cpSync(viewsDir, dir, { recursive: true }); // the real views: 500.ejs includes the language picker
writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // …but the dashboard view throws
writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // the dashboard view
cpSync(join(viewsDir, "500.ejs"), join(dir, "500.ejs"));
const app = createApp({ jwks: staticJwks([ecJwk]), viewsDir: dir });
try {
await new Promise<void>((resolve) => app.listen(0, resolve));
@@ -519,9 +516,9 @@ 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/permissions, which the gate reads.
// JWT middleware: a verified session cookie populates ctx.user/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 permission-gated route; no cookie / expired token → sign in", async (t) => {
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] });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
@@ -529,8 +526,8 @@ test("a verified session JWT authorizes a permission-gated route; no cookie / ex
const nowSec = Math.floor(Date.now() / 1000);
const secret = (cookie?: string) => fetch(url + "/demo/secret", { redirect: "manual", ...(cookie ? { headers: { cookie } } : {}) });
// Token carrying the gating permission → the handler runs (200).
const ok = await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" })}`);
// Token carrying the gating role → the handler runs (200).
const ok = await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" })}`);
assert.equal(ok.status, 200);
assert.equal(await ok.text(), "secret");
@@ -539,12 +536,12 @@ test("a verified session JWT authorizes a permission-gated route; no cookie / ex
const noCookie = await secret();
assert.equal(noCookie.status, 303);
assert.equal(noCookie.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}`)).status, 303);
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 303);
// The gated dashboard renders for any signed-in user; anonymous is bounced to sign in before any
// page renders (gate on /dashboard). The Admin section links come from the admin plugin — its nav
// composition + permission-filtering is covered in the admin-screen tests below.
const dash = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["admin"], sub: "u1" })}` } });
// composition + role-filtering is covered in the admin-screen tests below.
const dash = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}` } });
assert.equal(dash.status, 200);
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
assert.equal(anonDash.status, 303);
@@ -558,7 +555,7 @@ test("revocation denylist: a revoked subject's token stops authorizing on the ho
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const nowSec = Math.floor(Date.now() / 1000);
const secret = (iat: number) => fetch(url + "/demo/secret", { redirect: "manual", headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, iat, permissions: ["demo:read"], sub: "u1" })}` } });
const secret = (iat: number) => fetch(url + "/demo/secret", { redirect: "manual", headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, iat, roles: ["demo:read"], sub: "u1" })}` } });
assert.equal((await secret(nowSec)).status, 200); // before any revoke, the token authorizes
@@ -570,10 +567,10 @@ test("revocation denylist: a revoked subject's token stops authorizing on the ho
test("session re-mint: an expired JWT backed by a live Kratos session is silently re-minted; a dead session clears it", async (t) => {
const identity: Identity = { id: "u1", traits: { email: "a@b.c" } };
const nowSec = Math.floor(Date.now() / 1000);
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" });
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: "Permission", object: "demo:read", relation: "granted", subject_id: "user:u1" }] }) });
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "demo:read", relation: "members", subject_id: "user: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.
const app = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: live, kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
@@ -621,7 +618,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const nowSec = Math.floor(Date.now() / 1000);
const auth = (permissions: string[]) => ({ headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions, sub: "u1" })}` } });
const auth = (roles: string[]) => ({ headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles, sub: "u1" })}` } });
// requireSession: anonymous bounces to /login (remembering the page); a signed-in user reaches the handler.
const anon = await fetch(url + "/guarded/me", { redirect: "manual" });
@@ -631,7 +628,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
assert.equal(me.status, 200);
assert.match(await me.text(), /hi a@b\.c/);
// can: signed-in but lacking the permission → 403 page; carrying it → 200.
// can: signed-in but lacking the role → 403 page; carrying it → 200.
assert.equal((await fetch(url + "/guarded/admin-only", auth([]))).status, 403);
assert.equal((await fetch(url + "/guarded/admin-only", auth(["admin"]))).status, 200);
@@ -639,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-permission → the 403 page, with → 200.
// declarative route `permission` 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");
@@ -720,7 +717,7 @@ test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a s
assert.equal(stale.headers.get("location"), "/login");
// Already signed in → /login + /registration short-circuit to the app dashboard; /settings stays reachable.
const signedIn = { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions: [], sub: "u1" })}` }, redirect: "manual" as const };
const signedIn = { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, roles: [], sub: "u1" })}` }, redirect: "manual" as const };
for (const path of ["/login", "/registration"]) {
const res = await fetch(url + path, signedIn);
assert.equal(res.status, 303, `${path} while signed in → 303`);
@@ -825,7 +822,7 @@ test("renders a fetched flow as the themed auth page: fields post straight to Kr
assert.match(html, /<button type="submit" class="sso-btn" name="provider" value="google" formnovalidate>.*Sign in with Google<\/span><\/button>/s);
// The flow-level error renders as an alert.
assert.match(html, /class="alert alert-neg"/);
assert.match(html, /The credentials are invalid\./); // 4000006 → our wording (README → Translating)
assert.match(html, /The provided credentials are invalid\./);
});
// Login completion: /auth/complete is where Kratos lands the browser after login.
@@ -859,28 +856,23 @@ const fakeKeto = (tuples: RelationTuple[] = [], over: Partial<KetoClient> = {}):
const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
// CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field.
// CSRF cookie. get(path, roles)/post(path, body) carry them; `token` is the matching CSRF field.
const ADMIN_CSRF = "admin-secret";
async function adminHarness(t: TestContext, opts: AppOptions = {}) {
// Mount the plugin's catalogs the way server.ts does, so its screens render words, not keys.
const i18n = createI18n(await loadI18n({ pluginIds: [adminPlugin.id], pluginsDir: examplesPluginsDir }));
const app = createApp({ csrfSecret: ADMIN_CSRF, i18n, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts });
const app = createApp({ csrfSecret: ADMIN_CSRF, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const token = issueCsrfToken(ADMIN_CSRF);
const nowSec = Math.floor(Date.now() / 1000);
const cookie = (permissions: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, permissions, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
const get = (path: string, permissions: string[] = ADMIN_ALL) => fetch(url + path, { headers: { cookie: cookie(permissions) }, redirect: "manual" });
const cookie = (roles: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, roles, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
const get = (path: string, roles: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(roles) }, redirect: "manual" });
const post = (path: string, body: string) =>
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(ADMIN_ALL) }, method: "POST", redirect: "manual" });
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" });
return { get, post, token, url };
}
// What the plugin itself declares — the harness holds every screen's read and write, so a screen
// test exercises the screen rather than the gate. assertAdminGate covers the refusals.
const ADMIN_ALL = (adminManifest.permissions ?? []).map((p) => p.name);
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
async function assertAdminGate(url: string, get: (path: string, permissions?: string[]) => Promise<Response>, path: string) {
async function assertAdminGate(url: string, get: (path: string, roles?: string[]) => Promise<Response>, path: string) {
const anon = await fetch(url + path, { redirect: "manual" });
assert.equal(anon.status, 303);
assert.equal(anon.headers.get("location"), `/login?return_to=${encodeURIComponent(path)}`); // remembers the page
@@ -892,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: "Permission", object: "admin", relation: "granted", subject_id: `user:${identity.id}` }] }) });
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${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());
@@ -900,12 +892,12 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
return fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete${q}`, { headers: cookie ? { cookie } : {}, redirect: "manual" });
};
// Live Kratos session: permissions from Keto → projection → tokenize → JWT cookie, land on the dashboard.
// Live Kratos session: roles from Keto → projection → tokenize → JWT cookie, land on the dashboard.
const ok = await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s");
assert.equal(ok.status, 303);
assert.equal(ok.headers.get("location"), "/dashboard");
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions projected onto the identity for the tokenizer
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles projected onto the identity for the tokenizer
// return_to: a safe host-relative target lands the user back where they were headed; an
// off-origin one is ignored (open-redirect guard) and falls back to the dashboard.
@@ -1108,28 +1100,11 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
await assertAdminGate(url, get, "/admin/users");
// Nav: the admin plugin's section composes into the one global menu, and each screen is filtered
// by its own read permission — proving the drop-in nav fragment. A user holding only users:read
// sees Users and nothing else; holding none of the three, composeNav drops the emptied header.
// Nav: the admin plugin's section composes into the one global menu for an admin, and is filtered
// out for a signed-in non-admin (the gate on the section header) — proving the drop-in nav fragment.
assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/);
const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text();
assert.match(usersOnlyNav, /href="\/admin\/users"/);
assert.doesNotMatch(usersOnlyNav, /href="\/admin\/groups"/);
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/);
// The read/write split: users:read opens the list but is refused on every mutation, and the
// resources don't leak — a users holder is not a groups holder.
assert.equal((await get("/admin/users", ["users:read"])).status, 200);
assert.equal((await get("/admin/groups", ["users:read", "users:write"])).status, 403);
const readOnlyPost = await fetch(url + "/admin/users", {
body: `_csrf=${token}&email=nope@example.com`,
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${SESSION_COOKIE}=${mintJwt({ email: "r@x", exp: Math.floor(Date.now() / 1000) + 600, permissions: ["users:read"], sub: "reader1" })}; ${CSRF_COOKIE}=${token}` },
method: "POST",
redirect: "manual",
});
assert.equal(readOnlyPost.status, 403);
assert.equal(store.some((i) => i.traits?.email === "nope@example.com"), false);
// List: the admin sees the rows + the "add" link; the status filter narrows server-side.
const listHtml = await (await get("/admin/users")).text();
assert.match(listHtml, /ada@example\.com/);
@@ -1189,7 +1164,7 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
assert.equal((await post(`/admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/permissions/clients.
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/roles/clients.
assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404);
assert.equal((await get("/admin/users/%ZZ")).status, 404);
});
@@ -1238,138 +1213,107 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
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}`));
// Give it a permission first, so the delete below has an orphan to avoid leaving behind.
await post("/admin/groups/eng/permissions", `_csrf=${token}&permission=users%3Aread`);
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "users:read" && tp.subject_set?.object === "eng"));
// 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/);
const del = await post("/admin/groups/eng/delete", `_csrf=${token}`);
assert.equal(del.status, 303);
assert.equal(del.headers.get("location"), "/admin/groups");
assert.ok(!tuples.some((tp) => tp.object === "eng"));
// …and the permissions it held go with it. A Keto set exists only through its tuples, so an
// orphaned grant would resurrect the moment someone re-created a group with the same name.
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.subject_set?.object === "eng"));
// An invalid group name in the path → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/admin/groups/Bad%20Name")).status, 404);
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
});
// Granting permissions over HTTP, on the two screens that replaced the deleted Permissions screen.
// The offered set is the host's catalog (ctx.declaredPermissions, from what the installed plugins
// declare), so the checkboxes are a fixed list and the POST is the desired state.
test("admin permission grants: the picker offers the declared catalog, and a save is the desired set", async (t) => {
// Built-in Roles & permissions 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) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
const keto = fakeKeto(tuples);
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist();
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
// The user edit page renders one checkbox per declared permission, ticked where already held.
const edit = await (await get(`/admin/users/${ada}`)).text();
for (const name of ["users:read", "users:write", "groups:read", "groups:write", "oauth2-clients:read", "oauth2-clients:write"]) {
assert.match(edit, new RegExp(`value="${name.replace(":", ":")}"`), name);
}
assert.match(edit, /value="users:read"[^>]*checked/); // held → ticked
assert.doesNotMatch(edit, /value="groups:write"[^>]*checked/); // not held → unticked
// Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
const saved = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=groups%3Aread`);
assert.equal(saved.status, 303);
assert.deepEqual(
tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(),
["groups:read", "users:write"],
);
assert.equal(denylist.isRevoked(ada, 0), true); // a change to your own grants revokes live tokens
// A crafted POST can't grant something no plugin declares.
await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=superuser%3Aall`);
assert.ok(!tuples.some((tp) => tp.object === "superuser:all"));
// The same picker on a group writes the group's subject_set, which Keto resolves transitively.
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
await post("/admin/groups/eng/permissions", `_csrf=${token}&permission=groups%3Aread`);
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
const grace = randomUUID();
const identities: Identity[] = [
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
];
// 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}` },
];
// Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children.
const expandSet = (set: SubjectSet): ExpandTree => ({
children: tuples
.filter((tp) => tp.namespace === set.namespace && tp.object === set.object && tp.relation === set.relation)
.map((tp) => (tp.subject_id ? { tuple: { namespace: "", object: "", relation: "", subject_id: tp.subject_id }, type: "leaf" } : expandSet(tp.subject_set!))),
tuple: { namespace: "", object: "", relation: "", subject_set: set },
type: "union",
});
const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) });
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist(); // granting/revoking a *user's* role revokes their live tokens (a group change is transitive → left to lag)
const { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin });
// Revoking your own grants can remove the last users:write on the deployment, and the instant-revoke
// hook lands it on the next request — recovery would be a curl against Keto. Guarded like
// self-deactivate and self-delete are. (`admin1` is the harness's own sub.)
test("admin permission grants: you can't revoke your own permissions, but you can still grant", async (t) => {
const identities: Identity[] = [{ id: "admin1", traits: { email: "you@example.com" } }];
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:write", relation: "granted", subject_id: "user:admin1" }];
const keto = fakeKeto(tuples);
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { post, token } = await adminHarness(t, { keto, kratosAdmin });
await assertAdminGate(url, get, "/admin/roles");
const refused = await post("/admin/users/admin1/permissions", `_csrf=${token}`); // every box cleared
assert.equal(refused.status, 400);
assert.match(await refused.text(), /lock yourself out/);
assert.ok(tuples.some((tp) => tp.object === "users:write" && tp.subject_id === "user:admin1"), "nothing was revoked");
// List: the existing role shows + the "add" link.
const listHtml = await (await get("/admin/roles")).text();
assert.match(listHtml, /href="\/admin\/roles\/editor"/);
assert.match(listHtml, /href="\/admin\/roles\/new"/);
// Granting yourself more is not a lockout, so it goes through.
const granted = await post("/admin/users/admin1/permissions", `_csrf=${token}&permission=users%3Awrite&permission=groups%3Aread`);
assert.equal(granted.status, 303);
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1"));
});
// 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}`);
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.equal(denylist.isRevoked(ada, 0), true); // assigning a role to a user revokes their stale token so the grant lands now
// The read/write split is only honest if the UI models it: a users:read holder must not be shown
// buttons that 403 on submit. The gate already refuses them (asserted above); this is the affordance.
test("admin screens render no write affordance for a read-only holder", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const keto = fakeKeto([{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }]);
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
// Hydra is wired so the clients screen renders for real — without it the page is a 503 and the
// "no Register button" assertion below would pass without proving anything.
const reporting = { client_id: "existing", client_name: "Reporting" };
const hydra = stubHydra({ getClient: async (id) => (id === reporting.client_id ? reporting : null), listClients: async () => ({ clients: [reporting], nextPageToken: null }) });
const { get } = await adminHarness(t, { hydra, keto, kratosAdmin });
const readOnly = ["users:read", "groups:read"];
// 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(tuples.length, before);
const list = await (await get("/admin/users", readOnly)).text();
assert.doesNotMatch(list, /href="\/admin\/users\/new"/); // no "New user"
assert.match(list, /ada@example\.com/); // but the list itself is there — that's the point of :read
// Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not
// yet — though grace appears elsewhere as an assignable candidate, so target the effective <li>.
const effectiveLi = (email: string) => new RegExp(`<li><span class="cell-strong">${email.replace(".", "\\.")}`);
const detail = await (await get("/admin/roles/editor")).text();
assert.match(detail, effectiveLi("ada@example.com"));
assert.doesNotMatch(detail, effectiveLi("grace@example.com"));
// (The shell's own sign-out is a POST form, so assert on the affordances by name, not on <form>.)
const detail = await (await get(`/admin/users/${ada}`, readOnly)).text();
assert.doesNotMatch(detail, /Save changes/);
assert.doesNotMatch(detail, /Generate recovery code/);
assert.doesNotMatch(detail, /Delete user/);
assert.doesNotMatch(detail, /Save permissions/);
assert.match(detail, /type="checkbox"[^>]*disabled/); // the permissions are shown, just not editable
// Assign the `eng` group to the role → grace now holds it transitively (effective access via expand).
await post("/admin/roles/editor/members", `_csrf=${token}&member=group:eng`);
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
const withGroup = await (await get("/admin/roles/editor")).text();
assert.match(withGroup, effectiveLi("grace@example.com"));
const group = await (await get("/admin/groups/eng", readOnly)).text();
assert.doesNotMatch(group, /Add a member/);
assert.doesNotMatch(group, /Delete group/);
assert.doesNotMatch(group, /Save permissions/);
// Revoke the group membership.
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=group:eng`);
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
// The OAuth2-clients screen is held to the same rule (it was the one this test was written to catch).
const clientsRes = await get("/admin/clients", ["oauth2-clients:read"]);
assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
const clients = await clientsRes.text();
assert.match(clients, /Reporting/); // the list is there — that's what :read buys
assert.doesNotMatch(clients, /href="\/admin\/clients\/new"/);
// The detail page is where Delete lives, so check it too and not just the list.
const clientDetail = await (await get("/admin/clients/existing", ["oauth2-clients:read"])).text();
assert.match(clientDetail, /Reporting/);
assert.doesNotMatch(clientDetail, /clients\/existing\/delete/);
// 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}`);
assert.equal(denylist.isRevoked(grace, 0), true);
// A write-intent GET — a create form or a delete-confirm — refuses a reader outright rather than
// rendering a form whose submit would 403.
for (const path of ["/admin/users/new", "/admin/groups/new", `/admin/users/${ada}/delete`, "/admin/groups/eng/delete"]) {
assert.equal((await get(path, readOnly)).status, 403, path);
}
assert.equal((await get("/admin/clients/new", ["oauth2-clients:read"])).status, 403);
// Delete the role: a confirm step (GET) then the POST removes every member tuple, back to the list.
assert.match(await (await get("/admin/roles/editor/delete")).text(), /Cancel/);
const del = await post("/admin/roles/editor/delete", `_csrf=${token}`);
assert.equal(del.status, 303);
assert.equal(del.headers.get("location"), "/admin/roles");
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor"));
// A writer sees the affordances the reader didn't.
const writable = await (await get(`/admin/users/${ada}`, ["users:read", "users:write"])).text();
assert.match(writable, /Save changes/);
assert.match(writable, /Save permissions/);
// 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" });
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"));
// An invalid role name in the path → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/admin/roles/Bad%20Name")).status, 404);
assert.equal((await get("/admin/roles/%ZZ")).status, 404);
});
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
@@ -1399,7 +1343,7 @@ test("admin OAuth2 clients screen: gate, list, register (one-time secret), detai
// client and shows the one-time secret + id.
const formHtml = await (await get("/admin/clients/new")).text();
assert.match(formHtml, /Register client/);
assert.match(formHtml, /keep a secret/i); // guidance on the public-vs-confidential choice (apostrophes arrive escaped: t() text goes through <%= %>)
assert.match(formHtml, /can't keep a secret/i); // guidance on the public-vs-confidential choice
const created = await post("/admin/clients", `_csrf=${token}&name=Grafana&redirectUris=${encodeURIComponent("https://graf/cb")}&scope=openid+offline_access`);
assert.equal(created.status, 200); // not a redirect — the secret is shown once
const createdHtml = await created.text();
@@ -1451,136 +1395,3 @@ test("routePublic sends a plugin-id segment to its public/ dir, everything else
assert.deepEqual(routePublic("scheduling", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "" }); // bare /public/<id>, no file
assert.deepEqual(routePublic("css/styles.css", "/core", "/plugins", ids), { dir: "/core", subPath: "css/styles.css" }); // not a plugin → core
});
// ---- language (i18n) ----
// The installed catalogs, as server.ts wires them: the shipped core locales (en-US + sv-SE).
async function localeApp(t: TestContext): Promise<string> {
const app = createApp({ i18n: createI18n(await loadI18n()), jwks: staticJwks([ecJwk]) });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
return `http://localhost:${(app.address() as AddressInfo).port}`;
}
test("?locale serves that language and carries the choice onto the links the page renders", async (t) => {
const url = await localeApp(t);
const html = await (await fetch(`${url}/?locale=sv-SE`)).text();
assert.match(html, /<html lang="sv-SE" dir="ltr">/); // the document says what language it is in
assert.match(html, /Logga in/); // the landing page's own words
assert.doesNotMatch(html, /Operational web apps/);
// The chosen locale rides along, so clicking through the app stays in Swedish without a cookie.
assert.match(html, /href="\/login\?locale=sv-SE"/);
// …and the picker offers the other installed locale, pointing at this same page.
assert.match(html, /hreflang="en-US"/);
assert.match(html, /href="\/\?locale=en-US"/);
});
test("Accept-Language decides when the URL doesn't, and a lone language matches its region", async (t) => {
const url = await localeApp(t);
const swedish = await (await fetch(`${url}/`, { headers: { "accept-language": "sv;q=0.9, en;q=0.4" } })).text();
assert.match(swedish, /<html lang="sv-SE"/);
// The visitor never asked for a locale in the URL, so the links stay clean.
assert.match(swedish, /href="\/login"/);
const english = await (await fetch(`${url}/`, { headers: { "accept-language": "de-DE" } })).text();
assert.match(english, /<html lang="en-US"/); // nothing matches ⇒ the baseline
});
test("an uninstalled or malformed ?locale falls back instead of failing", async (t) => {
const url = await localeApp(t);
for (const bad of ["sv-FI", "klingon", "../../etc"]) {
const res = await fetch(`${url}/?locale=${encodeURIComponent(bad)}`);
assert.equal(res.status, 200);
assert.match(await res.text(), /<html lang="en-US"/, `expected en-US for ${bad}`);
}
});
test("a redirect the host emits keeps the visitor's language", async (t) => {
const url = await localeApp(t);
const res = await fetch(`${url}/dashboard?locale=sv-SE`, { redirect: "manual" }); // anonymous ⇒ sign in first
assert.equal(res.status, 303);
const location = res.headers.get("location") ?? "";
assert.match(location, /^\/login\?/);
assert.match(location, /locale=sv-SE/);
});
test("the error pages speak the visitor's language too", async (t) => {
const url = await localeApp(t);
const html = await (await fetch(`${url}/no-such-page?locale=sv-SE`)).text();
assert.match(html, /<html lang="sv-SE"/);
assert.match(html, /Sidan hittades inte/);
});
test("a plugin that owns a landing page, or short-circuits a hook, translates from its own catalog", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "pp-i18n-plugin-"));
mkdirSync(join(dir, "demo", "i18n"), { recursive: true });
t.after(() => rmSync(dir, { force: true, recursive: true }));
writeFileSync(join(dir, "demo", "i18n", "en-US.ts"), 'const m = { "demo.hello": "Hello from the plugin" };\nexport default m;\n');
// Every plugin-owned render path: the public landing, the gated dashboard, and a hook short-circuit.
const demo: Plugin = {
apiVersion: "1.0.0",
dashboard: (ctx) => ({ html: ctx.t("demo.hello") }),
home: (ctx) => ({ html: ctx.t("demo.hello") }),
hooks: { onRequest: (ctx) => (ctx.url.pathname === "/hooked" ? { html: ctx.t("demo.hello") } : undefined) },
id: "demo",
};
const i18n = createI18n(await loadI18n({ pluginIds: ["demo"], pluginsDir: dir }));
const app = createApp({ i18n, jwks: staticJwks([ecJwk]), plugins: [demo], pluginsDir: dir });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const nowSec = Math.floor(Date.now() / 1000);
const cookie = `${SESSION_COOKIE}=${mintJwt({ email: "a@b", exp: nowSec + 600, permissions: [], sub: "u1" })}`;
assert.equal(await (await fetch(`${url}/`)).text(), "Hello from the plugin");
assert.equal(await (await fetch(`${url}/hooked`)).text(), "Hello from the plugin");
assert.equal(await (await fetch(`${url}/dashboard`, { headers: { cookie } })).text(), "Hello from the plugin");
});
test("an error page renders without composing the menu — it exists for when the shell's data is what failed", async (t) => {
// The chrome getter is lazy on purpose; a render that reads no chrome must not trigger it, or a
// broken menu takes the error pages down with it.
let built = 0;
const menu: MenuConfig = { branding: { get name() { built++; return "Plainpages"; } }, override: {} };
const app = createApp({ jwks: staticJwks([ecJwk]), menu });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const res = await fetch(`${url}/no-such-page`);
assert.equal(res.status, 404);
assert.match(await res.text(), /Page not found/);
assert.equal(built, 0);
});
test("a POST-rendered page still offers the language picker, pointed at a page that answers GET", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "pp-post-lang-"));
mkdirSync(join(dir, "demo", "views"), { recursive: true });
t.after(() => rmSync(dir, { force: true, recursive: true }));
// The view renders the picker exactly as the shell does.
writeFileSync(join(dir, "demo", "views", "page.ejs"), `<%- include("partials/locale-switch") %>`);
const demo: Plugin = {
apiVersion: "1.0.0",
id: "demo",
routes: [
{ handler: () => ({ view: "page" }), method: "GET", path: "/thing" },
{ handler: () => ({ view: "page" }), method: "POST", path: "/thing" },
{ handler: () => ({ view: "page" }), method: "POST", path: "/thing/act" }, // POST-only: no GET sibling
],
};
const app = createApp({ i18n: createI18n(await loadI18n()), plugins: [demo], pluginsDir: dir });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const post = (path: string, headers: Record<string, string> = {}) => fetch(url + path, { headers, method: "POST" });
// A POST whose path also answers GET → the picker points at that page.
assert.match(await (await post("/demo/thing?locale=sv-SE")).text(), /href="\/demo\/thing\?locale=en-US"/);
// A POST-only path → the page the form was submitted from, so the link can't dead-end on a 405.
const fromForm = await post("/demo/thing/act?locale=sv-SE", { referer: `${url}/demo/thing?locale=sv-SE` });
assert.match(await fromForm.text(), /href="\/demo\/thing\?locale=en-US"/);
// …and with no referer to fall back on, the front page.
assert.match(await (await post("/demo/thing/act?locale=sv-SE")).text(), /href="\/\?locale=en-US"/);
});
+33 -135
View File
@@ -3,7 +3,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import ejs from "ejs";
import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type RequestCsrf } from "./builtin-routes.ts";
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 { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
@@ -11,10 +11,6 @@ import type { Denylist } from "../auth/denylist.ts";
import { buildDashboardModel } from "../ui/dashboard.ts";
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
import { GuardError, loginRedirect } from "../auth/guards.ts";
import { ENGLISH_I18N } from "../i18n/english.ts";
import type { I18n } from "../i18n/runtime.ts";
import { localeHref } from "../i18n/locale.ts";
import { ENGLISH_LOCALS, i18nLocals, type I18nRequest } from "../i18n/view-locals.ts";
import { runRequestHooks, runResponseHooks } from "../plugin-host/hooks.ts";
import type { HydraAdmin } from "../auth/hydra-admin.ts";
import type { JwksProvider } from "../auth/jwks.ts";
@@ -25,12 +21,11 @@ import type { KratosPublic } from "../auth/kratos-public.ts";
import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts";
import { remintSession } from "../auth/login.ts";
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
import type { Plugin, RouteHandler, RouteResult } from "../plugin-host/plugin.ts";
import type { SystemCapabilities } from "../plugin-host/system.ts";
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
import { buildAuthRoutes } from "../auth/routes.ts";
import { securityHeaders } from "./security-headers.ts";
import { localPath } from "./safe-url.ts";
import { routePublic, serveStatic } from "./static.ts";
import { renderPluginView } from "../plugin-host/view-resolver.ts";
@@ -45,10 +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
// Loaded translation catalogs (server.ts passes the discovered ones). Omitted ⇒ the built-in
// en-US catalog only, so an unwired app still renders real English.
i18n?: I18n;
jwks?: JwksProvider; // verify the session JWT → ctx.user/permissions; absent ⇒ always anonymous
jwks?: JwksProvider; // verify the session JWT → ctx.user/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
@@ -77,7 +69,6 @@ export function createApp(options: AppOptions = {}): Server {
const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own
const secureCookies = options.secureCookies ?? false;
const hydra = options.hydra;
const i18n = options.i18n ?? ENGLISH_I18N;
const jwks = options.jwks;
const keto = options.keto;
const kratos = options.kratos;
@@ -99,9 +90,6 @@ export function createApp(options: AppOptions = {}): Server {
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
// The permission catalog is a property of the installed plugin set, so it is computed once at
// wiring rather than per request.
const permissionCatalog = declaredPermissions(plugins);
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
@@ -119,37 +107,6 @@ export function createApp(options: AppOptions = {}): Server {
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
// Every view renders with its context's i18n locals (t/locale/dir/localeSwitch/localeParam) merged
// in, so a view — core or plugin, at any include depth — calls `t(...)` without its handler passing
// it. A plugin's context carries that plugin's translator, so its own catalog wins in its own views.
// They are merged LAST: these names are reserved (README → Building plugins), and a handler that
// happens to use one loses that key rather than breaking the shell that renders around it.
// Where the language picker on this page should point. Normally the page itself; after a POST
// that URL may answer no GET (POST /admin/users/:id/delete has no GET sibling), so fall back to
// the page the form was submitted from, then to the front page — the picker is on every page, so
// every one of its links has to land somewhere real.
const switchBase = (req: IncomingMessage, url: URL): string => {
const method = (req.method ?? "GET").toUpperCase();
if (method === "GET" || method === "HEAD") return `${url.pathname}${url.search}`;
const answersGet = matchRoute(plugins, "GET", url.pathname) !== null
|| matchBuiltinRoute(builtinRoutes, "GET", url.pathname) !== undefined;
return answersGet ? url.pathname : (sameOriginPath(req) ?? "/");
};
// Named field by field on purpose: spreading the context would trigger its lazy `chrome` getter,
// composing the menu for every render — including the standalone error pages, which exist to
// render when the shell's own data is what failed.
const localsOf = (ctx: RequestContext): I18nRequest => ({
locale: ctx.locale,
localeHref: ctx.localeHref,
locales: ctx.locales,
switchBase: switchBase(ctx.req, ctx.url),
t: ctx.t,
url: ctx.url,
});
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)) });
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)) });
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
res.end(html);
@@ -159,14 +116,12 @@ export function createApp(options: AppOptions = {}): Server {
// (rendered against its own views, native shell via ctx.chrome, with a fresh CSRF cookie for
// any form it ships). Else the built-in intro page with prominent sign-in / register links
// (`user` picks "go to dashboard" vs sign-in; the shell's Sign-out form needs the CSRF cookie).
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult | null> => {
csrf.setCookie();
if (homePlugin) {
// The plugin owns this page, so it runs on its own context — its catalog first, then core.
const pluginCtx = contextFor(homePlugin.id);
const result = (await homePlugin.home(pluginCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, contextFor, result);
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, homePlugin.id), pluginCtx.localeHref);
const result = (await homePlugin.home(ctx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, ctx, result);
await sendResult(ctx.res, result, (view, data) => renderView(homePlugin.id, view, data));
return null;
}
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
@@ -176,18 +131,17 @@ export function createApp(options: AppOptions = {}): Server {
// in, remembering /dashboard as return_to. A plugin may fully own it via `dashboard` — its
// 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, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult | null> => {
if (!ctx.user) 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) {
const pluginCtx = contextFor(dashboardPlugin.id); // as serveHome: the owner's own translator
const result = (await dashboardPlugin.dashboard(pluginCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, contextFor, result);
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref);
const result = (await dashboardPlugin.dashboard(ctx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, ctx, result);
await sendResult(ctx.res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
return null;
}
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, user: ctx.user, nav: ctx.chrome.nav, t: ctx.t }) }, view: "index" };
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, nav: ctx.chrome.nav, user: ctx.user }) }, view: "index" };
};
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
@@ -202,13 +156,9 @@ export function createApp(options: AppOptions = {}): Server {
// outbound fetch (the Ory clients via tracedFetch) and any deep module joins this request's trace
// and correlation with no logger threaded through their signatures.
const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<void> => {
// Error pages can render before this request has a context at all (a throw on the way to one),
// so they start on the built-in English and switch to the visitor's locale once it is resolved.
let renderPage: ViewRenderer = (view, data) => render(view, { ...data, ...ENGLISH_LOCALS });
try {
const method = req.method ?? "GET";
const url = new URL(req.url ?? "/", "http://localhost");
const pathname = url.pathname;
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
// Set before any branch so every response — static/redirect/error included — inherits them
// (writeHead merges these with its own headers; a plugin's RouteResult.headers can override).
@@ -222,12 +172,6 @@ export function createApp(options: AppOptions = {}): Server {
return;
}
// Rendered pages content-negotiate on Accept-Language, so a cache in front of us must key on
// it — otherwise the first visitor's language is served to everyone. Set after the static
// branch above: an asset is the same bytes in every language, and a Vary there would fragment
// its cache entry per raw header string.
res.setHeader("vary", "accept-language");
// Canonical host (APP_URL): a visitor who reached us on a different host (localhost vs
// 127.0.0.1, a secondary domain) is sent to the configured origin, path + query preserved, so
// the browser, the themed forms, and the cross-origin Kratos POST all share one cookie host —
@@ -242,16 +186,9 @@ export function createApp(options: AppOptions = {}): Server {
}
}
// Which language this request is served in: ?locale wins, else Accept-Language, else en-US.
// `explicit` (the URL asked) is what makes the choice travel: the chrome, this request's
// redirects and ctx.localeHref then carry ?locale onto the links they emit.
const { explicit, locale } = i18n.resolve({ acceptLanguage: req.headers["accept-language"], param: url.searchParams.get("locale") });
const carryLocale = (href: string): string => localeHref(href, explicit ? locale : null);
const t = i18n.translator(locale);
// Verify the session JWT once (cached JWKS) → ctx.user/permissions; none/invalid ⇒ anonymous.
// Verify the session JWT once (cached JWKS) → ctx.user/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 permissions from Keto, re-tokenize,
// 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;
@@ -286,33 +223,19 @@ 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, localeHref: carryLocale, menu, plugins, t, translatorFor: (id) => i18n.translator(locale, id), user }));
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }));
// The i18n half of every context: the locale, its translator, and the link carrier. A plugin
// route swaps in the plugin's own translator (its catalog first, then core).
const i18nFor = (pluginId?: string) => ({
locale,
localeHref: carryLocale,
locales: i18n.available,
t: pluginId === undefined ? t : i18n.translator(locale, pluginId),
});
// base context (no route params yet); reused for the built-in routes. A plugin-owned render
// (a landing slot, a hook short-circuit, a plugin route) gets `contextFor(id)` instead, so its
// own catalog is what `ctx.t` reads.
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
renderPage = viewsFor(ctx);
// 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 } : {}) });
// Plugin onRequest hooks run before routing and may short-circuit the request.
if (anyRequestHooks) {
const short = await runRequestHooks(plugins, contextFor);
const short = await runRequestHooks(plugins, ctx);
if (short) {
// Set the fresh CSRF cookie like every other page-emitting path, so a form the hook
// renders (its token is in ctx.chrome.csrfToken) has the matching double-submit cookie.
csrfMint.setCookie();
await sendResult(res, short.result, pluginViewsFor(short.ctx, short.plugin.id), carryLocale);
await sendResult(res, short.result, (view, data) => renderView(short.plugin.id, view, data));
return;
}
}
@@ -322,21 +245,19 @@ export function createApp(options: AppOptions = {}): Server {
// CSRF cookie is set so those forms have a valid double-submit token.
const match = matchRoute(plugins, method, pathname);
if (match) {
const routeCtx = contextFor(match.plugin.id, match.params);
if (!isAuthorized(match.route, routeCtx.permissions)) {
const routeCtx = buildContext(req, res, { chrome, log: reqLog, params: match.params, user, 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 permission gets the 403 page.
// return_to; a signed-in user who simply lacks the role gets the 403 page.
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
sendHtml(res, 403, await renderPage("403", {}));
reqLog.warn("forbidden: missing role", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return;
}
csrfMint.setCookie();
const result = (await match.route.handler(routeCtx)) ?? null;
// The responding plugin observes its own route, params and all; the others get a plain
// context for their own id (never another plugin's params).
if (anyResponseHooks) await runResponseHooks(plugins, (id) => (id === match.plugin.id ? routeCtx : contextFor(id)), result);
await sendResult(res, result, pluginViewsFor(routeCtx, match.plugin.id), carryLocale);
if (anyResponseHooks) await runResponseHooks(plugins, routeCtx, result); // observers; a throw → 500
await sendResult(res, result, (view, data) => renderView(match.plugin.id, view, data));
return;
}
@@ -345,7 +266,7 @@ export function createApp(options: AppOptions = {}): Server {
// null means the handler wrote to ctx.res itself.
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
if (builtin) {
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
await sendResult(res, await builtin.handler(ctx, csrfMint), render);
return;
}
@@ -355,28 +276,21 @@ export function createApp(options: AppOptions = {}): Server {
res.writeHead(405, { allow: allow.join(", "), "content-type": "text/plain; charset=utf-8" }).end("Method Not Allowed");
return;
}
sendHtml(res, 404, await renderPage("404", {}));
sendHtml(res, 404, await render("404", { title: "Not found" }));
} catch (err) {
// A guard thrown anywhere in handling maps to a response (not a 500): a `location` ⇒ a
// redirect (requireSession → /login), otherwise the status renders the error page.
if (err instanceof GuardError) {
if (res.headersSent) return void res.end();
if (err.location) return void res.writeHead(303, { location: err.location }).end();
try {
return void sendHtml(res, err.status, await renderPage("403", {}));
} catch (renderErr) {
// Same last resort as the 500 branch below: a throw here would leave the socket open
// (this catch is the one that would have handled it), so end the response ourselves.
reqLog.error("error page render failed", { error: renderErr instanceof Error ? (renderErr.stack ?? renderErr.message) : String(renderErr) });
return void res.writeHead(err.status, { "content-type": "text/plain; charset=utf-8" }).end("Forbidden");
}
return void sendHtml(res, err.status, await render("403", { title: "Forbidden" }));
}
reqLog.error("unhandled request error", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) });
if (res.headersSent) return void res.end(); // a partial body is already on the wire
try {
// Render before writing: if the 500 page itself throws, headers stay unsent
// and we fall back to plain text below instead of a half-written response.
sendHtml(res, 500, await renderPage("500", {}));
sendHtml(res, 500, await render("500", { title: "Server error" }));
} catch (renderErr) {
reqLog.error("error page render failed", { error: renderErr instanceof Error ? (renderErr.stack ?? renderErr.message) : String(renderErr) });
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end("Internal Server Error");
@@ -419,30 +333,14 @@ export function createApp(options: AppOptions = {}): Server {
});
}
// The Referer as a host-relative path, when it is one of ours — the page a form was submitted
// from. Anything off-origin or malformed is discarded rather than trusted into a link.
function sameOriginPath(req: IncomingMessage): string | null {
const referer = req.headers.referer;
if (typeof referer !== "string") return null;
try {
const url = new URL(referer);
if (req.headers.host !== undefined && url.host !== req.headers.host) return null;
return localPath(`${url.pathname}${url.search}`);
} catch {
return null;
}
}
type ViewRenderer = (view: string, data: Record<string, unknown>) => Promise<string>;
// Turn a handler's RouteResult into the HTTP response. `null` = the handler took over `ctx.res`
// itself (the void escape hatch). Author `headers` override the content-type default.
async function sendResult(res: ServerResponse, result: RouteResult | null, renderView: ViewRenderer, carryLocale: (href: string) => string = (href) => href): Promise<void> {
async function sendResult(res: ServerResponse, result: RouteResult | null, renderView: ViewRenderer): Promise<void> {
if (result == null || res.writableEnded) return;
if ("redirect" in result) {
// A redirect to one of our own pages keeps the visitor's chosen locale (a POST→redirect→GET
// would otherwise drop it); an off-site target is left exactly as the handler wrote it.
res.writeHead(result.status ?? 303, { location: carryLocale(result.redirect) }).end();
res.writeHead(result.status ?? 303, { location: result.redirect }).end();
return;
}
if ("json" in result) {
+1 -6
View File
@@ -14,15 +14,10 @@ export interface RequestCsrf {
token: string;
}
// A context scoped to a plugin: same request, but `t` reads that plugin's catalog first. The
// landing slots run a plugin's handler, so they must hand it one of these rather than the host's
// own context — otherwise the plugin's keys render as bare keys on the pages it owns.
export type PluginContextFactory = (pluginId: string) => RequestContext;
export interface BuiltinRoute {
// Returns a RouteResult, or null when the handler wrote to ctx.res itself
// (the landing slots dispatch a plugin's own result against that plugin's views).
handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | null> | RouteResult | null;
handler: (ctx: RequestContext, csrf: RequestCsrf) => Promise<RouteResult | null> | RouteResult | null;
method: "GET" | "POST"; // a GET route also answers HEAD, like plugin routes
path: string; // exact pathname
}
+4 -4
View File
@@ -23,7 +23,7 @@ test("buildContext parses the URL, exposes query, and defaults to an anonymous u
assert.equal(ctx.query.get("q"), "ann");
assert.equal(ctx.query.get("page"), "2");
assert.equal(ctx.user, null);
assert.deepEqual(ctx.permissions, []);
assert.deepEqual(ctx.roles, []);
assert.deepEqual(ctx.params, {});
});
@@ -33,12 +33,12 @@ test("buildContext threads path params supplied by the router", () => {
assert.equal(ctx.params.id, "42");
});
test("buildContext threads the user and derives permissions from it", () => {
test("buildContext threads the user and derives roles from it", () => {
const { req, res } = reqRes("/");
const user: User = { email: "a@b.c", id: "u1", permissions: ["admin", "editor"] };
const user: User = { email: "a@b.c", id: "u1", roles: ["admin", "editor"] };
const ctx = buildContext(req, res, { user });
assert.equal(ctx.user, user);
assert.equal(ctx.permissions, user.permissions); // same reference, never a divergent copy — buildContext is the only writer
assert.equal(ctx.roles, user.roles); // same reference, never a divergent copy — buildContext is the only writer
});
test("buildContext defaults a missing request URL to /", () => {
+8 -41
View File
@@ -1,61 +1,38 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
import type { PermissionDecl } from "../plugin-host/plugin.ts"; // type-only
import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only
import { DEFAULT_LOCALE } from "../i18n/catalog.ts";
import { ENGLISH } from "../i18n/english.ts";
import type { Translate } from "../i18n/translate.ts";
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.
// The signed-in user, projected from verified session JWT claims. Ory calls this record an
// "identity" (see README); Plainpages says user throughout.
// `id` = `sub`, plus `email` and the coarse `permissions` carried in the token.
// The authenticated user, projected from verified session JWT claims:
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token.
export interface User {
email: string;
id: string;
permissions: string[];
roles: string[];
}
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 locale this request is served in, e.g. "sv-SE" — also what <html lang> says.
locale: string;
// Carry the visitor's chosen locale onto a link this page renders. A no-op unless the request
// asked for one with ?locale (there is no locale cookie — the URL is where the choice lives), and
// on off-site URLs. The host already does this for the chrome and its own redirects; a plugin
// wraps the hrefs it builds itself.
localeHref(href: string): string;
// Every installed locale, sorted. With `localeLabel` (from #plugin-api) it is what a plugin needs
// to build its own language picker; the host's own picker is already in the shell.
locales: string[];
// 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.
log: Log;
// Every permission the installed plugins declare, deduped and sorted — the fixed list an admin
// screen offers when granting one. Pairs with `permissions` below: this is what *exists*, that is
// what *this user holds*. Empty when no installed plugin declares any.
declaredPermissions: readonly PermissionDecl[];
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
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
// 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;
// Translate a key in this request's locale: `ctx.t("shifts.title")`, `ctx.t("greeting", { name })`.
// Returns raw text — escape it like any other value when rendering. An unknown key renders as
// itself, so a plain string is always safe to pass.
t: Translate;
url: URL;
user: User | null; // the signed-in user, or null when anonymous
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;
@@ -66,15 +43,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;
declaredPermissions?: readonly PermissionDecl[];
user?: User | null;
locale?: string;
localeHref?: (href: string) => string;
locales?: string[];
log?: Log;
params?: Record<string, string>;
system?: SystemCapabilities;
t?: Translate;
user?: User | null;
verifyCsrf?: (submitted: string | null | undefined) => boolean;
}
@@ -95,20 +67,15 @@ export function buildContext(
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
return {
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
declaredPermissions: options.declaredPermissions ?? [],
user,
locale: options.locale ?? DEFAULT_LOCALE,
localeHref: options.localeHref ?? ((href) => href),
locales: options.locales ?? [DEFAULT_LOCALE],
log: options.log ?? SILENT_LOG,
params: options.params ?? {},
query: url.searchParams,
req,
res,
permissions: user?.permissions ?? [],
roles: user?.roles ?? [],
...(options.system ? { system: options.system } : {}),
t: options.t ?? ENGLISH,
url,
user,
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
};
}

Some files were not shown because too many files have changed in this diff Show More