Compare commits

..

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
270 changed files with 5805 additions and 16226 deletions
-5
View File
@@ -1,14 +1,9 @@
.git
# Load-bearing both ways: a stray copy would bake in at /app/node_modules and shadow /node_modules,
# and matching only the root one is what lets a baked plugin keep its own deps. Never `**/node_modules`.
node_modules
npm-debug.log
*.log
.DS_Store
# A plugin's .npmrc is where a private-registry token would sit — never in a shipped image.
plugins/**/.npmrc
e2e-tests/artifacts
# Orchestration, not test code — keep them out of the runner image (COPY e2e-tests/ ./)
e2e-tests/Dockerfile
+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.22.2-alpine3.24 node registry-cleanup/cleanup.ts
node:24.18.1-alpine3.24 node registry-cleanup/cleanup.ts
+9 -69
View File
@@ -1,29 +1,13 @@
name: Release
# The Hub overview names a released tag, so it is published here only, never on a push to main.
on:
push:
tags: ['v[0-9]+.[0-9]+.[0-9]+']
workflow_dispatch:
inputs:
overview_version:
description: 'Released version to republish the overview for, without the leading v (e.g. 0.1.0)'
required: true
jobs:
retag-image:
if: github.event_name == 'push'
runs-on: docker-host
steps:
- uses: actions/checkout@v7.0.1
# Before anything is published: the contract version IS the release version, so a tag that
# disagrees would ship a host misreporting itself to every plugin's compatibility check.
- name: Refuse a tag that disagrees with HOST_API_VERSION
env:
GIT_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
- uses: actions/checkout@v4.2.2
- name: Promote the commit-hash image to semver + latest
env:
GIT_TAG: ${{ github.ref_name }}
@@ -31,78 +15,34 @@ jobs:
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
REPO: gitea.larvit.se/${{ github.repository }}
run: |
set -euo pipefail
COMMIT=$(git rev-parse 'HEAD^{commit}')
VERSION=${GIT_TAG#v}
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
docker pull "$REPO:$COMMIT" \
|| { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; }
# No bare-major tag while major is 0: a 0.x minor is a contract break, so `:0` would move
# across one and abort boot for everything tracking it. `:0.1` only moves across patches.
TAGS="$VERSION ${VERSION%.*} latest"
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
for TAG in $TAGS; do
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
docker tag "$REPO:$COMMIT" "$REPO:$TAG"
docker push "$REPO:$TAG"
done
- name: Sync the release tags to Docker Hub
env:
DOCKERHUB_IMAGE: docker.io/${{ github.repository }}
DOCKERHUB_REPO: docker.io/${{ github.repository }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
GIT_TAG: ${{ github.ref_name }}
REPO: gitea.larvit.se/${{ github.repository }}
run: |
set -euo pipefail
COMMIT=$(git rev-parse 'HEAD^{commit}')
VERSION=${GIT_TAG#v}
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (docs/ci-cd.md)"; exit 1; }
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; }
printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
TAGS="$VERSION ${VERSION%.*} latest"
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
for TAG in $TAGS; do
docker tag "$REPO:$COMMIT" "$DOCKERHUB_IMAGE:$TAG"
docker push "$DOCKERHUB_IMAGE:$TAG"
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
docker tag "$REPO:$COMMIT" "$DOCKERHUB_REPO:$TAG"
docker push "$DOCKERHUB_REPO:$TAG"
done
- name: Log out of the registries
if: always()
run: |
set -uo pipefail
# This is cleanup: it must not fail a release that published, nor skip the overview job
# that follows. The runner's Docker config is shared, so a logout can lose a race
# (docs/decisions.md → "CI docker logins share the runner host's Docker config").
docker logout gitea.larvit.se || true
docker logout docker.io || true
publish-overview:
if: always() && (github.event_name == 'workflow_dispatch' || needs.retag-image.result == 'success')
needs: [retag-image]
runs-on: docker-host
steps:
- uses: actions/checkout@v7.0.1
if: github.event_name == 'push'
# Publish the named release's own tree, so the page never pairs one Plainpages tag with another
# release's sidecar pins. A version that was never released fails here.
- uses: actions/checkout@v7.0.1
if: github.event_name == 'workflow_dispatch'
with:
ref: refs/tags/v${{ inputs.overview_version }}
- name: Publish the Docker Hub overview
env:
DOCKERHUB_REPO: ${{ github.repository }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
GIT_TAG: ${{ github.ref_name }}
INPUT_VERSION: ${{ inputs.overview_version }}
run: |
set -euo pipefail
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
VERSION=${VERSION#v}
# An empty dispatch input falls back to the branch name, so gate this like a tag.
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
docker run --rm -v "$PWD:/repo" -w /repo \
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
node:24.22.2-alpine3.24 \
node release-tooling/dockerhub-overview.ts "$VERSION"
docker logout gitea.larvit.se
docker logout docker.io
+6 -16
View File
@@ -21,25 +21,22 @@ jobs:
-e RENOVATE_PLATFORM=gitea \
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
-e RENOVATE_TOKEN \
renovate/renovate:44.199.2
renovate/renovate:44.6.0
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
# trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit
# (a human owns that release), nothing new merged, or nothing that merged carried a `Release-Bump:`
# trailer — a release nobody can observe is noise. ff-only merges keep the renovate commit's
# (a human owns that release) or nothing new merged. ff-only merges keep the renovate commit's
# authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer;
# pre-1.0 shifts down (release-tooling/next-version.ts). Tag-only — release.yml promotes the
# 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).
auto-release:
runs-on: docker-host
needs: renovate
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
# A persisted token outranks the PAT in the push URL, and Gitea runs nothing for its pushes.
persist-credentials: false
- name: Tag a release for what Renovate merged
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
@@ -57,15 +54,8 @@ jobs:
fi
BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \
--format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; })
if [ -z "$BUMPS" ]; then
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
fi
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/next-version.ts "$LATEST" $BUMPS)
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
git show origin/main:src/plugin-host/plugin.ts \
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/contract-version.ts "$NEXT" -
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
git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT"
+2 -8
View File
@@ -3,10 +3,8 @@
*.log
node_modules
# Playwright E2E outputs (screenshots, html report, traces). The dir itself is tracked: an absent
# bind-mount source is created by the daemon as root, which the unprivileged runner cannot write.
/e2e-tests/artifacts/*
!/e2e-tests/artifacts/.gitkeep
# Playwright E2E outputs (screenshots, html report, traces)
e2e-tests/artifacts/
# plugins/ is a drop-in mount point, not committed code — keep it empty (see examples/plugins/ for the reference)
/plugins/*
@@ -15,7 +13,3 @@ node_modules
# 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
+135 -217
View File
@@ -3,137 +3,96 @@
Guidance for AI agents and contributors working in this repo. Read `README.md` for
commands and layout.
## Prose discipline
Every word in this repo is read again on every future task, so prose is a recurring cost. On **any**
change, sweep the prose you touched — this file, `README.md`, the example READMEs, and code
comments — and cut it back to what a competent reader could not infer:
- **Delete history.** Git holds it. No "this moved from X", "used to be Y", "was tried and
rejected", "(declined twice)", dated changelog entries (a decision's date in `docs/decisions.md`
is not one), or the symptom that prompted a fix. Record the decision and the reason it
*currently* turns on, nothing else.
- **Delete restatement.** A comment that says what the adjacent line says, a doc paragraph that
re-explains a table above it, a file-map entry that expands the filename. The fix is deletion,
not trimming.
- **Delete the self-evident** and anything already stated once elsewhere. **One home per fact** —
link to it instead of repeating it; the same sentence in five files is five chances to drift.
- **Give every accepted risk an expiry** ("valid while X"), and delete the entry once X stops
holding.
- **Keep** the surprising why, the footgun, the invariant, the external constraint, and the one-time
setup a reader cannot dig out of the code. Once a line has earned its place, make it short and
information-dense.
Trimming is not a separate task to schedule — do it in the same change, every time.
## How to work with tasks
Use the file `todo.md`.
For each todo item, interview the user extensively to deeply understand the scope and goal of
each. When done, check the completed task in `todo.md`. Commit all changes and push to a new
branch, create a PR and merge it when the CI/CD turns green.
For each todo item, interview the user extensively to deeply understand the scope and goal of each. When done, check the completed task in `todo.md`. Commit all changes and push to a new branch, create a PR and merge it when the CI/CD turns green.
## Project priorities (do not erode)
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
**A page is a document**: it scrolls, and the chrome scrolls with it. Nothing may bound the
viewport to hold content still — no `height: 100dvh` frame, no `overflow: hidden` on `body`, no
`position: sticky` header. Each such box buys an app-like look with CSS the next reader has to
reverse-engineer, and is one more thing to undo before the content under it can be reached.
Overlays are not this: the skip link, the mobile off-canvas nav and its scrim sit *above* the
document rather than holding it still, and have no other spelling — the document keeps scrolling
behind the open nav, accepted rather than overlooked. Only the document scroller gets keyboard
paging unconditionally and back/forward scroll restoration, and a page a box clips fails silently:
nothing in a test or a console says content is unreachable below the fold.
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`,
`postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks.
The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
Postgres database, which the host provisions but never reads or writes inside. Auth/identity/OAuth are
**Ory sidecar services** reached over their REST APIs with built-in `fetch` — no SDK. New
capabilities ship as **plugin folders** under `plugins/` that get their data from an upstream
service or their own database, not as core code.
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`,
`@larvit/log` — the last itself zero-dependency, for structured/OTLP logging).
Prefer the Node standard library; justify any new dependency; do not add
frameworks. The app is
**stateless — no database**. Auth/identity/OAuth are **Ory sidecar services**
(Kratos/Keto/Hydra, backed by Postgres), reached over their REST APIs with
built-in `fetch` — no SDK dependency. New capabilities ship as **plugin
folders** under `plugins/` that fetch their data from upstream services, not as
core code. See `README.md` for the architecture.
3. **Strict TypeScript** — `tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`,
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer exact types;
limit nullable and multi-option types.
4. **Environment-agnostic** — no `NODE_ENV` branching. Every behaviour is an **explicit config
toggle** read once in `src/config.ts`; compose files set them per deployment.
5. **Semantic, accessible DOM** — the right element for the job (landmarks, one `<h1>` per page +
sane heading order, lists, `<table>` with row/column headers, `<fieldset>`/`<legend>`, `<button>`
vs `<a>`); ARIA only to fill real gaps. Classes/ids name *meaning*, not looks.
6. **Full, parallel E2E** — every user-facing flow has a Playwright test, shipped in the same change
as the surface. Tests stay independent and side-effect-free so the suite runs `fullyParallel`.
7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the only way to
add domain features. It optimises for being powerful, predictable and overloadable, and the host
**fails loud at boot/discovery** rather than sandboxing at runtime. Runtime crash-isolation is a
deliberate **non-goal**.
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer
exact types and limit nullable and multi option types when possible. KISS.
4. **Environment-agnostic** — the app never asks *which environment* it runs in; there is
no `NODE_ENV` (or equivalent) branching. Every behaviour is an **explicit config
toggle** (e.g. `CACHE_TEMPLATES`, `REQUIRE_SECURE_SECRETS`, a future "disable email"),
read once in `src/config.ts`. Compose files set the toggles per deployment.
5. **Semantic, accessible DOM** — markup is a first-class concern. Use the right element
for the job (landmarks, one `<h1>` per page + sane heading order, lists, `<table>` with
row/column headers, `<fieldset>`/`<legend>`, `<button>` vs `<a>`); add ARIA only to fill
real gaps (`aria-current`, `aria-sort`, labels). Classes/ids name *meaning*, not looks.
Prefer native semantics over `div` + ARIA. New views and partials keep this bar.
6. **Full, parallel E2E** — every user-facing flow (each page, form, guard, plugin route)
has a Playwright E2E test, and a new surface ships *with* its E2E in the same change.
Tests stay independent and side-effect-free so the suite runs `fullyParallel` — keep it
that way as it grows (never serialise on shared state); parallelism is what keeps it
fast. E2E runs in Docker against the live stack — see `README.md`.
7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the
only way to add domain features. It optimises for being **powerful, predictable, and
overloadable** (a plugin can take over as much of a page as it wants), and the host
**fails loud at boot/discovery** (bad manifest, version mismatch, or conflict stops
startup with a clear message) rather than sandboxing at runtime. Runtime crash-isolation
is a deliberate **non-goal** — diagnose at deploy time, not in production. Keep this
contract stable; see `README.md` → Building plugins.
## Decisions
## Deliberate architectural deviations (don't re-flag)
Each title below is an entry in `docs/decisions.md`, with its reasoning and the premise it rests
on; a review honours them.
Intentional, reasoned choices — an architecture review should honor them, not re-raise
them. Revisit only if the stated reason stops holding.
### Structure & contracts
- `src/` is grouped by concern, and the core ships no domain screens
- Plugins and config import the host only through the two barrels
- The Ory client types are contract-visible through the barrel
- The barrel is a package, so a plugin folder may carry its own `package.json`
- Plugin storage hands over credentials, not a client
- Plugin settings are declared, not discovered
- The trust boundary is the `web` process, not the plugin
- `ory/postgres/init/init.sql` is the only home for the Ory databases' ACL
- `bootstrap.ts` stays under `src/auth/`
- `BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook
- `config/` is a plain dir with no `package.json` of its own
- A plugin `package.json` without `"type": "module"` is refused, not warned
- `examples/` mirrors the drop-in mount dirs
- `ctx.chrome` is lazily memoized, never built unconditionally
- A plugin-owned render always runs on that plugin's context
- Email is delegated to Kratos
### Authorization
- The vocabulary is `User` → `Group` → `Permission`, and there is no `Role`
- A permission name is always `<resource>:<action>`, enforced at discovery
- Permission names are authored in plugin code; only grants live in Keto
- A permission's `<resource>` is global, not plugin-scoped
- Declaring a permission stays optional
- `ADMIN_PERMISSIONS` defaults to empty, and an unusable value is dropped with a warning
- `actionForMethod` is plugin-local and stays out of `@plainpages/plugin-api`
- A gate is one of `public`, `session` or `permission`, named exactly once
- The reference plugin's two shift pages duplicate a view model and markup
- A `:read`-only holder is never shown a write affordance
- `users:write` and `groups:write` each amount to full administrative access
- The CSRF token is unbound to the session
- Plainpages says "user" everywhere, where Ory says "identity"
### i18n
- The locale lives in the URL, never in a cookie
- The core building blocks carry the locale, so a plugin doesn't have to
- `locale` and the i18n view locals are reserved by the host
- Catalogs are checked at boot, not at render
- `locales/` at the repo root is a drop-in mount
- The language picker is on every page, POST-rendered ones included
- An unknown translation key renders as itself
- `t()` returns raw text, and the view escapes it
- RTL is out of scope until there is a real use case
### UI
- A dropdown is a `<button popovertarget>` and a `[popover]`, never a `<details>`
- The document is the one scroller
- `ICON_NAMES` is a host-owned registry, not a plugin contract
### Build, test & release
- Dependencies install to `/node_modules`, above `WORKDIR /app`
- A container whose output a human then edits or deletes runs as the invoking user
- Anything the browser logs fails the E2E test that provoked it
- The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium
- The docs-only CI skip is `*.md` anywhere in the tree
- CI docker logins share the runner host's Docker config
- **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/`
(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/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
(`/`). (Declined twice.)
- **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web`
never touches SMTP. Customization is Kratos' built-in `courier.template_override_path`,
not app code — keeping `web` stateless and dependency-light (see [Email](README.md#email)).
- **Plugins and config import the host only via package.json `imports`** — `#plugin-api`
→ `src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts` — never a
relative `../../src/*` path. These two barrels are the whole author/operator contract
surface; the `src/*` behind them may be refactored freely. Depth-independent and
refactor-stable by design — don't "fix" a `#`-import back to a relative path.
**One caveat:** `#plugin-api` re-exports the Ory client types for the `ctx.system` surface
(`KratosAdmin`/`KetoClient`/`HydraAdmin` + their DTOs and error classes). Those shapes are
therefore **contract-visible** — changing them is a plugin-API break needing a major
`apiVersion` bump, not a free refactor. Keep the Ory clients stable, or bump the version.
- **A plugin/config folder must stay a plain folder — no `package.json` of its own.** Node
resolves `#`-specifiers against the nearest parent `package.json`; a `package.json` inside
the folder becomes its own scope and `#plugin-api`/`#menu-config` stop resolving. Accepted
cost of the `#`-import contract (fits the stateless, no-per-plugin-deps ethos). A plugin
kept in its own repo typechecks against the barrel only when mounted under the host tree
(or by adding a local `imports` map / vendored stub).
- **`examples/` mirrors the drop-in mount dirs** — `examples/plugins/<id>/` copies to
`plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirror folders are in
`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).
- **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),
and tokens sit in that file between login and logout. Same class: concurrent runs share the
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.
## Docker only — no host tooling
@@ -143,7 +102,6 @@ Docker Compose. **Never run `node`, `npm`, or `tsc` on the host.**
```bash
docker compose up # dev server, live reload
docker compose run --rm --no-deps web npm run typecheck # strict type check (--no-deps: skip Ory)
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --rm --no-deps --build e2e npm run typecheck # the E2E specs
docker compose run --rm --no-deps web npm test # tests
docker compose -f compose.yml up --build -d # production
```
@@ -152,108 +110,68 @@ docker compose -f compose.yml up --build -d # production
`README.md` serves two readers, in this order — preserve it when editing:
1. **First-time reader (top).** A one/two-sentence tagline, then a **Quick start** that gets the
stack up and a *minimal* plugin live. Nothing comes before Quick start. Keep its commands
copy-pasteable; deeper detail lives in its own section, linked.
2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered
by **what an adopter reaches for first**, not by architectural layering: Goals → Audience → Overview → Glossary → Users, groups
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file
map → Extending. Place a new section by how early an adopter needs it. **Users, groups &
permissions precedes Building plugins** because a manifest's `permission:` gate is unreadable
without the model, and it is the one home for that model.
1. **First-time reader (top).** A one/two-sentence tagline, then a **Quick start** that gets
the stack up (`docker compose up`, sign in) and a *minimal* plugin live. Nothing comes
before Quick start — no philosophy, no rationale. Keep its commands copy-pasteable and the
example plugin as small as possible; deeper detail lives in its own section, linked.
2. **Returning developer (rest).** A **Contents** ToC immediately after Quick start, then
sections ordered by **what a developer adopting Plainpages reaches for, in priority
order** — not by architectural layering. The value that sets the order: getting up and
running **building plugins** comes first, then **configuring and securing** the system
(Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are
deliberately deferred — they're not top of mind when starting out. Concretely: Overview →
Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
Architecture → Testing → Production → Observability → the JWT-rotation runbook → the
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.
Keep the ToC in sync when you add/rename/remove an `H2`/`H3`. **Don't document internals** — how a
script reaches a decision, what a function guards; a developer reads that off the code in seconds.
The README earns its length on how to use and operate Plainpages, the external contracts, and
one-time setup. A file-map or table row gets a clause, not a paragraph.
When editing: put content in the section it belongs to (don't prepend rationale above Quick
start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in
one home, linking to it rather than restating (credentials, env vars, rotation steps).
**Don't document internals here.** How a script reaches a decision, why one run behaved
differently from another, what a function guards — a developer doesn't need it day to day and
can read it off the code or a run's log in seconds. Prose like that only makes the README
longer and harder to consume, for humans and machines alike. It belongs in the code it
describes, or nowhere. The README earns its length on what you cannot dig out: how to use and
operate Plainpages, the external contracts, and one-time setup (secrets, accounts, tokens).
Same test before adding a row to a table or the file map — a clause, not a paragraph.
## Rules
- Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable**
(`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or decorators. Import
local modules with their `.ts` extension.
- **No `.mjs`.** Write modules as `.ts` — even standalone scripts run in bare `node:24` containers.
If a file genuinely must be plain JavaScript, use `.js`; `"type": "module"` is set in both
(`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or
decorators. Import local modules with their `.ts` extension.
- **No `.mjs`.** Write modules as `.ts` (Prio 1) — even standalone scripts run in bare
`node:24` containers (the e2e mock servers, `examples/shifts-upstream/server.ts`): Node
strips types and detects ESM from syntax, no package.json needed. If a file genuinely
must be plain JavaScript, use `.js` (Prio 2); `"type": "module"` is already set in both
`package.json`s, so `.js` is ESM.
- **No build step** and no compiled artifacts — do not add a bundler or `tsc` emit.
- Before finishing a change, run the typechecks and tests above; all must pass.
- Before finishing a change, run the typecheck and tests above; both must pass.
- Tests use the built-in `node --test` runner — no test framework dependency.
- English everywhere.
- Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never
ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images
by tag.
- **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist*: its
rules name exactly what carries the trailer, so a dependency outside them never escalates the
release version and nothing fails to say so. A new manifest, compose file, custom manager or dep
type is a decision: can it reach a running Plainpages? If yes it needs a rule; if no, record nothing
and let it ride the next patch.
- **`HOST_API_VERSION` *is* the release version.** Its `major.minor` must equal the release tag's, and
both release paths refuse a tag that disagrees (`release-tooling/contract-version.ts`). The patch
digit may lag on purpose: `checkApiVersion`
ignores patch, and auto-release cuts patch releases with no commit to bump a constant in. So a
dependency update big enough to force a **minor** is plugin-visible by definition — `auto-release`
stops rather than tagging, and the fix is to bump `HOST_API_VERSION` to that `X.Y.0` in a PR, merge
it, then tag. Never bump it to "catch up" with a patch release. **The contract surface
includes `views/partials/*.ejs`** — 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. Know the hole
that leaves: discovery fails loud on a bad `apiVersion`, but `include("menu", { open: true })`
silently ignores a dropped option, so the partial vocabulary is a surface the version check cannot
police for you.
- **The contract surface also includes the packaging promises** (README → Plugin dependencies): the
barrel is ambient at `/node_modules` with nothing for a plugin to declare, `"type": "module"` is
mandatory, and the host neither upgrades nor dedupes a plugin's dependencies. Same hole as the
partials — move the publish point, rename the package or start hoisting and every installed plugin
breaks with no version signal. Note the promise is deliberately *not* "your deps are yours alone":
build-time dedupe for baked images stays open, module-instance sharing stays unpromised.
- **Publishing `@plainpages/plugin-api` to a registry is deferred, not rejected.** Today it is
`private` and shaped as a shim — `index.ts` re-exports `../src/…`, so `npm pack` would ship a
broken tree. The trigger is the first plugin author outside this repo — the first who cannot
typecheck against a mounted host tree. Whoever does it must first make the artifact self-contained
(types-only `.d.ts`, or move the barrel into `plugin-api/`).
- A plugin's `apiVersion` is a **hand-written literal** semver — the host version it 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 host, so `checkApiVersion` can never fire.
- **Plugin route handlers are thin and per-route, keyed on `ctx.params`.** Register one handler per
`{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed `%`-encoding).
Don't funnel many routes into one dispatcher 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
into a small `withX` wrapper — see `examples/plugins/auth-admin/`.
- **A function stays within 50 lines and complexity 20** (ESLint's defaults, measured by
`src/function-limits.test.ts`). Split one that outgrows them; never add it to the test's
`grandfathered` list or raise a ceiling there.
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer agents.
- **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") 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:** slightly different wording is often the right
call, and a build-failing check takes that judgment away.
- Use well formed, standard compliant, rich URIs. **State lives in the URL or on the server, never in
a cookie.** Prefer state in the URL over POSTing it, for example on list pages with filters and
pagination. A message for the page a redirect lands on rides its query string — `info-msg`,
`warn-msg`, `error-msg` — since a fragment never reaches the server. A cookie carries only what
must be bound to the browser: the session (`plainpages_jwt`) and the CSRF token (`plainpages_csrf`).
Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not `ids=x,y`.
## Comments
Default to **no comment**. Delete one that restates the adjacent code, repeats a convention used
elsewhere, justifies self-evident code, or records history. Write one only for what a competent
reader of *this* codebase could not infer: a surprising why, a footgun, an invariant, an external
constraint. See [Prose discipline](#prose-discipline).
## Scoring run
The comprehension panel's seats fill their prompts from these fields.
- **Language:** strict TypeScript on Node 24, with EJS views and plain CSS
- **Kind:** a server-rendered web GUI host with a plugin system
- **Domain:** web authentication, authorization and server-rendered admin GUIs
- **Domain docs:** the Ory Kratos, Keto and Hydra documentation
- **3am question:** a visitor on an old TV browser reports that the sign-in button is blank. Which file and rule?
- English everywhere. Keep code comments short and information-dense. Self explained code
without any comment at all is the preferred solution.
- Do not comment about history in the code or README. Like "This function included X before,
but it moved to Y".
- Do not comment about the absence of things, if it is not very unexpected. Banned is things
like "This function does not calculate pi, that is done in function Z".
- Pin all dependencies and Docker images to exact, human-readable **semantic
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`).
- 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
host, so `checkApiVersion` can never fire and a breaking change slips through silently.
- **Plugin route handlers are thin and per-route, keyed on `ctx.params`.** Register one handler
per `{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed
`%`-encoding — no manual path-slicing/decoding). Don't funnel many routes into one dispatcher
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/`.
- 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.
- 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".
-340
View File
@@ -1,340 +0,0 @@
# Changelog
The release version **is** the plugin contract version (`HOST_API_VERSION`), so a minor is a
contract break: a plugin's `apiVersion` must match the host's `major.minor` or discovery refuses it
at boot. Entries start at 0.3.0.
## Unreleased
### Plugin contract 0.5: set `apiVersion` to `"0.5.0"`
The host's contract version is now 0.5.0, so discovery refuses a plugin whose `apiVersion` is still
`0.4.x`. Change the literal in each plugin's manifest when you upgrade, and add `assetHref` to any
`RequestContext` your tests build by hand: `assetHref: (href) => href`.
### The core dashboard view is `dashboard`, not `index`
`views/index.ejs` is now `views/dashboard.ejs`. The `apiVersion` check can't see this: a plugin view
that still does `include("index")` fails when it renders, with the 500 page. Change it to
`include("dashboard")`.
### `KetoClient.transactTuples` applies inserts and deletes as one transaction
`transactTuples({ delete, insert })` sends both lists to Keto in one `PATCH /admin/relation-tuples`,
so all of them apply or none does. A `KetoClient` you build by hand, such as a test stub, must add the
method. The admin example saves a user's or group's permissions through it, so a Keto failure leaves
every grant as it was and answers with the error page.
### The admin example deletes a group's grants and members in one Keto transaction
A Keto failure while deleting a group now leaves the group as it was, with its grants and members,
and answers with the error page. It used to leave some grants gone and the members still listed.
### The admin example keeps a user's permissions section when it refuses a self-deactivate or self-delete
The refusal page used to drop the permissions section of the user form.
### The admin example lists a user's or group's grants that no installed plugin declares, with a Remove
A direct grant of a name no installed plugin declares, such as one left by an uninstalled plugin,
used to stay invisible and unchangeable, and reinstalling the plugin silently reactivated it. The
user and group pages now list these apart from the checkboxes, each with a Remove button (hidden from
a `:read`-only holder). The example's catalogs gain `admin.grants.remove`, `admin.grants.removed`,
`admin.grants.undeclaredHint` and `admin.grants.undeclaredLegend`, and its routes gain
`POST /auth-admin/users/:id/permissions/remove` and `POST /auth-admin/groups/:name/permissions/remove`.
### An operator can hide or rename a plugin's profile-menu entry
A `profileMenu` entry takes an optional `id`, and `config/menu.ts`'s `override.hide` and
`override.rename` apply to it as they do to a nav node. Nav nodes and profile-menu entries share one
set of ids, so the boot refuses an entry whose `id` another entry or nav node already uses.
### The admin example plugin is now `auth-admin`, and its screens live at `/auth-admin/*`
The Users, Groups, OAuth2-clients and Plugin-settings screens move from `/admin/*` to
`/auth-admin/*`, and nothing redirects the old paths, so update any proxy rule, bookmark, alert or
log query that matches `/admin/`. An installed `plugins/admin/` still declares `apiVersion` 0.4, so
the boot refuses it: delete it, copy `examples/plugins/auth-admin/` into `plugins/`, and move any
`locales/plugins/admin/` to `locales/plugins/auth-admin/`.
### The `request` log line renames `method`, `path`, `status` and `ms`, and gains the caller, site and route
Update dashboards and log queries: the `request` line and the request span now use OpenTelemetry's
HTTP names. `method`, `path` and `status` become `http.request.method`, `url.path` and
`http.response.status_code`. `ms` is gone: latency is the span's duration, so it needs
`OTLP_ENDPOINT`. New are `http.route` (when a route matched), `server.address` (the `Host`),
`user_agent.original` and `client.address`. A plugin's own lines for a request carry them too,
except the status.
`client.address` is the socket's peer. Set `TRUSTED_PROXIES` (comma-separated IPs or CIDR ranges) to
the proxies in front of the host and it follows `X-Forwarded-For`; a bad entry refuses the boot.
README → Observability says which ranges to list. The log now holds callers' IPs and user agents,
which are personal data: say so in your privacy policy.
### `buildPaginationModel` builds the pagination partial's model
Call `buildPaginationModel(page, hrefFor, { label?, pageSizes?, pageParam?, pageSizeParam? })` from
`@plainpages/plugin-api` in place of mapping `paginate()`'s result to `partials/pagination` by hand.
`hrefFor(n)` is your list's URL on page n, and the rows-per-page form carries the same list state as
the links. A list that renamed its params in `parseListQuery` passes the same names here.
### A plugin can add links to the profile menu
Declare `profileMenu: [{ href, label, icon?, …gate }]` in the manifest. Each entry names exactly one
gate like a nav node, and shows above Sign out to a signed-in visitor who passes it. Discovery
refuses an entry without an `href`, a `label`, or exactly one gate.
### A plugin can version its own `/public/` files
Wrap the href of a script, image or other file your own markup links in `assetHref("/public/<id>/…")` in a view,
or `ctx.assetHref(…)` in a handler. The href gets the file's content hash, and the browser caches the
file for a year instead of revalidating it every five minutes.
### Discovery warns when a gate names a permission no plugin declares
A route or nav node that gates on a permission no installed plugin declares now logs a boot warning
naming the plugin and the permission. The admin screens offer only declared permissions, so nobody can
be granted such a gate from them. Add the name to the plugin's `permissions` to silence the warning.
### Discovery refuses a plugin with the id `error`
`/error` is the page Kratos sends a failed self-service flow to, and a plugin named `error` would
shadow it. Discovery now refuses that id at boot, like the other reserved ids. Rename such a plugin's
folder before upgrading.
### The admin's "group already exists" refusal is translated
Creating a group whose name is taken shows the refusal in the page's language, not always in English.
### A malformed URL variable's boot error no longer prints the value
When a URL variable such as `PLUGIN_DB_URL`, `PLUGIN_DB_ADMIN_URL` or `OTLP_ENDPOINT` can't be parsed,
the boot error names the variable and not its value, so a DSN whose password breaks parsing doesn't
put the password in the log.
### A secret plugin setting's bad value stays out of the boot error
When a `secret` setting's value fails validation, the boot error no longer prints it, so a malformed
DSN doesn't put its password in the log.
### The admin's recovery-code notice links to the recovery flow the code belongs to
After an admin generates a recovery code for a user, the notice asks them to send the user both the
code and its link. The link opens the recovery flow the code belongs to, where the user enters it.
Before, the notice linked a new recovery flow at its email step, which doesn't accept the code.
### A verification or recovery code pasted with whitespace around it is accepted
The code field on the verification and recovery pages takes a code with spaces or tabs around it.
Pasting a code from an email often adds them, and before, the browser refused the code. Those two
forms now post to Plainpages, which trims the code and passes the form on to Kratos at
`KRATOS_PUBLIC_URL`. A visitor who picked a language now keeps it through both flows.
### A filter shows its selected and focused choice in browsers without `:has()`
On Chromium below 105, Firefox below 121 and Safari below 15.4, a filter's segments and chips now show
their native radio or checkbox, which marks the selected choice and takes the keyboard focus ring.
Before, they showed neither. Newer browsers look as before.
### The tests fail on a stylesheet feature the browser floor lacks
`npm test` checks every stylesheet in `public/`, `examples/` and `plugins/` against Chromium 87,
Safari 15 and Firefox 115: a feature one of them lacks fails it unless an `@supports` around it
names that feature. Mount a plugin in `plugins/<id>/` to check its stylesheets. Pages render as
before in every browser.
### Every release reaches Docker Hub, and the Hub page has a short description
Releases 0.4.1 to 0.4.20 were tagged in git but never published to Docker Hub or as semver tags in
the Gitea registry; they are now, and every later release is. The Hub page gains a one-line
description.
### The admin example stops an administrator locking themselves out through a group
On the Groups screen, unticking a permission on a group you are in, removing yourself (or a nested
group you are in) from its members, or deleting the group is refused when it would take away a
permission you don't hold directly. The page names the permissions, and another administrator can
make the change. The check errs towards refusing: it also refuses when you would keep the permission
through another group, or keep your membership another way.
### The admin example titles a read-only user page "View user"
A holder of `users:read` without `users:write` sees the user page titled "View user", with a "View"
breadcrumb, instead of "Edit user".
### Browsers cache stylesheets instead of downloading them on every page
Every `/public/` URL the host renders carries `?v=<hash of the file>` and is cached for a year as
immutable: the shell's stylesheets, favicon and logo, the `styles` a plugin passes to the shell, and
the stylesheets of the standalone error and consent pages. A changed file gets a new hash, so
browsers fetch it at once. A plugin names its stylesheets exactly as before. Any other `/public/` URL, such as a script
a plugin's own markup links, is cached for five minutes, then revalidated with `ETag` or
`Last-Modified`. A cache or CDN in front of Plainpages may now store `/public/` responses.
### A language chosen on a sign-in link no longer drops an absolute return target
`/login?return_to=<absolute URL>&locale=sv-SE` passed the visitor to the dashboard after sign-in and
dropped the OAuth2 login challenge in `return_to`. The target now reaches Kratos, and the chosen
language is lost across that round-trip instead.
### The admin example refuses a permission save made from an out-of-date page
Two administrators editing one user's or group's permissions no longer overwrite each other. The
form carries a token for the grants it showed. A save that would change something, made after those
grants changed, is refused with "These permissions changed while you had the page open", and the page
shows the current grants. A POST with no token is refused the same way, so a script posting to
`/auth-admin/users/:id/permissions` or `/auth-admin/groups/:name/permissions` must first read the token from the
page's `baseline` field.
### The admin example confirms a permission save and reports a partial one
A save now lands on the page with "Permissions saved." On a user, a changed grant reaches the user's
live sessions at once when the revocation denylist is on.
### A read-only holder sees View, not Edit, on the admin example's users list
A holder of `users:read` without `users:write` gets a "View" row action with an eye icon. The admin
example's catalogs gain `admin.grants.saved`, `admin.grants.stale` and
`admin.users.view`, so a translated catalog mounted under `locales/plugins/auth-admin/` must add them.
### Every page carries a smaller icon sprite
`i-chart`, `i-copy` and `i-download` are gone from the inlined icon sprite, so a plugin that
referenced one now renders a blank icon. Nothing in the host or the example plugins used them. Pick
another id from `views/partials/icons.ejs`, or inline your own SVG.
### The README defines its terms and states its intended scale
A new Glossary defines host, plugin, manifest, chrome, nav fragment, permission name, system plugin
and denylist. Plugin dependencies also states the scale Plainpages is sized for: a handful of plugins
per deployment.
### The mobile nav stays reachable on engines without `:has()`
At 860px and narrower, an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23,
and Firefox below 121) hid the sidebar off-canvas with no way to open it, so the pages the nav links
to could not be reached. Now the sidebar moves off-canvas, behind the hamburger, only where `:has()`
works. Elsewhere it stays in the flow above the content and the hamburger is hidden.
### The whole nav tree shows on engines without `:has()`
On an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23, and Firefox below 121)
every nested nav entry was hidden with nothing to open it. Now the tree folds only where `:has()`
works and stays fully open elsewhere, with its toggles hidden. The theme switch, which also needs
`:has()`, is hidden there.
### Button and badge text meets 4.5:1 contrast
The primary button and the current page button take their text colour from the new `--on-accent`
token: white in light mode, near-black in dark mode, where white on the accent was 2.7:1. The
light-mode success, warning and info badge text is darker. A plugin that overrides `--accent` or
`--l-accent` should set `--l-on-accent` and `--d-on-accent` to match.
### Unused auth stylesheet rules are gone
`public/css/auth.css` no longer holds the `.auth-view` screen switch, `.tpl-*` preview controls,
`.forgot-alert`, `.register-alert`, `.remember` and `.auth-foot`; no core view used them. A plugin
view that did must carry its own rules.
### Colours render on engines without `oklch()`
The theme palette in `public/css/styles.css` is hex, not `oklch()`. On an engine older than
Chromium 111 (Tizen 2024, webOS TV 24) `var(--accent)` was invalid, so the primary button was white
text on no background. A plugin that sets a colour custom property, such as `--accent` or `--l-accent`,
should use hex, `rgb()` or `hsl()` too.
### One horizontal inset across the content pane
The topbar, filter bar, pager, form page and empty-table message inset their content by `--pad-x`
(12px), as table cells do, instead of 16px. A plugin that overrides `--pad-x` now moves all of them
together.
### Pages are not kept by a shared cache
Every page and JSON response now carries `Cache-Control: private, no-store`, so a cache or reverse
proxy in front of Plainpages can't store a signed-in page and serve it to the next visitor. A route
whose page is the same for everyone can still set its own `cache-control` through
`RouteResult.headers`; assets are unchanged.
## 0.4.0
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
that relied on filling it scrolls the document instead.
### The document scrolls, and the chrome scrolls with it
`.app` was a `100dvh` box with `overflow: hidden`, so a page was only reachable below the fold if its
own wrapper was a flex child with `overflow-y: auto`. `.table-wrap` and `.shell-auth` were; nothing
else was, and a long page in `.form-page` clipped everything past the window in every engine.
Now the shell is `min-height: 100dvh` and nothing bounds the viewport. The sidebar and topbar scroll
with the page, and keyboard paging, back/forward scroll restoration and find-in-page work without a
page doing anything.
The sticky `thead` on `data-table` goes with it: a header only sticks to a scrollport that moves, and
there is no longer one. A plugin that wants a full-height pane owns that in its own stylesheet; the
shell offers no opt-out, per the simplicity priority in `AGENTS.md`.
### Upgrading a plugin
1. Set `apiVersion: "0.4.0"`.
2. A page that scrolled the whole window needs no change — it now scrolls the document.
3. A page holding a region that filled the content column (`flex: 1 1 auto; min-height: 0` with its
own `overflow`) no longer gets a bounded column to fill, so that region grows and the page scrolls.
Either let it, or give the region its own height in the plugin's stylesheet.
4. A `data-table` no longer scrolls its rows in a bounded region: the page scrolls, and the header
scrolls with it.
The sidebar stretches the whole document, so on a long page its footer — theme, language, profile and
**Sign out** — sits at the end of that page rather than the bottom of the screen.
## 0.3.0
**Breaking.** Set `apiVersion: "0.3.0"`, and name a gate on every route and nav node.
### A session is a gate of its own
`session: true` takes any signed-in user, with no grant to hold — for a page whose data is the
visitor's own (their upstream account, their own tokens), where there is no distinction a permission
could name. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a
permission gate does.
Every route and nav node now names **exactly one** of `public: true`, `session: true` or
`permission: "<resource>:<action>"`, and a gate is spelled `true`:
- Naming **none** is refused. It used to mean public, so a forgotten gate published a page; it now
fails the boot instead.
- Naming **two** is refused, as before.
- Spelling one anything but `true` is refused — `public: false` and `session: "yes"` both set no gate
while reading as if they set one.
A section header gates nothing itself, so it takes `public: true` and lets each child decide; the
host still drops a header whose children all filtered out.
`Gate` is exported from `@plainpages/plugin-api`, and `Route` and `NavNode` extend it.
### Filter bars take a multi-select
The `filter-bar` partial gains a `multiselect` control — the same checkboxes on the same query
parameter as `chips`, but behind a button once the list is too long to lay on the bar. Config is
`{ name, legend?, note?, value?, options }`, and the panel says what a capped list left out.
### Fixed
- An identity carrying no email no longer yields a session at all. Login used to mint a JWT for one,
which every later request then rejected as anonymous — leaving the browser holding a dead cookie
and no way to tell why.
### Dependencies
- Node 24.20.0.
### Upgrading a plugin
1. Set `apiVersion: "0.3.0"`.
2. Give every route and nav node a gate. Anything that relied on omitting one was public — say
`public: true` outright.
A page that scopes rows to the signed-in visitor should join on `ctx.user.id`. An email address is
user-changeable and can be reassigned to someone else, who would then inherit the previous holder's
rows. The reference plugin's new `/scheduling/mine` page shows the shape.
+6 -12
View File
@@ -1,20 +1,14 @@
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
FROM node:24.22.2-alpine3.24
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
# Dev deps kept so typecheck/test run in-image.
COPY package.json package-lock.json .npmrc /deps/
RUN cd /deps && npm ci && mv node_modules /node_modules && rm -rf /deps
# The barrel as a package, so a plugin folder can own a package.json. Linked because it re-exports /app.
RUN mkdir -p /node_modules/@plainpages && ln -s /app/plugin-api /node_modules/@plainpages/plugin-api
FROM node:24.18.1-alpine3.24
WORKDIR /app
# Reproducible install from the lockfile. Dev deps kept so typecheck/test run in-image.
COPY package.json package-lock.json .npmrc ./
RUN npm ci
COPY . .
# The host uid running a lockfile edit has no home here, so npm's cache would land in unwritable /.
ENV npm_config_cache=/tmp/.npm
ENV PORT=3000
EXPOSE 3000
CMD ["node", "src/server.ts"]
@@ -2,15 +2,14 @@
A self-hostable foundation for server-rendered web apps — public or gated pages from a
zero-JS design system, with a config-driven menu and auth/permissions (Ory) baked in.
Every domain feature is a drop-in plugin folder, with a Postgres database of its own if it wants
one; the host itself is stateless, and there is no build step.
Every domain feature is a drop-in plugin folder; the app is stateless, no build step.
**Source, docs & issues: <https://gitea.larvit.se/larvit/plainpages>**
([GitHub mirror](https://github.com/larvit/plainpages))
## Tags
`X.Y.Z` · `X.Y` · `latest` — each is a release promoted from a CI-gated build.
`X.Y.Z` · `X.Y` · `X` · `latest` — each is a release promoted from a CI-gated build.
Pin the exact `X.Y.Z` you deploy.
## Quick start
@@ -22,7 +21,7 @@ so there is nothing to clone. In an empty directory, save this as `compose.yml`:
```yaml
services:
web:
image: larvit/plainpages:{{VERSION}}
image: larvit/plainpages:0.0.2
ports:
- "3000:3000"
environment:
@@ -41,7 +40,7 @@ services:
# One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user.
bootstrap:
image: larvit/plainpages:{{VERSION}}
image: larvit/plainpages:0.0.2
command: node src/auth/bootstrap.ts
depends_on:
kratos:
@@ -54,7 +53,7 @@ services:
restart: "on-failure:5"
postgres:
image: postgres:18.6-alpine3.23
image: postgres:18.4-alpine3.23
environment:
POSTGRES_DB: ory
POSTGRES_PASSWORD: ory
@@ -131,7 +130,7 @@ services:
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
mailpit:
image: axllent/mailpit:v1.33.1
image: axllent/mailpit:v1.30.1
ports:
- "8025:8025"
restart: unless-stopped
@@ -143,7 +142,7 @@ volumes:
Extract the Ory config the image ships, then start:
```bash
docker run --rm larvit/plainpages:{{VERSION}} tar -cf - ory | tar -xf -
docker run --rm larvit/plainpages:0.0.2 tar -cf - ory | tar -xf -
mkdir -p plugins
docker compose up -d
```
@@ -163,7 +162,7 @@ Every behaviour is an explicit env toggle read at boot — no `NODE_ENV`. The co
| --- | --- | --- |
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | `admin@plainpages.local` / `admin` | the seeded first admin (bootstrap service) |
| `APP_URL` | unset | canonical public URL; off-host visitors are redirected to it |
| `CACHE_TEMPLATES` | `false` | cache compiled templates and `/public/` asset versions (`true` in prod) |
| `CACHE_TEMPLATES` | `false` | cache compiled templates (`true` in prod) |
| `CSRF_SECRET` | dev throwaway | signs the CSRF token — set a real one in prod |
| `KRATOS_*` / `KETO_*` / `HYDRA_*` URLs | compose defaults | the Ory sidecar endpoints |
| `LOG_FORMAT` / `LOG_LEVEL` | `text` / `info` | `json` for structured prod logs |
@@ -179,10 +178,10 @@ Everything domain-specific is a plugin folder — the compose above mounts `./pl
into the app. Create `plugins/hello/plugin.ts`:
```ts
import { definePlugin } from "@plainpages/plugin-api";
import { definePlugin } from "#plugin-api";
export default definePlugin({
apiVersion: "0.5.0",
apiVersion: "1.0.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
+964 -1194
View File
File diff suppressed because it is too large Load Diff
@@ -35,8 +35,6 @@ test("nextVersion at/after 1.0.0: literal semver", () => {
assert.equal(nextVersion("v1.2.3", "major"), "v2.0.0");
assert.equal(nextVersion("v1.2.3", "minor"), "v1.3.0");
assert.equal(nextVersion("v1.2.3", "patch"), "v1.2.4");
// the whole chain: a major dependency bump releases a major host, once the 0.x shift-down is gone
assert.equal(nextVersion("v1.2.3", maxLevel(["patch", "major"])), "v2.0.0");
});
test("nextVersion rejects a tag that is not vX.Y.Z", () => {
@@ -1,5 +1,5 @@
// Pure release-version math for the Renovate auto-release (see renovate.yml → auto-release job,
// docs/ci-cd.md). Renovate stamps each commit with a `Release-Bump: <updateType>` trailer; the
// README → CI/CD). Renovate stamps each commit with a `Release-Bump: <updateType>` trailer; the
// workflow feeds those values here to pick the next `vX.Y.Z` tag. Kept side-effect-free and unit
// tested (next-version.test.ts) — the git/tag/push side lives in the workflow shell.
@@ -36,7 +36,7 @@ export function nextVersion(latestTag: string, level: Bump): string {
return `v${major}.${minor}.${patch + 1}`;
}
// CLI: node release-tooling/next-version.ts <latestTag> [updateType...] → prints the next tag.
// CLI: node auto-release/next-version.ts <latestTag> [updateType...] → prints the next tag.
if (process.argv[1]?.endsWith("/next-version.ts")) {
const [, , latestTag, ...updateTypes] = process.argv;
process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes)));
+3 -41
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$'
@@ -53,11 +49,6 @@ docker compose build web
step "Typecheck"
docker compose run --rm --no-deps web npm run typecheck
# The specs import @playwright/test, which only the runner image installs, so they typecheck there
# rather than in the web image. --no-deps skips web; the visual file just names the e2e service.
step "Typecheck e2e-tests"
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --rm --no-deps --build e2e npm run typecheck
step "Unit tests"
units=$(docker compose run --rm --no-deps web npm test 2>&1) || { echo "$units"; exit 1; }
echo "$units" | grep -E '^. (tests|pass|fail) ' || true
@@ -65,42 +56,13 @@ echo "$units" | grep -E '^. (tests|pass|fail) ' || true
count=$(echo "$units" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | head -1 || true)
[ "${count:-0}" -ge 50 ] || { echo "only ${count:-0} unit tests ran — test glob broken?"; exit 1; }
# Plugin storage against a real Postgres. The step above runs --no-deps, so this suite's integration
# test skips there — and it is the only thing proving the DDL actually grants what it claims, rather
# than that the SQL text is the text we wrote. `node --test` counts a skip, so the floor won't catch it.
step "Plugin storage (real Postgres)"
# Own project name, like every E2E suite below: the default project is the DEV stack, so a bare
# `down -v` here would delete the operator's pgdata — Ory identities and every plugin database.
# --wait, because initdb on a cold volume outlasts the suite's connect timeout.
storage_rc=0
storage_proj=plainpages-storage
storage_files=(-p "$storage_proj" -f compose.yml) # no override merge, like the e2e suites below
storage_dsn="postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/ory"
storage_out=""
docker compose "${storage_files[@]}" up -d --wait postgres >/dev/null || storage_rc=$?
# `if`, not `&&`: a false `&&` returns non-zero, which under `set -e` would exit before teardown.
if [ "$storage_rc" -eq 0 ]; then
# --build like the e2e suites: this stack mounts no source, so without it the step would test
# whatever `web` image that project last baked.
storage_out=$(docker compose "${storage_files[@]}" run --build --rm --no-deps \
-e "PLUGIN_DB_ADMIN_URL=$storage_dsn" \
web node --test src/plugin-host/storage.test.ts 2>&1) || storage_rc=$?
fi
docker compose "${storage_files[@]}" down -v >/dev/null 2>&1 || true # also covers a failed `up`
echo "$storage_out" | grep -E '^. (tests|pass|fail|skipped) ' || true
[ "$storage_rc" -eq 0 ] || { echo "$storage_out"; echo "plugin storage integration tests failed (exit $storage_rc)"; exit "$storage_rc"; }
# A skip here exits 0 and proves nothing — the same trap the unit floor above guards against.
echo "$storage_out" | grep -qE '^. skipped 0$' || { echo "storage integration test skipped — PLUGIN_DB_ADMIN_URL not wired through"; exit 1; }
# Run one E2E suite against its OWN named stack, then always tear it down (even on failure). The
# per-suite project name keeps a flaky teardown from leaking containers/volumes into the next suite.
# --user: the runner writes screenshots + the report into the checkout, so they must belong to
# whoever ran the gate — root-owned output needs sudo to delete, and a dev box may have none.
e2e() {
step "E2E: $1"
local proj="plainpages-e2e-$(basename "$1" .yml | tr '.' '-')" # dots aren't valid in a compose project name
local rc=0
docker compose -p "$proj" -f compose.yml -f "$1" run --user "$(id -u):$(id -g)" --build --rm e2e || rc=$?
docker compose -p "$proj" -f compose.yml -f "$1" run --build --rm e2e || rc=$?
docker compose -p "$proj" -f compose.yml -f "$1" down -v >/dev/null 2>&1 || true
[ "$rc" -eq 0 ] || { echo "E2E suite $1 failed (exit $rc)"; exit "$rc"; }
}
@@ -116,7 +78,7 @@ e2e e2e-tests/compose.full.yml # full browser flow: login (password + SSO),
step "E2E: e2e-tests/compose.devstack.yml (dev-stack login: localhost works + 127.0.0.1 canonicalised)"
devstack_files=(-f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml)
rc=0
docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" run --user "$(id -u):$(id -g)" --build --rm e2e || rc=$?
docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" run --build --rm e2e || rc=$?
docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" down -v >/dev/null 2>&1 || true
[ "$rc" -eq 0 ] || { echo "E2E suite e2e-tests/compose.devstack.yml failed (exit $rc)"; exit "$rc"; }
+6 -28
View File
@@ -1,9 +1,5 @@
# Development overrides, merged automatically by `docker compose up`.
# Mounts the source for live editing and restarts on change via `node --watch`.
# web connects with it and bootstrap provisions against it, so the two must agree — one home.
x-plugin-db-url: &plugin-db-url postgres://postgres:5432
services:
web:
command: node --watch src/server.ts
@@ -17,39 +13,21 @@ services:
CACHE_TEMPLATES: "false"
LOG_FORMAT: "text" # human-readable logs in dev (base sets json for prod log pipelines)
LOG_LEVEL: "debug" # verbose by default while developing (base defaults to info)
# Point plugin storage at the bundled Postgres, so a dropped-in plugin declaring `storage`
# works with no further config; the secret falls back to the dev throwaway (config.ts).
PLUGIN_DB_URL: *plugin-db-url
REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent
PLUGIN_SETTING_SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/
SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/
volumes:
- .:/app
- /app/node_modules
# 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
# Mirror web's source mount so bootstrap discovers the same plugins *and* runs the same code. Only
# dev needs saying: the base file gives both services the image's baked copy, and it is the
# `.:/app` above — dev-only — that makes web diverge onto the host tree. Without the mirror,
# bootstrap silently runs whatever `src/` was baked at image-build time, so an edit to
# bootstrap.ts appears to do nothing until someone remembers `--build`.
# It belongs here and not in the base file, where it would desynchronise prod and collide with the
# e2e stacks, which bind individual plugins *inside* /app/plugins.
bootstrap:
# Provisions the plugin databases web connects to above, as the dev superuser.
environment:
PLUGIN_DB_ADMIN_URL: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/ory
PLUGIN_DB_URL: *plugin-db-url
REQUIRE_SECURE_SECRETS: "false" # dev derives from the throwaway, as web does
volumes:
- .:/app
# 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 (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
# stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
# 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.22.2-alpine3.24
image: node:24.18.1-alpine3.24
command: node /srv/server.ts
restart: unless-stopped
volumes:
@@ -58,7 +36,7 @@ services:
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
mailpit:
image: axllent/mailpit:v1.33.1
image: axllent/mailpit:v1.30.6
ports:
- "8025:8025"
restart: unless-stopped
+7 -26
View File
@@ -17,17 +17,10 @@ services:
CACHE_TEMPLATES: "true"
CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}
LOG_FORMAT: "json" # structured logs for prod pipelines; set OTLP_ENDPOINT to also export to a collector
# Per-plugin Postgres storage. Explicit toggle: unset ⇒ off, and a plugin declaring `storage`
# refuses to boot rather than run without its data. The URL carries no credentials — each
# plugin's own password is derived from the secret (README → Plugin storage).
PLUGIN_DB_SECRET: ${PLUGIN_DB_SECRET:-}
PLUGIN_DB_URL: ${PLUGIN_DB_URL:-}
REQUIRE_SECURE_SECRETS: "true"
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-} # IPs/CIDRs of your reverse proxy; empty ⇒ X-Forwarded-For is ignored
# Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/
# consent handler) + the one-shot bootstrap (admin + JWKS seed). Postgres too: a plugin that
# declares `storage` opens its connection in onBoot, before the server listens.
# consent handler) + the one-shot bootstrap (admin + JWKS seed).
depends_on:
bootstrap:
condition: service_completed_successfully
@@ -37,20 +30,17 @@ services:
condition: service_healthy
hydra:
condition: service_healthy
postgres:
condition: service_healthy
# verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
# Read-only — bootstrap is the only writer.
volumes:
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
restart: unless-stopped
# The stack's storage: one database per Ory service (init/init.sql), plus one per plugin that
# declares `storage` — bootstrap creates those at boot, since only it holds superuser credentials.
# A plugin connects as its own role from inside web. Dev defaults below; supply
# Ory's storage only (Kratos/Keto/Hydra) — the web app never connects here.
# init/init.sql creates one database per service. Dev defaults below; supply
# POSTGRES_USER/PASSWORD via env in production.
postgres:
image: postgres:18.6-alpine3.23
image: postgres:18.4-alpine3.23
environment:
POSTGRES_USER: ${POSTGRES_USER:-ory}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ory}
@@ -137,25 +127,16 @@ services:
condition: service_healthy
keto:
condition: service_healthy
postgres:
condition: service_healthy
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
KRATOS_ADMIN_URL: http://kratos:4434
# The superuser DSN that creates each plugin's database and role lives ONLY here — never in
# web, so plugin code cannot read it out of its own environment. Unset ⇒ a plugin declaring
# `storage` fails the seed loudly. The secret must match web's; both derive the same passwords.
PLUGIN_DB_ADMIN_URL: ${PLUGIN_DB_ADMIN_URL:-}
PLUGIN_DB_SECRET: ${PLUGIN_DB_SECRET:-}
PLUGIN_DB_URL: ${PLUGIN_DB_URL:-} # only to refuse a mismatch: what bootstrap creates, web connects to
REQUIRE_SECURE_SECRETS: "true" # refuse the throwaway secret here too, before any role is created
volumes:
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
command: node src/auth/bootstrap.ts
-105
View File
@@ -1,105 +0,0 @@
# CI/CD
Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
[`ci.sh`](../README.md#the-full-gate-one-command) — the exact gate you run locally:
| Workflow | Trigger | Does |
| --- | --- | --- |
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image |
| `release.yml` | push of a `vX.Y.Z` tag, or manual | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `latest` (plus `X` once major ≥ 1), sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger |
| `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) |
| `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head |
| `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut a release tag for what merged |
`main` is not re-tested on push — its commits are meant to arrive already green from a
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
**Merge gate on `main`** (Gitea branch protection + repo merge settings, set via the API —
no repo files involved): direct pushes are blocked, changes land via PR only, the
`CI / full-gate (push)` status must be green (admins included), and the only merge style is
**fast-forward-only** — history stays linear and `main`'s head is the exact commit hash of
the merged branch, which is why the branch's push-triggered status carries over.
**Container images** — after a green gate, `ci.yml` builds the app image and pushes it as
`gitea.larvit.se/larvit/plainpages:<full commit hash>`. Because merges are fast-forward-only, the
image for any `main` commit already exists from that commit's branch gate — build once, promote by
re-tagging. The build runs inside the required gate, so a missing/expired token or a registry outage
blocks **all** merges until restored; use a non-expiring token or track its expiry. Hash tags
accumulate one image per gated push, so nightly `registry-cleanup.yml` prunes them
([`registry-cleanup/select-versions.ts`](../registry-cleanup/select-versions.ts) defines what survives). Don't add a
pattern-based org cleanup rule for this package — its age/count heuristics can't see branch heads or
release tags and would delete images the workflow protects.
**Releases** — pushing a semver git tag (`git tag v1.2.3 && git push origin v1.2.3`) runs
`release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `latest` and —
once the major reaches `1` — `1`; nothing is rebuilt, so the released image is byte-identical to the
gated one. While the major is `0` the bare-major tag is skipped, because a `0.x` minor is a contract
break and a moving `:0` would carry one. It fails loud if no
hash image exists — release tags must point at a commit that went through the gate. The same four
tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only.
The [contract check](../README.md#contract-versioning) guards the tag before anything is published, refusing one
whose `major.minor` disagrees with `HOST_API_VERSION` and naming the value to set.
**The Docker Hub overview** is published by a separate `publish-overview` job from
[`release-tooling/dockerhub-overview.md.tmpl`](../release-tooling/dockerhub-overview.md.tmpl), with
`{{VERSION}}` rendered to the release, so the Plainpages tag it tells adopters to pull cannot go
stale. The same job sets the page's short description. Its sidecar pins are Renovate-managed and gated against this repo's own compose files, so the
quick start stays a topology CI has actually run.
It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub
outage leaves the promotion green and the images untouched; and the page has its own door — run the
workflow manually with an `overview_version` input to republish it without cutting a release. That
input goes through the same contract check as a tag: a non-semver value, or one whose `major.minor`
disagrees with the tree being published, is refused. It uses
the same `DOCKERHUB_TOKEN` the image push uses, which is why that token needs the **delete** scope.
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is
read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift.
Tags go with `--prune`, so deleting one here deletes it there on the next run, and a tag *created* on
GitHub is swept away — cut releases on Gitea, never on the mirror. Branches other than `main` match
no refspec and are left alone.
**Dependency updates** — `renovate.yml` runs [Renovate](https://docs.renovatebot.com) nightly
(self-hosted, this repo only) against [`renovate.json`](../renovate.json), bumping npm deps, Docker base
images, Gitea action versions, and the image tags pinned inside workflow `run:` steps (a custom regex
manager, so nothing pinned drifts unmanaged). Version-locked sets move together in one PR — the Ory
images, and the Playwright runner + its browser image — and every bump keeps the **exact semver pin**
exact. Each PR runs the normal gate on its `renovate/*` branch and automerges once
`CI / full-gate (push)` is green; only a red gate needs a human.
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`) cuts **one**
`vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` since the last tag, and
**skips** when the tip isn't a Renovate commit, nothing new merged, or nothing that merged carried a
trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a
`Release-Bump: <updateType>` trailer onto the updates that reach a running Plainpages — the rules in
[`renovate.json`](../renovate.json) name them — and
[`release-tooling/next-version.ts`](../release-tooling/next-version.ts) turns the highest one into the next
version; pre-1.0 it never auto-crosses into `1.0.0`. Because the contract version *is* the release
version, an update big enough to reach a **minor** stops the job rather than tagging: bump
`HOST_API_VERSION` in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency *major*, since
`nextVersion` shifts it down to a `0.x` minor. `updateType` rates the *dependency's* own jump,
so the trailer is an allowlist: an update outside those rules carries none and rides the next patch
release instead of escalating it. It is **tag-only**: the tag hands off to
`release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by
the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here.
### One-time CI setup
| Actions var / secret | Value |
| --- | --- |
| `DOCKER_REGISTRY_USER` (var) + `DOCKER_REGISTRY_TOKEN` (secret) | A Gitea account with package write in the `larvit` org, and its access token with `read:package` + `write:package`. Reused by `registry-cleanup.yml`. |
| `DOCKERHUB_USER` (var) + `DOCKERHUB_TOKEN` (secret) | The public `larvit/plainpages` Docker Hub repo, and a **read/write/delete** token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT reaches every repo under it, and delete is destructive). Delete is what publishing the overview needs; pushing images alone would not. |
| `MIRROR_GITHUB_TOKEN` (secret) | A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its `main` must not block force-pushes and must carry no tag protection, which would reject the prune. |
| `RENOVATE_TOKEN` (secret) | The shared `renovate@larvit.se` bot's Gitea PAT, with write access to this repo. |
| `RENOVATE_GITHUB_TOKEN` (secret) | A **scopeless** (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. |
Gitea rejects secret names prefixed `GITEA_`/`GITHUB_`. Each job fails loud until its secret exists.
The container package is **org-owned**, so it lists under `larvit/-/packages` — link it to the repo's
Packages tab once: `POST /api/v1/packages/larvit/container/plainpages/-/link/plainpages`.
**The runner** — register one
[act_runner](https://docs.gitea.com/usage/actions/act-runner) in host mode labelled `docker-host`
(`labels: ["docker-host:host"]`) on a machine with Docker Engine + Compose, git, and Node +
github.com access. Runs must **never overlap** — the e2e stacks use fixed compose project names, the
devstack suite uses host networking, and the workflows share the Docker daemon's registry login — so
keep exactly one runner at capacity 1, with host ports 3000/4433 free.
-509
View File
@@ -1,509 +0,0 @@
# Comprehension history
## 2026-10-31T00:16:02Z, PR #181 at 56ff987, against eed1050
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | same | better | same | better | better |
| Inherited architect | same | better | same | better | better |
Mid A, decided by:
- Self-sufficiency, Locality: `public/css/styles.css:24` `:root`: the comment above the hex palette says why a colour must not be oklch(), where a colour is edited
- Locality: `src/ui/stylesheet-floor.test.ts:11` `declares no oklch() colour`: a colour edit that breaks the browser floor fails a test instead of an old engine
Inherited architect, decided by:
- Self-sufficiency, Locality: `public/css/styles.css:24` `:root`: the comment above the hex palette says why a colour must not be oklch(), where a colour is edited
- Locality: `src/ui/stylesheet-floor.test.ts:11` `declares no oklch() colour`: a red gate explains the symptom in the file it lands on
- Self-sufficiency: `README.md:1027` "Two browser floors": the colour rule now sits beside the floor rule
## 2026-10-31T00:43:19Z, PR #182 at 19987ba, against 3e70768
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | better | better | better | better |
| Inherited architect | better | better | better | better | better |
Mid A, decided by:
- Navigation, Shape, Self-sufficiency, Overall: `public/css/auth.css:1` `auth.css`: about 120 lines of rules for markup no view renders are gone, so a cold reader no longer rules them out first
- Locality, Self-sufficiency, Overall: `public/css/styles.css:31` `--on-accent`: the text colour on the accent is one token where four hard-coded `#fff` stood
- Self-sufficiency, Navigation, Overall: `public/css/styles.css:287` `@supports not selector(:has(a))`: the nav comment, the partial's header and the rule all say the tree folds only where `:has()` works
- Locality: `public/css/styles.css:241` `@supports selector(:has(a))`: the nav-fold behaviour now sits in two blocks about 50 lines apart, the second order-dependent; the comment names it
Inherited architect, decided by:
- Navigation, Shape, Overall: `public/css/auth.css:1` `auth.css`: the false `:target` banner and the phantom state machine are gone, so every selector left names something a view emits
- Self-sufficiency, Overall: `views/partials/nav-tree.ejs:4` `nav-tree.ejs`: the header and `public/css/styles.css:194` now say the branch folds only where `:has()` works; the old text was untrue on old engines
- Locality, Overall: `public/css/styles.css:31` `--l-on-accent`: one token feeds `.btn-primary`, `.page-btn[aria-current]` and `.brand-mark`
- Locality: `public/css/styles.css:287` `@supports not selector(:has(a))`: its `.nav-disc` rule sits about 85 lines from the nav rules it overrides, and `stylesheet-floor.test.ts` guards the order for `.theme-switch` only
- Self-sufficiency: `src/ui/stylesheet-contrast.test.ts:29` `stylesheet-contrast.test.ts`: the darker badge colours now have a stated 4.5:1 reason someone can find
## 2026-10-31T01:09:38Z, PR #183 at 0f20b38, against 364b047
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | same | same | same | better | better |
| Inherited architect | same | better | same | better | better |
Mid A, decided by:
- Self-sufficiency, Overall: `public/css/styles.css:1` `App Shell Template header`: the false `light-dark()` bullet is gone, so no reader has to work out which of two comments is lying
- Self-sufficiency, Overall: `public/css/styles.css:687` `@supports selector(:has(a))` in `@media (max-width: 860px)`: the guard and its comment say what an engine without `:has()` gets, without opening the CHANGELOG
Inherited architect, decided by:
- Self-sufficiency, Overall: `public/css/styles.css:5` `Architecture` header: the false `light-dark()` bullet is gone, so the header no longer contradicts the TOKENS comment
- Locality, Self-sufficiency, Overall: `public/css/styles.css:687` `@supports selector(:has(a))` in `@media (max-width: 860px)`: the guard and its comment put the dependency of the open state on `:has()` where an editor would change it
## 2026-10-31T01:19:49Z, PR #184 at af157c9, against bb8e6bc
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | same | same | better | better |
| Inherited architect | better | better | same | better | better |
Mid A, decided by:
- Self-sufficiency, Navigation, Overall: `public/css/styles.css:12` `TOKENS` header comment: says all three theme rules need `:has()`, names the engines without it and points to the `@supports not selector(:has(a))` block, where the old text called the swaps "rock-solid"
- Navigation, Self-sufficiency, Overall: `README.md:214` `## Glossary`: defines host, plugin, manifest, chrome, nav fragment, permission name, system plugin and denylist and links each to its home section
- Self-sufficiency: `README.md:815` `Plugin dependencies`: the scale bullet states the plugin count the per-plugin dependency design assumes
Inherited architect, decided by:
- Self-sufficiency, Locality, Overall: `public/css/styles.css:12` `1. TOKENS` comment: an editor of the theme mapping learns of the distant `:has()` fallback from the comment, where the old text said the swap could not fail
- Navigation, Self-sufficiency, Overall: `README.md:214` `## Glossary`: a cold reader reaches "chrome" or "denylist" and their home sections in one hop
- Self-sufficiency: `README.md:815` `### Plugin dependencies`: the scale bullet gives the plugin count the dependency design assumes
## 2026-10-31T07:26:19Z, PR #187 at 10152d2, against b336dee
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | same | same | better | better | better |
| Inherited architect | same | same | better | better | better |
Mid A, decided by:
- Self-sufficiency, Overall: `e2e-tests/mock-oidc.ts:18` `idToken`: the helpers' signatures now say what `res`, `nonce` and the `codes` map hold
- Self-sufficiency: `e2e-tests/proxy.ts:14` `toKratos`: `(path: string): boolean` shows the routing predicate's contract
- Self-sufficiency: `e2e-tests/tsconfig.json:2` `extends`: the specs are held to the host's strict flags, and the Dockerfile comment explains the path link
- Shape, Overall: `src/ui/icons.ts:4` `ICON_NAMES`: the comment "the icons the UI actually references" is true again
Inherited architect, decided by:
- Self-sufficiency, Overall: `e2e-tests/mock-oidc.ts:18` `b64`: typed helpers replace a trace through the `createServer` callback
- Self-sufficiency: `e2e-tests/proxy.ts:14` `toKratos`: the predicate's contract is readable on its own line
- Self-sufficiency: `e2e-tests/Dockerfile:11` `COPY tsconfig.json`: the relative `extends` path works in the checkout and in the image, and a comment names the link
- Shape, Overall: `src/ui/icons.ts:4` `ICON_NAMES`: the registry matches its description, with no dead ids
## 2026-10-31T12:28:23Z, PR #200 at 3592a30, against a8a9744
Ruling: same
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | better | better | same | better |
| Inherited architect | worse | worse | better | same | same |
| Maintainability senior | same | same | better | same | same |
| Junior A | better | better | better | better | better |
Mid A, decided by:
- Navigation, Locality, Shape: `src/ui/chrome.ts:74` `shellUser`: the deleted `buildShellContext` and `buildDashboardModel` left two shell models side by side, and now every page reads one `chrome`
- Locality, Shape: `src/auth/routes.ts:58` `flowFailure`: a named unit shared by `flowPage` and `codeFlowSubmit`, where the old `flowPage` held the whole error ladder inline
- Shape: `src/auth/kratos-public.ts:117` `submitFlow`: takes a flow type and id instead of an action URL, and the Referer quirk is explained where it applies
- Locality: `src/http/static.ts:93` `versioned`: reads the module-level `hashes` map, so an asset href depends on what an earlier request hashed
- Navigation, Shape: `src/ui/stylesheet-floor.ts:30` `floorViolations`: a 249-line regex walker that only tests use sits in `src/ui/` beside runtime modules, though the README now points straight at it
Inherited architect, decided by:
- Navigation: `public/css/styles.css:25` `:root`: the old comment next to the palette answered the 3am question and the new text drops it
- Navigation: `src/ui/stylesheet-floor.ts:1` `floorViolations`: its header never says an unsupported value in a custom property blanks every `var()` reading it, so a cold reader walks the regex walker to find the cause
- Navigation, Shape: `src/ui/stylesheet-floor.ts:12` `import`: test-only tooling that pulls a devDependency sits beside runtime modules and is missing from the README file map
- Locality: `src/http/static.ts:64` `hashes`: a module-level map shared by every `publicAssets()` instance makes a fresh href depend on request history elsewhere
- Locality: `src/http/app.ts:188` `renderPage`: `assetHref` is appended last at three render sites, and only a comment keeps a fourth from emitting unversioned hrefs
- Shape: `src/ui/chrome.ts:74` `shellUser`: removes the second shell model that duplicated `PageChrome`
- Shape: `src/auth/routes.ts:58` `flowFailure`: one named unit shared by `flowPage` and `codeFlowSubmit`, each handler reads in a screen
Maintainability senior, decided by:
- Shape, Locality: `src/ui/chrome.ts:74` `shellUser`: the deleted `ShellModel` left two shapes for the same chrome, and now the shell has one input and one builder
- Shape, Locality: `src/auth/routes.ts:58` `flowFailure`: the old `flowPage` mixed about 60 lines of error mapping with `return_to` handling, and now `flowReturnTo` and `flowFailure` read on their own
- Locality: `src/http/static.ts:64` `hashes`: a hidden module-level cache filled by two readers, and `assetHref` is merged by hand at three render sites
- Shape, Navigation: `src/ui/stylesheet-floor.ts:119` `positive`: a 249-line regex walker that only a test uses sits among runtime view modules
Junior A, decided by:
- Navigation, Self-sufficiency: `src/ui/stylesheet-floor.ts:1` `floorViolations`: the README's "Two browser floors" points here and the header lists what is checked, where the base had a prose rule and a hand-written regex test
- Shape, Locality: `src/ui/chrome.ts:74` `shellUser`: one shell view model replaces `ShellModel` beside `PageChrome`, and its helpers sit beside their only caller
- Shape, Locality: `src/auth/routes.ts:58` `flowFailure`: named units replace about 60 inline lines in `flowPage`, and `codeFlowSubmit` reuses them
- Self-sufficiency: `src/auth/kratos-admin.ts:68` `createRecoveryCode`: the comment no longer claims Kratos mails the link
- Locality: `src/http/static.ts:64` `hashes`: a module-level mutable map backs asset versioning, hidden state the base didn't have
## 2026-10-31T12:30:19Z, PR #200 at 3592a30
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Mid A | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Maintainability senior | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Inherited architect | 7 | 5.5 | 5.5 | 7.5 | 6.5 |
| Mean | 7.00 | 5.88 | 6.25 | 6.75 | 6.50 |
Junior A, hardest first:
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a regex-built `@supports` evaluator whose header says what it enforces but not how it decides a guard names a feature
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 165 lines in one closure where each stage reads state an earlier stage set
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: its 4xx branches can't fire from `codeFlowSubmit` because `submitFlow` throws only on 5xx, and the call site doesn't say so
Mid A, hardest first:
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-written CSS walker with three regex passes per declaration, opaque at line level
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: a fixed order of about 15 stages that nothing in the code enforces
- Self-sufficiency, Shape: `src/auth/login.ts:40` `readPermissions`: the comment names the tuple `#members` where the code uses `granted`
- Locality: `src/auth/routes.ts:121` `codeFlowSubmit`: one idea split across `flow-view.ts`, `routes.ts` and `kratos-public.ts`
Maintainability senior, hardest first:
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: a 167-line closure of complexity 43 whose order across closure state nothing names as a contract
- Self-sufficiency, Locality: `src/auth/routes.ts:58` `flowFailure`: on the submit path every 4xx branch is dead and nothing at the call site says which can fire
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: the one module whose correctness can't be checked by reading
- Self-sufficiency: `src/auth/bootstrap.ts:31` `permissionTuple`: the comment says `#members` while the code writes `granted`
Inherited architect, would restructure first:
- Shape, Navigation: `src/auth/flow-view.ts:152` `formAction`: the flow-to-route mapping has two homes, here and `routes.ts`, so a code flow's route and its form target are decided in different files
- Shape, Navigation: `src/ui/stylesheet-floor.ts:30` `floorViolations`: a CSS linter that never runs at runtime sits among the runtime view helpers
- Locality, Shape: `src/http/app.ts:185` `handleRequest`: a level of its own inside `createApp`
- Shape: `src/http/builtin-routes.ts:23` `matchBuiltinRoute`: holds no routes, because the table is inline in `app.ts:176` and `buildAuthRoutes`
## 2026-10-31T12:30:27Z, PR #200 rerun at 3592a30
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7 | 5.5 | 6 | 6.5 | 6 |
| Mid A | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Maintainability senior | 7 | 5.5 | 6 | 6.5 | 6 |
| Inherited architect | 7 | 6 | 6 | 6.5 | 6.5 |
| Mean | 7.00 | 5.75 | 6.13 | 6.50 | 6.25 |
Junior A, hardest first:
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a regex-built CSS walker that stayed opaque without tracing it by hand
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 320 lines of interdependent locals, with a fixed stage order that only a top-to-bottom read shows
- Self-sufficiency, Shape: `src/auth/login.ts:40` `completeLogin`: the header says `#members` where the code uses `granted`
Mid A, hardest first:
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 165 lines of order-dependent stages, with `renderPage` reassigned partway
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: how it decides a guard names a feature stayed opaque
- Self-sufficiency, Shape: `src/auth/bootstrap.ts:31` `seedPermissions`: the comment says `#members` where the code uses `granted`
Maintainability senior, hardest first:
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 167 lines and complexity 43 in a fixed order nobody states
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-rolled regex CSS walker, partly opaque
- Locality, Shape: `src/plugin-host/plugin.ts:126` `RESERVED_PLUGIN_IDS`: a hand-kept mirror of the host's path segments that has already drifted, as it omits `error`
- Self-sufficiency, Shape: `src/auth/login.ts:40` `readPermissions`: the comment says `#members` where the code uses `granted`
Inherited architect, would restructure first:
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: the most concerns and the highest complexity, already grandfathered, and every request-wide concern lands in it
- Shape, Navigation: `src/ui/stylesheet-floor.ts:15` `FLOOR`: the 3am rule's home is a test-only module under `ui/`
- Shape: `src/auth/routes.ts:275` `buildAuthRoutes`: the 19-module `src/auth/` mixes six concerns, and this file with `flow-view.ts` is where the next flow lands
## 2026-10-31T13:06:16Z, PR #202 at 8e73d16
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Mid A | 7 | 6 | 6 | 6.5 | 6.5 |
| Maintainability senior | 7 | 6 | 6 | 6.5 | 6.5 |
| Inherited architect | 6.5 | 6 | 6 | 6.5 | 6.5 |
| Mean | 6.88 | 6.00 | 6.13 | 6.50 | 6.50 |
Junior A, hardest first:
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 165 lines in a fixed order that only a top-to-bottom read shows
- Self-sufficiency, Shape: `src/auth/login.ts:40` `readPermissions`: the comment says `#members` where the code uses `granted`
- Self-sufficiency: `src/plugin-host/settings.ts:35` `SettingsOf`: nested conditional types that need the charter's rule to decode
Mid A, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: complexity 43 inside the 318-line `createApp` closure, and `switchBase` reads `builtinRoutes` declared 60 lines later
- Locality, Shape: `src/ui/stylesheet-floor.ts:119` `positive`: a regex CSS walker whose how stayed opaque
- Self-sufficiency: `src/auth/bootstrap.ts:31` `permissionTuple`: the comment says `#members` where the code uses `granted`
Maintainability senior, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: 167 lines and complexity 43 mixing twelve concerns whose order no type expresses
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-rolled regex CSS scanner traced by hand
- Self-sufficiency: `src/auth/gate.ts:13` `allows`: an empty gate is allowed, and the reason lives only in AGENTS.md
Inherited architect, would restructure first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: the largest hub, where every request-wide concern lands
- Shape, Navigation: `src/auth/routes.ts:275` `buildAuthRoutes`: the 19-module `src/auth/` mixes four concerns with no sub-folder
- Shape, Navigation: `src/ui/stylesheet-floor.ts:30` `floorViolations`: test tooling sits among the runtime UI modules
## 2026-10-31T13:06:21Z, PR #202 rerun at 8e73d16
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7 | 5.5 | 6 | 6 | 6 |
| Mid A | 7 | 6 | 6 | 6 | 6.5 |
| Maintainability senior | 7 | 6 | 6 | 6 | 6.5 |
| Inherited architect | 7 | 5.5 | 6 | 7 | 6.5 |
| Mean | 7.00 | 5.75 | 6.00 | 6.25 | 6.38 |
Junior A, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: closures over request state in a 318-line `createApp`, and it stayed opaque
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: the Kratos status semantics live outside the repo
- Self-sufficiency, Shape: `src/auth/login.ts:45` `readPermissions`: the comment says `#members` where the code uses `granted`
Mid A, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: one pipeline whose steps mutate what later steps read
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: traced by hand
- Self-sufficiency, Shape: `src/auth/login.ts:45` `readPermissions`: the comment says `#members` where the code uses `granted`
Maintainability senior, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: an unstated, load-bearing step order in shared closure state
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-rolled CSS scanner built on lookbehind regexes
- Self-sufficiency, Shape: `src/auth/bootstrap.ts:31` `permissionTuple`: the comment is false on the security-relevant path
Inherited architect, would restructure first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: stage order lives only in line order, in a grandfathered complexity-43 function
- Shape, Navigation: `src/auth/routes.ts:275` `buildAuthRoutes`: 19 flat modules in `src/auth/`, and the next flow lands here
- Shape, Navigation: `src/ui/stylesheet-floor.ts:15` `FLOOR`: the 3am rule's home is a test-only module under `ui/`
## 2026-11-08T13:22:47Z, PR #214 at 84368ea, against c2389f8
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | same | better | better | better |
| Inherited architect | better | same | better | better | better |
Mid A, decided by:
- Navigation, Shape, Overall: `src/test-tooling/stylesheet-floor.ts:1` `floorViolations`: test-only modules no longer sit beside runtime code in `ui/` and `http/`, and `AGENTS.md:77` names the folder
- Self-sufficiency: `src/auth/login.ts:40` `readPermissions`: the comment now names `#granted`, which the code queries
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: grouping and the per-group decision are separate named units
- Self-sufficiency: `src/test-tooling/stylesheet-floor.ts:10` file header: says why a custom property's value is checked for functions and units
- Self-sufficiency: `src/plugin-host/plugin.ts:234` `undeclaredGates`: its comment says why an undeclared gate matters
Inherited architect, decided by:
- Self-sufficiency, Shape, Overall: `src/auth/login.ts:40` `readPermissions`: the comment now names the relation the code queries
- Self-sufficiency: `src/auth/bootstrap.ts:31` `permissionTuple`: the contradicting comment is gone
- Navigation: `src/test-tooling/stylesheet-floor.ts:1` `floorViolations`: the folder is named in AGENTS.md and the README file map
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: one loop split into `parenGroups`, `Group` and `keepGroup`
- Self-sufficiency: `src/auth/routes.ts:117` `codeFlowSubmit`: a comment says which `flowFailure` branch can fire there
## 2026-11-08T13:44:57Z, PR #215 at 3a3bd77, against 01f368d
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | better | better | same | better |
| Inherited architect | better | better | better | better | better |
Mid A, decided by:
- Locality, Shape: `src/ui/paginate.ts:68` `buildPaginationModel`: the rows form's hidden inputs are read off hrefFor, so form and links can't drift, where each page used to list them by hand
- Shape, Navigation: `examples/plugins/admin/admin-users.ts:140` `buildUsersListModel`: three near-identical listPagination copies become one call each to a named helper
- Locality: `src/http/app.ts:271` `shared`: ctx and contextFor spread one option object instead of spelling the same list twice
- Shape: `src/ui/paginate.ts:1` header: names both steps truthfully, and PaginationModel types the partial's input
- Navigation: `src/http/context.ts:28` `RequestContext.assetHref`: versioning a plugin's asset is a documented contract member, not a host-internal local
Inherited architect, decided by:
- Shape, Locality, Navigation: `src/ui/paginate.ts:68` `buildPaginationModel`: one typed function in the module owning the page math replaces three hand-written copies in the example plugins
- Locality: `examples/plugins/admin/admin-users.ts:188` `listPagination`: the older version built the hidden inputs field by field, apart from listHref; the round trip through new URL is a little clever, paid for by its comment
- Locality, Shape: `src/http/app.ts:271` `shared`: the two buildContext calls differ only in the translator and params
- Self-sufficiency: `src/http/context.ts:28` `RequestContext.assetHref`: the contract field says on the spot what it does and how it differs from the view local
- Self-sufficiency: `README.md:1050` pagination sketch: URL to partial in five lines, with the param-renaming rule
## 2026-11-08T14:10:47Z, PR #216 at bb134a3, against 6ca63c6
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | same | better | same | better |
| Inherited architect | same | better | better | same | better |
Mid A, decided by:
- Navigation, Shape: `src/http/request-tracking.ts:9` `trackRequest`: the per-request logger, span and access line now live in a named module instead of the tail of the 318-line `createApp`
- Navigation, Shape: `src/http/request-fields.ts:33` `clientAddress`: the caller-IP and X-Forwarded-For rule has its own pure function in a file named for it
Inherited architect, decided by:
- Locality, Shape: `src/http/request-tracking.ts:9` `trackRequest`: the exactly-once finalize and close listener are one named unit, changeable without reading `createApp`'s other state
- Locality, Shape: `src/http/request-fields.ts:33` `clientAddress`: pure function of peer, header and list, with the reasons for reading from the right beside it
## 2026-11-08T18:34:54Z, PR #220 at 1b53b01, against 14a07dd
Ruling: same
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | same | same | same | same | same |
| Inherited architect | same | same | worse | same | same |
Inherited architect, decided by:
- Shape: `views/partials/shell.ejs:74` `user.menu`: a third meaning of `menu` beside the sidebar switch at line 19 and the `.menu` popover class
- Shape: `src/plugin-host/plugin.ts:9` `import type { ProfileMenuEntry }`: a second contract type defined under `ui/`, with a type-only import cycle through `chrome.ts`
## 2026-11-08T19:50:52Z, PR #223 at f8a6119, against b7e9780
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | better | better | same | better |
| Inherited architect | better | better | better | same | better |
Mid A, decided by:
- Navigation, Locality, Shape: `src/http/app.ts:183` `handleRequest`: a 20-line list of named stages replaces a 170-line closure
- Locality: `src/http/app.ts:213` `Host`: 19 fields passed to every stage, broader than any stage needs
- Shape, Navigation: `src/ui/chrome.ts:29` `ShellUser.profileLinks`: the name says what the field holds
- Navigation, Shape: `src/plugin-host/conflicts.ts:15` `findConflicts`: collision rules have their own file
Inherited architect, decided by:
- Locality, Shape, Navigation: `src/http/app.ts:183` `handleRequest`: named stages replace one 165-line closure
- Locality: `src/http/app.ts:213` `Host`: a 19-field bag wider than any stage needs
- Shape: `src/ui/chrome.ts:29` `ShellUser.profileLinks`: no longer collides with `MenuConfig` and the `menu` partial
- Navigation: `.gitea/workflows/release.yml:60` error pointer: names `docs/ci-cd.md`, one hop from the failure
## 2026-11-08T21:24:33Z, PR #228 at ed48c5a
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7.5 | 6 | 7 | 7 | 6.5 |
| Mid A | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Maintainability senior | 7.5 | 6.5 | 7 | 6.5 | 7 |
| Inherited architect | 6 | 6 | 6.5 | 7 | 6.5 |
| Mean | 7.00 | 6.13 | 6.75 | 6.75 | 6.63 |
Junior A, hardest first:
- Locality, Overall: `src/http/app.ts:67` `createApp`: a closure bag whose order matters; `switchBase` uses `builtinRoutes` declared further down
- Locality, Overall: `src/http/app.ts:274` `prepareRequest`: builds the context, the factory and the memoized chrome together, and every page path must remember to set the CSRF cookie
- Locality, Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:285` `lostToSelf`: the self-lockout rule is spread over four call sites and no single place states it
- Shape, Overall: `examples/plugins/auth-admin/admin-grants.ts:99` `buildPermissionPicker`: checked, inherited, read-only and undeclared rows interact and need a truth table
- Self-sufficiency: `src/auth/routes.ts:45` `flowReturnTo`: locale and return-to inputs interact, and no table gives the result per input
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: four Kratos error branches that need Ory knowledge the repo doesn't hold
- Locality: `src/ui/chrome.ts:55` `buildPluginChrome`: the locale rule is put into effect in three places with no single owner
- Self-sufficiency: `src/plugin-host/router.ts:4` header: promises order-independent specificity, but ties fall to discovery order
Mid A, hardest first:
- Locality, Overall: `examples/plugins/auth-admin/admin-groups.ts:285` `lostToSelf`: needs Keto's transitive group resolution rebuilt by hand, and over-reports
- Shape, Locality: `examples/plugins/auth-admin/admin-groups.ts:435` `groupsDelete`: deletes grants one at a time, so it can stop part-way
- Shape, Overall: `examples/plugins/auth-admin/admin-grants.ts:99` `buildPermissionPicker`: four overlapping ideas; `checked` means direct or effective
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos status codes map to different outcomes and need outside knowledge
- Shape, Locality: `src/http/app.ts:183` `handleRequest`: the 19-field `Host` bag and the state threaded between stages
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: a hand-rolled parser for `@supports` conditions
- Self-sufficiency: `src/plugin-host/settings.ts:35` `ValueOfDecl`: type-level inference with a `const` generic
- Self-sufficiency: `src/auth/jwt-middleware.ts:75` `verifyToken`: a denylisted token is thrown as expired so it gets re-minted
- Self-sufficiency: `examples/plugins/auth-admin/plugin.ts:22` `on`: each write-intent GET states its `"write"` override in the route table and in the handler
Maintainability senior, hardest first:
- Locality, Overall: `examples/plugins/auth-admin/admin-groups.ts:285` `lostToSelf`: the self-lockout guards rebuild Keto's transitive membership and no sentence states the invariant
- Shape, Overall: `src/http/app.ts:67` `createApp`: a long closure factory that is safe to read but not safe to change
- Shape, Self-sufficiency: `examples/plugins/auth-admin/admin-grants.ts:99` `buildPermissionPicker`: direct, effective and undeclared grant sets with a staleness token on top
- Self-sufficiency: `src/plugin-host/settings.ts:35` `ValueOfDecl`: type-level inference whose comments say what it does, not how
- Self-sufficiency: `src/auth/routes.ts:45` `flowFailure`: a matrix of Kratos statuses and flow states
- Locality: `src/auth/jwks.ts:68` `cachingJwks`: subtle cache state, and the denylist reuses the `expired` flag
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: a hand-written parser for `@supports` conditions
- Locality: `src/auth/bootstrap.ts:86` `seedAdmin`: calls Kratos and Keto with raw fetch instead of the client modules
- Self-sufficiency: `examples/plugins/auth-admin/views/partials/group-detail-body.ejs:2` locals header: documents `identity` and `del`, omits `canWrite` and `permissions`
Inherited architect, would restructure first:
- Navigation, Overall: `src/auth/routes.ts:1` `src/auth`: 19 modules holding sessions, Ory clients, flows, the OAuth2 provider and one-shot CLIs
- Navigation: `src/auth/bootstrap.ts:1` `bootstrap.ts`: a one-shot process that also provisions plugin storage, placed under auth
- Navigation, Locality: `src/ui/chrome.ts:55` `buildPluginChrome`: builds the chrome for every page, the host's own included
- Navigation: `views/index.ejs:1` `index.ejs`: the built-in /dashboard, and `home.ejs` is "/", and neither filename says so
- Locality: `public/css/styles.css:326` `.btn-primary`: one visual fact spans the partial, the rule, the tokens and the floor policy in `src/test-tooling/`
- Shape, Locality: `examples/plugins/auth-admin/admin-groups.ts:113` `admin-groups.ts`: pure view models, Keto queries, lockout policy and handlers in one 463-line file
- Shape: `src/http/app.ts:213` `Host`: a 19-field bag that every cross-cutting feature edits
- Self-sufficiency: `src/plugin-host/system.ts:1` header: calls the built-in admin screens the reference consumer, but they are a drop-in plugin
## 2026-11-08T21:30:30Z, PR #228 rerun at 0727d15
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7.5 | 6 | 6.5 | 6.5 | 6.5 |
| Mid A | 7 | 6 | 6.5 | 7 | 6.5 |
| Maintainability senior | 7.5 | 6.5 | 7 | 7 | 7 |
| Inherited architect | 7 | 6.5 | 6 | 7.5 | 6.5 |
| Mean | 7.25 | 6.25 | 6.50 | 7.00 | 6.63 |
Junior A, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:274` `prepareRequest`: a 19-field `Host` bag and a twice-memoized chrome threaded through one closure, and two return conventions (send and return null, or return a result)
- Locality, Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:285` `lostToSelf`: the self-lockout rule differs from the users side and nothing says whether that is deliberate
- Shape, Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:18` `LIST_FETCH_SIZE`: the comment says a Keto page but the call is Kratos `listIdentities`
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: branches on Kratos status and body text that need Ory knowledge
- Shape, Self-sufficiency: `src/auth/jwt-middleware.ts:75` `verifyToken`: a revoked token is reported as expired, with the effect in another file
- Locality: `src/i18n/load.ts:43` `loadI18n`: four catalog sources kept apart with a nested spread on one line
- Self-sufficiency: `src/server.ts:25` boot order: deleting `PLUGIN_DB_SECRET` is order-dependent and its reason sits in the decision log
- Self-sufficiency: `src/plugin-host/system.ts:1` header: calls the built-in admin screens the reference consumer
Mid A, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:274` `prepareRequest`: the request order lives only in the sequence of a long closure, and `serveHome` skips `sendResult`
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: a hand-written, regex-heavy reader of `@supports` conditions
- Locality: `src/auth/jwt-middleware.ts:75` `verifyToken`: revocation reported as expired works only through `resolveUser` in another file
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos statuses that need outside knowledge
- Locality: `src/auth/jwks.ts:68` `cachingJwks`: four interacting behaviours in one closure
- Shape: `src/i18n/load.ts:43` `loadI18n`: merges four catalog sources, with the chain order described in two files
- Locality: `src/i18n/locale.ts:81` `chosenLocale`: infers an explicit choice from the output of a URL builder
- Shape, Self-sufficiency: `examples/plugins/scheduling/plugin.ts:37` header: says "Roles" and `<id>:<action>` where the project has neither
- Self-sufficiency: `compose.yml:166` comment: says consent comes next, but `src/auth/oauth-consent.ts` exists
Maintainability senior, hardest first:
- Shape, Overall: `src/test-tooling/stylesheet-floor.ts:122` `positive`: a regex-driven mini-parser whose header states the rules but not how the helpers carry them
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: five branches that assume Kratos' error vocabulary
- Locality: `src/http/app.ts:274` `prepareRequest`: where the language picker points after a POST is spread over four units in three files
- Shape: `src/i18n/load.ts:43` `loadI18n`: three catalog sets, each with its own baseline, and a nested spread
- Locality: `src/auth/jwt-middleware.ts:84` `verifyToken`: the denylist travels as `expired`, so `resolveSession` can't show it
- Shape: `src/ui/chrome.ts:55` `buildPluginChrome`: labels are translated twice and `markCurrent` mutates the nodes
- Locality, Shape: `examples/plugins/auth-admin/admin-users.ts:348` `usersPermissions`: the stale-page, self-revoke and diff guards interact across two files, and `pickerOrNothing`'s promise is broken on the self-delete paths
- Shape: `src/plugin-host/router.ts:4` header: promises order-independent specificity, but ties fall to discovery order
Inherited architect, would restructure first:
- Shape, Navigation, Overall: `src/auth/bootstrap.ts:1` `src/auth`: 19 modules across the session, Kratos flows, OAuth2 and a second process entrypoint
- Navigation, Self-sufficiency: `src/auth/bootstrap.ts:1` header: lists the JWKS, admin and Keto steps but not the plugin storage step, and the file reads the environment outside `config.ts`
- Shape, Navigation: `views/index.ejs:1` `index.ejs`: the dashboard, with `home.ejs` as the landing page, and neither name says so
- Shape: `src/ui/chrome.ts:55` `buildPluginChrome`: builds the chrome for every page, not only plugin pages
- Locality, Navigation: `src/test-tooling/stylesheet-floor.ts:10` header: the custom-property footgun sits in test tooling instead of beside the tokens at `public/css/styles.css:9`
- Locality: `src/auth/bootstrap.ts:170` `provisionPluginStorage`: plugin storage lives in three places, across `storage.ts`, `storage-provisioning.ts` and `bootstrap.ts`
- Locality, Shape: `src/http/app.ts:183` `handleRequest`: every new manifest field or slot adds a stage here and a field to `Host`
- Self-sufficiency: `src/plugin-host/system.ts:1` header: calls the built-in admin screens the reference consumer
-522
View File
@@ -1,522 +0,0 @@
# Decisions
"Goal N" is README → Goals. "Code health" is the goal every project shares: the code is easy to
understand and change safely.
## `src/` is grouped by concern, and the core ships no domain screens
2026-06-24, the maintainer. Code health and goal 3. Valid while each concern folder stays one a
reader can name.
The folders are `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`, and `test-tooling/` (modules only
tests import), with `server.ts`, `config.ts`, `logger.ts` and the topology-guard `*.test.ts` at the
root. Tests are co-located. Add a new module to the folder owning its concern. Even the admin GUI
is a drop-in plugin (`examples/plugins/auth-admin/`).
## Plugins and config import the host only through the two barrels
2026-08-17, the maintainer. Goal 7. Valid while plugin authors typecheck against a host tree rather
than a published package.
`@plainpages/plugin-api` → `plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, and
`#menu-config` → `src/ui/menu-config.ts`; never a relative `../../src/*` path. These two barrels are
the whole contract surface, so don't "fix" either back to a relative path.
## The Ory client types are contract-visible through the barrel
2026-07-02, the maintainer. Goal 7. Valid while plugins get Ory admin clients through `ctx.system`.
`@plainpages/plugin-api` re-exports `KratosAdmin`, `KetoClient`, `HydraAdmin`, their DTOs and their
error classes. Changing those shapes needs a major `apiVersion` bump, not a free refactor.
## The barrel is a package, so a plugin folder may carry its own `package.json`
2026-08-17, the maintainer. Goal 7. Valid while plugins may depend on npm packages (README → Plugin
dependencies).
The Dockerfile links the barrel into `/node_modules`, above every plugin scope. Never let a copy
reach a plugin's own `node_modules`: two instances of the barrel break `instanceof` across the
boundary. `plugin-api.test.ts` guards this by asserting both paths reach one module.
## Plugin storage hands over credentials, not a client
2026-08-18, the maintainer. Goals 3 and 7. Valid while the host stays stateless (AGENTS.md →
priority 2).
README → Plugin storage describes the contract. The host takes `postgres` only to run the
provisioning DDL, and `storage-provisioning.ts` is the only module importing it. `storage.ts`
beside it stays pure, so `web` never loads a driver. `src/postgres.test.ts` guards both halves,
because one value imported from the wrong module breaks this invisibly. The driver is never
re-exported through the barrel, so no driver shape enters the contract.
Three properties hold the design together, so don't trade one away in isolation:
- Passwords are `HMAC-SHA256(PLUGIN_DB_SECRET, id)` rather than stored, which keeps the host
stateless. Whoever holds that secret holds every plugin database, so it ranks with the DB password
itself.
- The provisioning DSN reaches `bootstrap` only (`src/compose.test.ts` guards the split).
- Provisioning never drops anything, so uninstalling a plugin cannot destroy data; boot logs the
orphans instead.
Because the host's copy sits in the ambient `/node_modules`, a plugin can `import "postgres"`
without declaring it. That is incidental, not a packaging promise: a plugin must still depend on its
own driver.
## Plugin settings are declared, not discovered
2026-08-23, the maintainer. Goals 7 and 10. Valid while settings reach plugins through the
environment (README → Plugin settings).
`settings.ts` is pure and takes the env as an argument, so the whole matrix unit-tests without a
stack. Four rules carry the design:
- The prefix is `PLUGIN_SETTING_`, never bare `PLUGIN_`: a plugin id `db` with key `url` would
otherwise name the host's own `PLUGIN_DB_URL`.
- Keys are camelCase, so the `camelCase → SNAKE_CASE` mapping is total and no two keys collide.
`findConflicts` (`plugin-host/conflicts.ts`) catches the residual cross-plugin collision.
- `required` and `default` are mutually exclusive. That lets `SettingsOf` type a declared key as
present rather than `T | undefined`, so no plugin author casts.
- A secret's value reaches the plugin but never a log, an error or `ctx.declaredSettings`, not even
as a mask or a length.
An author mistake is refused at discovery, a bad operator value refuses the boot, and a stray
`PLUGIN_SETTING_` variable only warns, as an orphan database does.
## The trust boundary is the `web` process, not the plugin
2026-08-19, the maintainer. Goal 7. Valid while plugins are operator-installed code, not third-party
uploads.
Per-plugin databases and roles bound accidents, not hostile plugins: `PLUGIN_DB_SECRET` is in
`web`'s environment during `onBoot`, and a plugin already holds `ctx.system`'s Ory admin clients. So
cross-plugin DB isolation is containment, and README says so. This follows AGENTS.md → priority 7:
crash-isolation is a non-goal.
`server.ts` still deletes the secret from `process.env` right after `loadConfig`, before discovery
imports any plugin module. That ordering is the whole point: move the line earlier if anything,
never later.
## `ory/postgres/init/init.sql` is the only home for the Ory databases' ACL
2026-08-19, the maintainer. Code health. Valid while Ory's databases are created by Postgres' first
init.
Don't re-assert the `REVOKE CONNECT` from `bootstrap`. `REVOKE` only warns when the caller doesn't
own the database, so under the least-privilege provisioning account the README recommends it would
report success while changing nothing. It also hard-fails whenever `PLUGIN_DB_ADMIN_URL` names a
server with no `kratos`. The script runs only on first init, so a revoke added to it later never
reaches a volume that already exists: `docker compose down -v` is the dev remedy, and a deployed
install needs a migration.
## `bootstrap.ts` stays under `src/auth/`
2026-08-19, the maintainer. Code health. Valid until a third seeding concern lands.
It provisions plugin databases as well as seeding Ory, but it is the one-shot service's entrypoint,
not an auth module. Moving it to `src/bootstrap.ts` would edit `compose.yml`, five e2e compose files
and `src/compose.test.ts` for a rename.
## `BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook
2026-08-19, the maintainer. Goal 7. Valid while no plugin needs an orderly drain of its pools.
Removing any field from the context costs a major bump, so all six stay. Adding one costs only a
minor, so the context otherwise gains a field only when a plugin needs it. Pools handed to a plugin
are reaped on process exit.
## `config/` is a plain dir with no `package.json` of its own
2026-07-01, the maintainer. Goal 7. Valid while an operator's menu override needs no dependencies.
With a `package.json` there, `#menu-config` resolves against it instead and boot fails loud. If the
override ever needs dependencies, `config/` needs the package treatment `plugins/` has.
## A plugin `package.json` without `"type": "module"` is refused, not warned
2026-08-18, the maintainer. Goal 7. Valid while nothing is installed in the wild.
Refusing is the safe direction: refuse→warn relaxes freely, while warn→refuse breaks installed
plugins. Allowing it would cost only a warning and a re-parse per file, not a break: Node detects
module syntax, so even a `.js` helper loads. The cost of refusing is that an operator on a read-only
third-party mount cannot apply the remedy.
## `examples/` mirrors the drop-in mount dirs
2026-07-01, the maintainer. Goal 9. Valid while the examples are meant to be copied in unchanged.
`examples/plugins/<id>/` copies to `plugins/<id>/`, and `examples/config/menu.ts` to
`config/menu.ts`. Both mirrors are in `tsconfig.include` and resolve the host through the barrels,
so each typechecks in place and copies across unchanged. Never commit real plugins or config into
the root mount dirs: they ship empty.
## `ctx.chrome` is lazily memoized, never built unconditionally
2026-06-23, the maintainer. Goal 8. Valid while the public landing (`/`) renders without I/O.
Don't 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.
## A plugin-owned render always runs on that plugin's context
2026-08-03, the maintainer. Goal 7. Valid while each plugin has its own translator.
The landing slots (`home`, `dashboard`) and an `onRequest` short-circuit build their 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.
## Email is delegated to Kratos
2026-06-23, the maintainer. Goal 3. Valid while Kratos owns the recovery and verification flows.
`web` never touches SMTP. Mail is customized through Kratos' `courier.template_override_path`.
## The vocabulary is `User` → `Group` → `Permission`, and there is no `Role`
2026-08-03, the maintainer. Code health. Valid while Keto holds no namespace of its own.
Keto ships no namespaces: all four in `ory/keto/namespaces.keto.ts` are ours. A permission is one
operation ("read shifts"). A role is a bundle, which here is just a group with several grants, and
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>`, enforced at discovery
2026-08-05, the maintainer. Goal 7. Valid while permissions are named by plugin authors.
Examples: `scheduling:read`, `users:write`. A bare word names who someone is (a role), and roles are
groups here. `shapeError` checks every route, nav and profile-menu `permission`, and every declared
name, with `isValidPermissionName` (`plugin-host/plugin.ts`). A bad name refuses the boot like any
other manifest rule. The check is not left to the admin GUI, which an operator removes by not
copying it in.
## Permission names are authored in plugin code; only grants live in Keto
2026-08-05, the maintainer. Goal 2. Valid while the admin screens offer only declared names.
The host collects every installed plugin's declarations into one catalog (`declaredPermissions` →
`ctx.declaredPermissions`), and that catalog is the list the admin screens offer. Hence there is no
Permissions admin screen: nothing in a GUI invents a name, and holding one is a property of a user
or group, edited as a checkbox list there. A Keto tuple naming something no installed plugin
declares is not offered as a checkbox, and is never revoked by an unrelated save: the picker only
speaks for what it showed. A plugin may still gate on such a name, so it can be live. The user's or
group's page lists such a direct grant separately, each with its own Remove button.
## A permission's `<resource>` is global, not plugin-scoped
2026-08-05, the maintainer. Goal 7. Valid while cross-plugin sharing of permissions is wanted.
Hence `oauth2-clients`, not `clients`. Users are the host's, and plugins share them. The cost is
that collision-freedom is a convention rather than structural; the alternative penalizes the
sharing case.
## Declaring a permission stays optional
2026-08-05, the maintainer. Goal 7. Valid while plugins may share a permission.
Mandatory declaration would let `findConflicts` see every overlap, but it would then warn on exactly
the legitimate sharing case. Shape is enforced; declaration is not.
## `ADMIN_PERMISSIONS` defaults to empty, and an unusable value is dropped with a warning
2026-08-05, the maintainer. Goal 10. Valid while `bootstrap` is the only writer of grants.
Fail-loud belongs at the manifest boundary, where a developer authored the mistake. `bootstrap`
gates `web`, so refusing operator env would take the whole stack down;
`e2e-tests/compose.auth.yml` seeds a bad value to prove the container survives one.
The seed is a function of what `bootstrap` discovers, so a plugin dropped in after first boot needs
`docker compose up -d`, not `restart web`. `bootstrap`'s matching `./plugins` mount belongs in
`compose.override.yml` and nowhere else. 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.
## `actionForMethod` is plugin-local and stays out of `@plainpages/plugin-api`
2026-08-05, the maintainer. Code health. Valid while route tables name their permission per route.
Inside the auth-admin example it keeps the route table and the in-handler guard deriving from one
function, so 29 routes × 2 gate sites cannot drift. Generalised, it would make authorization a
function of the transport verb, and a route table must answer "what does this need?" on its own.
## A gate is one of `public`, `session` or `permission`, named exactly once
2026-09-02, the maintainer. Goal 7. Valid while routes and nav nodes are declared in plugin
manifests.
A route or nav node names exactly one gate. Discovery refuses none, two, and a flag spelled anything
but `true`, so a forgotten gate fails the boot rather than publishing a page. `src/auth/gate.ts` is
the one home of the rule that the plugin router, the host's own route table and the menu all read.
The rule binds manifests only; the comment on `allows` says why a node without a gate passes it.
`session` exists because a plugin whose data is the visitor's own (their upstream account, their own
tokens) has no distinction a permission could name. The alternative, granting every newly
registered user a permission, couples the identity lifecycle to a Keto write that nothing retries
when it fails. A page scoped to "mine" joins on `ctx.user.id`, never the email: an address is
user-changeable and can be reassigned to someone who would then inherit the previous holder's rows.
## The reference plugin's two shift pages duplicate a view model and markup
2026-09-02, the maintainer. Goal 9. Valid while `examples/plugins/scheduling` stays a teaching
artifact rather than a maintained product.
Each page then reads top to bottom on its own, and an example rarely changes, so the two copies cost
little.
## A `:read`-only holder is never shown a write affordance
2026-08-05, the maintainer. Goal 2. Valid while the admin GUI splits `:read` from `:write`.
The list and detail models carry `canWrite`, and the views drop create, save, delete, add and
remove. The permission picker still renders, disabled, because seeing who holds what is the point
of `:read`. A write-intent GET (a create form, a delete-confirm page) is the exception to
`actionForMethod` and gates on `:write`.
Three guards go with it:
- You cannot revoke your own direct grants, since undoing that self-lockout needs a `curl` against
Keto.
- A permission held through a group renders ticked but disabled, because unticked would state the
opposite of the truth.
- Unticking a permission on a group you are in, removing yourself (or a nested group you are in)
from its members, or deleting the group is refused when you would lose a permission you don't hold
directly. Undoing that self-lockout would need a `curl` against Keto too.
## `users:write` and `groups:write` each amount to full administrative access
2026-08-05, the maintainer. Goal 2. Valid while the admin GUI splits its write permissions by
resource.
`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.
## The CSRF token is unbound to the session
2026-10-30, the maintainer. Goal 5. Valid while the session cookie stays `SameSite=Lax` or stricter.
Leaving the token unbound is the usual cost of a signed double-submit token (`src/auth/csrf.ts`;
README → Security model). `SameSite=Lax` and HSTS cover that cost.
## Plainpages says "user" everywhere, where Ory says "identity"
2026-08-05, the maintainer. Code health. Valid while Kratos' wire shape names the DTO `Identity`.
House style, not a renamed concept. The single exception is the `Identity` DTO in
`src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape: don't rename it.
## The locale lives in the URL, never in a cookie
2026-08-03, the maintainer. Goal 5. Valid while a page's language should show in its address.
The host resolves the locale from `?locale=sv-SE`, then `Accept-Language`, then `en-US`. When the
URL asked for a locale, the host carries it onto the links it renders. A cookie would make a page's
language invisible in its address and unshareable; the cost is that a plugin wraps 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.
## The core building blocks carry the locale, so a plugin doesn't have to
2026-08-03, the maintainer. Goal 1. Valid while the locale lives in the URL.
The shell, `pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu`
wrap every href in `localeHref`. Nav and sign-in are wrapped in `chrome.ts`. The two GET forms carry
the locale as a hidden `locale` input, since a GET submit replaces the whole query string.
A form's `action` counts as a link: sign-out, consent and auth-card forms carry the locale too, or
picking a language and then saving anything drops back to `Accept-Language`. The obligation stays on
the building block, never on each call site. `ctx.localeHref` remains for hrefs a plugin's own
markup emits. The one round-trip that cannot carry the locale is the Kratos sign-in POST, to an
absolute off-site URL.
## `locale` and the i18n view locals are reserved by the host
2026-08-03, the maintainer. Goal 7. Valid while the locale lives in the URL.
`locale` is in `parseListQuery`'s reserved set, so a localized list page doesn't hand a plugin a
phantom `locale` filter. The view locals `t`, `locale`, `locales`, `localeHref`, `localeParam`,
`localeSwitch`, `dir` and `assetHref` are merged after a handler's `data`, so a collision loses the
key instead of breaking the shell.
## Catalogs are checked at boot, not at render
2026-08-03, the maintainer. Goal 7. Valid while catalogs are TypeScript modules loaded at boot.
Every locale is compared against its set's `en-US`: keys, string-vs-plural kind, and the plural
categories `Intl.PluralRules` requires. A mismatch stops startup. A plugin may ship fewer locales
than the host, with its strings falling back to `en-US` per key, but never one the host lacks.
## `locales/` at the repo root is a drop-in mount
2026-08-03, the maintainer. Goal 7. Valid while adding a language must not require forking the
image.
Like `plugins/` and `config/`: `locales/<tag>.ts` for the core, `locales/plugins/<id>/<tag>.ts` for
a plugin. A new tag adds a language; an existing one replaces that catalog wholesale. The shipped
`en-US` 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.
## The language picker is on every page, POST-rendered ones included
2026-08-04, the maintainer. Goal 2. Valid while the picker is expected on literally every page; if
that softens, hiding it after a POST is simpler.
A POST-rendered URL often answers no GET (`POST /auth-admin/users/:id/recovery`), so the host resolves
the picker's target (`app.ts` → `switchBase`): this path when it answers GET, else the same-origin
Referer, else `/`. The accepted cost: switching language there leaves that POST's own result behind.
## An unknown translation key renders as itself
2026-08-03, the maintainer. Goal 7. Valid while labels may be either a key or plain text.
That single rule 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, and the view escapes it
2026-08-03, the maintainer. Code health. Valid while views escape with `<%= %>`.
Messages go through `<%= %>` like any other value. One carrying markup uses `<%- %>`, and then its
`{{vars}}` are escaped at the call site. Don't move escaping into `t()`: every other value in a view
would become the odd one out.
## RTL is out of scope until there is a real use case
2026-08-04, the maintainer. Goal 6. Valid while no deployment needs an RTL language.
`textDirection` sets `<html dir>` because that is free and correct, but the stylesheet keeps
physical `left` and `right` properties. A genuine RTL locale needs those moved to logical ones
first.
## A dropdown is a `<button popovertarget>` and a `[popover]`, never a `<details>`
2026-08-05, the maintainer. Goals 4 and 6. Valid while the popover API is the only zero-JS way to
dismiss on an outside click.
The browser owns open and close, and the panel sits in the top layer, so a row kebab is not clipped
by `.table-wrap`'s `overflow`. Four rules hold it together:
- The panel carries `position-anchor: auto`: a bare `anchor()` resolves to nothing in all three
engines.
- The panel stays the trigger's next sibling inside the `.menu` wrapper, which the open-state style
and the old-browser fallback both read.
- The partial requires a caller-named `id` and fails loud without one, since that is the
`popovertarget` idref. Never generate one: nondeterministic HTML forecloses caching.
- Neither `aria-expanded` nor `aria-haspopup` is written. Every engine maintains the first itself on
a declarative `popovertarget` invoker, so a hand-written one replaces a live state with a static
lie. The second would promise `role="menu"` semantics these panels don't implement.
That `aria-expanded` guarantee is the declarative attribute's alone: open a panel from script and no
engine applies it, so "enhancing" one of these triggers would cost it its accessibility. `<details>`
stays where it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same
block for the profile menu, and its comment says why. Keep the two in step.
## The document is the one scroller
2026-09-09, the maintainer. Goal 4. Valid while AGENTS.md → priority 1 holds that a page is a
document.
`.app` is `min-height: 100dvh`. `.nav`'s `overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto`
are not leftovers of a bounded frame: they make the off-canvas panel usable with a long tree.
`#nav-toggle` is `position: fixed` because a label click focuses it, and a browser scrolls a focused
element into view.
## `ICON_NAMES` is a host-owned registry, not a plugin contract
2026-08-05, the maintainer. Goal 7. Valid while the `every icon <use> resolves` e2e test guards the
nav.
`src/ui/icons.ts` is deliberately not re-exported from `@plainpages/plugin-api`. The palette may
narrow when the last reference to an id goes, and a plugin needing one gets it re-registered in the
same change. The accepted cost: an unknown sprite id renders blank instead of failing loud.
## Dependencies install to `/node_modules`, above `WORKDIR /app`
2026-08-05, the maintainer. Code health. Valid while dev bind-mounts the checkout at `/app`.
Node resolves upward, so dev's `.:/app` bind mount has nothing to shadow. A volume at
`/app/node_modules` would not do: the daemon creates a mount destination as root whatever `--user`
says, leaving a root-owned dir in the checkout. Nothing may sit at that path, since it silently
shadows `/node_modules`; `src/compose.test.ts` guards the compose files, and `.dockerignore` the
image.
## A container whose output a human then edits or deletes runs as the invoking user
2026-08-05, the maintainer. Code health. Valid while a dev box may lack `sudo`.
That means `--user "$(id -u):$(id -g)"` for the E2E runner (artifacts) and a lockfile edit;
otherwise the output is root-owned. It is not universal: `bootstrap` writes `jwks.json` as root when
it is absent on first boot. The committed dev key makes that rare, and when it happens the rotation
runbook's host-side `>` needs the file re-owned first. That exception is valid while the dev key
ships committed.
Three consequences:
- `e2e-tests/artifacts/` is tracked (`.gitkeep`), since an absent bind-mount source is
daemon-created as root and that uid then cannot write it (README → Upgrading).
- The runner image sets `HOME=/tmp`, since an arbitrary uid has no passwd entry and would land on an
unwritable `/`.
- Rootless Docker wants the flag dropped, container root already being the invoking user.
Baking a `USER` into the image does not work: the image's `pwuser` is 1001, and no fixed uid matches
every host. `src/compose.test.ts` guards every documented command, and `src/ci-gate.test.ts` the
gate's own.
## Anything the browser logs fails the E2E test that provoked it
2026-08-05, the maintainer. Goal 4. Valid while pages ship no JavaScript of their own.
Every spec takes its `test` from `e2e-tests/console-guard.ts`, which fails a test on a console error
or warning, or an uncaught exception, on any page it opened. A zero-JS app has nothing to say in the
console, so the bar is zero rather than a curated tolerance list. The two exceptions are narrow: a
module-level allowance for the COOP header Chromium drops (the e2e stacks serve plain http over
container hostnames), and `allowConsole(re)` for a test whose own page provokes a message on
purpose.
`src/e2e-console-guard.test.ts` locks the wiring in the unit gate, since a spec importing `test`
straight from Playwright, or minting a page with a raw `newPage()` instead of `watchedPage()`, would
run unwatched and green. The accepted cost: a page outliving its test can log late and fail the
next one.
## The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium
2026-08-05, the maintainer. Goal 4. Valid while the Ory-backed specs share one backend.
`visual.spec.ts` and `language.spec.ts` are side-effect-free, so parallel runs don't collide, and a
console message only appears in the engine that renders the page (`ORY_FREE` in
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
so widening them means a stack per engine.
## The docs-only CI skip is `*.md` anywhere in the tree
2026-08-05, the maintainer. Code health. Valid while no markdown file is rendered or executed.
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` would otherwise read as docs and skip the gate over a
source file that was gone. `src/ci-gate.test.ts` locks the flags as a text guard, since the test
image ships neither `git` nor `bash`.
Because a `.md` change skips the gate, the Docker Hub overview is
`release-tooling/dockerhub-overview.md.tmpl`. A release reads it and a unit test guards it, so under
a `.md` name a broken `{{VERSION}}` would merge with that guard skipped.
`README.md` is the one markdown file tests read, so a README-only change skips them. That leaves two
gaps:
- An `apiVersion` sample in the README can go stale unchecked by
`release-tooling/contract-version.test.ts`. Accepted, because the README's samples are
illustrative; the copies authors build from (`examples/`, `views/`, the template) are gated.
- A README-only change that drops a command the Hub page shows fails only the next gated change, in
`release-tooling/dockerhub-overview.test.ts`.
## CI docker logins share the runner host's Docker config
2026-07-04, the maintainer. Code health. Valid while releases keep a single-maintainer cadence; if
these races start costing re-runs, serialize the workflows with a `concurrency` group.
The act_runner is host-mode, so `docker login` and `logout` in the workflows mutate one shared
`~/.docker/config.json`. Concurrent jobs can race: one job's logout can 401 another's push, and the
recovery is a re-run. Tokens sit in that file between login and logout. In the same class,
concurrent runs share the workspace dir, so `ci.sh`'s web-image build races another run's container
creation on the `<project>-web` tag.
-515
View File
@@ -1,515 +0,0 @@
# Reading cost
## Reading cost, chunk 57e864bce02a, finished 2026-10-30T23:59:00Z
100624 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app.test.ts` 9
- `README.md` 7
- `src/http/app.ts` 4
- `todo.md` 3
- `e2e-tests/playwright.config.ts` 2
- `AGENTS.md` 1
- `CHANGELOG.md` 1
- `ci.sh` 1
- `examples/plugins/scheduling/views/shifts.ejs` 1
- `src/auth/csrf.ts` 1
## Reading cost, chunk df5e30a4db5f, finished 2026-10-31T00:22:00Z
80251 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 5
- `README.md` 3
- `CHANGELOG.md` 2
- `public/css/styles.css` 2
- `AGENTS.md` 1
- `src/ui/stylesheet-floor.test.ts` 1
- `src/ui/theme-switch.test.ts` 1
## Reading cost, chunk 3bf0b1a97d77, finished 2026-10-31T00:48:14Z
167985 new input tokens before the first commit.
Read most (opens and searches):
- `public/css/styles.css` 17
- `todo.md` 7
- `README.md` 6
- `public/css/auth.css` 5
- `AGENTS.md` 1
- `CHANGELOG.md` 1
- `examples/plugins/scheduling/public/scheduling.css` 1
- `examples/plugins/scheduling/views/overview.ejs` 1
- `examples/plugins/scheduling/views/shifts.ejs` 1
- `src/http/app.test.ts` 1
## Reading cost, chunk 91d11dbe63f9, finished 2026-10-31T01:13:44Z
69010 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 7
- `README.md` 4
- `public/css/styles.css` 4
- `CHANGELOG.md` 1
- `src/ui/stylesheet-floor.test.ts` 1
## Reading cost, chunk 70e46c48db80, finished 2026-10-31T07:17:56Z
70843 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 18
- `todo.md` 6
- `public/css/styles.css` 5
- `CHANGELOG.md` 2
- `AGENTS.md` 1
- `src/auth/jwt-middleware.ts` 1
## Reading cost, chunk 9e8ffd0aa8dc, finished 2026-10-31T07:36:53Z
98975 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 9
- `ci.sh` 3
- `e2e-tests/visual.spec.ts` 3
- `todo.md` 3
- `CHANGELOG.md` 2
- `e2e-tests/compose.visual.yml` 2
- `e2e-tests/language.spec.ts` 2
- `e2e-tests/mock-oidc.ts` 2
- `src/ci-gate.test.ts` 2
- `.dockerignore` 1
## Reading cost, chunk 25b7840ff611, finished 2026-10-31T07:46:18Z
73781 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 5
- `README.md` 2
- `src/http/app.ts` 2
- `AGENTS.md` 1
- `src/http/app.test.ts` 1
- `src/ui/chrome.test.ts` 1
- `src/ui/chrome.ts` 1
- `views/dashboard.ejs` 1
- `views/partials/shell.ejs` 1
## Reading cost, chunk e4d3bb0ac092, finished 2026-10-31T08:02:27Z
154733 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 10
- `README.md` 6
- `package.json` 3
- `.gitignore` 1
- `.npmrc` 1
- `AGENTS.md` 1
- `ci.sh` 1
- `public/css/styles.css` 1
- `renovate.json` 1
- `src/e2e-console-guard.test.ts` 1
## Reading cost, chunk 2f36d29bd746, finished 2026-10-31T08:19:09Z
85029 new input tokens before the first commit.
Read most (opens and searches):
- `e2e-tests/full-flow.spec.ts` 4
- `README.md` 3
- `src/auth/routes.test.ts` 3
- `todo.md` 3
- `src/auth/kratos-public.ts` 2
- `src/http/app.test.ts` 2
- `ci.sh` 1
- `compose.yml` 1
- `e2e-tests/auth-refresh.spec.ts` 1
- `src/auth/flow-view.ts` 1
## Reading cost, chunk 3373527a4f72, finished 2026-10-31T08:52:59Z
138664 new input tokens before the first commit.
Read most (opens and searches):
- `examples/plugins/admin/admin-users.ts` 8
- `README.md` 7
- `src/http/app.test.ts` 7
- `e2e-tests/full-flow.spec.ts` 3
- `examples/plugins/admin/admin-groups.ts` 3
- `examples/plugins/admin/admin-users.test.ts` 3
- `examples/plugins/admin/i18n/en-US.ts` 3
- `src/ui/icons.ts` 3
- `todo.md` 3
- `CHANGELOG.md` 2
## Reading cost, chunk 0118907cad57, finished 2026-10-31T09:32:49Z
116614 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app.test.ts` 11
- `README.md` 7
- `src/http/app.ts` 6
- `src/ui/shell.test.ts` 5
- `e2e-tests/visual.spec.ts` 4
- `src/http/static.ts` 4
- `todo.md` 3
- `src/auth/flow-view.ts` 2
- `views/partials/shell.ejs` 2
- `AGENTS.md` 1
## Reading cost reset, 2026-10-31T09:34:55Z
## Reading cost, chunk 55cc9f01b477, finished 2026-10-31T09:42:24Z
94244 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app.test.ts` 6
- `README.md` 4
- `src/http/app-plugins.test.ts` 4
- `todo.md` 3
- `AGENTS.md` 2
- `docs/reading-cost.md` 2
- `examples/plugins/admin/README.md` 2
- `ci.sh` 1
- `examples/plugins/admin/admin-clients.test.ts` 1
- `examples/plugins/admin/admin-grants.test.ts` 1
## Reading cost, chunk 4a8c9037acde, finished 2026-10-31T10:07:56Z
126616 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app-admin.test.ts` 7
- `examples/plugins/admin/admin-groups.ts` 4
- `README.md` 3
- `todo.md` 3
- `AGENTS.md` 2
- `examples/plugins/admin/README.md` 2
- `examples/plugins/admin/admin-grants.ts` 2
- `examples/plugins/admin/i18n/en-US.ts` 2
- `CHANGELOG.md` 1
- `examples/plugins/admin/admin-groups.test.ts` 1
## Reading cost, chunk c90b25a50479, finished 2026-10-31T10:40:23Z
90935 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 9
- `todo.md` 5
- `AGENTS.md` 3
- `CHANGELOG.md` 1
- `package.json` 1
- `registry-cleanup/cleanup.ts` 1
- `release-tooling/dockerhub-overview.md.tmpl` 1
- `release-tooling/dockerhub-overview.test.ts` 1
- `release-tooling/dockerhub-overview.ts` 1
- `src/ci-gate.test.ts` 1
## Reading cost, chunk 984f4d4c8a76, finished 2026-10-31T11:18:20Z
209334 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 6
- `todo.md` 6
- `src/ui/stylesheet-floor.test.ts` 4
- `src/ui/stylesheet-floor.ts` 4
- `CHANGELOG.md` 3
- `AGENTS.md` 2
- `examples/plugins/scheduling/public/scheduling.css` 2
- `package.json` 2
- `public/css/styles.css` 2
- `ci.sh` 1
## Reading cost, chunk de26287532f7, finished 2026-10-31T11:29:32Z
68410 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 5
- `public/css/styles.css` 3
- `src/ui/stylesheet-floor.test.ts` 3
- `CHANGELOG.md` 2
- `README.md` 2
- `AGENTS.md` 1
- `src/ui/theme-switch.test.ts` 1
## Reading cost, chunk 4a258a237c53, finished 2026-10-31T12:04:07Z
154777 new input tokens before the first commit.
Read most (opens and searches):
- `e2e-tests/full-flow.spec.ts` 9
- `todo.md` 6
- `README.md` 5
- `src/auth/flow-view.ts` 5
- `src/auth/flow-view.test.ts` 4
- `src/auth/routes.ts` 4
- `src/auth/kratos-public.test.ts` 3
- `src/i18n/locales/en-US.ts` 3
- `e2e-tests/compose.full.yml` 2
- `src/auth/kratos-admin.ts` 2
## Reading cost, chunk d8b41620d631, finished 2026-10-31T12:37:38Z
70583 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 5
- `AGENTS.md` 2
- `README.md` 2
- `examples/plugins/admin/README.md` 2
- `src/auth/kratos-admin.ts` 2
- `src/http/app-admin.test.ts` 2
- `CHANGELOG.md` 1
- `e2e-tests/full-flow.spec.ts` 1
- `examples/plugins/admin/admin-users.test.ts` 1
- `examples/plugins/admin/admin-users.ts` 1
## Reading cost, chunk f0a49c300aac, finished 2026-10-31T12:47:27Z
63526 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 4
- `src/plugin-host/settings.test.ts` 3
- `todo.md` 3
- `CHANGELOG.md` 2
- `src/plugin-host/settings.ts` 2
- `AGENTS.md` 1
## Reading cost, chunk 1a31ef102e70, finished 2026-10-31T13:14:10Z
70498 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 4
- `src/auth/bootstrap.test.ts` 4
- `src/auth/bootstrap.ts` 4
- `todo.md` 4
- `CHANGELOG.md` 1
- `src/config.test.ts` 1
- `src/config.ts` 1
- `src/plugin-host/storage-provisioning.ts` 1
- `src/plugin-host/storage.ts` 1
## Reading cost, chunk 80e22d10c810, finished 2026-11-08T13:06:23Z
130001 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 6
- `todo.md` 4
- `examples/plugins/admin/admin-groups.ts` 2
- `src/auth/bootstrap.ts` 2
- `src/auth/login.ts` 2
- `src/http/app-admin.test.ts` 2
- `AGENTS.md` 1
- `CHANGELOG.md` 1
- `examples/plugins/admin/i18n/sv-SE.ts` 1
- `src/auth/routes.ts` 1
## Reading cost, chunk b94ae3839c24, finished 2026-11-08T13:27:14Z
87251 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 9
- `README.md` 7
- `src/plugin-host/plugin.test.ts` 6
- `src/plugin-host/plugin.ts` 5
- `src/test-tooling/stylesheet-floor.ts` 4
- `AGENTS.md` 3
- `src/test-tooling/app-harness.ts` 3
- `src/plugin-host/discovery.ts` 2
- `src/test-tooling/stylesheet-floor.test.ts` 2
- `.dockerignore` 1
## Reading cost, chunk dcd9bc01df44, finished 2026-11-08T13:55:26Z
212195 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 10
- `todo.md` 8
- `src/http/app.ts` 4
- `src/http/app.test.ts` 3
- `src/http/static.ts` 3
- `CHANGELOG.md` 2
- `examples/plugins/admin/admin-users.ts` 2
- `release-tooling/contract-version.test.ts` 2
- `examples/plugins/admin/admin-clients.ts` 1
- `examples/plugins/admin/admin-groups.ts` 1
## Reading cost, chunk a6f3d52327a4, finished 2026-11-08T14:17:22Z
50555 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 4
## Reading cost, chunk 177d8e8081a6, finished 2026-11-08T18:14:49Z
66196 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 5
- `examples/plugins/admin/plugin.ts` 1
## Reading cost, chunk dd4d721e79d4, finished 2026-11-08T18:52:37Z
90532 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 8
- `README.md` 7
- `src/plugin-host/plugin.ts` 6
- `src/plugin-host/plugin.test.ts` 4
- `src/ui/chrome.ts` 4
- `AGENTS.md` 3
- `examples/plugins/scheduling/plugin.ts` 3
- `src/plugin-host/plugin-api.ts` 3
- `src/ui/chrome.test.ts` 3
- `CHANGELOG.md` 2
## Reading cost, chunk f4e8ff50cb2a, finished 2026-11-08T19:37:35Z
73698 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app.ts` 7
- `src/function-limits.test.ts` 2
- `src/http/context.ts` 2
- `src/http/static.ts` 2
- `src/plugin-host/plugin.ts` 2
- `todo.md` 2
- `AGENTS.md` 1
- `src/http/builtin-route.ts` 1
- `src/plugin-host/hooks.ts` 1
## Reading cost reset, 2026-11-08T19:39:42Z
## Reading cost, chunk 385be0aa8aef, finished 2026-11-08T19:57:05Z
87493 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 19
- `src/plugin-host/plugin.ts` 14
- `src/plugin-host/plugin.test.ts` 9
- `todo.md` 9
- `AGENTS.md` 4
- `docs/ci-cd.md` 4
- `src/compose.test.ts` 2
- `src/ci-gate.test.ts` 1
- `src/function-limits.test.ts` 1
- `src/plugin-host/discovery.ts` 1
## Reading cost, chunk 7100f96a1d8d, finished 2026-11-08T20:19:54Z
96357 new input tokens before the first commit.
Read most (opens and searches):
- `AGENTS.md` 11
- `README.md` 4
- `docs/decisions.md` 4
- `.gitea/workflows/release.yml` 2
- `examples/plugins/admin/admin-shared.ts` 2
- `src/postgres.test.ts` 2
- `todo.md` 2
- `src/auth/gate.ts` 1
- `src/auth/jwt.ts` 1
- `src/server.ts` 1
## Reading cost, chunk ac2b7411e145, finished 2026-11-08T20:50:21Z
105677 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 15
- `src/http/app-admin.test.ts` 7
- `todo.md` 5
- `CHANGELOG.md` 3
- `ci.sh` 3
- `examples/README.md` 3
- `examples/plugins/auth-admin/admin-shared.test.ts` 3
- `src/plugin-host/conflicts.ts` 3
- `AGENTS.md` 2
- `compose.yml` 2
## Reading cost, chunk 0d9d4ffb47aa, finished 2026-11-08T20:58:38Z
48347 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app.ts` 4
- `src/http/app.test.ts` 2
- `todo.md` 2
- `src/plugin-host/plugin.ts` 1
## Reading cost, chunk 8d5c5ad2ab7c, finished 2026-11-08T21:39:34Z
145906 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app-admin.test.ts` 8
- `README.md` 6
- `CHANGELOG.md` 5
- `compose.yml` 5
- `docs/decisions.md` 5
- `examples/plugins/auth-admin/admin-grants.test.ts` 4
- `examples/plugins/auth-admin/admin-users.ts` 4
- `src/auth/keto-client.ts` 4
- `todo.md` 4
- `examples/plugins/auth-admin/README.md` 3
## Reading cost, chunk ef984469151f, finished 2026-11-08T22:01:41Z
38668 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 7
- `AGENTS.md` 1
- `README.md` 1
- `docs/decisions.md` 1
+1 -7
View File
@@ -1,6 +1,6 @@
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/.
# Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network.
FROM mcr.microsoft.com/playwright:v1.64.1-noble
FROM mcr.microsoft.com/playwright:v1.62.1-noble
WORKDIR /e2e-tests
@@ -8,11 +8,5 @@ COPY e2e-tests/package.json e2e-tests/package-lock.json ./
RUN npm ci
COPY e2e-tests/ ./
# e2e-tests/tsconfig.json extends it, so the specs are held to the host's strictness flags.
COPY tsconfig.json /tsconfig.json
# Runs as the invoking `--user` so artifacts land owned by them, not root — and an arbitrary uid has
# no passwd entry here, so its home would be the unwritable `/`. npm's cache follows HOME.
ENV HOME=/tmp
CMD ["npx", "playwright", "test"]
View File
+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" });
+1 -13
View File
@@ -2,7 +2,7 @@
# visual suite (e2e-tests/compose.visual.yml) covers the design system; this is its full-stack counterpart:
# real Postgres + Kratos + Keto + bootstrap + web, with a SHORT tokenizer TTL (ory/kratos/e2e.yml)
# and zero clock skew, so the JWT lapses and re-mints within seconds instead of ~10m.
# docker compose -f compose.yml -f e2e-tests/compose.auth.yml run --user "$(id -u):$(id -g)" --build --rm e2e
# docker compose -f compose.yml -f e2e-tests/compose.auth.yml run --build --rm e2e
# docker compose -f compose.yml -f e2e-tests/compose.auth.yml down -v # tear down after
services:
web:
@@ -30,18 +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.
#
# `admin` rides along on purpose: it was this setting's default until 2026-08-05 and is not a legal
# `<resource>:<action>` name, so it is exactly the leftover an upgrading deployment carries. An
# earlier revision made that fatal, and bootstrap gates `web` — so if the boot ever refuses operator
# env again, `web` never turns healthy and this suite fails instead of CI going green over it.
bootstrap:
environment:
ADMIN_PERMISSIONS: admin,users:read
# Shorten the session→JWT TTL and expose a network-resolvable base_url (ory/kratos/e2e.yml),
# merged after the base config.
kratos:
+1 -1
View File
@@ -4,7 +4,7 @@
# runs against the *plain* `docker compose up` topology and drives the browser on the HOST network, so
# it sees http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host
# browser does. Merge the dev override so the live stack is byte-for-byte `docker compose up`:
# docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml run --user "$(id -u):$(id -g)" --build --rm e2e
# docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml run --build --rm e2e
# docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml down -v # tear down
services:
web:
+11 -26
View File
@@ -1,12 +1,12 @@
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
# login, emailed-code verification + recovery, 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 --user "$(id -u):$(id -g)" --build --rm e2e
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --build --rm e2e
# docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after
services:
web:
# The base's full depends_on applies (Hydra included — the auth-admin plugin's OAuth2-clients
# The base's full depends_on applies (Hydra included — the admin plugin's OAuth2-clients
# screen needs it); only the reference plugin's upstream is added. SSO is enabled here only
# (clean clone stays password-only): the mock provider's whole array is the env-settable form
# Kratos offers, mapped through the committed claims jsonnet.
@@ -24,18 +24,18 @@ services:
timeout: 4s
retries: 30
# plugins/ is empty in the image; bind the example plugins in so the browser flow can open the
# gated /scheduling/shifts page and the /auth-admin/* screens (the admin screens ship as a drop-in
# plugin, mounted at /app/plugins/auth-admin, reaching the host's Ory clients via ctx.system).
# gated /scheduling/shifts page and the /admin/* screens (the admin screens ship as a drop-in
# plugin, mounted at /app/plugins/admin, reaching the host's Ory clients via ctx.system).
volumes:
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
- ./examples/plugins/auth-admin:/app/plugins/auth-admin: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:
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
- ./examples/plugins/auth-admin:/app/plugins/auth-admin:ro
- ./examples/plugins/admin:/app/plugins/admin:ro
# Browser-facing URLs (base_url, every ui_url, the after-login redirect) move to the gateway host.
# `--dev`: the browser hits the gateway over http, but Kratos marks cookies Secure for a
@@ -53,7 +53,7 @@ services:
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
shifts-upstream:
image: node:24.22.2-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.22.2-alpine3.24
image: node:24.18.1-alpine3.24
command: ["node", "/mock-oidc.ts"]
environment:
ISSUER: http://mock-oidc:9000
@@ -79,20 +79,9 @@ services:
timeout: 4s
retries: 15
# Catches the courier's mail (kratos.yml → smtp://mailpit:1025), so a test can read an emailed code.
mailpit:
image: axllent/mailpit:v1.33.1
mem_limit: 128m
memswap_limit: 128m
healthcheck:
test: ["CMD", "/mailpit", "readyz"]
interval: 2s
timeout: 4s
retries: 15
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
proxy:
image: node:24.22.2-alpine3.24
image: node:24.18.1-alpine3.24
command: ["node", "/proxy.ts"]
depends_on:
web:
@@ -114,16 +103,12 @@ services:
dockerfile: e2e-tests/Dockerfile
command: ["npx", "playwright", "test", "full-flow.spec.ts"]
depends_on:
mailpit:
condition: service_healthy
mock-oidc:
condition: service_healthy
proxy:
condition: service_healthy
environment:
BASE_URL: http://proxy
KETO_WRITE_URL: http://keto:4467
KRATOS_ADMIN_URL: http://kratos:4434
MAILPIT_URL: http://mailpit:8025
volumes:
- ./e2e-tests/artifacts:/e2e-tests/artifacts
+1 -1
View File
@@ -3,7 +3,7 @@
# it via the Kratos session and accepts. Runs against the real stack (Postgres + Kratos + Keto +
# Hydra + bootstrap + web). The runner drives the flow over HTTP (fetch, manual cookies), so it
# reaches the Ory services by their compose-network names.
# docker compose -f compose.yml -f e2e-tests/compose.oauth.yml run --user "$(id -u):$(id -g)" --build --rm e2e
# docker compose -f compose.yml -f e2e-tests/compose.oauth.yml run --build --rm e2e
# docker compose -f compose.yml -f e2e-tests/compose.oauth.yml down -v # tear down after
services:
web:
+4 -6
View File
@@ -1,7 +1,6 @@
# 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.
# docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --build --rm e2e
# 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.
# Screenshots + HTML report land in ./e2e-tests/artifacts/ (git-ignored).
@@ -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 };
+17 -10
View File
@@ -1,13 +1,20 @@
import { expect, test } from "./console-guard.ts";
import { expect, test } from "@playwright/test";
// The from-scratch dev experience the banner advertises: `docker compose up`, open the printed
// login URL, sign in as the seeded admin, land on the dashboard. A host-scoped Kratos CSRF cookie
// cannot cross `localhost`↔`127.0.0.1`, so a cross-host login POST loses it and Kratos redirects to
// its error sink; APP_URL canonicalises every off-host visitor onto one cookie host instead.
// 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
// the dashboard, signed in. Originally this dumped the user on http://127.0.0.1:3000/error?id=…
// ("Page not found"): the banner printed `localhost` but kratos.yml hard-coded `127.0.0.1`, and a
// host-scoped Kratos CSRF cookie can't cross `localhost`↔`127.0.0.1`, so the cross-host login POST
// lost it and Kratos redirected to its error sink.
//
// The runner is on the host network against the plain `docker compose up` topology, so it sees
// http://localhost:3000 and http://127.0.0.1:4433 exactly as a host browser does. The proxied
// full-flow suite cannot catch this — it fronts web + Kratos on one origin.
// The fix makes APP_URL the single source for the public host: the web app canonicalises every
// off-host visitor onto it (so localhost / 127.0.0.1 / any alias funnel to one cookie host), Kratos'
// browser URLs derive from it, and a real /error page replaces the 404.
//
// This is faithful to the user's environment: the runner uses the host network
// (e2e-tests/compose.devstack.yml) against the plain `docker compose up` topology, so it sees
// http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host browser
// does. The proxied full-flow suite can't catch this regression — it fronts web + Kratos on one origin.
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
const ADMIN_PASSWORD = "admin";
@@ -19,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.
+31 -238
View File
@@ -1,5 +1,4 @@
import type { Browser, Page } from "@playwright/test";
import { allowConsole, 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,13 +9,10 @@ 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
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
const KETO_WRITE = process.env.KETO_WRITE_URL ?? "http://keto:4467";
const MAILPIT = process.env.MAILPIT_URL ?? "http://mailpit:8025";
// Drive the themed password login form → Kratos → /auth/complete → dashboard, signed in.
async function loginPassword(page: Page): Promise<void> {
@@ -28,192 +24,70 @@ 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 is most easily dropped; the core building blocks carry it through.
test("a sorted, paged admin list keeps the visitor's language", async () => {
await page.goto("/auth-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("/auth-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^="/auth-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="/auth-admin/users"]')).toHaveCount(1);
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(1);
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1);
});
test("users CRUD: create a user, see it listed, then delete it via the confirm step", async () => {
const email = `e2e-${suffix}@plainpages.local`;
await page.goto("/auth-admin/users/new");
await page.goto("/admin/users/new");
await page.fill('input[name="email"]', email);
await page.fill('input[name="first"]', "E2E");
await page.fill('input[name="last"]', "User");
await page.locator('.form-card button[type="submit"]').click();
await expect(page).toHaveURL(/\/auth-admin\/users(\?|$)/); // PRG back to the list
await expect(page).toHaveURL(/\/admin\/users(\?|$)/); // PRG back to the list
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^="/auth-admin/users/"]').first()).toBeVisible();
// Delete through the confirm interstitial (the row's Edit link carries the id).
const editHref = await row.locator('a[href^="/auth-admin/users/"]').first().getAttribute("href");
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
await page.goto(`${editHref}/delete`);
await page.getByRole("button", { name: "Delete user" }).click(); // the confirm form's danger button
await expect(page).toHaveURL(/\/auth-admin\/users(\?|$)/);
await expect(page).toHaveURL(/\/admin\/users(\?|$)/);
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
});
// Kratos accepts an admin-minted code only in the flow it was minted on, which the notice's link opens.
test("recovery code: the notice's link opens the flow that accepts the code, and the code signs the user in", async () => {
test.setTimeout(90_000);
const email = `admin-recovery-${suffix}@plainpages.local`;
await createIdentity(email);
await page.goto("/auth-admin/users");
const editHref = await page.locator("tr", { hasText: email }).locator('a[href^="/auth-admin/users/"]').first().getAttribute("href");
await page.goto(editHref!);
await page.getByRole("button", { name: "Generate recovery code" }).click();
const code = await page.locator(".recovery-code code").innerText();
const link = await page.locator(".alert-body a").getAttribute("href");
const user = await watchedPage(await browser.newContext());
await user.goto(link!);
await user.fill('input[name="code"]', code);
await user.getByRole("button", { name: "Continue" }).click();
await expect(user).toHaveURL(/\/settings/);
await user.context().close();
});
test("groups CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async ({ browser }) => {
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}`;
await page.goto("/auth-admin/groups/new");
await page.goto("/admin/groups/new");
await page.fill('input[name="name"]', group);
await page.locator('select[name="member"]').selectOption({ index: 1 });
await page.locator('.form-card button[type="submit"]').click();
await expect(page).toHaveURL(/\/auth-admin\/groups(\?|\/|$)/);
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(`/auth-admin/groups/${group}`);
allowConsole(/status of 409 .*\/auth-admin\/groups\/[^/]+\/permissions/); // the stale save below, refused on purpose
// A second browser signed in as the same admin, opened before the save below, so its form carries the old grants.
const staleTab = await watchedPage(await browser.newContext({ storageState: await page.context().storageState() }));
await staleTab.goto(`/auth-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(`/auth-admin/groups/${group}`));
await expect(page.getByText("Permissions saved.")).toBeVisible();
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
// The other tab no longer matches Keto, so its save is refused and shows the grant made above.
await staleTab.locator('input[name="permission"][value="scheduling:write"]').check();
await staleTab.locator('form:has(input[name="permission"]) button[type="submit"]').click();
await expect(staleTab.getByText("changed while you had the page open")).toBeVisible();
await expect(staleTab.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
await expect(staleTab.locator('input[name="permission"][value="scheduling:write"]')).not.toBeChecked();
await staleTab.context().close();
// No screen can grant a name no plugin declares, so write one to Keto directly.
const stray = "retired:write";
const put = await fetch(`${KETO_WRITE}/admin/relation-tuples`, {
body: JSON.stringify({ namespace: "Permission", object: stray, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } }),
headers: { "content-type": "application/json" },
method: "PUT",
});
expect(put.ok).toBe(true);
await page.goto(`/auth-admin/groups/${group}`);
await expect(page.getByRole("heading", { name: "Permissions no installed plugin declares" })).toBeVisible();
await page.getByRole("button", { name: `Remove ${stray}` }).click();
await expect(page.getByText("Permission removed.")).toBeVisible();
await expect(page.getByText(stray)).toHaveCount(0);
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked(); // the declared grant stays
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 () => {
const name = `e2e-client-${suffix}`;
await page.goto("/auth-admin/clients");
await page.goto("/admin/clients");
await page.getByRole("link", { name: "Register client" }).click();
await page.fill('input[name="name"]', name);
await page.fill('textarea[name="redirectUris"]', "https://app.example.com/callback");
@@ -226,17 +100,17 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("#csecret")).toHaveValue(/.+/);
// Listed; the row header links to the plain detail, which never shows the secret again.
await page.goto("/auth-admin/clients");
await page.goto("/admin/clients");
const row = page.locator("tr", { hasText: name });
await expect(row).toBeVisible();
await row.getByRole("link", { name }).click();
await expect(page).toHaveURL(new RegExp(`/auth-admin/clients/${clientId}`));
await expect(page).toHaveURL(new RegExp(`/admin/clients/${clientId}`));
await expect(page.locator("#csecret")).toHaveCount(0);
// Delete through the confirm interstitial (danger link on the detail → confirm form's button).
await page.getByRole("link", { name: "Delete client" }).click();
await page.getByRole("button", { name: "Delete client" }).click();
await expect(page).toHaveURL(/\/auth-admin\/clients(\?|$)/);
await expect(page).toHaveURL(/\/admin\/clients(\?|$)/);
await expect(page.locator("tr", { hasText: name })).toHaveCount(0);
});
@@ -244,36 +118,17 @@ test.describe.serial("authenticated admin journey", () => {
await page.goto("/scheduling/shifts");
await expect(page.locator("h1")).toHaveText("Shifts");
await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream
// The admin owns none of the demo's rows, so an empty page is the no-leak assertion.
await page.goto("/scheduling/mine");
await expect(page.locator("h1")).toHaveText("My shifts");
await expect(page.getByText("No shifts are assigned to admin@plainpages.local")).toBeVisible();
await expect(page.locator("table")).not.toContainText("Morning — Front desk");
});
test("plugin settings: the screen names the variable that sets each declared key", async () => {
await page.goto("/auth-admin/plugin-settings");
await expect(page.locator("h1")).toHaveText("Plugin settings");
// The reference plugin's one declared setting, and the variable an operator would set for it.
const scheduling = page.locator("table").filter({ hasText: "PLUGIN_SETTING_SCHEDULING_UPSTREAM" });
await expect(scheduling).toContainText("upstream");
await expect(scheduling).toContainText("http://shifts-upstream:4000"); // resolved, and its source shown
// Every installed plugin gets a section, so "declares none" is distinguishable from "not installed".
await expect(page.locator("h2", { hasText: "admin" })).toHaveCount(1);
});
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
await expect(page.locator("#profile-menu .menu-item")).toHaveText(["My shifts", "Sign out"]);
await expect(page.locator('#profile-menu a.menu-item[href="/scheduling/mine"]')).toHaveCount(1);
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).
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login(\?|$)/);
await expect(page.locator('.sidebar a[href="/auth-admin/users"]')).toHaveCount(0);
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
});
});
@@ -281,27 +136,16 @@ test("return_to: a deep link while logged out returns to that page after login",
test.setTimeout(90_000);
// A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos
// flow server-side, so it's consumed, not shown in the settled URL).
await page.goto("/auth-admin/users");
await page.goto("/admin/users");
await expect(page).toHaveURL(/\/login(\?|$)/);
await page.fill('input[name="identifier"]', ADMIN_EMAIL);
await page.fill('input[name="password"]', ADMIN_PASSWORD);
await page.locator('.auth-form button[type="submit"]').click();
// Completion routes through /auth/complete (mints the JWT) and on to the requested page, not the dashboard.
await expect(page).toHaveURL(/\/auth-admin\/users(\?|$)/);
await expect(page).toHaveURL(/\/admin\/users(\?|$)/);
await expect(page.locator("h1")).toHaveText("Users");
});
test("settings: an anonymous visit signs in first, then lands on the settings page", async ({ page }) => {
test.setTimeout(90_000);
await page.goto("/settings");
await expect(page).toHaveURL(/\/login(\?|$)/);
await page.fill('input[name="identifier"]', ADMIN_EMAIL);
await page.fill('input[name="password"]', ADMIN_PASSWORD);
await page.locator('.auth-form button[type="submit"]').click();
await expect(page).toHaveURL(/\/settings(\?|$)/);
await expect(page.locator("h1")).toHaveText("Account settings");
});
test("mocked SSO login: the provider button signs a user in via OIDC", async ({ page }) => {
test.setTimeout(90_000);
await page.goto("/login");
@@ -309,57 +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.
await expect(page.locator('.sidebar a[href="/auth-admin/users"]')).toHaveCount(0);
});
// The newest one-time code Kratos' courier mailed to `to`, read from the mail catcher (compose.full.yml).
async function mailedCode(to: string): Promise<string> {
let code = "";
await expect.poll(async () => {
const search = (await (await fetch(`${MAILPIT}/api/v1/search?query=${encodeURIComponent(`to:"${to}"`)}`)).json()) as { messages: { ID: string }[] };
const id = search.messages[0]?.ID;
if (!id) return "";
const message = (await (await fetch(`${MAILPIT}/api/v1/message/${id}`)).json()) as { Text: string };
code = /\b(\d{6})\b/.exec(message.Text)?.[1] ?? "";
return code;
}, { timeout: 30_000 }).not.toBe("");
return code;
}
async function createIdentity(email: string): Promise<void> {
const created = await fetch(`${KRATOS_ADMIN}/admin/identities`, {
body: JSON.stringify({ schema_id: "default", traits: { email } }),
headers: { "content-type": "application/json" },
method: "POST",
});
expect(created.status).toBe(201);
}
// Fills the email step, then pastes the mailed code with whitespace around it, as a paste often brings.
async function enterMailedCode(page: Page, email: string): Promise<void> {
await page.fill('input[name="email"]', email);
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.locator('input[name="code"]')).toBeVisible();
await page.fill('input[name="code"]', ` ${await mailedCode(email)}\t `);
await page.getByRole("button", { name: "Continue" }).click();
}
test("verification: a code pasted with whitespace around it is accepted", async ({ page }) => {
test.setTimeout(90_000);
const email = `verify-${suffix}@plainpages.local`;
await createIdentity(email);
await page.goto("/verification");
await enterMailedCode(page, email);
await expect(page.getByText("You successfully verified your email address.")).toBeVisible();
});
test("recovery: a pasted code signs in to the settings page, in the chosen language", async ({ page }) => {
test.setTimeout(90_000);
const email = `recover-${suffix}@plainpages.local`;
await createIdentity(email);
await page.goto("/recovery?locale=en-US");
await enterMailedCode(page, email);
await expect(page).toHaveURL(/\/settings\?.*locale=en-US/);
await expect(page.locator("h1")).toHaveText("Account settings");
// 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");
});
+5 -5
View File
@@ -2,7 +2,7 @@
// never leaves the compose network. Auto-approves /authorize (no provider login UI), then signs an
// RS256 id_token Kratos verifies against /jwks. stdlib only, in-memory, NOT app code. The single
// host (mock-oidc:9000) is reachable by both the browser (/authorize) and Kratos (token/jwks).
import { createServer, type ServerResponse } from "node:http";
import { createServer } from "node:http";
import { createSign, generateKeyPairSync, randomUUID } from "node:crypto";
const ISSUER = process.env.ISSUER ?? "http://mock-oidc:9000";
@@ -15,8 +15,8 @@ const KID = "mock-1";
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const jwk = { ...publicKey.export({ format: "jwk" }), alg: "RS256", kid: KID, use: "sig" };
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
function idToken(nonce: string): string {
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
function idToken(nonce) {
const now = Math.floor(Date.now() / 1000);
const header = b64({ alg: "RS256", kid: KID, typ: "JWT" });
const payload = b64({
@@ -27,8 +27,8 @@ function idToken(nonce: string): string {
return `${header}.${payload}.${sig}`;
}
const codes = new Map<string, string>(); // single-use auth code → the nonce Kratos sent (echoed into the id_token)
const json = (res: ServerResponse, body: unknown): void => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
const codes = new Map(); // single-use auth code → the nonce Kratos sent (echoed into the id_token)
const json = (res, body) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
createServer((req, res) => {
const url = new URL(req.url ?? "/", ISSUER);
+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
+23 -397
View File
@@ -1,24 +1,24 @@
{
"name": "plainpages-e2e",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plainpages-e2e",
"version": "0.1.0",
"devDependencies": {
"@playwright/test": "1.64.1",
"@types/node": "24.19.2",
"typescript": "7.0.2"
"@playwright/test": "1.62.1"
}
},
"node_modules/@playwright/test": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.1"
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
@@ -27,376 +27,44 @@
"node": ">=20"
}
},
"node_modules/@types/node": {
"version": "24.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.1"
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -405,48 +73,6 @@
"engines": {
"node": ">=20"
}
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc"
},
"engines": {
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
}
}
}
+3 -5
View File
@@ -1,15 +1,13 @@
{
"name": "plainpages-e2e",
"version": "0.1.0",
"private": true,
"description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).",
"type": "module",
"scripts": {
"test": "playwright test",
"typecheck": "tsc --noEmit"
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "1.64.1",
"@types/node": "24.19.2",
"typescript": "7.0.2"
"@playwright/test": "1.62.1"
}
}
+1 -14
View File
@@ -3,14 +3,10 @@ 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",
fullyParallel: true,
workers: 4, // Playwright's default is half the cores, so a run's parallelism would depend on the machine
forbidOnly: true,
reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]],
use: {
@@ -19,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"] } }],
});
+1 -1
View File
@@ -11,7 +11,7 @@ const PORT = Number(process.env.PORT ?? 80);
// Kratos public owns these prefixes (self-service flows, sessions, its well-known/schemas); the
// browser hits them via the flow action + OIDC callbacks. Everything else is the web app.
const toKratos = (path: string): boolean => ["/self-service", "/sessions", "/.well-known/ory", "/schemas"].some((p) => path === p || path.startsWith(`${p}/`));
const toKratos = (path) => ["/self-service", "/sessions", "/.well-known/ory", "/schemas"].some((p) => path === p || path.startsWith(`${p}/`));
createServer((req, res) => {
const target = toKratos(req.url ?? "/") ? KRATOS : WEB;
-5
View File
@@ -1,5 +0,0 @@
{
"extends": "../tsconfig.json",
"compilerOptions": { "lib": ["ESNext", "DOM"] },
"include": ["*.ts"]
}
+25 -203
View File
@@ -1,81 +1,35 @@
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` });
// Either side of the stylesheet's 860px and 560px breakpoints.
const VIEWPORTS = [
{ name: "desktop-xl", width: 1920, height: 1080 },
{ name: "laptop", width: 1280, height: 800 },
{ name: "tablet", width: 768, height: 1024 },
{ name: "phone", width: 390, height: 844 },
{ name: "phone-small", width: 320, height: 568 },
] as const;
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() }]);
});
// A key press, not scrollIntoView (a script can scroll a box no reader can) and not the wheel
// (Firefox's synthetic event never reaches the document).
for (const [name, path, tail] of [
["the starter dashboard", "/dashboard", ".form-actions .btn"],
["the public landing", "/", ".landing-actions .btn"],
] as const) {
for (const { width } of VIEWPORTS) {
test(`${name} scrolls to its end at ${width}px wide`, async ({ page }) => {
await page.setViewportSize({ width, height: 200 });
await page.goto(path);
const overflows = await page.evaluate(() => document.documentElement.scrollHeight > window.innerHeight);
expect(overflows, "the page must overflow, or it proves nothing").toBe(true);
await page.keyboard.press("End");
await expect(page.locator(tail).last()).toBeInViewport({ ratio: 1 });
});
}
}
// Green only while #nav-toggle is position: fixed — a label tap focuses it, and focus scrolls into view.
test("closing the mobile drawer leaves the reader where the scrim found them", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 200 });
await page.goto("/dashboard");
await page.locator(".hamburger").click();
await expect(page.locator("#nav-toggle")).toBeChecked();
// Scripted, because a key press with focus on the toggle does not scroll in every engine — and
// what is under test is closing the drawer, not how the reader got down the page.
await page.evaluate(() => window.scrollTo(0, 120));
const at = await page.evaluate(() => window.scrollY);
expect(at, "the page must have somewhere to scroll behind the scrim").toBeGreaterThan(0);
// The exposed strip beside the 264px panel: the scrim spans the viewport, so its centre is under
// the drawer and a centre click lands on the panel instead.
await page.locator(".scrim").click({ position: { x: 340, y: 100 } });
await expect(page.locator("#nav-toggle")).not.toBeChecked();
expect(await page.evaluate(() => window.scrollY), "closing the drawer must not move the page").toBe(at);
});
test("captures the live pages for review", async ({ page }) => {
await page.goto("/dashboard");
await expect(page.locator(".sidebar")).toBeVisible();
@@ -86,20 +40,12 @@ test("captures the live pages for review", async ({ page }) => {
await page.goto("/dashboard");
await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio
await shot(page, "live-03-dark");
});
// The page is a document, so a column wider than the viewport can only show as sideways scroll.
for (const { name, width, height } of VIEWPORTS) {
for (const [path, label] of [["/dashboard", "dashboard"], ["/", "landing"], ["/scheduling", "plugin"]] as const) {
test(`the ${label} page fits ${width}px wide, with no sideways scroll`, async ({ page }) => {
await page.setViewportSize({ width, height });
await page.goto(path);
const [scrollWidth, clientWidth] = await page.evaluate(() => [document.documentElement.scrollWidth, document.documentElement.clientWidth] as const);
expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
await shot(page, `live-${label}-${name}`);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/dashboard");
await shot(page, "live-04-mobile");
await page.setViewportSize({ width: 1280, height: 800 });
});
}
}
test("every icon <use> resolves to a defined <symbol> (no broken graphics)", async ({ page }) => {
await page.goto("/dashboard");
@@ -112,8 +58,9 @@ test("every icon <use> resolves to a defined <symbol> (no broken graphics)", asy
expect(missing).toEqual([]);
});
// The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component and
// exercised live by the full-flow E2E's admin Users list, so it has no Ory-free counterpart here.
// (The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component
// (list-query/data-table/filter-bar) and exercised live with real data by the full-flow E2E's admin
// Users list. The mock-data dashboard that used to host it in this Ory-free suite is gone.)
test("theme switch flips the palette with no JavaScript", async ({ page }) => {
await page.goto("/dashboard");
@@ -123,36 +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();
await expect(trigger).toBeFocused(); // the browser returns focus, so no trigger needs a tabindex
});
test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/dashboard");
@@ -165,21 +82,6 @@ test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({
expect(offCanvas).toBe(true);
});
test("the hamburger opens the drawer over the scrim, and a link in it navigates", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/dashboard");
await page.locator(".hamburger").click();
const sidebar = page.locator(".sidebar");
await expect(sidebar).toBeInViewport({ ratio: 1 });
// A click is refused while the scrim sits above the drawer, which a lower z-index would cause.
await sidebar.locator("summary").first().click(); // the dashboard is the current page, so its links are in a folded branch
const link = sidebar.locator(".nav-children a.nav-self").first();
const target = await link.getAttribute("href");
await link.click();
await expect(page).toHaveURL((url) => url.pathname === target?.split("?")[0]);
});
test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a tokenless POST is refused", async ({ page }) => {
await page.goto("/dashboard");
// The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit).
@@ -197,92 +99,25 @@ 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");
});
// Measured on the rendered elements, so a theme block that drops --on-accent fails here.
const contrastOf = (el: Element): number => {
const luminance = (rgb: string): number => {
const [r = 0, g = 0, b = 0] = (rgb.match(/[\d.]+/g) ?? []).map((c) => {
const v = Number(c) / 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const style = getComputedStyle(el);
const [hi = 0, lo = 0] = [luminance(style.color), luminance(style.backgroundColor)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
for (const [name, colorScheme, forceDark] of [["light", "light", false], ["dark", "dark", false], ["forced dark", "light", true]] as const) {
test(`text on the accent reaches 4.5:1 in ${name} mode`, async ({ page, context }) => {
await context.clearCookies();
await page.emulateMedia({ colorScheme });
await page.goto("/");
if (forceDark) await page.locator("#theme-dark").check({ force: true });
// No page of this stack has a second page of results, so add the current-page button.
await page.locator("#main-content").evaluate((el) => el.insertAdjacentHTML("beforeend", '<a class="page-btn" aria-current="page" href="#">2</a>'));
for (const selector of [".landing .btn-primary", ".brand-mark", ".page-btn"]) {
await expect.poll(() => page.locator(selector).first().evaluate(contrastOf), { message: selector }).toBeGreaterThanOrEqual(4.5); // polls past the colour transition
}
});
}
test("a nav branch is folded until its toggle opens it — no JavaScript", async ({ page }) => {
await page.goto("/dashboard");
const child = page.locator('.sidebar a[href="/scheduling"]');
await expect(child).toBeHidden();
await page.locator('.sidebar summary[aria-label="Toggle Scheduling"]').click();
await expect(child).toBeVisible();
});
test("the reference plugin's lead text is inset by --pad-x, like the bars around it", async ({ page }) => {
await page.goto("/scheduling");
const [inset, padX] = await page.locator(".scheduling-page > p").evaluate((p) => [
p.getBoundingClientRect().left - (p.parentElement as HTMLElement).getBoundingClientRect().left,
parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--pad-x")),
] as const);
expect(inset).toBe(padX);
});
// transferSize is 0 only for a response the browser's own cache served; a 304 still costs a round trip.
test("a repeat page view fetches no stylesheet again", async ({ page }) => {
const stylesheets = (): Promise<{ name: string; transferSize: number }[]> => page.evaluate(() =>
performance.getEntriesByType("resource")
.filter((entry): entry is PerformanceResourceTiming => entry instanceof PerformanceResourceTiming && entry.initiatorType === "link" && entry.name.includes(".css"))
.map(({ name, transferSize }) => ({ name, transferSize })));
await page.goto("/");
const first = await stylesheets();
expect(first.length, "the landing links styles.css and auth.css").toBeGreaterThanOrEqual(2);
for (const { name, transferSize } of first) expect(transferSize, `${name} on the first view`).toBeGreaterThan(0);
await page.goto("/");
const repeat = await stylesheets();
// A cache hit completes in no set order, so the two views list the same files in any order.
const names = (entries: { name: string }[]): string[] => entries.map(({ name }) => name).sort();
expect(names(repeat)).toEqual(names(first));
for (const { name, transferSize } of repeat) expect(transferSize, `${name} on the repeat view`).toBe(0);
});
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();
await expect(page.getByRole("link", { name: "Back home" })).toBeVisible();
});
// The reference plugin (plugins/scheduling) ships discovered in the image, and shows all three
// gates: the public Overview is reachable by anyone, My shifts takes any session, and the shifts
// list needs a permission. The authenticated list/form flow is the full E2E (full-flow.spec).
// Side-effect-free.
test("the reference plugin: public Overview is open to all, My shifts takes any session, the gated Shifts redirects to /login", async ({ page, request }) => {
// The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is
// reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated,
// so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full
// E2E (full-flow.spec). Side-effect-free.
test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => {
// `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these
// probes are genuinely anonymous.
// The public overview is reachable with no session (200), not bounced to sign in.
@@ -301,23 +136,10 @@ test("the reference plugin: public Overview is open to all, My shifts takes any
expect(res.status()).toBe(303);
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
// A `session: true` route bounces an anonymous visitor the same way — no permission involved.
const mine = await request.get("/scheduling/mine", { maxRedirects: 0 });
expect(mine.status()).toBe(303);
expect(mine.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fmine");
// 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
await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
await expect(page.locator('.sidebar nav a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough
// No shifts upstream on this stack, so this also pins the degraded page: the reason, never a 500
// and never a claim about what is assigned.
await page.goto("/scheduling/mine");
await expect(page.getByRole("heading", { name: "My shifts" })).toBeVisible();
await expect(page.getByText("Couldn't reach the scheduling service")).toBeVisible();
await expect(page.getByText("No shifts are assigned to")).toHaveCount(0);
});
+3 -3
View File
@@ -5,7 +5,7 @@ across (or bind-mount your own) and restart.
| Path | Copy into | Example of |
| --- | --- | --- |
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `@plainpages/plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
| [`plugins/auth-admin/`](plugins/auth-admin/) | `plugins/auth-admin/` | The system-admin plugin: the Users / Groups / OAuth2-clients / Plugin-settings 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/auth-admin/README.md). |
| [`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 / 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 `PLUGIN_SETTING_SCHEDULING_UPSTREAM` at the real thing instead. |
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. |
+10 -11
View File
@@ -1,27 +1,26 @@
// Reference config/menu.ts — copy into the empty config/ mount at the repo root:
// Reference config/menu.ts — copy into the (empty) config/ mount at the repo root:
// cp examples/config/menu.ts config/menu.ts
// Absent config = built-in defaults.
// config/ ships empty; mount your own or copy this in. Absent config = built-in defaults.
//
// Brand the app, reorder/rename/group/hide nav nodes and rename/hide profile-menu entries (by their
// `id`) across all plugins — the override always wins, applied before the per-user permission
// filter. Every field is optional.
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README → The menu system.
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins —
// the override always wins, applied before the per-user permission filter. Every field is
// optional; delete one to fall back to the default.
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README.md (The menu system).
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 nav-node id; `rename` and `hide` also
// take a profile-menu entry's id, such as "scheduling:profile-mine".
// 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)
},
+48
View File
@@ -0,0 +1,48 @@
# Admin — the system-administration plugin
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 restart web
```
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
Most plugins fetch their data from an upstream service of their own (see the [scheduling
reference](../scheduling/README.md)). The admin screens instead administer **Plainpages' own identity
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 (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 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 `permission: "admin"`, rendering the core building blocks in `views/`.
## Layout
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission token, and the
route table — one thin handler per method+path, all gated by `permission: "admin"`.
- `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
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 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`.
@@ -1,6 +1,6 @@
// Built-in OAuth2 clients admin screen: the pure view-model + Hydra-payload builders. A client
// is an Ory Hydra OAuth2 client (apps that log in *through* us); writes go only to Hydra. The
// HTTP routing/gate/CSRF + live Hydra calls (incl. the one-time secret) are exercised in src/http/app-admin.test.ts.
// HTTP routing/gate/CSRF + live Hydra calls (incl. the one-time secret) are exercised in app.test.ts.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
@@ -59,15 +59,15 @@ test("validateClientInput requires a name, ≥1 redirect URI, and absolute redir
test("buildClientsListModel filters by search, paginates; the name links to the detail page", () => {
const clients = Array.from({ length: 30 }, (_, i) => ({ client_id: `id-${String(i).padStart(2, "0")}`, client_name: `app-${String(i).padStart(2, "0")}`, token_endpoint_auth_method: "client_secret_basic" }));
const all = buildClientsListModel({ clients, url: "http://x/auth-admin/clients" });
const all = buildClientsListModel({ clients, url: "http://x/admin/clients" });
assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.title, "OAuth2 clients");
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
assert.equal(first.rowHeader.text, "app-00");
assert.equal(first.rowHeader.href, "/auth-admin/clients/id-00");
assert.equal(first.rowHeader.href, "/admin/clients/id-00");
const one = buildClientsListModel({ clients, url: "http://x/auth-admin/clients?q=app-07" });
const one = buildClientsListModel({ clients, url: "http://x/admin/clients?q=app-07" });
assert.equal(one.pagination.summary.total, 1);
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
});
@@ -75,7 +75,7 @@ test("buildClientsListModel filters by search, paginates; the name links to the
test("buildClientFormModel: a register form with name + scope fields; values reflected on error", () => {
const m = buildClientFormModel({ csrfToken: "tok.sig" });
assert.equal(m.title, "Register client");
assert.equal(m.form.action, "/auth-admin/clients");
assert.equal(m.form.action, "/admin/clients");
assert.equal(m.form.submitLabel, "Register client");
assert.equal(m.form.csrfToken, "tok.sig");
assert.equal(m.form.nameField.required, true);
@@ -94,7 +94,7 @@ test("buildClientDetailModel: client info + delete action; the one-time secret +
const plain = buildClientDetailModel({ client });
assert.equal(plain.title, "Acme");
assert.equal(plain.delete.action, "/auth-admin/clients/c1/delete");
assert.equal(plain.delete.action, "/admin/clients/c1/delete");
assert.equal(plain.created, false);
assert.equal(plain.secret, undefined);
@@ -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 { buildPaginationModel, can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/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,64 +116,77 @@ 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: buildPaginationModel(page, (n) => listHref(state, { page: n }), { label: t("admin.clients.pagination"), pageSizes: PAGE_SIZES }),
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>) {
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
return {
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: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
// ---- register form + detail view models ----
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,
@@ -187,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,
};
}
@@ -231,74 +237,71 @@ 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 /auth-admin/clients — the list.
// 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 /auth-admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never
// 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 ?? "" });
return clientDetailResult(ctx, created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) });
});
// GET /auth-admin/clients/new — the register form.
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})), "write");
// GET /admin/clients/new — the register form.
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})));
// GET /auth-admin/clients/:id — the detail (read-only; the secret is shown only once, at creation).
// 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)));
// GET /auth-admin/clients/:id/delete — the deliberate confirm step.
// GET /admin/clients/:id/delete — the deliberate confirm step.
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 /auth-admin/clients/:id/delete — perform it.
// POST /admin/clients/:id/delete — perform it.
export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => {
await guardedForm(ctx); // CSRF-verify the POST
await hydra.deleteClient(id);
@@ -1,7 +1,7 @@
// Built-in Groups admin screen: the pure view-model + Keto-tuple builders. A group is a
// Keto subject set (Group:<name>#members); membership tuples carry users (subject_id) or nested
// groups (subject_set). The HTTP routing/gate/CSRF + live Keto/Kratos calls are exercised over
// HTTP in src/http/app-admin.test.ts.
// HTTP in app.test.ts.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
@@ -14,7 +14,7 @@ import {
memberView,
parseSubject,
} from "./admin-groups.ts";
import type { RelationTuple } from "@plainpages/plugin-api";
import type { RelationTuple } from "#plugin-api";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (group: string, n: number): RelationTuple =>
@@ -56,22 +56,22 @@ test("memberView resolves a user subject to its email (else the raw id) and a su
test("buildGroupsListModel filters by search, sorts, paginates; the name links to the detail page", () => {
const groups = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `team-${String(i).padStart(2, "0")}` }));
const all = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups" });
const all = buildGroupsListModel({ groups, url: "http://x/admin/groups" });
assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.title, "Groups");
// The group name is the row header, linking to its detail page.
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
assert.equal(first.rowHeader.text, "team-00");
assert.equal(first.rowHeader.href, "/auth-admin/groups/team-00");
assert.equal(first.rowHeader.href, "/admin/groups/team-00");
// Search narrows + shows a pill.
const one = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups?q=team-07" });
const one = buildGroupsListModel({ groups, url: "http://x/admin/groups?q=team-07" });
assert.equal(one.pagination.summary.total, 1);
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
// Sort by members descending puts the biggest group first.
const desc = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups?sort=-members" });
const desc = buildGroupsListModel({ groups, url: "http://x/admin/groups?sort=-members" });
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "team-29");
});
@@ -79,7 +79,7 @@ test("buildGroupFormModel: a create form with a required name field + member opt
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.title, "New group");
assert.equal(m.form.action, "/auth-admin/groups");
assert.equal(m.form.action, "/admin/groups");
assert.equal(m.form.submitLabel, "Create group");
assert.equal(m.form.csrfToken, "tok.sig");
assert.equal(m.form.nameField.name, "name");
@@ -105,8 +105,8 @@ test("buildGroupDetailModel: members → rows, add-options exclude current membe
const m = buildGroupDetailModel({ candidates, group: { name: "eng" }, members });
assert.equal(m.title, "eng");
assert.equal(m.members.rows.length, 2);
assert.equal(m.members.action, "/auth-admin/groups/eng/members/delete");
assert.equal(m.add.action, "/auth-admin/groups/eng/members");
assert.equal(m.members.action, "/admin/groups/eng/members/delete");
assert.equal(m.add.action, "/admin/groups/eng/members");
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
assert.equal(m.delete.action, "/auth-admin/groups/eng/delete");
assert.equal(m.delete.action, "/admin/groups/eng/delete");
});
@@ -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 { buildPaginationModel, can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, grantsNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, REMOVED_NOTICE, SAVED_NOTICE, userSubject } 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: buildPaginationModel(page, (n) => listHref(state, { page: n }), { label: t("admin.groups.pagination"), pageSizes: PAGE_SIZES }),
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,37 +159,52 @@ 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>) {
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: "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: "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 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,
@@ -203,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;
@@ -276,68 +279,41 @@ async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
return page.tuples.length > 0;
}
const isMember = (keto: KetoClient, actor: string, group: string): Promise<boolean> =>
keto.check({ namespace: GROUP_NS, object: group, relation: MEMBERS, subject_id: `user:${actor}` });
// The self-lockout guard, the group-side twin of the Users screen's self-revoke one: of the permissions
// a change takes from a group the actor is in, those they don't hold directly. One they also hold
// through another group is included, since telling those apart needs a reverse Keto query.
async function lostToSelf(keto: KetoClient, actor: string, taken: readonly string[]): Promise<string[]> {
if (taken.length === 0) return [];
const direct = new Set(await heldPermissions(keto, userSubject(actor)));
return taken.filter((name) => !direct.has(name));
}
// Leaving a group takes everything it confers, including through its own parents.
async function lostOnLeaving({ ctx, keto, user }: GroupsDeps, group: string): Promise<string[]> {
return lostToSelf(keto, user.id, await effectivePermissions(keto, groupSubject(group), ctx.declaredPermissions));
}
async function lostOnDelete(deps: GroupsDeps, group: string): Promise<string[]> {
return (await isMember(deps.keto, deps.user.id, group)) ? lostOnLeaving(deps, group) : [];
}
async function refuseSelfLockout(deps: GroupsDeps, name: string, action: string, lost: string[]): Promise<RouteResult> {
deps.ctx.log.warn("admin: refused a group change that would lock the actor out", { action, actor: deps.user.id, group: name, lost: lost.join(",") });
return { ...(await groupDetailPage(deps, name, { pageError: deps.ctx.t("admin.groups.error.selfLockout", { permissions: lost.join(", ") }) })), status: 400 };
}
// 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 /auth-admin/groups — the list.
// 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 /auth-admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
// POST /admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
export const groupsCreate = withGroups(async (deps) => {
const { ctx, keto, user } = deps;
const form = (await guardedForm(ctx))!;
@@ -345,72 +321,25 @@ 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 (await groupExists(keto, name)) return reject(ctx.t("admin.groups.error.duplicate"));
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 });
return { redirect: detailHref(name) };
});
// GET /auth-admin/groups/new — the create form.
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
// GET /admin/groups/new — the create form.
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}));
// The detail + membership page. `notice` and `error` ride the permission picker; `pageError` tops the
// page, for any change refused because it would lock the actor out.
async function groupDetailPage({ ctx, keto, kratosAdmin }: GroupsDeps, name: string, say: { error?: string; notice?: string; pageError?: string } = {}): Promise<RouteResult> {
// 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: PermissionPicker = {
...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
}),
...(say.error ? { error: say.error } : {}),
...(say.notice ? { notice: say.notice } : {}),
};
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t, ...(say.pageError ? { error: say.pageError } : {}) }) }, view: "group-detail" };
}
// GET /auth-admin/groups/:name — the detail + membership page.
export const groupsDetail = withGroupName((deps, name) =>
groupDetailPage(deps, name, { notice: grantsNotice(deps.ctx.url, deps.ctx.t) ?? "" }));
// POST /auth-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 (deps, name) => {
const { ctx, keto, user } = deps;
const form = (await guardedForm(ctx))!;
const subject = groupSubject(name);
const held = await heldPermissions(keto, subject);
const diff = grantDiff(ctx.declaredPermissions, held, form.getAll(PERMISSIONS_FIELD));
if (isStale(form, ctx.declaredPermissions, held, diff)) return { ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.stale") })), status: 409 };
const lost = diff.revoke.length > 0 && await isMember(keto, user.id, name) ? await lostToSelf(keto, user.id, diff.revoke) : [];
if (lost.length > 0) return refuseSelfLockout(deps, name, "save", lost);
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)}?info-msg=${SAVED_NOTICE}` };
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members }) }, view: "group-detail" };
});
// POST /auth-admin/groups/:name/permissions/remove — drop one grant no installed plugin declares.
export const groupsPermissionsRemove = withGroupName(async ({ ctx, keto, user }, name) => {
const form = (await guardedForm(ctx))!;
const removed = await removeUndeclaredGrant(keto, groupSubject(name), ctx.declaredPermissions, form.get(REMOVE_FIELD));
if (removed) ctx.log.info("admin: undeclared group permission removed", { actor: user.id, group: name, removed });
return { redirect: `${detailHref(name)}?info-msg=${REMOVED_NOTICE}` };
});
// POST /auth-admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
// POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
const form = (await guardedForm(ctx))!;
const tuple = memberTuple(name, (form.get("member") ?? "").trim());
@@ -418,47 +347,28 @@ export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
return { redirect: detailHref(name) };
});
// GET /auth-admin/groups/:name/delete — the deliberate confirm step.
export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
const lost = await lostOnDelete(deps, name);
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
// 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 { 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"),
}) }, view: "confirm" };
}, "write");
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
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" });
});
// POST /auth-admin/groups/:name/delete — one Keto transaction removes the group's member tuples
// and the grants it holds, so a Keto failure leaves the group as it was.
export const groupsDelete = withGroupName(async (deps, name) => {
const { ctx, keto, user } = deps;
// 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
const lost = await lostOnDelete(deps, name);
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
// 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);
const grants = held.map((permission) => grantTuple(permission, subject));
const members = await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS });
await keto.transactTuples({ delete: [...grants, ...members], insert: [] });
ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") });
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS });
ctx.log.info("admin: group deleted", { actor: user.id, group: name });
return { redirect: ADMIN_GROUPS_BASE };
});
// POST /auth-admin/groups/:name/members/delete — remove one member.
export const groupsRemoveMember = withGroupName(async (deps, name) => {
const { ctx, keto, user } = deps;
// POST /admin/groups/:name/members/delete — remove one member.
export const groupsRemoveMember = withGroupName(async ({ ctx, keto }, name) => {
const form = (await guardedForm(ctx))!;
const tuple = memberTuple(name, (form.get("member") ?? "").trim());
if (!tuple) return { redirect: detailHref(name) };
// Removing the actor, or a nested group they are in, can take them out of this group.
const removesSelf = tuple.subject_set ? await isMember(keto, user.id, tuple.subject_set.object) : tuple.subject_id === `user:${user.id}`;
const lost = removesSelf ? await lostOnLeaving(deps, name) : [];
if (lost.length > 0) return refuseSelfLockout(deps, name, "remove", lost);
await keto.deleteTuple(tuple);
if (tuple) await keto.deleteTuple(tuple);
return { redirect: detailHref(name) };
});
+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) };
});
@@ -0,0 +1,70 @@
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
// (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, 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 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 {
const url = new URL("http://localhost/admin/users");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
req.method = opts.method ?? "GET";
return {
chrome: CHROME, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
roles: opts.user?.roles ?? [], url, user: opts.user ?? null, verifyCsrf: opts.verifyCsrf ?? (() => true),
};
}
// ---- nav fragment ----
test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => {
assert.equal(ADMIN_NAV.id, "admin");
assert.equal(ADMIN_NAV.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Roles", "OAuth2 clients"]);
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree
});
// ---- auth gates ----
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 () => {
const post = (over: { body?: string; verifyCsrf?: (s: string | null | undefined) => boolean }) => fakeCtx({ method: "POST", ...over });
const ok = await guardedForm(post({ body: "_csrf=tok&name=Bo", verifyCsrf: () => true }));
assert.equal(ok?.get("name"), "Bo");
await assert.rejects(guardedForm(post({ body: "_csrf=nope&name=Bo", verifyCsrf: () => false })), // ctx.verifyCsrf rejects
(e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(await guardedForm(fakeCtx({ method: "GET" })), undefined); // not a mutation → no gate, no body read
});
// ---- confirm-page model ----
test("buildConfirmModel wires the danger action, message, breadcrumbs and title (shell comes from ctx.chrome)", () => {
const model = buildConfirmModel({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: "Delete" }],
cancelHref: ADMIN_USERS_BASE, confirmAction: `${ADMIN_USERS_BASE}/u1/delete`, confirmLabel: "Delete user",
message: "Delete ada@x.io?", title: "Delete user",
});
assert.deepEqual(model.confirm, { action: `${ADMIN_USERS_BASE}/u1/delete`, label: "Delete user" });
assert.equal(model.message, "Delete ada@x.io?");
assert.equal(model.cancelHref, ADMIN_USERS_BASE);
assert.equal(model.title, "Delete user");
assert.deepEqual(model.breadcrumbs.at(-1), { label: "Delete" });
});
+78
View File
@@ -0,0 +1,78 @@
// Shared plumbing for the admin example plugin: the section nav fragment, the admin-only gate, the
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers
// (themed not-found / capability-unavailable). Ported from the former built-in admin screens;
// everything imports the host only through the #plugin-api barrel.
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type User } from "#plugin-api";
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";
export type AdminScreen = "clients" | "groups" | "roles" | "users";
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into
// the one global menu, filters per user (the header's `permission` drops the whole subtree for a
// 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: "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",
permission: ADMIN_PERMISSION,
};
// 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)
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin role required");
return user;
}
// Read + CSRF-verify a mutation's form body once (double-submit via ctx.verifyCsrf); non-POST ⇒
// undefined. A POST without a valid token is refused (GuardError → 403).
export async function guardedForm(ctx: RequestContext): Promise<URLSearchParams | undefined> {
if ((ctx.req.method ?? "GET").toUpperCase() !== "POST") return undefined;
const form = await readFormBody(ctx.req);
if (!ctx.verifyCsrf(form.get(CSRF_FIELD))) throw new GuardError(403, "invalid CSRF token");
return form;
}
// 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: "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: `${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
// (brand/csrf/theme/user/nav) from ctx.chrome; this carries only the page body + title/breadcrumbs.
export function buildConfirmModel(opts: {
breadcrumbs: { href?: string; label: string }[];
cancelHref: string;
confirmAction: string;
confirmLabel: string;
message: string;
title: string;
}) {
return {
breadcrumbs: opts.breadcrumbs,
cancelHref: opts.cancelHref,
confirm: { action: opts.confirmAction, label: opts.confirmLabel },
message: opts.message,
title: opts.title,
};
}
@@ -1,8 +1,8 @@
// Users admin screen (example plugin): the pure view-model + Kratos-payload builders. The HTTP
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app-admin.test.ts.
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app.test.ts.
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Identity } from "@plainpages/plugin-api";
import type { Identity } from "#plugin-api";
import {
buildUserFormModel,
buildUsersListModel,
@@ -38,42 +38,34 @@ test("toUserView maps traits → name/initials/email/state; falls back to the em
test("buildUsersListModel filters by search + status, sorts, and paginates", () => {
const people = Array.from({ length: 30 }, (_, i) => identity(i + 1, { state: i % 2 ? "inactive" : "active" }));
const all = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users" });
const all = buildUsersListModel({ identities: people, url: "http://x/admin/users" });
assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.title, "Users");
// Search narrows to one and shows a pill.
const one = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?q=user7%40example.com" });
const one = buildUsersListModel({ identities: people, url: "http://x/admin/users?q=user7%40example.com" });
assert.equal(one.pagination.summary.total, 1);
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
// Status filter keeps only inactive identities.
const inactive = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?status=inactive" });
const inactive = buildUsersListModel({ identities: people, url: "http://x/admin/users?status=inactive" });
assert.equal(inactive.pagination.summary.total, 15);
assert.deepEqual(inactive.filterBar.pills.map((p) => p.label), ["Status"]);
// Sort by email descending reverses the order.
const emailOf = (m: ReturnType<typeof buildUsersListModel>, i: number) => (m.table.rows[i]!.cells[1]) as string;
const desc = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?sort=-email" });
const desc = buildUsersListModel({ identities: people, url: "http://x/admin/users?sort=-email" });
assert.ok(emailOf(desc, 0) > emailOf(desc, 1));
// Edit link points at the per-identity route.
assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/auth-admin/users/${people[0]!.id}$`));
assert.deepEqual([all.table.rows[0]!.actions![0]!.icon, all.table.rows[0]!.actions![0]!.label], ["i-edit", "Edit"]);
});
// The detail page of a `users:read` holder is read-only, so the row action must not promise an edit.
test("buildUsersListModel: a read-only holder's row action says View, with the same target", () => {
const people: Identity[] = [{ id: "u1", traits: { email: "a@x.io" } }];
const action = buildUsersListModel({ canWrite: false, identities: people, url: "http://x/auth-admin/users" }).table.rows[0]!.actions![0]!;
assert.deepEqual([action.icon, action.label, action.href], ["i-eye", "View", "/auth-admin/users/u1"]);
assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/admin/users/${people[0]!.id}$`));
});
test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => {
const m = buildUserFormModel({ csrfToken: "tok.sig" });
assert.equal(m.title, "New user");
assert.equal(m.form.action, "/auth-admin/users");
assert.equal(m.form.action, "/admin/users");
assert.equal(m.form.submitLabel, "Create user");
assert.equal(m.form.csrfToken, "tok.sig");
const names = m.form.fields.map((f) => f.name);
@@ -85,7 +77,7 @@ test("buildUserFormModel: create mode has an editable email + password, no edit
test("buildUserFormModel: edit mode prefills, locks email, and exposes state/delete/recovery actions", () => {
const m = buildUserFormModel({ identity: identity(3) });
assert.equal(m.title, "Edit user");
assert.equal(m.form.action, `/auth-admin/users/${id(3)}`);
assert.equal(m.form.action, `/admin/users/${id(3)}`);
assert.equal(m.form.submitLabel, "Save changes");
const email = m.form.fields.find((f) => f.name === "email")!;
assert.equal(email.value, "user3@example.com");
@@ -100,19 +92,6 @@ test("buildUserFormModel: edit mode prefills, locks email, and exposes state/del
assert.equal(buildUserFormModel({ identity: identity(4, { state: "inactive" }) }).edit!.nextLabel, "Reactivate");
});
test("buildUserFormModel: the title and breadcrumb say New, Edit, or View for a users:read holder", () => {
const cases: [Parameters<typeof buildUserFormModel>[0], string, string][] = [
[{}, "New user", "New"],
[{ identity: identity(3) }, "Edit user", "Edit"],
[{ canWrite: false, identity: identity(3) }, "View user", "View"],
];
for (const [opts, title, crumb] of cases) {
const m = buildUserFormModel(opts);
assert.equal(m.title, title);
assert.deepEqual(m.breadcrumbs, [{ href: "/auth-admin/users", label: "Users" }, { label: crumb }]);
}
});
test("createIdentityPayload: schema/state/traits, name only when given, password only when set", () => {
assert.deepEqual(createIdentityPayload({ email: "a@b.c", first: "Ada", last: "Lovelace", password: "" }), {
schema_id: "default",
+378
View File
@@ -0,0 +1,378 @@
// Users admin screen: list Kratos identities (filter/sort/paginate) +
// create/edit/deactivate/delete/trigger-recovery. Writes go only to Kratos via the admin client
// (README "stateless"). Pure builders turn identities + the request URL into building-block view
// models; below them are thin per-route handlers (keyed on ctx.params) over a shared `withUser` gate
// — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG).
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
import { 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;
const PAGE_SIZES = [25, 50, 100];
// One Kratos page is fetched and filtered/sorted/paged in memory — the admin API offers no
// full-text search or sort. Ample for an admin tool; raise if a deployment outgrows it.
const LIST_FETCH_SIZE = 250;
const STATE_TONE: Record<string, string> = { active: "pos", inactive: "warn" };
export interface UserView {
email: string;
id: string;
initials: string;
name: string;
state: string; // Kratos identity state: "active" | "inactive"
}
export interface UserInput {
email: string;
first: string;
last: string;
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 {
first: typeof nm.first === "string" ? nm.first.trim() : "",
last: typeof nm.last === "string" ? nm.last.trim() : "",
};
}
export function toUserView(identity: Identity): UserView {
const email = typeof identity.traits?.email === "string" ? (identity.traits.email as string) : "";
const { first, last } = nameParts(identity);
const full = `${first} ${last}`.trim();
const name = full || email.split("@")[0] || email;
const initials = (first && last ? first[0]! + last[0]! : name.slice(0, 2) || "U").toUpperCase();
return { email, id: identity.id, initials, name, state: identity.state ?? "active" };
}
// ---- Kratos payloads ----
export function createIdentityPayload(input: UserInput): Record<string, unknown> {
const traits: Record<string, unknown> = { email: input.email };
if (input.first || input.last) traits.name = { first: input.first, last: input.last };
const payload: Record<string, unknown> = { schema_id: SCHEMA_ID, state: "active", traits };
if (input.password) payload.credentials = { password: { config: { password: input.password } } };
return payload;
}
// A full-identity PUT must carry schema/state/traits. Keep the existing email (the form's email is
// read-only) and other traits; rewrite name from the input (cleared ⇒ drop it).
export function updateIdentityPayload(identity: Identity, input: UserInput): Record<string, unknown> {
const traits: Record<string, unknown> = { ...(identity.traits ?? {}) };
if (input.first || input.last) traits.name = { first: input.first, last: input.last };
else delete traits.name;
return { schema_id: identity.schema_id ?? SCHEMA_ID, state: identity.state ?? "active", traits };
}
export function setStatePayload(identity: Identity, state: "active" | "inactive"): Record<string, unknown> {
return { schema_id: identity.schema_id ?? SCHEMA_ID, state, traits: { ...(identity.traits ?? {}) } };
}
// ---- view models ----
interface ListState {
page: number;
pageSize: number;
q: string;
sort: string | null;
status: string;
}
const SORT: Record<string, (u: UserView) => string> = {
email: (u) => u.email,
name: (u) => u.name,
status: (u) => u.state,
};
const COLUMNS = [
{ 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.
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.status && s.status !== "all") p.set("status", s.status);
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_USERS_BASE}?${qs}` : ADMIN_USERS_BASE;
}
export function buildUsersListModel(opts: {
csrfToken?: string;
identities: Identity[];
url: URL | URLSearchParams | string;
}) {
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;
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
const needle = query.q.toLowerCase();
const all = opts.identities.map(toUserView);
let list = all.filter((u) =>
(!needle || u.name.toLowerCase().includes(needle) || u.email.toLowerCase().includes(needle)) &&
(status === "all" || u.state === status));
if (sort) {
const get = SORT[sort.field] as (u: UserView) => string;
const dir = sort.dir === "desc" ? -1 : 1;
list = [...list].sort((a, b) => get(a).localeCompare(get(b)) * 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, status };
return {
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) {
return {
actions: true,
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: c.label, sort: dir, sortable: true };
}),
rows: rows.map((u) => ({
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: cap(u.state), tone: STATE_TONE[u.state] ?? "info" } },
],
name: u.name,
})),
};
}
function listFilterBar(state: ListState, total: number) {
const pills: { label: string; remove: string; value: string }[] = [];
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: "Apply filters",
clearHref: ADMIN_USERS_BASE,
label: "Filter users",
pills,
rows: [[
{ 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>) {
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: "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: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
export interface FieldConfig {
autocomplete?: string;
hint?: string;
icon?: string;
id: string;
label: string;
name: string;
optional?: boolean;
readonly?: boolean;
required?: boolean;
type?: string;
value?: string;
}
export function buildUserFormModel(opts: {
csrfToken?: string;
error?: string;
identity?: Identity | null;
recovery?: RecoveryCode;
values?: Partial<UserInput>;
}) {
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 ?? "" };
const email = editing ? view!.email : (opts.values?.email ?? "");
const idPath = editing ? `${ADMIN_USERS_BASE}/${encodeURIComponent(view!.id)}` : ADMIN_USERS_BASE;
const fields: FieldConfig[] = [
{ 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: "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" });
return {
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: editing ? "Edit" : "New" }],
edit: editing ? {
deleteAction: `${idPath}/delete`,
id: view!.id,
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 ? "Save changes" : "Create user" },
recovery: opts.recovery,
title: editing ? "Edit user" : "New user",
};
}
// ---- request handler (imperative shell) ----
function readUserInput(form: URLSearchParams): UserInput {
return {
email: (form.get("email") ?? "").trim(),
first: (form.get("first") ?? "").trim(),
last: (form.get("last") ?? "").trim(),
password: form.get("password") ?? "",
};
}
// 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 `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 = requireAdmin(ctx);
const kratosAdmin = ctx.system?.kratosAdmin;
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>): 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);
});
}
const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult =>
({ 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({ 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.
export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
const input = readUserInput((await guardedForm(ctx))!);
try {
await kratosAdmin.createIdentity(createIdentityPayload(input));
} catch (err) {
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 });
return { redirect: ADMIN_USERS_BASE };
});
// GET /admin/users/new — the empty create form.
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})));
// GET /admin/users/:id — the edit form, prefilled.
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 ({ ctx, kratosAdmin }, identity, id) => {
const input = readUserInput((await guardedForm(ctx))!);
try {
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
} catch (err) {
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)}` };
});
// POST /admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live
// 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: "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);
ctx.log.info("admin: user state changed", { actor: user.id, state: nextState, target: id });
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
});
// 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: "You can't delete your own account.", identity }), status: 400 });
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`;
const view = toUserView(identity);
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
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" });
});
// 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: "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 });
return { redirect: ADMIN_USERS_BASE };
});
// POST /admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
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, { identity, recovery });
});
function createError(err: KratosError): string {
return err.status === 409
? "A user with that email already exists."
: "Could not create the user — check the email and try again.";
}
+65
View File
@@ -0,0 +1,65 @@
// 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.
//
// It is a *system* plugin: its handlers reach the host's Ory admin clients (Kratos/Keto/Hydra) and the
// instant-revoke hook via ctx.system, which the host populates when those services are wired (the dev
// stack wires all of them). Where a capability is absent the screen degrades to a themed 503.
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, 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";
// 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: "Administer users, groups, roles, and OAuth2 clients", token: ADMIN_PERMISSION }],
routes: [
// Users
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
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
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,
@@ -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("/auth-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,
@@ -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("/auth-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,
@@ -0,0 +1,38 @@
<%#
Admin OAuth2 client detail body, captured into the shell content slot. Config:
client { firstParty, id, name, public, redirectUris[], scopes[] }
created bool just registered → success banner
secret? string one-time client secret (confidential clients), shown once right after create
del { action } delete the client
csrfToken
%><%
const c = locals.client;
const del = locals.del;
-%>
<div class="form-page">
<% if (locals.created) { -%>
<%- 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">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>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>
<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>
@@ -0,0 +1,17 @@
<%#
Destructive-action confirm body, captured into the shell content slot. Zero-JS: the
delete is a deliberate second step (a POST form), with a cancel link back. Config:
message string
confirm { action, label } the danger POST endpoint + button label
cancelHref string
csrfToken
%>
<div class="form-page">
<section class="form-card admin-actions" aria-label="Confirm action">
<p><%= locals.message %></p>
<div class="form-actions">
<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>
@@ -0,0 +1,42 @@
<%#
Admin group membership body, captured into the shell content slot. Config:
group { name }
members { action, rows: { kind:"group"|"user", label, subject }[] } action = remove-member endpoint
add { action, options: {label,value}[] } action = add-member endpoint
del { action } delete the whole group
csrfToken, error?
%><%
const group = locals.group;
const members = locals.members;
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">Members</h2>
<% if (members.rows.length) { -%>
<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" ? "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">No members yet.</p>
<% } -%>
</section>
<section class="form-card" aria-labelledby="add-h">
<h2 class="card-title" id="add-h">Add a member</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>Add</button></form>
<% } else { -%>
<p class="cell-muted">All users and groups are already members.</p>
<% } -%>
</section>
<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>
@@ -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>
@@ -0,0 +1,36 @@
<%#
Admin user create/edit form body, captured into the shell content slot. Config:
form { action, csrfToken, submitLabel, cancelHref, fields: field.ejs config[] }
edit? { nextLabel, stateAction, recoveryAction, deleteAction } (edit mode only)
recovery? { code? } shown after a recovery code is generated (recovery is code-based)
error? string shown when a write was rejected
%><%
const form = locals.form;
const edit = locals.edit;
const recovery = locals.recovery;
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- 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>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="<%= 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="<%= form.cancelHref %>">Cancel</a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
<% 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,
}) %>
@@ -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,
@@ -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("/auth-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,
-66
View File
@@ -1,66 +0,0 @@
# auth-admin — the plugin that administers users, groups, permissions, OAuth2 clients and plugin settings
The Users / Groups / OAuth2-clients screens for running Plainpages itself, shipped as a **drop-in
example plugin** so a fresh clone has no admin GUI until you opt in. Copy this folder into `plugins/`
(its folder name `auth-admin` is its id and mount path, so the screens live at `/auth-admin/*`) and restart:
```bash
cp -r examples/plugins/auth-admin plugins/auth-admin
docker compose up -d
```
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. An older copy already in
`plugins/` is yours — the host never updates it — so re-copy after a pull; a stale one stops the boot
with a message naming it ([README → Upgrading](../../../README.md#upgrading)).
Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`), the nav
labels included. Each pure view-model builder takes an optional `t` defaulting to the plugin's own
English, so a unit test reads in words rather than keys.
## What it demonstrates — a *system* plugin
Most plugins fetch their data from an upstream service of their own (see the [scheduling
reference](../scheduling/README.md)). The admin screens instead administer **Plainpages' own identity
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.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.
`ctx.system` is populated only when the host wired those services. Where a capability is absent the
screen degrades to a themed 503 rather than crashing. 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` — split into `:read` and
`:write`, so a helpdesk account can be given `users:read` alone. Holding none of the six 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`).
A grant of a name no installed plugin declares is listed apart on the same page, with a Remove.
## 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
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
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
unit-test as pure functions with no host; the HTTP routing/gate/CSRF is covered in
`src/http/app-admin.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`.
@@ -1,158 +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 src/http/app-admin.test.ts.
import assert from "node:assert/strict";
import { test } from "node:test";
import type { PermissionDecl } from "@plainpages/plugin-api";
import type { KetoClient } from "@plainpages/plugin-api";
import { applyGrants, baselineToken, buildPermissionPicker, grantDiff, grantTuple, groupSubject, isStale, removeUndeclaredGrant, 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: "/auth-admin/users/u1/permissions", declared, direct: ["users:write"] });
assert.equal(picker.action, "/auth-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
});
// An inherited permission rendered unticked would say "not held" about a grant that reaches the JWT,
// and unticking it writes nothing, reading as a successful revoke. So 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);
});
test("baselineToken depends on the offered grants held, not on their order or on undeclared ones", () => {
const token = baselineToken(declared, ["users:read", "users:write"]);
assert.match(token, /^[0-9a-f]+$/);
assert.equal(baselineToken(declared, ["users:write", "users:read"]), token);
assert.equal(baselineToken(declared, ["users:read", "users:write", "legacy:thing"]), token); // undeclared: never shown, never saved
assert.notEqual(baselineToken(declared, ["users:read"]), token);
assert.notEqual(baselineToken(declared, []), token);
assert.notEqual(baselineToken(declared, []), ""); // an empty set is a state too, not an absent token
assert.equal(buildPermissionPicker({ action: "/x", declared, direct: ["users:read", "users:write"] }).baseline, token);
});
test("isStale refuses a missing or old token, but only for a save that would change something", () => {
const held = ["users:read"];
const change = grantDiff(declared, held, ["users:read", "users:write"]);
const nothing = grantDiff(declared, held, ["users:read"]);
const current = new URLSearchParams({ baseline: baselineToken(declared, held) });
const old = new URLSearchParams({ baseline: baselineToken(declared, []) });
assert.equal(isStale(current, declared, held, change), false);
assert.equal(isStale(old, declared, held, change), true);
assert.equal(isStale(new URLSearchParams(), declared, held, change), true); // no token at all
assert.equal(isStale(old, declared, held, nothing), false); // a repeated save has nothing to refuse
});
test("applyGrants sends the whole change as one Keto transaction, and a failure applies none of it", async () => {
const calls: { delete: string[]; insert: string[] }[] = [];
const keto = (fail = false): KetoClient => ({
check: async () => false,
deleteTuple: async () => { throw new Error("unused"); },
expand: async () => ({ type: "leaf" }),
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
transactTuples: async (c) => {
if (fail) throw new Error("boom");
calls.push({ delete: c.delete.map((tp) => tp.object), insert: c.insert.map((tp) => tp.object) });
},
writeTuple: async () => { throw new Error("unused"); },
});
const diff = { grant: ["a:read", "b:read"], revoke: ["c:read"] };
await applyGrants(keto(), userSubject("u1"), diff);
assert.deepEqual(calls, [{ delete: ["c:read"], insert: ["a:read", "b:read"] }]);
await assert.rejects(applyGrants(keto(true), userSubject("u1"), diff), /boom/);
});
test("buildPermissionPicker lists direct grants no plugin declares, with a remove action only for a writer", () => {
const direct = ["zed:read", "users:read", "old:write"];
const picker = buildPermissionPicker({ action: "/auth-admin/users/u1/permissions", declared, direct });
assert.deepEqual(picker.undeclared?.names, ["old:write", "zed:read"]);
assert.equal(picker.undeclared?.action, "/auth-admin/users/u1/permissions/remove");
assert.ok(picker.undeclared?.remove);
assert.equal(buildPermissionPicker({ action: "/x", declared, direct, readOnly: true }).undeclared?.remove, undefined);
assert.equal(buildPermissionPicker({ action: "/x", declared, direct: ["users:read"] }).undeclared, undefined);
assert.equal(picker.baseline, buildPermissionPicker({ action: "/x", declared, direct: ["users:read"] }).baseline, "an undeclared grant doesn't make a form stale");
});
test("removeUndeclaredGrant deletes one held, undeclared grant and refuses anything else", async () => {
const subject = userSubject("u1");
const tuples = [grantTuple("old:write", subject), grantTuple("users:read", subject), grantTuple("old:write", userSubject("u2"))];
const deleted: string[] = [];
const keto: KetoClient = {
check: async () => false,
deleteTuple: async (tp) => { deleted.push(`${tp.object} ${tp.subject_id}`); },
expand: async () => ({ type: "leaf" }),
listRelations: async (q = {}) => ({ nextPageToken: null, tuples: tuples.filter((tp) => tp.subject_id === q.subject_id) }),
transactTuples: async () => { throw new Error("unused"); },
writeTuple: async () => { throw new Error("unused"); },
};
assert.equal(await removeUndeclaredGrant(keto, subject, declared, "old:write"), "old:write");
assert.deepEqual(deleted, ["old:write user:u1"]);
assert.equal(await removeUndeclaredGrant(keto, subject, declared, "users:read"), undefined, "a declared name is the picker's to change");
assert.equal(await removeUndeclaredGrant(keto, subject, declared, "never:held"), undefined);
assert.equal(await removeUndeclaredGrant(keto, subject, declared, null), undefined);
assert.deepEqual(deleted, ["old:write user:u1"]);
});
-188
View File
@@ -1,188 +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: a grant is a property of a
// user or a group, edited where they are.
import { createHash } from "node:crypto";
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
const PERMISSION_NS = "Permission";
const GRANTED = "granted";
export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two forms post
export const REMOVE_FIELD = "remove"; // the hidden field naming the undeclared grant a Remove form drops
export const BASELINE_FIELD = "baseline"; // the hidden field carrying baselineToken
export const SAVED_NOTICE = "permissions-saved"; // the `info-msg` a successful save redirects with
export const REMOVED_NOTICE = "permission-removed"; // the `info-msg` a Remove redirects with; its own words, since the checkboxes were not saved
// Only these exact values count, so a crafted link can't put words on the page.
export function grantsNotice(url: URL, t: Translate): string | undefined {
const msg = url.searchParams.get("info-msg");
if (msg === SAVED_NOTICE) return t("admin.grants.saved");
return msg === REMOVED_NOTICE ? t("admin.grants.removed") : undefined;
}
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;
baseline: string; // baselineToken of the direct grants this form was rendered from
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;
notice?: string; // a confirmation, rendered above the list
// 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;
undeclared: UndeclaredGrants | undefined; // set when the subject holds a name no installed plugin declares
}
// Direct grants of names nobody declares (their plugin was uninstalled, or the name was written to
// Keto by hand). grantDiff never touches them, so they get their own list and a Remove per name.
export interface UndeclaredGrants {
action: string; // where each Remove posts
field: string;
hint: string;
legend: string;
names: string[];
remove: string | undefined; // the button label; unset for a reader, who is shown the names only
}
// 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 undeclared = undeclaredHeld(opts.declared, 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,
baseline: baselineToken(opts.declared, opts.direct),
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"),
undeclared: undeclared.length === 0 ? undefined : {
action: `${opts.action}/remove`,
field: REMOVE_FIELD,
hint: t("admin.grants.undeclaredHint"),
legend: t("admin.grants.undeclaredLegend"),
names: undeclared,
remove: opts.readOnly === true ? undefined : t("admin.grants.remove"),
},
};
}
const undeclaredHeld = (declared: readonly PermissionDecl[], held: readonly string[]): string[] => {
const offered = new Set(declared.map((d) => d.name));
return held.filter((name) => !offered.has(name)).sort();
};
// 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[]): GrantChange {
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(),
};
}
// Deletes one held grant of a name no plugin declares and returns the name. Deletes nothing for a
// declared name, which the picker owns (so a Remove can't take away this plugin's own gates and needs
// no self-lockout guard), or for one not held, as after a double click.
export async function removeUndeclaredGrant(keto: KetoClient, subject: GrantSubject, declared: readonly PermissionDecl[], name: string | null): Promise<string | undefined> {
if (name === null || declared.some((d) => d.name === name)) return undefined;
if (!(await heldPermissions(keto, subject)).includes(name)) return undefined;
await keto.deleteTuple(grantTuple(name, subject));
return name;
}
// A token for the direct grants a form was rendered from. Like grantDiff it covers only the declared
// names the form offered. It is a digest so an empty set is still a token; it is a guard against a
// stale page, not a secret, since anyone who can read the page can compute it.
export function baselineToken(declared: readonly PermissionDecl[], held: string[]): string {
const offered = new Set(declared.map((d) => d.name));
return createHash("sha256").update(held.filter((name) => offered.has(name)).sort().join("\n")).digest("hex").slice(0, 16);
}
// True when the form's token is missing or no longer matches Keto *and* the save would change
// something. A save that changes nothing, such as a double-clicked Save whose first POST landed, is
// harmless, so it is not refused.
export const isStale = (form: { get(name: string): string | null }, declared: readonly PermissionDecl[], held: string[], diff: GrantChange): boolean =>
(diff.grant.length > 0 || diff.revoke.length > 0) && form.get(BASELINE_FIELD) !== baselineToken(declared, held);
export interface GrantChange { grant: string[]; revoke: string[] }
export const applyGrants = (keto: KetoClient, subject: GrantSubject, diff: GrantChange): Promise<void> =>
keto.transactTuples({ delete: diff.revoke.map((name) => grantTuple(name, subject)), insert: diff.grant.map((name) => grantTuple(name, subject)) });
@@ -1,55 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { PageChrome, PluginSettings } from "@plainpages/plugin-api";
import { buildPluginSettingsModel } from "./admin-plugin-settings.ts";
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
const CATALOG: readonly PluginSettings[] = [
{
pluginId: "scheduling",
settings: [
{ description: "Where shifts come from", envName: "PLUGIN_SETTING_SCHEDULING_UPSTREAM", key: "upstream", required: true, secret: false, source: "env", type: "url", value: "https://shifts.test" },
{ envName: "PLUGIN_SETTING_SCHEDULING_MODE", key: "mode", required: false, secret: false, source: "default", type: "enum", value: "strict", values: ["strict", "lenient"] },
{ envName: "PLUGIN_SETTING_SCHEDULING_NOTE", key: "note", required: false, secret: false, source: "unset", type: "string" },
],
},
{ pluginId: "quiet", settings: [] },
];
test("a row carries the variable to set and where the value came from", () => {
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
const rows = model.groups[0]?.table.rows ?? [];
assert.deepEqual(rows.map((r) => r.name), ["upstream", "mode", "note"]);
assert.deepEqual(rows[0]?.cells, [
{ rowHeader: { text: "upstream" } }, "Where shifts come from", "url", "Yes", "PLUGIN_SETTING_SCHEDULING_UPSTREAM", "Environment", "https://shifts.test",
]);
assert.equal(rows[1]?.cells[2], "enum (strict, lenient)"); // the choices are the useful half of the type
assert.equal(rows[2]?.cells[5], "Not set");
});
test("a plugin declaring nothing still gets a section, so it is visibly installed", () => {
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
assert.deepEqual(model.groups.map((g) => g.pluginId), ["scheduling", "quiet"]);
assert.deepEqual(model.groups[1]?.table.rows, []);
assert.match(model.groups[1]?.emptyText ?? "", /no settings/i);
});
test("a secret renders as set-or-not, never as a value, a mask or a length", () => {
const settings: readonly PluginSettings[] = [{
pluginId: "billing",
settings: [
{ envName: "PLUGIN_SETTING_BILLING_API_KEY", key: "apiKey", required: false, secret: true, source: "env", type: "string" },
{ envName: "PLUGIN_SETTING_BILLING_WEBHOOK_KEY", key: "webhookKey", required: false, secret: true, source: "unset", type: "string" },
],
}];
const rows = buildPluginSettingsModel({ chrome: CHROME, settings }).groups[0]?.table.rows ?? [];
assert.equal(rows[0]?.cells[6], "Secret — set");
assert.equal(rows[1]?.cells[6], "Secret — not set");
});
test("two tables on one page need distinct row-action id stems", () => {
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
const stems = model.groups.map((g) => g.table.actionsId);
assert.equal(new Set(stems).size, stems.length);
});
@@ -1,75 +0,0 @@
// Plugin settings admin screen: what each installed plugin declares it can be configured with, the
// variable that sets it, and how each key resolved. Read-only — the host reads settings from the
// environment at boot, so changing one is a deploy, not a form.
import { type PageChrome, type PluginSettings, type RouteHandler, type SettingSummary, type Translate } from "@plainpages/plugin-api";
import { ADMIN_EN, requirePermission } from "./admin-shared.ts";
interface SettingsGroup {
emptyText: string;
pluginId: string;
table: {
actionsId: string;
caption: string;
columns: { label: string }[];
rows: { cells: (string | { rowHeader: { text: string } })[]; name: string }[];
};
}
// One group per installed plugin, including those declaring nothing — an operator who cannot find
// their plugin here has not installed it, which is the other half of what this screen answers.
export function buildPluginSettingsModel(opts: { chrome: PageChrome; settings: readonly PluginSettings[]; t?: Translate }) {
const t = opts.t ?? ADMIN_EN;
return {
breadcrumbs: [{ label: t("admin.pluginSettings.title") }],
chrome: opts.chrome,
groups: opts.settings.map((plugin): SettingsGroup => ({
emptyText: t("admin.pluginSettings.none"),
pluginId: plugin.pluginId,
table: {
actionsId: `settings-${plugin.pluginId}`, // two tables share this page, so the stem must differ
caption: t("admin.pluginSettings.caption", { plugin: plugin.pluginId }),
columns: [
{ label: t("admin.pluginSettings.column.key") },
{ label: t("admin.pluginSettings.column.description") },
{ label: t("admin.pluginSettings.column.type") },
{ label: t("admin.pluginSettings.column.required") },
{ label: t("admin.pluginSettings.column.variable") },
{ label: t("admin.pluginSettings.column.source") },
{ label: t("admin.pluginSettings.column.value") },
],
rows: plugin.settings.map((setting) => ({
cells: [
{ rowHeader: { text: setting.key } },
setting.description ?? "",
typeLabel(setting),
t(setting.required ? "admin.pluginSettings.yes" : "admin.pluginSettings.no"),
setting.envName,
t(`admin.pluginSettings.source.${setting.source}`),
valueLabel(setting, t),
],
name: setting.key,
})),
},
})),
title: t("admin.pluginSettings.title"),
};
}
// An enum's choices are the useful half of its type — they are what the operator must pick from.
function typeLabel(setting: SettingSummary): string {
return setting.type === "enum" && setting.values ? `${setting.type} (${setting.values.join(", ")})` : setting.type;
}
// A secret never renders its value — not the value, not a mask of it, not its length. Whether it
// resolved and from where is what an operator needs, and the source column already says the rest.
function valueLabel(setting: SettingSummary, t: Translate): string {
if (setting.secret) return t(setting.source === "unset" ? "admin.pluginSettings.secretUnset" : "admin.pluginSettings.secretSet");
return setting.value ?? t("admin.pluginSettings.unset");
}
// GET /auth-admin/plugin-settings
export const pluginSettingsList: RouteHandler = (ctx) => {
requirePermission(ctx, "plugin-settings");
return { data: { chrome: ctx.chrome, model: buildPluginSettingsModel({ chrome: ctx.chrome, settings: ctx.declaredSettings, t: ctx.t }) }, view: "plugin-settings" };
};
@@ -1,102 +0,0 @@
// 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
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app-admin.test.ts.
// Import only from the @plainpages/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 "@plainpages/plugin-api";
import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, permissionName, requirePermission } 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 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 {
const url = new URL("http://localhost/auth-admin/users");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
req.method = opts.method ?? "GET";
return {
assetHref: (href) => href, chrome: CHROME, declaredPermissions: [], declaredSettings: [], 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),
};
}
// ---- nav fragment ----
test("ADMIN_NAV: an ungated Admin header whose screens each gate on their own read permission", () => {
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 them 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.open, undefined); // the host current-marks + opens; the fragment stays static
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/auth-admin/users", "/auth-admin/groups", "/auth-admin/clients", "/auth-admin/plugin-settings"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read", "plugin-settings: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", "admin.nav.pluginSettings"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients", "Plugin settings"]);
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
});
// ---- 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=%2Fauth-admin%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("guardedForm: valid double-submit → the parsed body, bad token → 403, non-POST → undefined", async () => {
const post = (over: { body?: string; verifyCsrf?: (s: string | null | undefined) => boolean }) => fakeCtx({ method: "POST", ...over });
const ok = await guardedForm(post({ body: "_csrf=tok&name=Bo", verifyCsrf: () => true }));
assert.equal(ok?.get("name"), "Bo");
await assert.rejects(guardedForm(post({ body: "_csrf=nope&name=Bo", verifyCsrf: () => false })), // ctx.verifyCsrf rejects
(e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(await guardedForm(fakeCtx({ method: "GET" })), undefined); // not a mutation → no gate, no body read
});
// ---- confirm-page model ----
test("buildConfirmModel wires the danger action, message, breadcrumbs and title (shell comes from ctx.chrome)", () => {
const model = buildConfirmModel({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: "Delete" }],
cancelHref: ADMIN_USERS_BASE, confirmAction: `${ADMIN_USERS_BASE}/u1/delete`, confirmLabel: "Delete user",
message: "Delete ada@x.io?", title: "Delete user",
});
assert.deepEqual(model.confirm, { action: `${ADMIN_USERS_BASE}/u1/delete`, label: "Delete user" });
assert.equal(model.message, "Delete ada@x.io?");
assert.equal(model.cancelHref, ADMIN_USERS_BASE);
assert.equal(model.title, "Delete user");
assert.deepEqual(model.breadcrumbs.at(-1), { label: "Delete" });
});
-105
View File
@@ -1,105 +0,0 @@
// Shared plumbing for the admin example plugin: the section nav fragment, the screen gate, the
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers.
// Everything imports the host only through the @plainpages/plugin-api barrel.
import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts";
// This plugin's English — its catalog, then the host's — for a view model built outside a request,
// i.e. its unit tests. At runtime the handlers pass ctx.t instead.
export const ADMIN_EN: Translate = englishTranslator(enUS);
export const ADMIN_USERS_BASE = "/auth-admin/users";
export const ADMIN_GROUPS_BASE = "/auth-admin/groups";
export const ADMIN_CLIENTS_BASE = "/auth-admin/clients";
export const ADMIN_PLUGIN_SETTINGS_BASE = "/auth-admin/plugin-settings";
// 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" | "plugin-settings" | "users";
export type AdminAction = "read" | "write";
// `<resource>:<action>` (README → Naming a permission).
export function permissionName(resource: AdminResource, action: AdminAction): string {
return `${resource}:${action}`;
}
// Every screen reads on GET/HEAD and mutates on POST. The route table and the in-handler guard both
// go through this rather than each spelling the permission out, so they cannot drift. Deliberately
// local: generalised, it would make authorization a function of the transport verb
// (docs/decisions.md → "`actionForMethod` is plugin-local and stays out of
// `@plainpages/plugin-api`").
export function actionForMethod(method: string): AdminAction {
const verb = method.toUpperCase();
return verb === "GET" || verb === "HEAD" ? "read" : "write";
}
// The plugin's nav fragment: the "Admin" header + its four screens, each gated on its own read
// permission. composeNav drops a header left with no visible children, so a user holding none of
// them never sees the section. The host current-marks the active item — no `current`/`open` 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_PLUGIN_SETTINGS_BASE, icon: "i-sliders", id: "plugin-settings", label: "admin.nav.pluginSettings", permission: permissionName("plugin-settings", "read") },
],
icon: "i-shield",
id: "admin",
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
public: true, // the header gates nothing; every child needs a permission, and an empty header is dropped
};
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
// declares the same permission, so this is defence-in-depth and what a direct unit test relies on.
// `action` defaults to the method's, and is passed explicitly by a *write-intent GET* — a create
// form or a delete-confirm page — which refuses a reader rather than rendering a form whose submit
// would 403. The route table declares the same override, so the two cannot disagree.
export function requirePermission(ctx: RequestContext, resource: AdminResource, action?: AdminAction): 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`);
return user;
}
// Read + CSRF-verify a mutation's form body once (double-submit via ctx.verifyCsrf); non-POST ⇒
// undefined. A POST without a valid token is refused (GuardError → 403).
export async function guardedForm(ctx: RequestContext): Promise<URLSearchParams | undefined> {
if ((ctx.req.method ?? "GET").toUpperCase() !== "POST") return undefined;
const form = await readFormBody(ctx.req);
if (!ctx.verifyCsrf(form.get(CSRF_FIELD))) throw new GuardError(403, "invalid CSRF token");
return form;
}
// 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" };
}
// 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" };
}
// Model for the shared destructive-confirm page (views/confirm.ejs). The view reads the shell fields
// (brand/csrf/theme/user/nav) from ctx.chrome; this carries only the page body + title/breadcrumbs.
export function buildConfirmModel(opts: {
breadcrumbs: { href?: string; label: string }[];
cancelHref: string;
confirmAction: string;
confirmLabel: string;
message: string;
title: string;
}) {
return {
breadcrumbs: opts.breadcrumbs,
cancelHref: opts.cancelHref,
confirm: { action: opts.confirmAction, label: opts.confirmLabel },
message: opts.message,
title: opts.title,
};
}
-461
View File
@@ -1,461 +0,0 @@
// Users admin screen: list Kratos identities (filter/sort/paginate) +
// create/edit/deactivate/delete/trigger-recovery. Pure builders turn identities + the request URL
// into building-block view models; below them are thin per-route handlers keyed on ctx.params, over
// a shared `withUser` gate.
import { buildPaginationModel, can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, grantsNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, REMOVED_NOTICE, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
const DEFAULT_PAGE_SIZE = 25;
const PAGE_SIZES = [25, 50, 100];
// One Kratos page is fetched and filtered/sorted/paged in memory — the admin API offers no
// full-text search or sort. Ample for an admin tool; raise if a deployment outgrows it.
const LIST_FETCH_SIZE = 250;
const STATE_TONE: Record<string, string> = { active: "pos", inactive: "warn" };
export interface UserView {
email: string;
id: string;
initials: string;
name: string;
state: string; // Kratos identity state: "active" | "inactive"
}
export interface UserInput {
email: string;
first: string;
last: string;
password: string;
}
function nameParts(identity: Identity): { first: string; last: string } {
const nm = ((identity.traits?.name ?? {}) as { first?: unknown; last?: unknown });
return {
first: typeof nm.first === "string" ? nm.first.trim() : "",
last: typeof nm.last === "string" ? nm.last.trim() : "",
};
}
export function toUserView(identity: Identity): UserView {
const email = typeof identity.traits?.email === "string" ? (identity.traits.email as string) : "";
const { first, last } = nameParts(identity);
const full = `${first} ${last}`.trim();
const name = full || email.split("@")[0] || email;
const initials = (first && last ? first[0]! + last[0]! : name.slice(0, 2) || "U").toUpperCase();
return { email, id: identity.id, initials, name, state: identity.state ?? "active" };
}
// ---- Kratos payloads ----
export function createIdentityPayload(input: UserInput): Record<string, unknown> {
const traits: Record<string, unknown> = { email: input.email };
if (input.first || input.last) traits.name = { first: input.first, last: input.last };
const payload: Record<string, unknown> = { schema_id: SCHEMA_ID, state: "active", traits };
if (input.password) payload.credentials = { password: { config: { password: input.password } } };
return payload;
}
// A full-identity PUT must carry schema/state/traits. Keep the existing email (the form's email is
// read-only) and other traits; rewrite name from the input (cleared ⇒ drop it).
export function updateIdentityPayload(identity: Identity, input: UserInput): Record<string, unknown> {
const traits: Record<string, unknown> = { ...(identity.traits ?? {}) };
if (input.first || input.last) traits.name = { first: input.first, last: input.last };
else delete traits.name;
return { schema_id: identity.schema_id ?? SCHEMA_ID, state: identity.state ?? "active", traits };
}
export function setStatePayload(identity: Identity, state: "active" | "inactive"): Record<string, unknown> {
return { schema_id: identity.schema_id ?? SCHEMA_ID, state, traits: { ...(identity.traits ?? {}) } };
}
// ---- view models ----
interface ListState {
page: number;
pageSize: number;
q: string;
sort: string | null;
status: string;
}
const SORT: Record<string, (u: UserView) => string> = {
email: (u) => u.email,
name: (u) => u.name,
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" },
];
// Canonical list URL from the current state + per-link overrides; omits defaults so links stay tidy.
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.status && s.status !== "all") p.set("status", s.status);
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_USERS_BASE}?${qs}` : ADMIN_USERS_BASE;
}
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;
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
const needle = query.q.toLowerCase();
const all = opts.identities.map(toUserView);
let list = all.filter((u) =>
(!needle || u.name.toLowerCase().includes(needle) || u.email.toLowerCase().includes(needle)) &&
(status === "all" || u.state === status));
if (sort) {
const get = SORT[sort.field] as (u: UserView) => string;
const dir = sort.dir === "desc" ? -1 : 1;
list = [...list].sort((a, b) => get(a).localeCompare(get(b)) * 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, 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: buildPaginationModel(page, (n) => listHref(state, { page: n }), { label: t("admin.users.pagination"), pageSizes: PAGE_SIZES }),
table: listTable(rows, state, sort, t, opts.canWrite !== false),
title: t("admin.users.title"),
};
}
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate, canWrite: boolean) {
return {
actions: true,
caption: t("admin.users.title"),
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 };
}),
rows: rows.map((u) => ({
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: canWrite ? "i-edit" : "i-eye", label: t(canWrite ? "common.edit" : "admin.users.view") }],
cells: [
{ user: { initials: u.initials, name: u.name } },
u.email,
{ badge: { label: t(`admin.users.status.${u.state}`), tone: STATE_TONE[u.state] ?? "info" } },
],
name: u.name,
})),
};
}
function listFilterBar(state: ListState, total: number, t: Translate) {
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}`) });
return {
applyLabel: t("filter.apply"), // an untranslated core key still resolves: the host catalog is the fallback
clearHref: ADMIN_USERS_BASE,
label: t("admin.users.filter"),
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" },
], type: "segmented", value: state.status },
{ type: "spacer" },
]],
};
}
export interface FieldConfig {
autocomplete?: string;
hint?: string;
icon?: string;
id: string;
label: string;
name: string;
optional?: boolean;
readonly?: boolean;
required?: boolean;
type?: string;
value?: string;
}
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 ?? "" };
const email = editing ? view!.email : (opts.values?.email ?? "");
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 },
];
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" });
const canWrite = opts.canWrite !== false;
const mode = !editing ? "new" : canWrite ? "edit" : "view";
return {
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: t({ edit: "common.edit", new: "common.new", view: "admin.users.view" }[mode]) }],
canWrite, // the view drops every write affordance when false; the host already 403s the POSTs
edit: editing ? {
deleteAction: `${idPath}/delete`,
id: view!.id,
nextLabel: view!.state === "inactive" ? t("admin.users.reactivate") : t("admin.users.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,
recovery: opts.recovery,
title: t({ edit: "admin.users.edit", new: "admin.users.new", view: "admin.users.viewTitle" }[mode]),
};
}
// ---- request handler (imperative shell) ----
function readUserInput(form: URLSearchParams): UserInput {
return {
email: (form.get("email") ?? "").trim(),
first: (form.get("first") ?? "").trim(),
last: (form.get("last") ?? "").trim(),
password: form.get("password") ?? "",
};
}
// 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; }
// 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 {
return async (ctx) => {
const user = requirePermission(ctx, "users", action);
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 });
};
}
// 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 {
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" });
// GET /auth-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" };
});
// POST /auth-admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input.
export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
const input = readUserInput((await guardedForm(ctx))!);
try {
await kratosAdmin.createIdentity(createIdentityPayload(input));
} catch (err) {
if (err instanceof KratosError) return { ...formResult(ctx, { error: createError(err, ctx.t), values: input }), status: 400 };
throw err;
}
ctx.log.info("admin: user created", { actor: user.id, email: input.email });
return { redirect: ADMIN_USERS_BASE };
});
// GET /auth-admin/users/new — the empty create form.
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})), "write");
// GET /auth-admin/users/:id — the edit form, prefilled.
export const usersEditForm = withTarget(async (deps, identity, id) => {
const permissions = await userPermissionPicker(deps, id, undefined, grantsNotice(deps.ctx.url, deps.ctx.t));
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, notice?: 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 } : {}),
...(notice ? { notice } : {}),
};
}
// POST /auth-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 held = await heldPermissions(keto, subject);
const refuse = async (error: string, status: number): Promise<RouteResult> => {
const permissions = await userPermissionPicker(deps, id, error);
return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), identity, ...(permissions ? { permissions } : {}) }), status };
};
const diff = grantDiff(ctx.declaredPermissions, held, form.getAll(PERMISSIONS_FIELD));
if (isStale(form, ctx.declaredPermissions, held, diff)) return refuse(ctx.t("admin.grants.stale"), 409);
// 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 — leaving a `curl` against Keto as the only way back in.
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(",") });
return refuse(ctx.t("admin.grants.selfRevoke"), 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)}?info-msg=${SAVED_NOTICE}` };
});
// POST /auth-admin/users/:id/permissions/remove — drop one direct grant no installed plugin declares.
export const usersPermissionsRemove = withTarget(async ({ ctx, keto, revoke, user }, _identity, id) => {
const form = (await guardedForm(ctx))!;
if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto"));
const removed = await removeUndeclaredGrant(keto, userSubject(id), ctx.declaredPermissions, form.get(REMOVE_FIELD));
if (removed) {
revoke?.(id);
ctx.log.info("admin: undeclared user permission removed", { actor: user.id, removed, target: id });
}
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}?info-msg=${REMOVED_NOTICE}` };
});
// POST /auth-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;
const input = readUserInput((await guardedForm(ctx))!);
try {
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
} catch (err) {
if (err instanceof KratosError) return refuseForm(deps, identity, id, ctx.t("admin.users.error.save"));
throw err;
}
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
});
// POST /auth-admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live
// tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account.
export const usersState = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin, revoke, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST (no fields read)
if (id === user.id) return refuseForm(deps, identity, id, ctx.t("admin.users.error.selfDeactivate"));
const nextState = identity.state === "inactive" ? "active" : "inactive";
await kratosAdmin.updateIdentity(id, setStatePayload(identity, nextState));
if (nextState === "inactive") revoke?.(id);
ctx.log.info("admin: user state changed", { actor: user.id, state: nextState, target: id });
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
});
// GET /auth-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 refuseForm(deps, identity, id, deps.ctx.t("admin.users.error.selfDelete"));
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"),
}) }, view: "confirm" });
}, "write");
// POST /auth-admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
export const usersDelete = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin, revoke, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST
if (id === user.id) return refuseForm(deps, identity, id, ctx.t("admin.users.error.selfDelete"));
await kratosAdmin.deleteIdentity(id);
revoke?.(id);
ctx.log.info("admin: user deleted", { actor: user.id, target: id });
return { redirect: ADMIN_USERS_BASE };
});
// POST /auth-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;
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)) });
});
// The 400 re-render of the user's form with `error` above it. `usersPermissions` refuses through its
// own `refuse`, which puts the error in the permissions section instead.
async function refuseForm(deps: UsersDeps, identity: Identity, id: string, error: string): Promise<RouteResult> {
return { ...formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), error, identity, ...(await pickerOrNothing(deps, id)) }), status: 400 };
}
// The picker as a spreadable fragment, so every form re-render keeps 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 {
return err.status === 409
? t("admin.users.error.duplicate")
: t("admin.users.error.create");
}
-166
View File
@@ -1,166 +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.remove": "Remove",
"admin.grants.removed": "Permission removed.",
"admin.grants.save": "Save permissions",
"admin.grants.saved": "Permissions saved.",
"admin.grants.selfRevoke": "You can't revoke your own permissions — ask another administrator, so you can't lock yourself out.",
"admin.grants.stale": "These permissions changed while you had the page open, so nothing was saved. The list now shows the current state. Review it and save again.",
"admin.grants.undeclaredHint": "These permissions are held here, but no installed plugin declares them, so the list above can't change them. A plugin may still check one without declaring it.",
"admin.grants.undeclaredLegend": "Permissions no installed plugin declares",
"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.error.duplicate": "A group with that name already exists.",
"admin.groups.error.selfLockout": "This change would lock you out of {{permissions}}, which you hold through this group. Ask another administrator to make it.",
"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.pluginSettings": "Plugin settings",
"admin.nav.section": "Admin",
"admin.nav.users": "Users",
"admin.pluginSettings.caption": "Settings declared by {{plugin}}",
"admin.pluginSettings.column.description": "Description",
"admin.pluginSettings.column.key": "Key",
"admin.pluginSettings.column.required": "Required",
"admin.pluginSettings.column.source": "Source",
"admin.pluginSettings.column.type": "Type",
"admin.pluginSettings.column.value": "Value",
"admin.pluginSettings.column.variable": "Variable",
"admin.pluginSettings.no": "No",
"admin.pluginSettings.none": "This plugin declares no settings.",
"admin.pluginSettings.secretSet": "Secret — set",
"admin.pluginSettings.secretUnset": "Secret — not set",
"admin.pluginSettings.source.default": "Default",
"admin.pluginSettings.source.env": "Environment",
"admin.pluginSettings.source.unset": "Not set",
"admin.pluginSettings.title": "Plugin settings",
"admin.pluginSettings.unset": "—",
"admin.pluginSettings.yes": "Yes",
"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": "Send the user this link and code. They open the link and enter the code to set a new password (generate a fresh code if it has expired):",
"admin.users.recovery.link": "password-reset link",
"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",
"admin.users.view": "View",
"admin.users.viewTitle": "View user",
};
export type AdminMessages = typeof messages;
export default messages;
-164
View File
@@ -1,164 +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.remove": "Ta bort",
"admin.grants.removed": "Behörigheten är borttagen.",
"admin.grants.save": "Spara behörigheter",
"admin.grants.saved": "Behörigheterna är sparade.",
"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.grants.stale": "Behörigheterna ändrades medan du hade sidan öppen, så ingenting sparades. Listan visar nu aktuellt läge. Granska den och spara igen.",
"admin.grants.undeclaredHint": "Behörigheterna innehas här, men ingen installerad plugin deklarerar dem, så listan ovan kan inte ändra dem. En plugin kan ändå kontrollera någon av dem utan att deklarera den.",
"admin.grants.undeclaredLegend": "Behörigheter som ingen installerad plugin deklarerar",
"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.error.duplicate": "Det finns redan en grupp med det namnet.",
"admin.groups.error.selfLockout": "Ändringen skulle låsa ute dig från {{permissions}}, som du har genom den här gruppen. Be en annan administratör att göra den.",
"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.pluginSettings": "Tilläggsinställningar",
"admin.nav.section": "Administration",
"admin.nav.users": "Användare",
"admin.pluginSettings.caption": "Inställningar som {{plugin}} deklarerar",
"admin.pluginSettings.column.description": "Beskrivning",
"admin.pluginSettings.column.key": "Nyckel",
"admin.pluginSettings.column.required": "Obligatorisk",
"admin.pluginSettings.column.source": "Källa",
"admin.pluginSettings.column.type": "Typ",
"admin.pluginSettings.column.value": "Värde",
"admin.pluginSettings.column.variable": "Variabel",
"admin.pluginSettings.no": "Nej",
"admin.pluginSettings.none": "Det här tillägget deklarerar inga inställningar.",
"admin.pluginSettings.secretSet": "Hemlighet — satt",
"admin.pluginSettings.secretUnset": "Hemlighet — inte satt",
"admin.pluginSettings.source.default": "Standardvärde",
"admin.pluginSettings.source.env": "Miljövariabel",
"admin.pluginSettings.source.unset": "Inte satt",
"admin.pluginSettings.title": "Tilläggsinställningar",
"admin.pluginSettings.unset": "—",
"admin.pluginSettings.yes": "Ja",
"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": "Skicka länken och koden till användaren. Användaren öppnar länken och anger koden för att sätta ett nytt lösenord (skapa en ny kod om den hunnit gå ut):",
"admin.users.recovery.link": "länk 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",
"admin.users.view": "Visa",
"admin.users.viewTitle": "Visa användare",
};
export default messages;
@@ -1,65 +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 "@plainpages/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, 4);
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 CRUD screens × read/write, plus read-only plugin settings — a screen that never writes
// declares no `:write`, since a permission nothing gates on is one an operator can only mis-grant.
// There is deliberately no `permissions:` pair either: 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",
"plugin-settings:read",
"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 CRUD screen; plugin settings has none
});
-79
View File
@@ -1,79 +0,0 @@
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system. Copy
// this folder to plugins/auth-admin (then restart) to enable it — see README → Quick start.
//
// It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke
// hook via ctx.system. Where a capability is absent the screen degrades to a themed 503.
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api";
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsPermissionsRemove, groupsRemoveMember } from "./admin-groups.ts";
import { pluginSettingsList } from "./admin-plugin-settings.ts";
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersPermissionsRemove, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } 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");
const pluginSettings = on("plugin-settings");
export default definePlugin({
apiVersion: "0.5.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" },
{ description: "View the settings each installed plugin declares, and how they resolved", name: "plugin-settings:read" },
],
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),
users("POST", "/users/:id/permissions/remove", usersPermissionsRemove),
// 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),
groups("POST", "/groups/:name/permissions/remove", groupsPermissionsRemove),
// 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),
// Plugin settings — read-only, so no :write route and no write-intent GET.
pluginSettings("GET", "/plugin-settings", pluginSettingsList),
],
});
@@ -1,40 +0,0 @@
<%#
Admin OAuth2 client detail body, captured into the shell content slot. Config:
client { firstParty, id, name, public, redirectUris[], scopes[] }
created bool just registered → success banner
secret? string one-time client secret (confidential clients), shown once right after create
del { action } delete the client
csrfToken
%><%
const c = locals.client;
const del = locals.del;
-%>
<div class="form-page">
<% if (locals.created) { -%>
<%- include("partials/alert", { text: t("admin.clients.createdNotice"), 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>
</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>
</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>
<% } -%>
</div>
@@ -1,17 +0,0 @@
<%#
Destructive-action confirm body, captured into the shell content slot. Zero-JS: the
delete is a deliberate second step (a POST form), with a cancel link back. Config:
message string
confirm { action, label } the danger POST endpoint + button label
cancelHref string
csrfToken
%>
<div class="form-page">
<section class="form-card admin-actions" aria-label="<%= t("admin.users.confirm") %>">
<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>
</div>
</section>
</div>
@@ -1,49 +0,0 @@
<%#
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
add { action, options: {label,value}[] } action = add-member endpoint
del { action } delete the whole group
csrfToken, error?
%><%
const group = locals.group;
const members = locals.members;
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"><%= t("admin.groups.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>
<% 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>
<% }) -%>
</tbody></table></div>
<% } else { -%>
<p class="cell-muted"><%= t("admin.groups.noMembers") %></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>
<% 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>
<% } else { -%>
<p class="cell-muted"><%= t("admin.groups.allMembers") %></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>
<% } -%>
</div>
@@ -1,75 +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, baseline, choices, empty, error, field, hint, inheritedNote, legend, notice, pending, readOnly, submit, undeclared }).
%>
<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.notice) { -%>
<%- include("partials/alert", { text: permissions.notice, tone: "pos" }) %>
<% } -%>
<% 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 { -%>
<%# The fragment brings the card, and the notice in it, into view on a long page. %>
<form method="post" action="<%= localeHref(permissions.action + '#permissions-h') %>">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="hidden" name="baseline" value="<%= permissions.baseline %>">
<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>
<% } -%>
<% } -%>
<% if (permissions.undeclared) { const u = permissions.undeclared; -%>
<h3 class="card-title" id="undeclared-h"><%= u.legend %></h3>
<p class="cell-muted"><%= u.hint %></p>
<ul class="plain-list" aria-labelledby="undeclared-h">
<% u.names.forEach((name) => { -%>
<li>
<% if (u.remove) { -%>
<form method="post" action="<%= localeHref(u.action + '#permissions-h') %>">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="hidden" name="<%= u.field %>" value="<%= name %>">
<span class="cell-muted"><%= name %></span>
<button class="btn btn-danger" type="submit"><%= u.remove %><span class="sr-only"> <%= name %></span></button>
</form>
<% } else { -%>
<span class="cell-muted"><%= name %></span>
<% } -%>
</li>
<% }) -%>
</ul>
<% } -%>
</section>
@@ -1,41 +0,0 @@
<%#
Admin user create/edit form body, captured into the shell content slot. Config:
form { action, csrfToken, submitLabel, cancelHref, fields: field.ejs config[] }
edit? { nextLabel, stateAction, recoveryAction, deleteAction } (edit mode only)
recovery? { code, link } shown after a recovery code is generated; link is the URL of the flow the code was minted on, the only flow that accepts the code
error? string shown when a write was rejected
%><%
const form = locals.form;
const edit = locals.edit;
const recovery = locals.recovery;
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- 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="<%= recovery.link %>"><%= t("admin.users.recovery.link") %></a></span><span class="recovery-code"><code><%= recovery.code %></code></span></div></div>
<% } -%>
<form class="form-card" method="post" action="<%= localeHref(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) { -%>
<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>
</section>
<% } -%>
</div>
@@ -1,24 +0,0 @@
<%#
Plugin settings admin list: one section per installed plugin, each a table of what it declares
and how each key resolved (admin-plugin-settings.ts). Read-only — no actions, no forms.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
let body = "";
for (const group of model.groups) {
// A plugin id is the folder name, which discovery constrains to [a-z0-9-] — no escaping needed.
body += '<h2 class="h2">' + group.pluginId + "</h2>";
body += group.table.rows.length === 0
? '<p class="muted">' + group.emptyText + "</p>"
: include("partials/data-table", group.table);
}
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+8 -24
View File
@@ -10,29 +10,20 @@ What it demonstrates:
service and renders the rows with the core building blocks (`shifts.ejs` → app shell, filter-bar,
data-table). Search round-trips the URL; zero-JS. (It fetches **all** rows for brevity — for a
large list, parse `page`/`pageSize` from `parseListQuery`, forward them upstream as a `?limit`/
`?offset`, and render `pagination.ejs` with `paginate()` and `buildPaginationModel()`, as the admin example plugin
does.)
`?offset`, and render `pagination.ejs` with `paginate()`, exactly as the admin example plugin does.)
- **A form that forwards a write upstream** — `GET /scheduling/shifts/new` renders the form,
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
reusing the core `field` partial.
- **All three route gates** — the Overview is `public` (anyone), "My shifts" is `session` (any
signed-in visitor, showing only rows assigned to them), and "Shifts" is gated on `scheduling:read` /
`scheduling:write`; a leaf whose gate a visitor fails is invisible in the menu.
- **Ownership joined on the identity id** — "My shifts" asks the upstream for `assigneeId=ctx.user.id`,
the opaque subject id, and renders the row's separate `assignee` display name. An email address is
user-changeable and can be reassigned to someone else, who would then inherit those rows.
- **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).)
- **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.
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`).
## Upstream
Set `PLUGIN_SETTING_SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory
Set `SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory
mock (`examples/shifts-upstream/`) so `docker compose up` shows the plugin working out of the box.
A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook).
@@ -43,14 +34,9 @@ Your backend must expose two routes; the plugin treats any non-2xx as a recovera
| Route | Request | Success | Response body |
| --- | --- | --- | --- |
| `GET /shifts` | `Accept: application/json`, optional `?assigneeId=<id>` | `200` | JSON array of `{ id, title, assignee, assigneeId, start, end }` (all strings; missing fields coerce to `""`). With `assigneeId`, only that person's rows |
| `GET /shifts` | `Accept: application/json` | `200` | JSON array of `{ id, title, assignee, start, end }` (all strings; missing fields coerce to `""`) |
| `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) |
`POST /shifts` carries the assignee as a **display name only**, so a shift created through this
plugin's form belongs to nobody and surfaces on no one's "My shifts" — don't go hunting for it
there. Resolving a name to an identity id needs a directory this demo has none of; a real backend
does that join at create time and stores the `assigneeId` alongside the name.
Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the
form re-renders. The plugin only validates that `title` and `assignee` are non-empty.
@@ -60,8 +46,6 @@ cosmetically) — normalise to your backend's format there if it matters.
## Granting access
A user sees the shift list once they hold the `scheduling:read` permission 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. "My shifts" needs no grant at all — signing in is
its whole gate; it lists the rows this demo upstream holds against the signed-in visitor's id, and
the demo's seeded rows belong to three made-up people, so a freshly seeded admin sees it empty.
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.
-44
View File
@@ -1,44 +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 "@plainpages/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.mine.empty": "No shifts are assigned to {{email}}.",
"scheduling.mine.title": "My shifts",
"scheduling.nav.mine": "My shifts",
"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.mine": "See my shifts",
"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;
-38
View File
@@ -1,38 +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.mine.empty": "Inga pass är tilldelade {{email}}.",
"scheduling.mine.title": "Mina pass",
"scheduling.nav.mine": "Mina 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.mine": "Visa mina pass",
"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;
+18 -35
View File
@@ -2,63 +2,46 @@
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
import { definePlugin } from "@plainpages/plugin-api";
import { createShift, createUpstream, listShifts, MINE_PATH, myShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
import { definePlugin } from "#plugin-api";
import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
// stateless). Its URL is a declared setting, so it is resolved and validated before onBoot hands it
// over — which is after this manifest is built, hence the getter.
let upstreamUrl = "";
const upstream = createUpstream(() => upstreamUrl);
// stateless). Configure via env; the dev compose points it at a tiny mock (examples/shifts-upstream).
const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstream:4000";
const upstream = createUpstream(upstreamUrl);
export default definePlugin({
apiVersion: "0.5.0", // the host contract this was built against — a literal, never HOST_API_VERSION
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
hooks: { onBoot: ({ settings }) => { upstreamUrl = settings.upstream; } },
// onBoot runs after discovery, before the server listens: validate the plugin's own config so a
// 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: MINE_PATH, id: "scheduling:mine", label: "scheduling.nav.mine", session: 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",
public: true, // the header gates nothing; each child names its own gate, and an empty header is dropped
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 },
],
// Also offered in the profile dropdown above Sign out, since the page is the visitor's own.
profileMenu: [{ href: MINE_PATH, icon: "i-cal", id: "scheduling:profile-mine", label: "scheduling.nav.mine", session: true }],
// 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 role.
routes: [
{ handler: overview(), method: "GET", path: "/", public: true },
{ handler: myShifts(upstream), method: "GET", path: "/mine", session: true },
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
],
// Operator-supplied config: one PLUGIN_SETTING_SCHEDULING_UPSTREAM variable, validated as a URL at
// boot. The default points at the mock backend the dev compose runs (examples/shifts-upstream).
settings: [
{
default: "http://shifts-upstream:4000",
description: "Base URL of the backend this plugin reads shifts from and writes them to",
key: "upstream",
type: "url",
},
],
});
@@ -4,9 +4,3 @@
.scheduling-page .table-wrap {
margin-top: var(--space-3, 0.75rem);
}
/* Inset what sits directly in the pane by --pad-x, as the filter bar, table and pager already are. */
.scheduling-page > p,
.scheduling-page > .alert,
.scheduling-page > .btn {
margin-inline: var(--pad-x);
}

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