1 Commits

Author SHA1 Message Date
renovate-bot c30ebf9dcb chore(deps): update playwright to v1.62.1
renovate/artifacts Artifact file update failure
CI / full-gate (push) Failing after 1m9s
Release-Bump: minor
2026-07-31 04:18:07 +00:00
216 changed files with 3251 additions and 8784 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 -3
View File
@@ -7,9 +7,7 @@ jobs:
full-gate:
runs-on: docker-host
steps:
- uses: actions/checkout@v7.0.1
with:
fetch-depth: 0 # ci.sh's docs-only check needs history; checkout defaults to depth 1
- uses: actions/checkout@v4.2.2
- run: bash ci.sh
- name: Push app image tagged with the commit hash
env:
+2 -5
View File
@@ -9,13 +9,10 @@ jobs:
github-mirror:
runs-on: docker-host
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
fetch-tags: true # load-bearing for --prune below: no local tags would delete every remote one
# --prune so a tag deleted here doesn't live on at GitHub forever. It only removes refs a
# refspec DESTINATION matches — so tags; main is a non-glob dst, other branches match nothing.
- run: |
git push --force --prune \
git push --force \
"https://x-access-token:${{ secrets.MIRROR_GITHUB_TOKEN }}@github.com/larvit/plainpages.git" \
refs/remotes/origin/main:refs/heads/main 'refs/tags/*:refs/tags/*'
+2 -2
View File
@@ -8,7 +8,7 @@ jobs:
prune-stale-images:
runs-on: docker-host
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/checkout@v4.2.2
- name: Delete hash images that are neither release-tagged nor a branch head
env:
REGISTRY_TOKEN: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
@@ -19,4 +19,4 @@ jobs:
run: |
docker run --rm -v "$PWD:/repo" -w /repo \
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
node:24.19.0-alpine3.24 node registry-cleanup/cleanup.ts
node:24.18.0-alpine3.24 node registry-cleanup/cleanup.ts
+8 -66
View File
@@ -2,27 +2,12 @@ name: Release
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.19.0-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 }}
@@ -30,77 +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 (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
# Cleanup, and the runner's Docker config is shared (AGENTS.md) — a lost race here must not
# fail a release that published, nor skip the overview job that follows.
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.19.0-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.19.0-alpine3.24 \
node release-tooling/dockerhub-overview.ts "$VERSION"
docker logout gitea.larvit.se
docker logout docker.io
+6 -16
View File
@@ -10,32 +10,29 @@ jobs:
steps:
- name: Run Renovate against this repo
env:
GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }}
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
run: |
docker run --rm \
-e GITHUB_COM_TOKEN \
-e LOG_LEVEL=info \
-e RENOVATE_ENDPOINT=https://gitea.larvit.se/api/v1 \
-e RENOVATE_GIT_AUTHOR="Renovate Bot <renovate@larvit.se>" \
-e RENOVATE_PLATFORM=gitea \
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
-e RENOVATE_TOKEN \
renovate/renovate:44.39.2
renovate/renovate:43.288.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
- name: Tag a release for what Renovate merged
@@ -55,15 +52,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.19.0-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.19.0-alpine3.24 \
node release-tooling/contract-version.ts "$NEXT" -
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.16.0-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
+118 -381
View File
@@ -3,317 +3,94 @@
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, 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, run the stability reviewer agent in a loop and address all feedback until there is none. If you are not very confident of how to address it, ask the user. Check the completed task in this file. 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.
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.
## Deliberate architectural deviations (don't re-flag)
Intentional, reasoned choices — an architecture review should honor them, not re-raise them.
Revisit only if the stated reason stops holding.
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**, not flat — `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`,
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. The core ships **no domain
screens** — even the admin GUI is a drop-in plugin (`examples/plugins/admin/`).
- **Plugins and config import the host only through a barrel** — `@plainpages/plugin-api`
`plugin-api/index.ts``src/plugin-host/plugin-api.ts`, `#menu-config``src/ui/menu-config.ts`,
never a relative `../../src/*` path. These two barrels are the whole contract surface; don't "fix"
either back to a relative path. Three consequences:
- `@plainpages/plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their
DTOs and error classes), so those shapes are **contract-visible** — changing them needs a major
`apiVersion` bump, not a free refactor.
- **The barrel is a package, not a `#`-import, so a plugin folder may carry its own
`package.json`** and depend on npm packages (README → Plugin dependencies). The Dockerfile links
it 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, which
`plugin-api.test.ts` guards by asserting both paths reach one module.
- **Plugin storage hands over credentials, not a client** (README → Plugin storage). The host takes
`postgres` 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 it invisibly). It 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 is what 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); and 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 — incidental, not a
packaging promise, and a plugin must still depend on its own driver.
- **Plugin settings are declared, not discovered** (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_`, because 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, with the residual cross-plugin
collision caught by `findConflicts`; `required` and `default` are mutually exclusive, which is what
lets `SettingsOf` type a declared key as present rather than `T | undefined`, so no plugin author
casts; and 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 (the orphan-database precedent).
- **The trust boundary is the `web` process, not the plugin.** 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 rather than implying a sandbox. Consistent with priority #7
(crash-isolation is a non-goal). `server.ts` still deletes the secret from `process.env` right
after `loadConfig`, which is before discovery imports any plugin module — the ordering is the whole
point, so move it earlier if anything, **never later**. **Valid while plugins are
operator-installed code, not third-party uploads.**
- **`ory/postgres/init/init.sql` is the only home for the Ory databases' ACL** — 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, and it hard-fails whenever `PLUGIN_DB_ADMIN_URL` names a server with no
`kratos`. It 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, a deployed install needs a migration.
- **`bootstrap.ts` stays under `src/auth/`** even though it now provisions plugin databases as well
as seeding Ory. 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. Reconsider when a third seeding concern lands.
- **`BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook.** Adding
to the context costs a minor bump and removing one a major, so the shape errs small elsewhere. Pools
handed to a plugin are reaped on process exit — revisit if a plugin ever needs an orderly drain.
- **`config/` is still a plain dir — no `package.json` of its own**, or `#menu-config` resolves
against that instead and boot fails loud. An operator's menu override has no use for
dependencies; if that changes, it needs the same package treatment.
- **A plugin `package.json` without `"type": "module"` is refused, not warned.** Allowing it costs a
warning and a re-parse per file, not a break — Node detects module syntax, so even a `.js` helper
loads — and an operator on a read-only third-party mount cannot apply the remedy. Refused anyway
because the direction is safe: refuse→warn relaxes freely, warn→refuse breaks installed plugins.
**Valid while nothing is installed in the wild.**
- **`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 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/config into the root mount dirs — they ship empty.
- **`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 (`/`).
- **A plugin-owned render always runs on that plugin's context.** 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** (it renders + sends recovery/verification mail); `web` never
touches SMTP. Customization is Kratos' `courier.template_override_path`, not app code.
### Authorization
- **Vocabulary: `User``Group``Permission`, and there is no `Role`.** 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 (groups nest). Ory's own
"permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier.
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
word names *who someone is* (a role), and roles are groups here. **Enforced at discovery**
(`isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every route/nav
`permission` and every declared name), fail-loud like any other manifest rule — not only in the
admin GUI, which an operator removes by not copying it in.
- **Names are authored in plugin code; only grants live in Keto.** The host collects every installed
plugin's declarations into one catalog (`declaredPermissions``ctx.declaredPermissions`), and
that catalog *is* the list the admin screens offer. Hence **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 gates nothing, is not
offered, and is never revoked by an unrelated save — the picker only speaks for what it showed.
- `<resource>` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`): users are
the *host's*, and cross-plugin sharing is a goal. Cost: collision-freedom is a convention rather
than structural. Accepted — the alternative penalizes the sharing case.
- **Declaring a permission stays optional.** Mandatory declaration would let `findConflicts` see all
overlaps, but would then warn on exactly that legitimate sharing case. Shape is enforced;
declaration is not.
- `ADMIN_PERMISSIONS` **defaults to empty**, and **an unusable value is dropped with a warning,
never fatal** — fail-loud belongs at the manifest boundary where a developer authored the
mistake, whereas `bootstrap` gates `web`, so refusing operator env takes 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). Valid while bootstrap is the only
writer of grants.
- **`actionForMethod` is plugin-local and must not migrate into `@plainpages/plugin-api`.** Inside the 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 — a route table must answer "what does this need?" on its own.
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
`canWrite` and the views drop create/save/delete/add/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`. Two
grant-specific guards go with it: you cannot revoke your own **direct** grants (self-lockout would
need a `curl` against Keto to undo), and a permission held *through a group* renders
ticked-but-disabled, because unticked stated the opposite of the truth. **Known gap:** the group
paths are unguarded — unticking a permission on a group you belong to, leaving it, or deleting it
can still strip your own access. The robust "last effective holder" check needs a reverse Keto
query and is deferred.
- **`users:write` and `groups:write` are equivalent to full administrative access**: `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.
- **Plainpages says "user" everywhere; Ory's word is "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.
### i18n
- **The locale lives in the URL, never in a cookie.** `?locale=sv-SE``Accept-Language``en-US`,
and when the URL asked for one the host carries it onto the links it renders. 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; a plugin doesn't have to.** 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 it 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 it 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 it is the Kratos sign-in POST (absolute off-site URL).
- **`locale` is a host-owned query param** — in `parseListQuery`'s reserved set, so a localized list
page doesn't hand a plugin a phantom `locale` filter. The i18n view locals (`t`, `locale`, `locales`,
`localeHref`, `localeParam`, `localeSwitch`, `dir`) are likewise reserved, 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.** Every locale is compared against its set's `en-US`
— keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` requires — and a
mismatch stops startup. A plugin may ship fewer locales than the host (its strings fall back to
`en-US` per key), never one the host lacks.
- **`locales/` at the repo root is a drop-in mount**, 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. Adding a language must never require forking the image. 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.** A POST-rendered URL often
answers no GET (`POST /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 `/`.
Accepted cost: switching language there leaves that POST's own result behind. Valid while the picker
is expected on literally every page — if that softens, hiding it after a POST is simpler.
- **An unknown translation key renders as itself.** 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; the view escapes it.** 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.** `textDirection` sets `<html dir>` because
that is free and correct, but the stylesheet keeps physical `left`/`right` properties; a genuine RTL
locale needs those moved to logical ones first. Valid while no deployment needs an RTL language.
### UI
- **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then
owns open/close — the only zero-JS way to dismiss by clicking outside — 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); it 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 the caching decision); and **neither `aria-expanded` nor
`aria-haspopup` is written**, because a zero-JS invoker cannot keep the first truthful and the
second would promise `role="menu"` semantics these panels don't implement. `<details>` stays where
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
the two in step.
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
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. Accepted cost: an
unknown sprite id renders blank instead of failing loud (the `every icon <use> resolves` e2e test
catches anything reaching the nav).
### Build, test & release
- **Deps install to `/node_modules`, above `WORKDIR /app`** — Node resolves upward, so dev's `.:/app`
bind mount has nothing to shadow. Not a volume at `/app/node_modules`: 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 now — it shadows `/node_modules` silently (`src/compose.test.ts` guards the compose
files, `.dockerignore` the image).
- **A container whose output a human then edits or deletes runs as `--user "$(id -u):$(id -g)"`** —
the E2E runner (artifacts) and a lockfile edit, or the output is root-owned and needs `sudo`, which
a dev box may not have. 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 (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 `/`; and
rootless Docker wants the flag *dropped*, container root already being the invoking user. Baking a
`USER` in instead does not work — the image's `pwuser` is 1001 and no fixed uid matches every host.
`src/compose.test.ts` guards every documented command, `src/ci-gate.test.ts` the gate's own.
- **Anything the browser logs fails the E2E test that provoked it.** Every spec takes its `test` from
`e2e-tests/console-guard.ts`, which fails a test on a console error/warning or 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. 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.**
`visual.spec.ts` + `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, not just the root.** 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 — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is
`release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test
guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard
skipped. `README.md` is the one markdown a test reads — `release-tooling/contract-version.test.ts`
checks its `apiVersion` samples — and a README-only change skips that check; accepted, because
those samples are illustrative and the copies that matter (`examples/`, `views/`, the template) are
gated. **Valid while no markdown file is rendered or executed.**
`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 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. Accepted for a single-maintainer
cadence; serialize with a workflow `concurrency` group if it ever bites.
## Docker only — no host tooling
@@ -331,97 +108,57 @@ 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: Overview → 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).
## 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 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/admin/`.
- **`handleRequest` (`src/http/app.ts`) is a known complexity hotspot** — ~160 lines tracking
canonical host, static, locale, session + re-mint, CSRF, chrome, hooks, plugin routing, builtin
routing, 405/404 and error mapping. The pure parts are already extracted and separately tested;
what remains is orchestration. Planned split along those seams; don't grow it further without
taking one out.
- 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. Prefer state in the URL over POSTing it, for
example on list pages with filters and pagination. 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).
- English everywhere. Keep code comments short and information-dense. Self explained code
without any comment at all is the preferred solution.
- 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/`.
- Run the stability reviewer agent after every implementation of something that can be like
a PR. That includes any change pushed directly to main.
Skip this if the changes are purely documentation and/or comments.
- 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".
+6 -12
View File
@@ -1,20 +1,14 @@
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
FROM node:24.19.0-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.0-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.31.0
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
```
@@ -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.2.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>" }) },
+874 -1102
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", () => {
@@ -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)));
+2 -61
View File
@@ -12,30 +12,6 @@ cd "$(dirname "$0")"
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
) || return 1
[ -n "$changed" ] || return 1
! printf '%s\n' "$changed" | grep -qvE '\.md$'
}
if docs_only; then
step "Only *.md changed since main — nothing to test, skipping the gate"
exit 0
fi
# Pins that MUST move in lockstep: a browser/runner mismatch yields confusing E2E failures.
step "Playwright pin lockstep (e2e-tests/Dockerfile image == e2e-tests/package.json @playwright/test)"
# `|| true` so a no-match doesn't trip `set -e`/`pipefail` before the explicit check below can report.
@@ -44,12 +20,6 @@ pkg=$(grep -oE '"@playwright/test": "[0-9.]+"' e2e-tests/package.json | grep -oE
[ -n "$img" ] && [ "$img" = "$pkg" ] || { echo "Playwright pin mismatch/unreadable: image v$img vs @playwright/test $pkg"; exit 1; }
echo "ok ($img)"
# Explicit rebuild: without it a stale web image from a previous branch supplies node_modules
# (the source is bind-mounted but deps are baked in), so a dep bump gets typechecked/tested
# against the OLD packages. Cheap when deps are unchanged (npm ci layer is cache-keyed).
step "Build web image"
docker compose build web
step "Typecheck"
docker compose run --rm --no-deps web npm run typecheck
@@ -60,42 +30,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"; }
}
@@ -111,7 +52,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.19.0-alpine3.24
image: node:24.18.0-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.31.0
image: axllent/mailpit:v1.30.6
ports:
- "8025:8025"
restart: unless-stopped
+7 -25
View File
@@ -17,16 +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
# 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
@@ -36,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}
@@ -136,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
-4
View File
@@ -9,8 +9,4 @@ RUN npm ci
COPY e2e-tests/ ./
# 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:
+18 -17
View File
@@ -1,16 +1,21 @@
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
# login, menu filtering by permission, users/groups/OAuth2-clients CRUD + permission granting, a plugin page, logout. A
# tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
# Full browser E2E — the real Playwright UI flow against the live stack: password +
# mocked-SSO login, menu filtering by role, users/groups/roles 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 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.
depends_on:
# First-party + SSO flows need Kratos + Keto + bootstrap, not Hydra — drop it so the stack is
# leaner. 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.
depends_on: !override
bootstrap:
condition: service_completed_successfully
kratos:
condition: service_healthy
keto:
condition: service_healthy
shifts-upstream:
condition: service_healthy
environment:
@@ -30,7 +35,7 @@ services:
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
- ./examples/plugins/admin:/app/plugins/admin:ro
# bootstrap grants the demo admin every discovered plugin's permission names, so it needs the
# bootstrap grants the demo admin every discovered plugin's permission tokens, so it needs the
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
bootstrap:
volumes:
@@ -47,13 +52,9 @@ services:
SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS: >-
[{"id":"mock","provider":"generic","label":"Mock SSO","client_id":"plainpages-e2e","client_secret":"e2e-secret","issuer_url":"http://mock-oidc:9000","scope":["openid","email"],"mapper_url":"file:///etc/config/kratos/oidc/claims.jsonnet"}]
# --dev permits the http issuer (the base file drops it for an https prod issuer).
hydra:
command: serve all --dev -c /etc/config/hydra/hydra.yml
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
shifts-upstream:
image: node:24.19.0-alpine3.24
image: node:24.18.0-alpine3.24
command: ["node", "/server.ts"]
volumes:
- ./examples/shifts-upstream/server.ts:/server.ts:ro
@@ -66,7 +67,7 @@ services:
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
mock-oidc:
image: node:24.19.0-alpine3.24
image: node:24.18.0-alpine3.24
command: ["node", "/mock-oidc.ts"]
environment:
ISSUER: http://mock-oidc:9000
@@ -81,7 +82,7 @@ services:
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
proxy:
image: node:24.19.0-alpine3.24
image: node:24.18.0-alpine3.24
command: ["node", "/proxy.ts"]
depends_on:
web:
+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.
+15 -132
View File
@@ -1,5 +1,4 @@
import type { Browser, Page } from "@playwright/test";
import { expect, test, watchedPage } from "./console-guard.ts";
import { type Browser, type Page, expect, test } from "@playwright/test";
import { randomUUID } from "node:crypto";
// Full browser E2E: the real Playwright UI against the live stack via the same-origin
@@ -10,7 +9,7 @@ import { randomUUID } from "node:crypto";
// journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
// (no cross-group shared backend writes) or serialise the file if that ever changes.
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin permission in Keto
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin role in Keto
const ADMIN_PASSWORD = "admin";
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
@@ -25,86 +24,21 @@ async function loginPassword(page: Page): Promise<void> {
await expect(page.locator(".profile-mail")).toHaveText(ADMIN_EMAIL); // waits through the redirect chain
}
// The themed Kratos page in another language: our own chrome, Kratos' own strings mapped by id, and
// the card's own links keeping the choice (they are rendered by the flow body, not by the menu).
test("the login page speaks the visitor's language, links included", async ({ browser }) => {
const page = await watchedPage(await browser.newContext());
await page.goto("/login?locale=sv-SE");
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await expect(page.getByRole("heading", { name: "Logga in" })).toBeVisible();
await expect(page.getByLabel("Lösenord", { exact: true })).toBeVisible(); // Kratos' own field, labelled via auth.field.password
await expect(page.getByRole("link", { name: "Glömt lösenordet?" })).toHaveAttribute("href", /locale=sv-SE/);
await expect(page.getByRole("link", { name: "Skapa ett" })).toHaveAttribute("href", /locale=sv-SE/);
await page.context().close();
});
test.describe.serial("authenticated admin journey", () => {
let browser: Browser;
let page: Page;
test.beforeAll(async ({ browser: b }) => {
browser = b;
page = await watchedPage(await browser.newContext());
page = await (await browser.newContext()).newPage();
test.setTimeout(90_000);
await loginPassword(page);
});
test.afterAll(async () => { await page.context().close(); });
// The list screens rebuild their query from the list state (sort/page/filter), so they are where a
// chosen language 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("/admin/users?locale=sv-SE");
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await expect(page.getByRole("heading", { name: "Användare" })).toBeVisible();
await page.getByRole("link", { name: /E-postadress/ }).click(); // a sort header
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await page.getByRole("button", { name: "Använd filter" }).click(); // the filter bar's GET form
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await page.getByRole("button", { name: "Visa" }).click(); // the rows-per-page GET form
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
// The breadcrumb is the chrome's way back up — it is rendered by the shell, not by the screen.
await page.getByRole("navigation", { name: "Sidsökväg" }).getByRole("link").first().click();
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
});
// A POST that re-renders a page: the write must keep the language, and the picker — which is on
// every page — must point somewhere that answers GET rather than at the POST-only URL.
test("a write keeps the visitor's language, and the picker still works on the POST-rendered page", async () => {
await page.goto("/admin/users?locale=sv-SE");
await page.getByRole("link", { name: "Ny användare" }).click();
await page.fill('input[name="email"]', `lang-${suffix}@plainpages.local`);
await page.getByRole("button", { name: "Skapa användare" }).click();
await expect(page).toHaveURL(/locale=sv-SE/); // the POST → redirect → GET keeps it
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
// Open the new user's edit page the way the CRUD test does — the row's Edit link carries the id.
const row = page.locator("tr", { hasText: `lang-${suffix}@plainpages.local` });
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
await page.goto(`${editHref}`);
await expect(page.locator('button[aria-label="Språk"]')).toHaveCount(1);
await page.getByRole("button", { name: "Skapa återställningskod" }).click(); // POST-only route
await expect(page.getByText("Återställningskod skapad")).toBeVisible();
// The picker is here too, and following it lands on a real page in the other language.
await page.locator('button[aria-label="Språk"]').click();
await page.getByRole("link", { name: /English/i }).click();
expect(page.url()).toContain("locale=en-US");
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
await expect(page.getByRole("heading", { name: "Edit user" })).toBeVisible(); // not a 405
});
test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => {
// The signed-in admin holds every permission the two mounted plugins declare (the bootstrap
// seeds exactly those), so both gated sections are present in the menu (collapsed by default →
// assert they're in the DOM, not necessarily visible).
test("menu filters by role: an admin sees the gated Admin section + the plugin", async () => {
// The signed-in admin holds admin + scheduling:read/write, so both gated sections are present
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
await page.goto("/dashboard");
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(1);
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1);
@@ -122,11 +56,6 @@ test.describe.serial("authenticated admin journey", () => {
const row = page.locator("tr", { hasText: email });
await expect(row).toBeVisible();
// Row actions sit behind the kebab popover: opening it reveals them, in the top layer, so the
// scrolling table around the row cannot clip the panel.
await row.locator("button.kebab").click();
await expect(row.locator('a[href^="/admin/users/"]').first()).toBeVisible();
// Delete through the confirm interstitial (the row's Edit link carries the id).
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
await page.goto(`${editHref}/delete`);
@@ -136,7 +65,7 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
});
test("groups CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async () => {
test("groups + roles CRUD: create one of each (writes go to Keto) and see them listed", async () => {
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form
// enforces it); pick the first option (a user) from the required picker.
const group = `e2e-grp-${suffix}`;
@@ -147,46 +76,13 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
await expect(page.locator("main")).toContainText(group);
// Permissions are declared in plugin code, so the group's detail page offers them as a fixed
// checkbox list rather than a create form — there is no Permissions screen to visit.
await page.goto(`/admin/groups/${group}`);
const scheduling = page.locator('input[name="permission"][value="scheduling:read"]');
await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer
await expect(scheduling).not.toBeChecked();
await scheduling.check();
await page.locator('form:has(input[name="permission"]) button[type="submit"]').click();
await expect(page).toHaveURL(new RegExp(`/admin/groups/${group}`));
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
});
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("/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");
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();
// Hydra returns the secret exactly once, so the POST renders the detail directly (no PRG).
await expect(page.locator("h1")).toHaveText("Client registered");
const clientId = await page.locator("#cid").inputValue();
expect(clientId).toBeTruthy();
await expect(page.locator("#csecret")).toHaveValue(/.+/);
// Listed; the row header links to the plain detail, which never shows the secret again.
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(`/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(/\/admin\/clients(\?|$)/);
await expect(page.locator("tr", { hasText: name })).toHaveCount(0);
await expect(page).toHaveURL(/\/admin\/roles(\?|\/|$)/);
await expect(page.locator("main")).toContainText(role);
});
test("plugin page: the reference plugin renders its upstream shifts inside the native shell", async () => {
@@ -195,22 +91,9 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream
});
test("plugin settings: the screen names the variable that sets each declared key", async () => {
await page.goto("/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
// Sign out is the only item in it — the menu offers nothing that goes nowhere.
await expect(page.locator("#profile-menu .menu-item")).toHaveText(["Sign out"]);
await page.locator("summary.profile").click(); // open the profile dropdown
await page.locator('form[action="/logout"] button[type="submit"]').click();
await page.waitForURL(/\/login(\?|$)/);
// The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav).
@@ -241,6 +124,6 @@ test("mocked SSO login: the provider button signs a user in via OIDC", async ({
await page.locator(".sso-btn").click();
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
// A fresh SSO identity holds no permissions, so the gated Admin section stays hidden.
// A fresh SSO identity holds no roles, so the gated Admin section stays hidden.
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
});
-81
View File
@@ -1,81 +0,0 @@
import { readFileSync } from "node:fs";
import { createPrivateKey, sign } from "node:crypto";
import { expect, test, watchedPage } from "./console-guard.ts";
// Language switching in a real browser, Ory-free (the visual stack). Proves the whole path a
// visitor takes: pick a language, read the page in it, and stay in it while clicking around —
// including into a plugin, whose words come from its own catalog (plugins/scheduling/i18n/).
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
const SESSION_COOKIE = "plainpages_jwt";
// Same trick as visual.spec.ts: sign a session JWT with the committed dev tokenizer key so the
// gated pages render without standing up Ory.
function devSession(permissions: string[] = []): string {
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
const key = createPrivateKey({ format: "jwk", key: jwk });
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
const now = Math.floor(Date.now() / 1000);
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, permissions, sub: "lang-demo" })}`;
return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`;
}
test("the switcher changes language, and the choice survives clicking through the app", async ({ page, context }) => {
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession(["scheduling:read"]) }]);
await page.goto("/dashboard");
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible();
// The picker sits in the sidebar footer beside the theme switch; each entry is a plain link to
// this same page in that language (zero-JS).
await page.locator('button[aria-label="Language"]').click();
await page.getByRole("link", { name: /svenska/i }).click();
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.getByRole("heading", { name: "Startpanel" })).toBeVisible(); // the starter dashboard, in Swedish
await expect(page.getByRole("link", { name: "Översikt", exact: true })).toBeVisible(); // the menu too
await page.screenshot({ fullPage: true, path: `artifacts/screenshots/${test.info().project.name}/live-05-swedish.png` });
// Clicking a menu item keeps Swedish — the host carries the choice onto the links it renders,
// and the plugin's own page is translated from its own catalog. The section's own label comes
// from the plugin's catalog too, so opening it proves the nav fragment was translated.
await page.locator('summary[aria-label="Visa eller dölj Schemaläggning"]').click();
await page.getByRole("link", { name: "Pass", exact: true }).click();
await expect(page).toHaveURL(/\/scheduling\/shifts\?locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await expect(page.getByRole("heading", { name: "Pass" })).toBeVisible();
await expect(page.getByRole("button", { name: "Sök" })).toBeVisible(); // the core filter bar, in Swedish
// The filter bar is a GET form: submitting it replaces the whole query string, so the choice
// survives only because the form carries it as a hidden field.
await page.getByRole("button", { name: "Sök" }).click();
await expect(page).toHaveURL(/locale=sv-SE/);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
// …and back to English the same way.
await page.locator('button[aria-label="Språk"]').click();
await page.getByRole("link", { name: /English/i }).click();
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
await expect(page.getByRole("heading", { name: "Shifts" })).toBeVisible();
});
test("a browser that asks for Swedish gets it without touching the URL", async ({ browser }) => {
const context = await browser.newContext({ locale: "sv" }); // a browser set to Swedish, no region
const page = await watchedPage(context);
await page.goto(`${BASE_URL}/`);
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
const signIn = page.locator("#main-content").getByRole("link", { name: "Logga in" });
await expect(signIn).toBeVisible();
// Nothing was chosen in the URL, so the links stay plain — the browser asks again on the next hit.
await expect(signIn).toHaveAttribute("href", "/login");
await context.close();
});
test("an uninstalled language falls back to English rather than failing", async ({ page }) => {
const response = await page.goto("/?locale=sv-FI"); // sv-SE is installed; sv-FI is not
expect(response?.status()).toBe(200);
await expect(page.locator("html")).toHaveAttribute("lang", "en-US");
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "./console-guard.ts";
import { expect, test } from "@playwright/test";
// Full-stack OAuth2 login + consent E2E: another app logs in *through* plainpages. Hydra
// starts an authorization flow and hands the browser to web's /oauth2/login; web resolves it via
+17 -15
View File
@@ -1,28 +1,30 @@
{
"name": "plainpages-e2e",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plainpages-e2e",
"version": "0.1.0",
"devDependencies": {
"@playwright/test": "1.62.1"
"@playwright/test": "1.49.1"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz",
"integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
"playwright": "1.49.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
"node": ">=18"
}
},
"node_modules/fsevents": {
@@ -41,35 +43,35 @@
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
"playwright-core": "1.49.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
"node": ">=18"
}
}
}
+1
View File
@@ -1,5 +1,6 @@
{
"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",
+1 -13
View File
@@ -3,9 +3,6 @@ import { defineConfig, devices } from "@playwright/test";
// Visual + functional checks against the live app (the `web` compose service, BASE_URL). Run via
// e2e-tests/compose.visual.yml. Parallel per the project's E2E principle; deterministic colorScheme/viewport
// so the rendered design is stable across runs.
const ORY_FREE = /\/(visual|language)\.spec\.ts$/;
export default defineConfig({
testDir: ".",
outputDir: "artifacts/test-output",
@@ -18,14 +15,5 @@ export default defineConfig({
screenshot: "only-on-failure",
viewport: { width: 1280, height: 800 },
},
// The Ory-free suites run in all three engines: the console guard (console-guard.ts) only sees an
// engine's warnings when that engine renders the page, and the newest platform features in the app
// (popover, CSS anchor positioning, `:has()`) are exactly where engines disagree. They stay
// side-effect-free, so three parallel runs of them don't collide. The Ory-backed suites write to
// one shared backend and stay on chromium.
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", testMatch: ORY_FREE, use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", testMatch: ORY_FREE, use: { ...devices["Desktop Safari"] } },
],
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
+16 -45
View File
@@ -1,29 +1,31 @@
import { createPrivateKey, sign } from "node:crypto";
import { readFileSync } from "node:fs";
import type { Page } from "@playwright/test";
import { allowConsole, expect, test } from "./console-guard.ts";
import { mkdir } from "node:fs/promises";
import { expect, test, type Page } from "@playwright/test";
const SHOTS = "artifacts/screenshots";
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
const SESSION_COOKIE = "plainpages_jwt"; // src/auth/login.ts — web verifies it against the committed dev JWKS
// Per engine: the three projects run this suite in parallel and would otherwise write one file.
const shot = (page: Page, name: string): Promise<Buffer> =>
page.screenshot({ fullPage: true, path: `artifacts/screenshots/${test.info().project.name}/${name}.png` });
page.screenshot({ fullPage: true, path: `${SHOTS}/${name}.png` });
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
function devSession(permissions: string[] = []): string {
function devSession(roles: string[] = []): string {
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
const key = createPrivateKey({ format: "jwk", key: jwk });
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
const now = Math.floor(Date.now() / 1000);
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, permissions, sub: "visual-demo" })}`;
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, roles, sub: "visual-demo" })}`;
return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`;
}
test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); });
// The dashboard is gated: a page navigation needs a session. Plant one per test — a plain
// member (no permissions) so the gated scheduling nav stays filtered out.
// member (no roles) so the gated scheduling nav stays filtered out.
test.beforeEach(async ({ context }) => {
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
});
@@ -56,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");
@@ -67,35 +70,6 @@ test("theme switch flips the palette with no JavaScript", async ({ page }) => {
expect(dark).not.toBe(light);
});
// The menus are <button popovertarget> + [popover], so the browser dismisses them: the visitor no
// longer has to click the trigger again to get rid of one. Driven through the language picker; the
// profile menu is the same block. Anchoring is asserted too — without `position-anchor` the panel
// silently detaches and lands in the middle of the viewport.
test("a popover menu sits on its trigger and closes on an outside click or Esc — no JavaScript", async ({ page }) => {
await page.goto("/dashboard");
const trigger = page.locator('button[aria-label="Language"]');
const panel = page.locator('button[aria-label="Language"] + .menu-pop');
await expect(panel).toBeHidden();
await trigger.click();
await expect(panel).toBeVisible();
// Anchored to the button that opened it: directly above (.up), right edges flush.
const t = (await trigger.boundingBox())!;
const p = (await panel.boundingBox())!;
expect(Math.abs(p.x + p.width - (t.x + t.width))).toBeLessThan(2);
expect(t.y - (p.y + p.height)).toBeGreaterThan(-1); // above the trigger, subpixel-tolerant
expect(t.y - (p.y + p.height)).toBeLessThan(12);
await page.getByRole("heading", { name: "Starter dashboard" }).click(); // anywhere else on the page
await expect(panel).toBeHidden();
await trigger.click();
await expect(panel).toBeVisible();
await page.keyboard.press("Escape");
await expect(panel).toBeHidden();
});
test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/dashboard");
@@ -125,17 +99,14 @@ test("the public landing at / is ungated and links to sign in + register", async
await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
await page.goto("/");
await expect(page.locator(".landing")).toBeVisible();
// the same app shell every page renders — the menu shows even signed out (permission-filtered).
// the same app shell every page renders — the menu shows even signed out (role-filtered).
await expect(page.locator(".sidebar")).toBeVisible();
await expect(page.locator('use[href="#i-gear"]')).toHaveCount(0); // no settings cog to offer a signed-out visitor
// Scoped to the landing itself: the anonymous sidebar offers a "Sign in" link of its own.
await expect(page.locator("#main-content").getByRole("link", { name: "Sign in" })).toHaveAttribute("href", "/login");
await expect(page.locator("#main-content").getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
await shot(page, "live-05-public-landing");
});
test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => {
allowConsole(/status of 404 .*\/no-such-page$/); // the navigation under test, which Chromium and WebKit log — not a sub-resource of it
const res = await page.goto("/no-such-page");
expect(res?.status()).toBe(404);
await expect(page.getByRole("heading", { name: "Page not found" })).toBeVisible();
@@ -165,7 +136,7 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red
expect(res.status()).toBe(303);
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
// The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav,
// The signed-in member (no scheduling role) sees the public Scheduling → Overview leaf in the nav,
// but the gated Shifts leaf is filtered out.
await page.goto("/dashboard");
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
+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/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
| [`plugins/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. |
+9 -8
View File
@@ -1,25 +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 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.
// 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 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)
},
+19 -36
View File
@@ -1,22 +1,17 @@
# Admin — the system-administration plugin
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/`
(it keeps the id and mount path `admin`, so the screens live at `/admin/*`) and restart:
The Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. These used to be
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
screens live at `/admin/*`) and restart:
```bash
cp -r examples/plugins/admin plugins/admin
docker compose up -d
docker compose restart web
```
The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so
the section appears in the menu and the screens work immediately. 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.
The seeded `admin@plainpages.local` already holds the `admin` role, so the section appears in the
menu and the screens work immediately.
## What it demonstrates — a *system* plugin
@@ -25,41 +20,29 @@ reference](../scheduling/README.md)). The admin screens instead administer **Pla
stack**, so they use the privileged **`ctx.system`** surface the host exposes to a system plugin:
- **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users).
- **`ctx.system.keto`** — read/write the Keto relationship graph (group membership, permission grants).
- **`ctx.system.keto`** — read/write the Keto relationship graph (Groups, Roles).
- **`ctx.system.hydra`** — register/list/delete Ory Hydra OAuth2 clients.
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
user's role change kills that subject's live tokens at once instead of waiting out the JWT TTL.
`ctx.system` is populated only when the host wired those services. 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`).
`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 Admin nav fragment, the six permissions the plugin declares, and
the route table — one thin handler per method+path, gated via `permissionName(resource, actionForMethod(method))`
so a GET needs `:read` and a POST `:write`.
- `admin-grants.ts` — the permission picker and the grant diff, shared by the Users and Groups
screens: what a submitted checkbox set grants and revokes, against the host's declared catalog.
- `admin-users.ts` · `admin-groups.ts` · `admin-clients.ts` — each a set of pure
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission token, and the
route table — one thin handler per method+path, all gated by `permission: "admin"`.
- `admin-users.ts` · `admin-groups.ts` · `admin-roles.ts` · `admin-clients.ts` — each a set of pure
view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
screen's permission gate + the needed `ctx.system` clients once.
- `admin-shared.ts` — the permission naming (`permissionName` / `actionForMethod`), the shared gate
(`requirePermission`), CSRF form reader (`guardedForm`), confirm
admin gate + the needed `ctx.system` clients once.
- `admin-shared.ts` — the shared gate (`requireAdmin`), CSRF form reader (`guardedForm`), confirm
model, nav fragment, and the not-found / unavailable helpers.
- `views/` — the screens' EJS, plus the admin-specific body partials under `views/partials/`. They
`include()` the core building-block partials (shell, data-table, filter-bar, field, …).
The three screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders
The four screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders
unit-test as pure functions with no host; the HTTP routing/gate/CSRF is covered in
`src/http/app.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`.
+45 -58
View File
@@ -5,8 +5,8 @@
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
import { can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@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,57 +116,56 @@ export function buildClientsListModel(opts: {
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q };
return {
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.nav.section") }, { label: t("admin.clients.title") }],
canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, t),
pagination: listPagination(state, page, t),
table: listTable(rows, t),
title: t("admin.clients.title"),
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "Admin" }, { label: "OAuth2 clients" }],
filterBar: listFilterBar(state),
pagination: listPagination(state, page),
table: listTable(rows),
title: "OAuth2 clients",
};
}
function listTable(rows: ClientView[], t: Translate) {
function listTable(rows: ClientView[]) {
return {
caption: t("admin.clients.title"),
columns: [{ label: t("admin.clients.column.name") }, { label: t("admin.clients.column.id") }, { label: t("admin.clients.column.type") }],
caption: "OAuth2 clients",
columns: [{ label: "Name" }, { label: "Client ID" }, { label: "Type" }],
rows: rows.map((c) => ({
cells: [
{ rowHeader: { href: detailHref(c.id), text: c.name } },
{ className: "cell-muted", text: c.id },
{ badge: { label: c.public ? t("admin.clients.public") : t("admin.clients.confidential"), tone: c.public ? "warn" : "info" } },
{ badge: { label: c.public ? "Public" : "Confidential", tone: c.public ? "warn" : "info" } },
],
name: c.name,
})),
};
}
function listFilterBar(state: ListState, t: Translate) {
function listFilterBar(state: ListState) {
const pills: { label: string; remove: string; value: string }[] = [];
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
return {
applyLabel: t("filter.apply"),
applyLabel: "Apply",
clearHref: ADMIN_CLIENTS_BASE,
label: t("admin.clients.filter"),
label: "Filter clients",
pills,
rows: [[
{ label: t("admin.clients.searchLabel"), name: "q", placeholder: t("admin.clients.searchPlaceholder"), type: "search", value: state.q },
{ label: "Search clients", name: "q", placeholder: "Search name or client ID…", type: "search", value: state.q },
{ type: "spacer" },
]],
};
}
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
return {
label: t("admin.clients.pagination"),
label: "Clients pagination",
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
pages: page.pages.map((p) =>
p.ellipsis ? { ellipsis: true }
: p.current ? { current: true, label: String(p.page) }
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
@@ -179,20 +175,18 @@ function listPagination(state: ListState, page: ReturnType<typeof paginate>, t:
export function buildClientFormModel(opts: {
csrfToken?: string;
error?: string;
t?: Translate;
values?: Partial<ClientInput>;
}) {
const t = opts.t ?? ADMIN_EN;
const v = opts.values;
const nameField: FieldConfig = {
autocomplete: "off", icon: "i-box", id: "name", label: t("admin.clients.field.name"), name: "name", required: true, value: v?.name ?? "",
autocomplete: "off", icon: "i-box", id: "name", label: "Name", name: "name", required: true, value: v?.name ?? "",
};
const scopeField: FieldConfig = {
hint: t("admin.clients.field.scopesHint"), id: "scope", label: t("admin.clients.field.scopes"), name: "scope",
hint: "Space-separated scopes the client may request.", id: "scope", label: "Scopes", name: "scope",
value: v?.scope ?? DEFAULT_SCOPE,
};
return {
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.clients.title") }, { label: t("admin.clients.register") }],
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: "Register" }],
error: opts.error,
form: {
action: ADMIN_CLIENTS_BASE,
@@ -203,31 +197,27 @@ export function buildClientFormModel(opts: {
public: v?.public ?? false,
redirectUris: (v?.redirectUris ?? []).join("\n"),
scopeField,
submitLabel: t("admin.clients.registerClient"),
submitLabel: "Register client",
},
title: t("admin.clients.registerTitle"),
title: "Register client",
};
}
export function buildClientDetailModel(opts: {
canWrite?: boolean;
client: ClientView;
created?: boolean; // just registered → success banner + the one-time secret (if any)
csrfToken?: string;
secret?: string; // one-time client_secret (confidential clients), shown once right after create
t?: Translate;
}) {
const t = opts.t ?? ADMIN_EN;
const base = detailHref(opts.client.id);
return {
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.clients.title") }, { label: opts.client.name }],
canWrite: opts.canWrite !== false,
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { label: opts.client.name }],
client: opts.client,
created: opts.created ?? false,
csrfToken: opts.csrfToken ?? "",
delete: { action: `${base}/delete` },
secret: opts.secret,
title: opts.created ? t("admin.clients.created") : opts.client.name,
title: opts.created ? "Client registered" : opts.client.name,
};
}
@@ -247,49 +237,47 @@ function readClientInput(form: URLSearchParams): ClientInput {
// Hydra capability (else a themed 503). Each route below is a thin handler over these.
interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; }
function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
const user = requirePermission(ctx, "oauth2-clients", action);
const user = requireAdmin(ctx);
const hydra = ctx.system?.hydra;
if (!hydra) return unavailable(ctx, ctx.t("admin.capability.hydra"));
if (!hydra) return unavailable(ctx, "Hydra OAuth2 admin");
return inner({ ctx, hydra, user });
};
}
// Same, plus the target client from ctx.params.id (unknown → themed 404).
function withClient(inner: (deps: ClientsDeps, client: OAuth2Client, id: string) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
function withClient(inner: (deps: ClientsDeps, client: OAuth2Client, id: string) => Promise<RouteResult>): RouteHandler {
return withClients(async (deps) => {
const id = deps.ctx.params["id"] ?? "";
const client = await deps.hydra.getClient(id);
if (!client) return notFound(deps.ctx);
return inner(deps, client, id);
}, action);
});
}
const clientFormResult = (ctx: RequestContext, extra: { error?: string; values?: Partial<ClientInput> }): RouteResult =>
({ data: { chrome: ctx.chrome, model: buildClientFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-form" });
const canWriteClients = (ctx: RequestContext): boolean => can(ctx, permissionName("oauth2-clients", "write"));
({ data: { chrome: ctx.chrome, model: buildClientFormModel({ csrfToken: ctx.chrome.csrfToken, ...extra }) }, view: "client-form" });
const clientDetailResult = (ctx: RequestContext, client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): RouteResult =>
({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ canWrite: canWriteClients(ctx), client: toClientView(client), csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-detail" });
({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ client: toClientView(client), csrfToken: ctx.chrome.csrfToken, ...extra }) }, view: "client-detail" });
// GET /admin/clients — the list.
export const clientsList = withClients(async ({ ctx, hydra }) => {
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
return { data: { chrome: ctx.chrome, model: buildClientsListModel({ canWrite: canWriteClients(ctx), clients, csrfToken: ctx.chrome.csrfToken, t: ctx.t, url: ctx.url }) }, view: "clients" };
return { data: { chrome: ctx.chrome, model: buildClientsListModel({ clients, csrfToken: ctx.chrome.csrfToken, url: ctx.url }) }, view: "clients" };
});
// POST /admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never
// returns it again). A Hydra 4xx (bad redirect/scope) re-renders the form (400); a 5xx rethrows → 500.
export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
const input = readClientInput((await guardedForm(ctx))!);
const error = validateClientInput(input, ctx.t);
const error = validateClientInput(input);
if (error) return { ...clientFormResult(ctx, { error, values: input }), status: 400 };
let created: OAuth2Client;
try {
created = await hydra.createClient(clientPayload(input));
} catch (err) {
if (err instanceof HydraError && err.status < 500) return { ...clientFormResult(ctx, { error: ctx.t("admin.clients.error.rejected"), values: input }), status: 400 };
if (err instanceof HydraError && err.status < 500) return { ...clientFormResult(ctx, { error: "Hydra rejected the client — check the redirect URIs and scopes.", values: input }), status: 400 };
throw err;
}
ctx.log.info("admin: oauth2 client registered", { actor: user.id, client: created.client_id ?? "" });
@@ -297,7 +285,7 @@ export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
});
// GET /admin/clients/new — the register form.
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})), "write");
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})));
// GET /admin/clients/:id — the detail (read-only; the secret is shown only once, at creation).
export const clientsDetail = withClient((deps, client) => Promise.resolve(clientDetailResult(deps.ctx, client)));
@@ -306,13 +294,12 @@ export const clientsDetail = withClient((deps, client) => Promise.resolve(client
export const clientsDeleteConfirm = withClient((deps, client, id) => {
const base = detailHref(id);
const name = toClientView(client).name;
const tt = deps.ctx.t;
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: tt("admin.clients.title") }, { href: base, label: name }, { label: tt("common.delete") }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.clients.delete"),
message: tt("admin.clients.deleteMessage", { name }), title: tt("admin.clients.delete"),
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: "OAuth2 clients" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete client",
message: `Delete client ${name}? Apps using it can no longer sign in through Plainpages.`, title: "Delete client",
}) }, view: "confirm" });
}, "write");
});
// POST /admin/clients/:id/delete — perform it.
export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => {
@@ -1,83 +0,0 @@
// The pure half of permission granting: what a submitted checkbox set changes, and the picker the
// two screens render from it. The Keto writes and the HTTP round trip are covered in app.test.ts.
import assert from "node:assert/strict";
import { test } from "node:test";
import type { PermissionDecl } from "@plainpages/plugin-api";
import { buildPermissionPicker, grantDiff, grantTuple, groupSubject, userSubject } from "./admin-grants.ts";
const declared: PermissionDecl[] = [
{ description: "View users", name: "users:read" },
{ description: "Edit users", name: "users:write" },
{ name: "groups:read" },
];
test("grantTuple targets a user by subject_id and a group by subject_set", () => {
assert.deepEqual(grantTuple("users:read", userSubject("u1")), { namespace: "Permission", object: "users:read", relation: "granted", subject_id: "user:u1" });
assert.deepEqual(grantTuple("users:read", groupSubject("eng")), {
namespace: "Permission", object: "users:read", relation: "granted",
subject_set: { namespace: "Group", object: "eng", relation: "members" },
});
});
test("grantDiff: the submitted set is the desired state — tick grants, untick revokes, unchanged is a no-op", () => {
assert.deepEqual(grantDiff(declared, ["users:read"], ["users:read", "users:write"]), { grant: ["users:write"], revoke: [] });
assert.deepEqual(grantDiff(declared, ["users:read", "users:write"], ["users:read"]), { grant: [], revoke: ["users:write"] });
assert.deepEqual(grantDiff(declared, ["users:read"], ["users:read"]), { grant: [], revoke: [] });
assert.deepEqual(grantDiff(declared, ["users:read"], []), { grant: [], revoke: ["users:read"] }); // every box cleared
});
test("grantDiff ignores anything the plugins don't declare, in both directions", () => {
// A crafted POST can't grant a name no plugin gates on…
assert.deepEqual(grantDiff(declared, [], ["superuser:all"]), { grant: [], revoke: [] });
// …and a held name that is no longer declared (its plugin was uninstalled) is left alone rather
// than silently revoked by an unrelated save — this screen only speaks for what it offered.
assert.deepEqual(grantDiff(declared, ["legacy:thing"], ["users:read"]), { grant: ["users:read"], revoke: [] });
});
test("buildPermissionPicker ticks what is held and carries each declaration's description", () => {
const picker = buildPermissionPicker({ action: "/admin/users/u1/permissions", declared, direct: ["users:write"] });
assert.equal(picker.action, "/admin/users/u1/permissions");
assert.deepEqual(picker.choices.map((c) => c.name), ["users:read", "users:write", "groups:read"]);
assert.deepEqual(picker.choices.map((c) => c.checked), [false, true, false]);
assert.equal(picker.choices[0]?.description, "View users");
assert.equal(picker.choices[2]?.description, ""); // a declaration may omit one
assert.equal(picker.empty, undefined);
assert.equal(picker.readOnly, false);
assert.equal(picker.inheritedNote, undefined); // nothing is group-held here
});
// 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);
});
-123
View File
@@ -1,123 +0,0 @@
// Permission grants, shared by the Users and Groups screens. A permission is held by a user
// (`Permission:<name>#granted@user:<id>`) or by a whole group (`…@Group:<name>#members`), and Keto
// resolves a group's grant transitively at login.
//
// The set of permissions that *exist* is `ctx.declaredPermissions` — the host's catalog, built from
// what the installed plugins declare in code. Nothing here invents a name, which is why the old
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
const PERMISSION_NS = "Permission";
const GRANTED = "granted";
export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two forms post
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
export const userSubject = (id: string): GrantSubject => ({ subject_id: `user:${id}` });
export const groupSubject = (name: string): GrantSubject => ({ subject_set: { namespace: "Group", object: name, relation: "members" } });
export function grantTuple(permission: string, subject: GrantSubject): RelationTuple {
return { namespace: PERMISSION_NS, object: permission, relation: GRANTED, ...subject };
}
// The permissions this subject holds *directly* — one Keto read filtered by the subject, not one per
// declared name. This is the edge the picker edits; `effectivePermissions` adds what a group confers.
export async function heldPermissions(keto: KetoClient, subject: GrantSubject): Promise<string[]> {
const held = new Set<string>();
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ namespace: PERMISSION_NS, relation: GRANTED, ...subject, ...(pageToken ? { pageToken } : {}) });
for (const tuple of page.tuples) held.add(tuple.object);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
return [...held].sort();
}
// Every declared permission the subject effectively holds — direct grants *plus* anything reached
// through a group, which is what actually lands in their JWT. One Keto check per declared name;
// the catalog is small and this is an admin screen (login does the same walk).
export async function effectivePermissions(keto: KetoClient, subject: GrantSubject, declared: readonly PermissionDecl[]): Promise<string[]> {
const held = await Promise.all(declared.map((decl) => keto.check({ namespace: PERMISSION_NS, object: decl.name, relation: GRANTED, ...subject })));
return declared.filter((_, i) => held[i]).map((decl) => decl.name);
}
export interface PermissionChoice {
checked: boolean; // held directly — the only state this form can change
description: string;
// Effective through a group, not granted directly. Rendered ticked but disabled: the grant is real
// (it reaches the JWT), and it is removed by editing the group, not this subject.
inherited: boolean;
name: string;
}
export interface PermissionPicker {
action: string;
choices: PermissionChoice[];
empty: string | undefined; // set when no plugin declares a permission — the picker has nothing to offer
error?: string; // a rejected save (e.g. the self-revoke guard), rendered above the list
field: string;
hint: string;
inheritedNote: string | undefined; // set when at least one choice is group-held, to explain the disabled row
legend: string;
// Set for a group: its members hold these transitively, so a change reaches them at their next
// re-mint rather than at once. The user picker revokes live tokens, so it says nothing.
pending: string | undefined;
readOnly: boolean; // the viewer holds :read but not :write — show the state, offer no save
submit: string;
}
// The checkbox list: every declared permission, ticked where this subject holds it. A fixed list
// means the form is the whole truth — what it posts back *is* the desired set of *direct* grants
// (grantDiff). An inherited row is disabled, so it never posts and can never be diffed into a revoke.
export function buildPermissionPicker(opts: {
action: string;
declared: readonly PermissionDecl[];
direct: string[];
effective?: string[]; // omit when the caller can't resolve group-held grants; then only direct shows
readOnly?: boolean;
t?: Translate;
transitive?: boolean; // a group: its members inherit, so the change lands at their next re-mint
}): PermissionPicker {
const t = opts.t ?? ((k: string) => k);
const directSet = new Set(opts.direct);
const effectiveSet = new Set(opts.effective ?? opts.direct);
const choices = opts.declared.map((decl) => ({
checked: directSet.has(decl.name) || effectiveSet.has(decl.name),
description: decl.description ?? "",
inherited: !directSet.has(decl.name) && effectiveSet.has(decl.name),
name: decl.name,
}));
return {
action: opts.action,
choices,
empty: opts.declared.length === 0 ? t("admin.grants.none") : undefined,
field: PERMISSIONS_FIELD,
// A reader sees every row disabled, so "tick to grant" is false and "greyed-out means group-held"
// is worse than false — it would misattribute a *direct* grant to a group that doesn't hold it.
hint: t(opts.readOnly === true ? "admin.grants.hintReadOnly" : "admin.grants.hint"),
inheritedNote: opts.readOnly !== true && choices.some((c) => c.inherited) ? t("admin.grants.inherited") : undefined,
legend: t("admin.grants.legend"),
pending: opts.transitive === true ? t("admin.grants.pending") : undefined,
readOnly: opts.readOnly === true,
submit: t("admin.grants.save"),
};
}
// What a submitted set changes. Pure so the diff is testable without Keto: only declared names are
// considered, so a crafted POST cannot grant something no plugin gates on, and a held-but-undeclared
// name (left over from an uninstalled plugin) is never silently revoked by an unrelated save.
export function grantDiff(declared: readonly PermissionDecl[], held: string[], wanted: string[]): { grant: string[]; revoke: string[] } {
const offered = new Set(declared.map((d) => d.name));
const heldSet = new Set(held);
const wantedSet = new Set(wanted.filter((name) => offered.has(name)));
return {
grant: [...wantedSet].filter((name) => !heldSet.has(name)).sort(),
revoke: [...heldSet].filter((name) => offered.has(name) && !wantedSet.has(name)).sort(),
};
}
export async function applyGrants(keto: KetoClient, subject: GrantSubject, diff: { grant: string[]; revoke: string[] }): Promise<void> {
for (const name of diff.grant) await keto.writeTuple(grantTuple(name, subject));
for (const name of diff.revoke) await keto.deleteTuple(grantTuple(name, subject));
}
+1 -1
View File
@@ -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 =>
+45 -92
View File
@@ -6,9 +6,8 @@
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
// each returning a RouteResult.
import { can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type User } from "#plugin-api";
import { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
const GROUP_NS = "Group";
@@ -91,8 +90,8 @@ const SORT: Record<string, (g: GroupView) => number | string> = {
name: (g) => g.name,
};
const COLUMNS = [
{ key: "name", label: "admin.groups.column.name" },
{ key: "members", label: "admin.groups.column.members" },
{ key: "name", label: "Group" },
{ key: "members", label: "Members" },
];
function detailHref(name: string): string {
@@ -111,13 +110,10 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
}
export function buildGroupsListModel(opts: {
canWrite?: boolean;
csrfToken?: string;
groups: GroupView[];
t?: Translate;
url: URL | URLSearchParams | string;
}) {
const t = opts.t ?? ADMIN_EN;
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
@@ -140,22 +136,21 @@ export function buildGroupsListModel(opts: {
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
return {
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }],
canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, t),
pagination: listPagination(state, page, t),
table: listTable(rows, state, sort, t),
title: t("admin.groups.title"),
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Admin" }, { label: "Groups" }],
filterBar: listFilterBar(state),
pagination: listPagination(state, page),
table: listTable(rows, state, sort),
title: "Groups",
};
}
function listTable(rows: GroupView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) {
function listTable(rows: GroupView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
return {
caption: t("admin.groups.title"),
caption: "Groups",
columns: COLUMNS.map((c) => {
const dir = sort && sort.field === c.key ? sort.dir : undefined;
const next = dir === "asc" ? `-${c.key}` : c.key;
return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
return { href: listHref(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true };
}),
rows: rows.map((g) => ({
cells: [{ rowHeader: { href: detailHref(g.name), text: g.name } }, String(g.memberCount)],
@@ -164,34 +159,34 @@ function listTable(rows: GroupView[], state: ListState, sort: { dir: "asc" | "de
};
}
function listFilterBar(state: ListState, t: Translate) {
function listFilterBar(state: ListState) {
const pills: { label: string; remove: string; value: string }[] = [];
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
return {
applyLabel: t("filter.apply"),
applyLabel: "Apply",
clearHref: ADMIN_GROUPS_BASE,
label: t("admin.groups.filter"),
label: "Filter groups",
pills,
rows: [[
{ label: t("admin.groups.searchLabel"), name: "q", placeholder: t("admin.groups.searchPlaceholder"), type: "search", value: state.q },
{ label: "Search groups", name: "q", placeholder: "Search group name…", type: "search", value: state.q },
{ type: "spacer" },
]],
};
}
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
if (state.sort) hidden.push({ name: "sort", value: state.sort });
return {
label: t("admin.groups.pagination"),
label: "Groups pagination",
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
pages: page.pages.map((p) =>
p.ellipsis ? { ellipsis: true }
: p.current ? { current: true, label: String(p.page) }
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
@@ -202,16 +197,14 @@ export function buildGroupFormModel(opts: {
csrfToken?: string;
error?: string;
memberOptions: MemberOption[];
t?: Translate;
values?: { member?: string; name?: string };
}) {
const t = opts.t ?? ADMIN_EN;
const nameField: FieldConfig = {
autocomplete: "off", hint: t("admin.groups.field.nameHint"), icon: "i-layers",
id: "name", label: t("admin.groups.field.name"), name: "name", required: true, value: opts.values?.name ?? "",
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-layers",
id: "name", label: "Group name", name: "name", required: true, value: opts.values?.name ?? "",
};
return {
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: t("common.new") }],
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: "New" }],
error: opts.error,
form: {
action: ADMIN_GROUPS_BASE,
@@ -220,46 +213,39 @@ export function buildGroupFormModel(opts: {
memberOptions: opts.memberOptions,
nameField,
selectedMember: opts.values?.member ?? "",
submitLabel: t("admin.groups.create"),
submitLabel: "Create group",
},
title: t("admin.groups.new"),
title: "New group",
};
}
export function buildGroupDetailModel(opts: {
canWrite?: boolean; // false ⇒ a `groups:read` holder: show the members, offer no edit
candidates: MemberOption[];
csrfToken?: string;
error?: string;
group: { name: string };
members: MemberView[];
permissions?: PermissionPicker;
t?: Translate;
}) {
const t = opts.t ?? ADMIN_EN;
const name = opts.group.name;
const base = detailHref(name);
const taken = new Set(opts.members.map((m) => m.subject));
const self = `group:${name}`; // a group can't be a member of itself
const options = opts.candidates.filter((c) => c.value !== self && !taken.has(c.value));
const canWrite = opts.canWrite !== false;
return {
add: { action: `${base}/members`, options },
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: name }],
canWrite, // the view drops add/remove/delete when false; the host already 403s those POSTs
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { label: name }],
csrfToken: opts.csrfToken ?? "",
delete: { action: `${base}/delete` },
error: opts.error,
group: { name },
members: { action: `${base}/members/delete`, rows: opts.members },
permissions: opts.permissions,
title: name,
};
}
// ---- request handler (imperative shell) ----
// Drain every page of a relation-tuple query.
// Drain every page of a relation-tuple query. (Reused by the Roles screen — same membership model.)
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
const out: RelationTuple[] = [];
let pageToken: string | undefined;
@@ -293,39 +279,38 @@ async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
return page.tuples.length > 0;
}
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate (`groups:read` on
// a GET, `groups:write` on a POST) + the Keto and Kratos capabilities (else a themed 503). Each route
// below is a thin handler over these.
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate + the Keto and
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: User; }
function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
const user = requirePermission(ctx, "groups", action);
const user = requireAdmin(ctx);
const keto = ctx.system?.keto;
const kratosAdmin = ctx.system?.kratosAdmin;
if (!keto || !kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.keto"));
if (!keto || !kratosAdmin) return unavailable(ctx, "Keto and Kratos identity admin");
return inner({ ctx, keto, kratosAdmin, user });
};
}
// Same, plus the validated :name from ctx.params (an invalid group name → themed 404).
function withGroupName(inner: (deps: GroupsDeps, name: string) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
function withGroupName(inner: (deps: GroupsDeps, name: string) => Promise<RouteResult>): RouteHandler {
return withGroups((deps) => {
const name = deps.ctx.params["name"] ?? "";
if (!isValidGroupName(name)) return Promise.resolve(notFound(deps.ctx));
return inner(deps, name);
}, action);
});
}
const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
return { data: { chrome: deps.ctx.chrome, model: buildGroupFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, t: deps.ctx.t, ...extra }) }, view: "group-form" };
return { data: { chrome: deps.ctx.chrome, model: buildGroupFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "group-form" };
};
// GET /admin/groups — the list.
export const groupsList = withGroups(async ({ ctx, keto }) => {
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ canWrite: can(ctx, permissionName("groups", "write")), csrfToken: ctx.chrome.csrfToken, groups, t: ctx.t, url: ctx.url }) }, view: "groups" };
return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ csrfToken: ctx.chrome.csrfToken, groups, url: ctx.url }) }, view: "groups" };
});
// POST /admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
@@ -336,8 +321,8 @@ export const groupsCreate = withGroups(async (deps) => {
const member = (form.get("member") ?? "").trim();
const tuple = memberTuple(name, member);
const reject = async (error: string): Promise<RouteResult> => ({ ...(await groupFormResult(deps, { error, values: { member, name } })), status: 400 });
if (!isValidGroupName(name)) return reject(ctx.t("admin.groups.validation.name"));
if (!tuple) return reject(ctx.t("admin.groups.validation.member"));
if (!isValidGroupName(name)) return reject("Group names use lowercase letters, digits, dashes and underscores.");
if (!tuple) return reject("Pick a member to add as the group's first member.");
if (await groupExists(keto, name)) return reject("A group with that name already exists.");
await keto.writeTuple(tuple);
ctx.log.info("admin: group created", { actor: user.id, group: name });
@@ -345,38 +330,13 @@ export const groupsCreate = withGroups(async (deps) => {
});
// GET /admin/groups/new — the create form.
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}));
// GET /admin/groups/:name — the detail + membership page.
export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => {
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
const subject = groupSubject(name);
const [direct, effective] = await Promise.all([heldPermissions(keto, subject), effectivePermissions(keto, subject, ctx.declaredPermissions)]);
const permissions = buildPermissionPicker({
action: `${detailHref(name)}/permissions`,
declared: ctx.declaredPermissions,
direct,
effective, // a group nested in another group inherits its permissions too
readOnly: !can(ctx, permissionName("groups", "write")),
t: ctx.t,
transitive: true, // members inherit, so a change here lands at their next re-mint, not at once
});
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t }) }, view: "group-detail" };
});
// POST /admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold
// a group's permissions transitively, so the change reaches them at their next login or re-mint —
// the documented instant-revoke tradeoff for anything held through a group.
export const groupsPermissions = withGroupName(async ({ ctx, keto, user }, name) => {
const form = (await guardedForm(ctx))!;
const subject = groupSubject(name);
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
await applyGrants(keto, subject, diff);
if (diff.grant.length > 0 || diff.revoke.length > 0) {
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: diff.grant.join(","), group: name, revoked: diff.revoke.join(",") });
}
return { redirect: detailHref(name) };
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members }) }, view: "group-detail" };
});
// POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
@@ -390,25 +350,18 @@ export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
// GET /admin/groups/:name/delete — the deliberate confirm step.
export const groupsDeleteConfirm = withGroupName((deps, name) => {
const base = detailHref(name);
const tt = deps.ctx.t;
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: tt("admin.groups.title") }, { href: base, label: name }, { label: tt("common.delete") }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"),
message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"),
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: "Groups" }, { href: base, label: name }, { label: "Delete" }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete group",
message: `Delete group ${name}? This removes the group and all its memberships.`, title: "Delete group",
}) }, view: "confirm" });
}, "write");
});
// POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
export const groupsDelete = withGroupName(async ({ ctx, keto, user }, name) => {
await guardedForm(ctx); // CSRF-verify the POST
// Drop what the group *holds* before what it *contains*: a Keto set exists only through its
// tuples, so leaving the grants behind would resurrect every permission the moment someone
// re-created a group with the same name.
const subject = groupSubject(name);
const held = await heldPermissions(keto, subject);
for (const permission of held) await keto.deleteTuple(grantTuple(permission, subject));
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS });
ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") });
ctx.log.info("admin: group deleted", { actor: user.id, group: name });
return { redirect: ADMIN_GROUPS_BASE };
});
@@ -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 /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" };
};
+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) };
});
+17 -49
View File
@@ -1,17 +1,16 @@
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
// (requirePermission/guardedForm gate every admin write) and reused across all three screens, so pin the
// (requireAdmin/guardedForm gate every admin write) and reused across all four screens, so pin the
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts.
// Import only from the @plainpages/plugin-api barrel — the same contract boundary the plugin code uses.
// Import only from the #plugin-api barrel — the same contract boundary the plugin code uses.
import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
import { test } from "node:test";
import { GuardError, isValidPermissionName, type Log, type PageChrome, type RequestContext, type User } from "@plainpages/plugin-api";
import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, permissionName, requirePermission } from "./admin-shared.ts";
import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api";
import { ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] };
const writer: User = { email: "cy@x.io", id: "u3", permissions: ["users:read", "users:write"] };
const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:read"] };
const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] };
const member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
@@ -19,59 +18,28 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
req.method = opts.method ?? "GET";
return {
chrome: CHROME, declaredPermissions: [], 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),
chrome: CHROME, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
roles: opts.user?.roles ?? [], url, user: opts.user ?? null, verifyCsrf: opts.verifyCsrf ?? (() => true),
};
}
// ---- nav fragment ----
test("ADMIN_NAV: an ungated Admin header whose screens each gate on their own read permission", () => {
test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => {
assert.equal(ADMIN_NAV.id, "admin");
// No gate on the header: a user may hold one screen's permission and not another's. composeNav
// drops a header left with no visible children, so holding none of 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.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients", "/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
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Roles", "OAuth2 clients"]);
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree
});
// ---- auth gates ----
test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => {
assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page
assert.throws(() => requirePermission(fakeCtx({ user: member }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(requirePermission(fakeCtx({ user: reader }), "users"), reader);
// The whole point of the split: users:read opens the list but not the create/delete POSTs.
assert.throws(() => requirePermission(fakeCtx({ method: "POST", user: reader }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(requirePermission(fakeCtx({ method: "POST", user: writer }), "users"), writer);
// Resources don't leak into each other: a users holder is not a groups holder.
assert.throws(() => requirePermission(fakeCtx({ user: writer }), "groups"), (e: unknown) => e instanceof GuardError && e.status === 403);
test("requireAdmin: anonymous → 401→/login, signed-in non-admin → 403, admin → the user", () => {
assert.throws(() => requireAdmin(fakeCtx({ user: null })), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page
assert.throws(() => requireAdmin(fakeCtx({ user: member })), (e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(requireAdmin(fakeCtx({ user: admin })), admin);
});
test("guardedForm: valid double-submit → the parsed body, bad token → 403, non-POST → undefined", async () => {
+24 -49
View File
@@ -1,65 +1,40 @@
// 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.
// 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, 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);
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 const ADMIN_PLUGIN_SETTINGS_BASE = "/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 AdminScreen = "clients" | "groups" | "roles" | "users";
export type AdminAction = "read" | "write";
// `<resource>:<action>` (README → Naming a permission).
export function permissionName(resource: AdminResource, action: AdminAction): string {
return `${resource}:${action}`;
}
// 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 (AGENTS.md).
export function actionForMethod(method: string): AdminAction {
const verb = method.toUpperCase();
return verb === "GET" || verb === "HEAD" ? "read" : "write";
}
// The plugin's nav fragment: an ungated "Admin" header + its three screens, each gated on its own
// read permission. The header carries no `permission` because a user may hold one screen's and not
// another's; composeNav drops a header left with no visible children, so a user holding none of the
// three never sees the section. The host current-marks the active item — no `current`/`open` here.
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into
// the one global menu, filters per user (the header's `permission` drops the whole subtree for a
// non-admin), and current-marks the active item — so there is no `current`/`open` state here.
export const ADMIN_NAV: NavNode = {
children: [
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") },
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") },
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") },
{ href: ADMIN_PLUGIN_SETTINGS_BASE, icon: "i-sliders", id: "plugin-settings", label: "admin.nav.pluginSettings", permission: permissionName("plugin-settings", "read") },
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
{ href: ADMIN_ROLES_BASE, icon: "i-shield", id: "roles", label: "Roles" },
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" },
],
icon: "i-shield",
id: "admin",
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
label: "Admin",
permission: ADMIN_PERMISSION,
};
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
// declares the same permission, so 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 {
// The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the
// host enforces this before the handler runs; this is defence-in-depth and what a direct unit test
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
export function requireAdmin(ctx: RequestContext): User {
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
const permission = permissionName(resource, action ?? actionForMethod(ctx.req.method ?? "GET"));
if (!can(ctx, permission)) throw new GuardError(403, `${permission} required`);
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin role required");
return user;
}
@@ -74,13 +49,13 @@ export async function guardedForm(ctx: RequestContext): Promise<URLSearchParams
// A themed "not found" (bad id/name in the path) rendered in the admin shell — 404, never a 500.
export function notFound(ctx: RequestContext): RouteResult {
return { data: { chrome: ctx.chrome, message: ctx.t("admin.notFound.message"), title: ctx.t("admin.notFound.title") }, status: 404, view: "notice" };
return { data: { chrome: ctx.chrome, message: "That item doesn't exist.", title: "Not found" }, status: 404, view: "notice" };
}
// A capability the plugin needs isn't on ctx.system (Ory not wired). Login already requires these in
// a real deployment, so this is the honest 503 fallback for a misconfigured host, not a crash.
export function unavailable(ctx: RequestContext, what: string): RouteResult {
return { data: { chrome: ctx.chrome, message: ctx.t("admin.unavailable.message", { what }), title: ctx.t("admin.unavailable.title") }, status: 503, view: "notice" };
return { data: { chrome: ctx.chrome, message: `${what} is not configured on this deployment.`, title: "Admin unavailable" }, status: 503, view: "notice" };
}
// Model for the shared destructive-confirm page (views/confirm.ejs). The view reads the shell fields
+1 -1
View File
@@ -2,7 +2,7 @@
// 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,
+73 -148
View File
@@ -1,11 +1,11 @@
// 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.
// 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 { 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, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
import { ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
const DEFAULT_PAGE_SIZE = 25;
@@ -30,6 +30,8 @@ export interface UserInput {
password: string;
}
const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
function nameParts(identity: Identity): { first: string; last: string } {
const nm = ((identity.traits?.name ?? {}) as { first?: unknown; last?: unknown });
return {
@@ -86,9 +88,9 @@ const SORT: Record<string, (u: UserView) => string> = {
status: (u) => u.state,
};
const COLUMNS = [
{ key: "name", label: "admin.users.column.name" },
{ key: "email", label: "admin.users.column.email" },
{ key: "status", label: "admin.users.column.status" },
{ key: "name", label: "Name" },
{ key: "email", label: "Email" },
{ key: "status", label: "Status" },
];
// Canonical list URL from the current state + per-link overrides; omits defaults so links stay tidy.
@@ -105,13 +107,10 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
}
export function buildUsersListModel(opts: {
canWrite?: boolean;
csrfToken?: string;
identities: Identity[];
t?: Translate;
url: URL | URLSearchParams | string;
}) {
const t = opts.t ?? ADMIN_EN;
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const status = query.filters.status?.[0] ?? "all";
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
@@ -134,71 +133,70 @@ export function buildUsersListModel(opts: {
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken, status };
return {
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.nav.section") }, { label: t("admin.users.title") }],
canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, all.length, t),
pagination: listPagination(state, page, t),
table: listTable(rows, state, sort, t),
title: t("admin.users.title"),
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Admin" }, { label: "Users" }],
filterBar: listFilterBar(state, all.length),
pagination: listPagination(state, page),
table: listTable(rows, state, sort),
title: "Users",
};
}
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) {
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
return {
actions: true,
caption: t("admin.users.title"),
caption: "Users",
columns: COLUMNS.map((c) => {
const dir = sort && sort.field === c.key ? sort.dir : undefined;
const next = dir === "asc" ? `-${c.key}` : c.key; // asc→desc, else→asc
return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
return { href: listHref(state, { page: 1, sort: next }), label: c.label, sort: dir, sortable: true };
}),
rows: rows.map((u) => ({
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: "i-edit", label: t("common.edit") }],
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: "i-edit", label: "Edit" }],
cells: [
{ user: { initials: u.initials, name: u.name } },
u.email,
{ badge: { label: t(`admin.users.status.${u.state}`), tone: STATE_TONE[u.state] ?? "info" } },
{ badge: { label: cap(u.state), tone: STATE_TONE[u.state] ?? "info" } },
],
name: u.name,
})),
};
}
function listFilterBar(state: ListState, total: number, t: Translate) {
function listFilterBar(state: ListState, total: number) {
const pills: { label: string; remove: string; value: string }[] = [];
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
if (state.status !== "all") pills.push({ label: t("admin.users.status.label"), remove: listHref(state, { page: 1, status: "all" }), value: t(`admin.users.status.${state.status}`) });
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
if (state.status !== "all") pills.push({ label: "Status", remove: listHref(state, { page: 1, status: "all" }), value: cap(state.status) });
return {
applyLabel: t("filter.apply"), // an untranslated core key still resolves: the host catalog is the fallback
applyLabel: "Apply filters",
clearHref: ADMIN_USERS_BASE,
label: t("admin.users.filter"),
label: "Filter users",
pills,
rows: [[
{ label: t("admin.users.searchLabel"), name: "q", placeholder: t("admin.users.searchPlaceholder"), type: "search", value: state.q },
{ legend: t("admin.users.status.label"), name: "status", options: [
{ count: total, label: t("admin.users.status.all"), value: "all" },
{ label: t("admin.users.status.active"), value: "active" },
{ label: t("admin.users.status.inactive"), value: "inactive" },
{ label: "Search users", name: "q", placeholder: "Search name or email…", type: "search", value: state.q },
{ legend: "Status", name: "status", options: [
{ count: total, label: "All", value: "all" },
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
], type: "segmented", value: state.status },
{ type: "spacer" },
]],
};
}
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
if (state.status !== "all") hidden.push({ name: "status", value: state.status });
if (state.sort) hidden.push({ name: "sort", value: state.sort });
return {
label: t("admin.users.pagination"),
label: "Users pagination",
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
pages: page.pages.map((p) =>
p.ellipsis ? { ellipsis: true }
: p.current ? { current: true, label: String(p.page) }
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
rows: { hidden, label: "Rows", name: "pageSize", options: PAGE_SIZES, submitLabel: "Go", value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
@@ -218,16 +216,12 @@ export interface FieldConfig {
}
export function buildUserFormModel(opts: {
canWrite?: boolean; // false ⇒ a `users:read` holder: show the state, render no write affordance
csrfToken?: string;
error?: string;
identity?: Identity | null;
permissions?: PermissionPicker; // editing only — a user that doesn't exist yet can hold nothing
recovery?: RecoveryCode;
t?: Translate;
values?: Partial<UserInput>;
}) {
const t = opts.t ?? ADMIN_EN;
const editing = opts.identity != null;
const view = editing ? toUserView(opts.identity!) : null;
const np = editing ? nameParts(opts.identity!) : { first: opts.values?.first ?? "", last: opts.values?.last ?? "" };
@@ -235,30 +229,27 @@ export function buildUserFormModel(opts: {
const idPath = editing ? `${ADMIN_USERS_BASE}/${encodeURIComponent(view!.id)}` : ADMIN_USERS_BASE;
const fields: FieldConfig[] = [
{ autocomplete: "email", icon: "i-mail", id: "email", label: t("admin.users.field.email"), name: "email", required: !editing, type: "email", value: email,
...(editing ? { hint: t("admin.users.field.emailHint"), readonly: true } : {}) },
{ id: "first", label: t("admin.users.field.first"), name: "first", optional: true, value: np.first },
{ id: "last", label: t("admin.users.field.last"), name: "last", optional: true, value: np.last },
{ autocomplete: "email", icon: "i-mail", id: "email", label: "Email", name: "email", required: !editing, type: "email", value: email,
...(editing ? { hint: "The login identifier — can't be changed here.", readonly: true } : {}) },
{ id: "first", label: "First name", name: "first", optional: true, value: np.first },
{ id: "last", label: "Last name", name: "last", optional: true, value: np.last },
];
if (!editing) fields.push({ autocomplete: "new-password", hint: t("admin.users.field.passwordHint"), icon: "i-lock", id: "password", label: t("admin.users.field.password"), name: "password", optional: true, type: "password" });
if (!editing) fields.push({ autocomplete: "new-password", hint: "Optional — leave blank to have the user set one via a recovery code.", icon: "i-lock", id: "password", label: "Password", name: "password", optional: true, type: "password" });
const canWrite = opts.canWrite !== false;
return {
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: editing ? t("common.edit") : t("common.new") }],
canWrite, // the view drops every write affordance when false; the host already 403s the POSTs
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { label: editing ? "Edit" : "New" }],
edit: editing ? {
deleteAction: `${idPath}/delete`,
id: view!.id,
nextLabel: view!.state === "inactive" ? t("admin.users.reactivate") : t("admin.users.deactivate"),
nextLabel: view!.state === "inactive" ? "Reactivate" : "Deactivate",
recoveryAction: `${idPath}/recovery`,
state: view!.state,
stateAction: `${idPath}/state`,
} : undefined,
error: opts.error,
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? t("admin.users.save") : t("admin.users.create") },
permissions: editing ? opts.permissions : undefined,
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? "Save changes" : "Create user" },
recovery: opts.recovery,
title: editing ? t("admin.users.edit") : t("admin.users.new"),
title: editing ? "Edit user" : "New user",
};
}
@@ -273,42 +264,39 @@ function readUserInput(form: URLSearchParams): UserInput {
};
}
// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (`users:read` on a
// GET, `users:write` on a POST) and the Kratos capability (else a themed 503). Each route below is a
// thin handler over these.
// `keto` is optional the way every other capability here is: without it the page still lists and
// edits users, it just can't show the permission picker.
interface UsersDeps { ctx: RequestContext; keto: KetoClient | undefined; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (admin only) and
// the Kratos capability (else a themed 503). Each route below is a thin handler over these.
interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
// Resolve the shared deps, then run `inner`. The route's own `permission` already gated at the host;
// `requirePermission` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
// Resolve the shared deps, then run `inner`. The route's `permission: "admin"` already gated at the
// host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
const user = requirePermission(ctx, "users", action);
const user = requireAdmin(ctx);
const kratosAdmin = ctx.system?.kratosAdmin;
if (!kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.kratos"));
return inner({ ctx, keto: ctx.system?.keto, kratosAdmin, revoke: ctx.system?.revoke, user });
if (!kratosAdmin) return unavailable(ctx, "Kratos identity admin");
return inner({ ctx, kratosAdmin, revoke: ctx.system?.revoke, user });
};
}
// Same, plus the target identity from ctx.params.id (unknown id → themed 404). The router already
// decoded the id and 404s malformed %-encoding, so no manual decode is needed here.
function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) => Promise<RouteResult>): RouteHandler {
return withUser(async (deps) => {
const id = deps.ctx.params["id"] ?? "";
const identity = await deps.kratosAdmin.getIdentity(id);
if (!identity) return notFound(deps.ctx);
return inner(deps, identity, id);
}, action);
});
}
const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult =>
({ data: { chrome: ctx.chrome, model: buildUserFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "user-form" });
({ data: { chrome: ctx.chrome, model: buildUserFormModel({ csrfToken: ctx.chrome.csrfToken, ...extra }) }, view: "user-form" });
// GET /admin/users — the filtered/sorted/paged list.
export const usersList = withUser(async ({ ctx, kratosAdmin }) => {
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
return { data: { chrome: ctx.chrome, model: buildUsersListModel({ canWrite: canWriteUsers(ctx), csrfToken: ctx.chrome.csrfToken, identities, t: ctx.t, url: ctx.url }) }, view: "users" };
return { data: { chrome: ctx.chrome, model: buildUsersListModel({ csrfToken: ctx.chrome.csrfToken, identities, url: ctx.url }) }, view: "users" };
});
// POST /admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input.
@@ -317,7 +305,7 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
try {
await kratosAdmin.createIdentity(createIdentityPayload(input));
} catch (err) {
if (err instanceof KratosError) return { ...formResult(ctx, { error: createError(err, ctx.t), values: input }), status: 400 };
if (err instanceof KratosError) return { ...formResult(ctx, { error: createError(err), values: input }), status: 400 };
throw err;
}
ctx.log.info("admin: user created", { actor: user.id, email: input.email });
@@ -325,73 +313,18 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
});
// GET /admin/users/new — the empty create form.
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})), "write");
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})));
// GET /admin/users/:id — the edit form, prefilled.
export const usersEditForm = withTarget(async (deps, identity, id) => {
const permissions = await userPermissionPicker(deps, id);
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
});
const canWriteUsers = (ctx: RequestContext): boolean => can(ctx, permissionName("users", "write"));
// The checkbox list of declared permissions: ticked where this user holds one, and disabled where
// the grant comes from a group (real, but removed on that group). Undefined when Keto isn't wired —
// the rest of the edit page still works.
async function userPermissionPicker(deps: UsersDeps, id: string, error?: string): Promise<PermissionPicker | undefined> {
if (!deps.keto) return undefined;
const subject = userSubject(id);
const [direct, effective] = await Promise.all([
heldPermissions(deps.keto, subject),
effectivePermissions(deps.keto, subject, deps.ctx.declaredPermissions),
]);
return {
...buildPermissionPicker({
action: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}/permissions`,
declared: deps.ctx.declaredPermissions,
direct,
effective,
readOnly: !canWriteUsers(deps.ctx),
t: deps.ctx.t,
}),
...(error ? { error } : {}),
};
}
// POST /admin/users/:id/permissions — the submitted checkboxes are the desired set of *direct*
// grants; grant what's newly ticked, revoke what's newly unticked. A change to a user's own grants
// revokes their live tokens so it lands now rather than at the next re-mint.
export const usersPermissions = withTarget(async (deps, identity, id) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto"));
const subject = userSubject(id);
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
// Self-lockout guard, matching the self-deactivate/self-delete ones: revoking your own grants can
// remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very
// next request — 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(",") });
const permissions = await userPermissionPicker(deps, id, ctx.t("admin.grants.selfRevoke"));
return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), identity, ...(permissions ? { permissions } : {}) }), status: 400 };
}
await applyGrants(keto, subject, diff);
if (diff.grant.length > 0 || diff.revoke.length > 0) {
revoke?.(id);
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: diff.grant.join(","), revoked: diff.revoke.join(","), target: id });
}
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
});
export const usersEditForm = withTarget((deps, identity) => Promise.resolve(formResult(deps.ctx, { identity })));
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
export const usersUpdate = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin } = deps;
export const usersUpdate = withTarget(async ({ ctx, kratosAdmin }, identity, id) => {
const input = readUserInput((await guardedForm(ctx))!);
try {
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
} catch (err) {
// Re-render with the picker, or the permissions section vanishes off the page on a failed save.
if (err instanceof KratosError) return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), error: ctx.t("admin.users.error.save"), identity, ...(await pickerOrNothing(deps, id)) }), status: 400 };
if (err instanceof KratosError) return { ...formResult(ctx, { error: "Could not save changes — check the fields and try again.", identity }), status: 400 };
throw err;
}
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
@@ -401,7 +334,7 @@ export const usersUpdate = withTarget(async (deps, identity, id) => {
// tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account.
export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
await guardedForm(ctx); // CSRF-verify the POST (no fields read)
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDeactivate"), identity }), status: 400 };
if (id === user.id) return { ...formResult(ctx, { error: "You can't deactivate your own account.", identity }), status: 400 };
const nextState = identity.state === "inactive" ? "active" : "inactive";
await kratosAdmin.updateIdentity(id, setStatePayload(identity, nextState));
if (nextState === "inactive") revoke?.(id);
@@ -411,21 +344,20 @@ export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user },
// GET /admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete.
export const usersDeleteConfirm = withTarget((deps, identity, id) => {
if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: deps.ctx.t("admin.users.error.selfDelete"), identity }), status: 400 });
if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: "You can't delete your own account.", identity }), status: 400 });
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`;
const view = toUserView(identity);
const tt = deps.ctx.t;
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: tt("admin.users.title") }, { href: back, label: view.name }, { label: tt("common.delete") }],
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: tt("admin.users.delete"),
message: tt("admin.users.deleteMessage", { email: view.email }), title: tt("admin.users.delete"),
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: "Users" }, { href: back, label: view.name }, { label: "Delete" }],
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: "Delete user",
message: `Delete ${view.email}? This permanently removes the account and can't be undone.`, title: "Delete user",
}) }, view: "confirm" });
}, "write");
});
// POST /admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
await guardedForm(ctx); // CSRF-verify the POST
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDelete"), identity }), status: 400 };
if (id === user.id) return { ...formResult(ctx, { error: "You can't delete your own account.", identity }), status: 400 };
await kratosAdmin.deleteIdentity(id);
revoke?.(id);
ctx.log.info("admin: user deleted", { actor: user.id, target: id });
@@ -433,21 +365,14 @@ export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user },
});
// POST /admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
export const usersRecovery = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin } = deps;
export const usersRecovery = withTarget(async ({ ctx, kratosAdmin }, identity, id) => {
await guardedForm(ctx); // CSRF-verify the POST
const recovery = await kratosAdmin.createRecoveryCode(id);
return formResult(ctx, { canWrite: canWriteUsers(ctx), identity, recovery, ...(await pickerOrNothing(deps, id)) });
return formResult(ctx, { identity, recovery });
});
// The picker as a spreadable fragment, so a re-render never silently drops the section.
async function pickerOrNothing(deps: UsersDeps, id: string): Promise<{ permissions?: PermissionPicker }> {
const permissions = await userPermissionPicker(deps, id);
return permissions ? { permissions } : {};
}
function createError(err: KratosError, t: Translate): string {
function createError(err: KratosError): string {
return err.status === 409
? t("admin.users.error.duplicate")
: t("admin.users.error.create");
? "A user with that email already exists."
: "Could not create the user — check the email and try again.";
}
-156
View File
@@ -1,156 +0,0 @@
// The admin plugin's own catalog — the baseline its other locales are written against. Its keys
// are looked up before the host's, so this plugin owns its words without prefixing them.
const messages = {
"admin.capability.hydra": "Hydra OAuth2 admin",
"admin.capability.keto": "Keto and Kratos identity admin",
"admin.capability.kratos": "Kratos identity admin",
"admin.clients.column.id": "Client ID",
"admin.clients.column.name": "Name",
"admin.clients.column.type": "Type",
"admin.clients.confidential": "Confidential",
"admin.clients.consent.firstParty": "First-party (auto-granted)",
"admin.clients.consent.label": "Consent",
"admin.clients.consent.screen": "Shows the consent screen",
"admin.clients.created": "Client registered",
"admin.clients.createdNotice": "Client registered.",
"admin.clients.delete": "Delete client",
"admin.clients.deleteMessage": "Delete client {{name}}? Apps using it can no longer sign in through Plainpages.",
"admin.clients.error.rejected": "Hydra rejected the client — check the redirect URIs and scopes.",
"admin.clients.field.name": "Name",
"admin.clients.field.redirectUris": "Redirect URIs",
"admin.clients.field.redirectUrisHint": "One per line — where the app is sent back after sign-in.",
"admin.clients.field.scopes": "Scopes",
"admin.clients.field.scopesHint": "Space-separated scopes the client may request.",
"admin.clients.field.typeHint":
"Browser and mobile apps can't keep a secret — choose Public. Server-side apps that can store one — leave it Confidential.",
"admin.clients.filter": "Filter clients",
"admin.clients.pagination": "Clients pagination",
"admin.clients.public": "Public",
"admin.clients.publicPkce": "Public (PKCE)",
"admin.clients.register": "Register",
"admin.clients.registerClient": "Register client",
"admin.clients.registerTitle": "Register client",
"admin.clients.rereg": "To change a client, delete and re-register — this issues a new client ID and secret. The secret is shown only once, at registration.",
"admin.clients.searchLabel": "Search clients",
"admin.clients.searchPlaceholder": "Search name or client ID…",
"admin.clients.secret": "Client secret",
"admin.clients.secretHint": "Copy these now — the secret can't be shown again. Store them where the app reads its credentials.",
"admin.clients.title": "OAuth2 clients",
"admin.clients.validation.name": "Enter a name for the client.",
"admin.clients.validation.redirectUri": "\"{{uri}}\" is not a valid redirect URI — use an absolute URL like https://app.example.com/callback.",
"admin.clients.validation.redirectUris": "Add at least one redirect URI.",
"admin.common.chooseMember": "Choose a user or group…",
"admin.common.group": "Group",
"admin.common.member": "Member",
"admin.common.type": "Type",
"admin.common.user": "User",
"admin.grants.hint": "Which permissions exist is set by the plugins installed on this system. Tick to grant, untick to revoke.",
"admin.grants.hintReadOnly": "Which permissions exist is set by the plugins installed on this system. You can see these, but not change them.",
"admin.grants.inherited": "Greyed-out permissions come from a group. Change them on that group.",
"admin.grants.legend": "Permissions",
"admin.grants.none": "No installed plugin declares a permission, so there is nothing to grant.",
"admin.grants.pending": "Members get this at their next sign-in (up to 10 minutes).",
"admin.grants.save": "Save permissions",
"admin.grants.selfRevoke": "You can't revoke your own permissions — ask another administrator, so you can't lock yourself out.",
"admin.groups.actions": "Group actions",
"admin.groups.addMember": "Add a member",
"admin.groups.allMembers": "All users and groups are already members.",
"admin.groups.column.members": "Members",
"admin.groups.column.name": "Group",
"admin.groups.create": "Create group",
"admin.groups.delete": "Delete group",
"admin.groups.deleteMessage": "Delete group {{name}}? This can't be undone.",
"admin.groups.field.name": "Group name",
"admin.groups.field.nameHint": "Lowercase letters, digits, dashes and underscores.",
"admin.groups.filter": "Filter groups",
"admin.groups.firstMember": "First member",
"admin.groups.firstMemberHint": "A group exists once it has a member; add more after creating it.",
"admin.groups.members": "Members",
"admin.groups.membersOf": "Members of {{name}}",
"admin.groups.new": "New group",
"admin.groups.noMembers": "No members yet.",
"admin.groups.pagination": "Groups pagination",
"admin.groups.searchLabel": "Search groups",
"admin.groups.searchPlaceholder": "Search group name…",
"admin.groups.title": "Groups",
"admin.groups.validation.member": "Pick a member to add as the group's first member.",
"admin.groups.validation.name": "Group names use lowercase letters, digits, dashes and underscores.",
"admin.nav.clients": "OAuth2 clients",
"admin.nav.groups": "Groups",
"admin.nav.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": "Give it to the user — they enter it to set a new password (generate a fresh one if it has expired):",
"admin.users.recovery.link": "the password-reset screen",
"admin.users.recovery.generate": "Generate recovery code",
"admin.users.recovery.title": "Recovery code generated",
"admin.users.save": "Save changes",
"admin.users.searchLabel": "Search users",
"admin.users.searchPlaceholder": "Search name or email…",
"admin.users.status.active": "Active",
"admin.users.status.all": "All",
"admin.users.status.inactive": "Inactive",
"admin.users.status.label": "Status",
"admin.users.title": "Users",
};
export type AdminMessages = typeof messages;
export default messages;
-154
View File
@@ -1,154 +0,0 @@
import type { AdminMessages } from "./en-US.ts";
const messages: AdminMessages = {
"admin.capability.hydra": "Hydra OAuth2-administration",
"admin.capability.keto": "Keto- och Kratos-identitetsadministration",
"admin.capability.kratos": "Kratos identitetsadministration",
"admin.clients.column.id": "Klient-ID",
"admin.clients.column.name": "Namn",
"admin.clients.column.type": "Typ",
"admin.clients.confidential": "Konfidentiell",
"admin.clients.consent.firstParty": "Förstapart (godkänns automatiskt)",
"admin.clients.consent.label": "Godkännande",
"admin.clients.consent.screen": "Visar godkännandesidan",
"admin.clients.created": "Klienten är registrerad",
"admin.clients.createdNotice": "Klienten är registrerad.",
"admin.clients.delete": "Radera klient",
"admin.clients.deleteMessage": "Ta bort klienten {{name}}? Appar som använder den kan inte längre logga in via Plainpages.",
"admin.clients.error.rejected": "Hydra nekade klienten — kontrollera omdirigerings-URI:erna och scopen.",
"admin.clients.field.name": "Namn",
"admin.clients.field.redirectUris": "Omdirigerings-URI:er",
"admin.clients.field.redirectUrisHint": "En per rad — dit appen skickas tillbaka efter inloggning.",
"admin.clients.field.scopes": "Scope",
"admin.clients.field.scopesHint": "Mellanslagsseparerade scope som klienten får begära.",
"admin.clients.field.typeHint":
"Webbläsar- och mobilappar kan inte hålla en hemlighet — välj Publik. Serverappar som kan lagra en — låt stå som Konfidentiell.",
"admin.clients.filter": "Filtrera klienter",
"admin.clients.pagination": "Sidnavigering för klienter",
"admin.clients.public": "Publik",
"admin.clients.publicPkce": "Publik (PKCE)",
"admin.clients.register": "Registrera",
"admin.clients.registerClient": "Registrera klient",
"admin.clients.registerTitle": "Registrera klient",
"admin.clients.rereg":
"För att ändra en klient: ta bort den och registrera på nytt — det ger ett nytt klient-ID och en ny hemlighet. Hemligheten visas bara en gång, vid registreringen.",
"admin.clients.searchLabel": "Sök klienter",
"admin.clients.searchPlaceholder": "Sök på namn eller klient-ID…",
"admin.clients.secret": "Klienthemlighet",
"admin.clients.secretHint": "Kopiera nu — hemligheten kan inte visas igen. Spara uppgifterna där appen läser dem.",
"admin.clients.title": "OAuth2-klienter",
"admin.clients.validation.name": "Ange ett namn för klienten.",
"admin.clients.validation.redirectUri": "\"{{uri}}\" är inte en giltig omdirigerings-URI — använd en absolut URL som https://app.example.com/callback.",
"admin.clients.validation.redirectUris": "Lägg till minst en omdirigerings-URI.",
"admin.common.chooseMember": "Välj en användare eller grupp…",
"admin.common.group": "Grupp",
"admin.common.member": "Medlem",
"admin.common.type": "Typ",
"admin.common.user": "Användare",
"admin.grants.hint": "Vilka behörigheter som finns bestäms av de plugins som är installerade. Kryssa i för att tilldela, ur för att återkalla.",
"admin.grants.hintReadOnly": "Vilka behörigheter som finns bestäms av de plugins som är installerade. Du kan se dem, men inte ändra dem.",
"admin.grants.inherited": "Gråmarkerade behörigheter kommer från en grupp. Ändra dem på gruppen.",
"admin.grants.legend": "Behörigheter",
"admin.grants.none": "Ingen installerad plugin deklarerar någon behörighet, så det finns inget att tilldela.",
"admin.grants.pending": "Medlemmar får detta vid nästa inloggning (upp till 10 minuter).",
"admin.grants.save": "Spara behörigheter",
"admin.grants.selfRevoke": "Du kan inte återkalla dina egna behörigheter — be en annan administratör, så att du inte låser ute dig själv.",
"admin.groups.actions": "Gruppåtgärder",
"admin.groups.addMember": "Lägg till en medlem",
"admin.groups.allMembers": "Alla användare och grupper är redan medlemmar.",
"admin.groups.column.members": "Medlemmar",
"admin.groups.column.name": "Grupp",
"admin.groups.create": "Skapa grupp",
"admin.groups.delete": "Radera grupp",
"admin.groups.deleteMessage": "Ta bort gruppen {{name}}? Det går inte att ångra.",
"admin.groups.field.name": "Gruppnamn",
"admin.groups.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
"admin.groups.filter": "Filtrera grupper",
"admin.groups.firstMember": "Första medlem",
"admin.groups.firstMemberHint": "En grupp finns så snart den har en medlem; lägg till fler efteråt.",
"admin.groups.members": "Medlemmar",
"admin.groups.membersOf": "Medlemmar i {{name}}",
"admin.groups.new": "Ny grupp",
"admin.groups.noMembers": "Inga medlemmar ännu.",
"admin.groups.pagination": "Sidnavigering för grupper",
"admin.groups.searchLabel": "Sök grupper",
"admin.groups.searchPlaceholder": "Sök på gruppnamn…",
"admin.groups.title": "Grupper",
"admin.groups.validation.member": "Välj en medlem som gruppens första medlem.",
"admin.groups.validation.name": "Gruppnamn använder små bokstäver, siffror, bindestreck och understreck.",
"admin.nav.clients": "OAuth2-klienter",
"admin.nav.groups": "Grupper",
"admin.nav.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": "Ge den till användaren — koden anges för att sätta ett nytt lösenord (skapa en ny om den hunnit gå ut):",
"admin.users.recovery.link": "sidan för lösenordsåterställning",
"admin.users.recovery.generate": "Skapa återställningskod",
"admin.users.recovery.title": "Återställningskod skapad",
"admin.users.save": "Spara ändringar",
"admin.users.searchLabel": "Sök användare",
"admin.users.searchPlaceholder": "Sök på namn eller e-postadress…",
"admin.users.status.active": "Aktiv",
"admin.users.status.all": "Alla",
"admin.users.status.inactive": "Inaktiv",
"admin.users.status.label": "Status",
"admin.users.title": "Användare",
};
export default messages;
-65
View File
@@ -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
});
+49 -61
View File
@@ -1,77 +1,65 @@
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system. Copy
// this folder to plugins/admin (then restart) to enable it — see README → Quick start.
// 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 and the instant-revoke
// hook via ctx.system. Where a capability is absent the screen degrades to a themed 503.
// 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 "@plainpages/plugin-api";
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
import { pluginSettingsList } from "./admin-plugin-settings.ts";
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts";
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts";
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-roles.ts";
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
// One route factory per screen: a GET gates on `<resource>:read` and a POST on `<resource>:write`,
// derived through the same two helpers the in-handler guard uses, so the table below cannot drift
// from it. The host redirects an anonymous visitor to /login, gives a signed-in user missing the
// permission the 403 page, and filters the nav the same way. Handlers are thin and keyed on
// ctx.params (the host extracts :id / :name), the idiomatic per-route style.
// `action` overrides the method's default for a *write-intent GET* — a create form or a
// delete-confirm page, which exists only to start a write and so refuses a reader rather than
// rendering a form whose submit would 403. The handler's own guard takes the same override.
const on = (resource: AdminResource) => (method: HttpMethod, path: string, handler: RouteHandler, action?: AdminAction): Route =>
({ handler, method, path, permission: permissionName(resource, action ?? actionForMethod(method)) });
const users = on("users");
const groups = on("groups");
const clients = on("oauth2-clients");
const pluginSettings = on("plugin-settings");
// 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: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV],
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" },
],
permissions: [{ description: "Administer users, groups, roles, and OAuth2 clients", token: ADMIN_PERMISSION }],
routes: [
// Users
users("GET", "/users", usersList),
users("POST", "/users", usersCreate),
users("GET", "/users/new", usersNewForm, "write"),
users("GET", "/users/:id", usersEditForm),
users("POST", "/users/:id", usersUpdate),
users("POST", "/users/:id/state", usersState),
users("GET", "/users/:id/delete", usersDeleteConfirm, "write"),
users("POST", "/users/:id/delete", usersDelete),
users("POST", "/users/:id/recovery", usersRecovery),
users("POST", "/users/:id/permissions", usersPermissions),
r("GET", "/users", usersList),
r("POST", "/users", usersCreate),
r("GET", "/users/new", usersNewForm),
r("GET", "/users/:id", usersEditForm),
r("POST", "/users/:id", usersUpdate),
r("POST", "/users/:id/state", usersState),
r("GET", "/users/:id/delete", usersDeleteConfirm),
r("POST", "/users/:id/delete", usersDelete),
r("POST", "/users/:id/recovery", usersRecovery),
// Groups
groups("GET", "/groups", groupsList),
groups("POST", "/groups", groupsCreate),
groups("GET", "/groups/new", groupsNewForm, "write"),
groups("GET", "/groups/:name", groupsDetail),
groups("POST", "/groups/:name/members", groupsAddMember),
groups("GET", "/groups/:name/delete", groupsDeleteConfirm, "write"),
groups("POST", "/groups/:name/delete", groupsDelete),
groups("POST", "/groups/:name/members/delete", groupsRemoveMember),
groups("POST", "/groups/:name/permissions", groupsPermissions),
r("GET", "/groups", groupsList),
r("POST", "/groups", groupsCreate),
r("GET", "/groups/new", groupsNewForm),
r("GET", "/groups/:name", groupsDetail),
r("POST", "/groups/:name/members", groupsAddMember),
r("GET", "/groups/:name/delete", groupsDeleteConfirm),
r("POST", "/groups/:name/delete", groupsDelete),
r("POST", "/groups/:name/members/delete", groupsRemoveMember),
// Roles
r("GET", "/roles", rolesList),
r("POST", "/roles", rolesCreate),
r("GET", "/roles/new", rolesNewForm),
r("GET", "/roles/:name", rolesDetail),
r("POST", "/roles/:name/members", rolesAddMember),
r("GET", "/roles/:name/delete", rolesDeleteConfirm),
r("POST", "/roles/:name/delete", rolesDelete),
r("POST", "/roles/:name/members/delete", rolesRemoveMember),
// OAuth2 clients
clients("GET", "/clients", clientsList),
clients("POST", "/clients", clientsCreate),
clients("GET", "/clients/new", clientsNewForm, "write"),
clients("GET", "/clients/:id", clientsDetail),
clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"),
clients("POST", "/clients/:id/delete", clientsDelete),
// Plugin settings — read-only, so no :write route and no write-intent GET.
pluginSettings("GET", "/plugin-settings", pluginSettingsList),
r("GET", "/clients", clientsList),
r("POST", "/clients", clientsCreate),
r("GET", "/clients/new", clientsNewForm),
r("GET", "/clients/:id", clientsDetail),
r("GET", "/clients/:id/delete", clientsDeleteConfirm),
r("POST", "/clients/:id/delete", clientsDelete),
],
});
@@ -3,7 +3,7 @@
shell. Doubles as the post-register page when `created`/`secret` are set.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/client-detail-body", { canWrite: model.canWrite, client: model.client, created: model.created, csrfToken: chrome.csrfToken, del: model.delete, secret: model.secret });
const body = include("partials/client-detail-body", { client: model.client, created: model.created, csrfToken: chrome.csrfToken, del: model.delete, secret: model.secret });
-%>
<%- include("partials/shell", {
body,
+2 -3
View File
@@ -1,13 +1,12 @@
<%#
OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as
the Groups screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
the Roles screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination);
// Only offer "Register client" to an oauth2-clients:write holder — a :read one would get the 403 page.
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/clients/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.clients.registerClient") + '</a>';
const actions = '<a class="btn btn-primary" href="/admin/clients/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Register client</a>';
-%>
<%- include("partials/shell", {
actions,
@@ -2,7 +2,7 @@
Group admin detail / membership page: the group-detail body in the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/group-detail-body", { add: model.add, canWrite: model.canWrite, csrfToken: model.csrfToken, del: model.delete, error: model.error, group: model.group, members: model.members, permissions: model.permissions });
const body = include("partials/group-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, error: model.error, group: model.group, members: model.members });
-%>
<%- include("partials/shell", {
body,
+1 -2
View File
@@ -6,8 +6,7 @@
const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination);
// Only offer "New group" to a groups:write holder — a groups:read one would get the 403 page.
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>';
const actions = '<a class="btn btn-primary" href="/admin/groups/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add group</a>';
-%>
<%- include("partials/shell", {
actions,
@@ -11,30 +11,28 @@
-%>
<div class="form-page">
<% if (locals.created) { -%>
<%- include("partials/alert", { text: t("admin.clients.createdNotice"), tone: "pos" }) %>
<%- include("partials/alert", { text: "Client registered.", tone: "pos" }) %>
<% } -%>
<% if (locals.secret) { -%>
<section class="form-card" aria-labelledby="secret-h">
<h2 class="card-title" id="secret-h"><%= t("admin.clients.secret") %></h2>
<p class="field-hint"><%= t("admin.clients.secretHint") %></p>
<div class="field"><label for="cid"><%= t("admin.clients.column.id") %></label><input class="input" id="cid" type="text" value="<%= c.id %>" readonly></div>
<div class="field"><label for="csecret"><%= t("admin.clients.secret") %></label><input class="input" id="csecret" type="text" value="<%= locals.secret %>" readonly></div>
<h2 class="card-title" id="secret-h">Client secret</h2>
<p class="field-hint">Copy these now — the secret can't be shown again. Store them where the app reads its credentials.</p>
<div class="field"><label for="cid">Client ID</label><input class="input" id="cid" type="text" value="<%= c.id %>" readonly></div>
<div class="field"><label for="csecret">Client secret</label><input class="input" id="csecret" type="text" value="<%= locals.secret %>" readonly></div>
</section>
<% } -%>
<section class="form-card" aria-labelledby="client-h">
<h2 class="card-title" id="client-h"><%= c.name %></h2>
<dl class="detail-list">
<dt><%= t("admin.clients.column.id") %></dt><dd><%= c.id %></dd>
<dt><%= t("admin.clients.column.type") %></dt><dd><%= c.public ? t("admin.clients.publicPkce") : t("admin.clients.confidential") %></dd>
<dt><%= t("admin.clients.consent.label") %></dt><dd><%= c.firstParty ? t("admin.clients.consent.firstParty") : t("admin.clients.consent.screen") %></dd>
<dt><%= t("admin.clients.field.scopes") %></dt><dd><%= c.scopes.length ? c.scopes.join(" ") : "—" %></dd>
<dt><%= t("admin.clients.field.redirectUris") %></dt><dd><% if (c.redirectUris.length) { %><ul class="plain-list"><% c.redirectUris.forEach((u) => { %><li><%= u %></li><% }) %></ul><% } else { %>—<% } %></dd>
<dt>Client ID</dt><dd><%= c.id %></dd>
<dt>Type</dt><dd><%= c.public ? "Public (PKCE)" : "Confidential" %></dd>
<dt>Consent</dt><dd><%= c.firstParty ? "First-party (auto-granted)" : "Shows the consent screen" %></dd>
<dt>Scopes</dt><dd><%= c.scopes.length ? c.scopes.join(" ") : "—" %></dd>
<dt>Redirect URIs</dt><dd><% if (c.redirectUris.length) { %><ul class="plain-list"><% c.redirectUris.forEach((u) => { %><li><%= u %></li><% }) %></ul><% } else { %>—<% } %></dd>
</dl>
</section>
<% if (locals.canWrite !== false) { -%>
<section class="form-card admin-actions" aria-label="<%= t("admin.clients.title") %>">
<p class="field-hint"><%= t("admin.clients.rereg") %></p>
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.clients.delete") %></a>
<section class="form-card admin-actions" aria-label="Client actions">
<p class="field-hint">To change a client, delete and re-register — this issues a new client ID and secret. The secret is shown only once, at registration.</p>
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete client</a>
</section>
<% } -%>
</div>
@@ -10,20 +10,20 @@
<% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<form class="form-card" method="post" action="<%= form.action %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("partials/field", form.nameField) %>
<div class="field">
<label for="redirectUris"><%= t("admin.clients.field.redirectUris") %></label>
<label for="redirectUris">Redirect URIs</label>
<textarea class="input" id="redirectUris" name="redirectUris" rows="3" placeholder="https://app.example.com/callback"><%= form.redirectUris %></textarea>
<span class="field-hint"><%= t("admin.clients.field.redirectUrisHint") %></span>
<span class="field-hint">One per line — where the app is sent back after sign-in.</span>
</div>
<%- include("partials/field", form.scopeField) %>
<label class="check"><input type="checkbox" name="public"<% if (form.public) { %> checked<% } %>> Public client (SPA / native app, PKCE — no secret)</label>
<span class="field-hint"><%= t("admin.clients.field.typeHint") %></span>
<span class="field-hint">Browser and mobile apps can't keep a secret — choose Public. Server-side apps that can store one — leave it Confidential.</span>
<label class="check"><input type="checkbox" name="firstParty"<% if (form.firstParty) { %> checked<% } %>> First-party (auto-grant consent — skip the consent screen)</label>
<div class="form-actions">
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
@@ -7,11 +7,11 @@
csrfToken
%>
<div class="form-page">
<section class="form-card admin-actions" aria-label="<%= t("admin.users.confirm") %>">
<section class="form-card admin-actions" aria-label="Confirm action">
<p><%= locals.message %></p>
<div class="form-actions">
<a class="btn" href="<%= localeHref(locals.cancelHref) %>"><%= t("common.cancel") %></a>
<form method="post" action="<%= localeHref(locals.confirm.action) %>"><input type="hidden" name="_csrf" value="<%= locals.csrfToken %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= locals.confirm.label %></button></form>
<a class="btn" href="<%= locals.cancelHref %>">Cancel</a>
<form method="post" action="<%= locals.confirm.action %>"><input type="hidden" name="_csrf" value="<%= locals.csrfToken %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= locals.confirm.label %></button></form>
</div>
</section>
</div>
@@ -1,7 +1,7 @@
<%#
Admin group membership body, captured into the shell content slot. Config:
group { name }
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = remove-member endpoint
members { action, rows: { kind:"group"|"user", label, subject }[] } action = remove-member endpoint
add { action, options: {label,value}[] } action = add-member endpoint
del { action } delete the whole group
csrfToken, error?
@@ -17,33 +17,26 @@
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<section class="form-card" aria-labelledby="members-h">
<h2 class="card-title" id="members-h"><%= t("admin.groups.members") %></h2>
<h2 class="card-title" id="members-h">Members</h2>
<% if (members.rows.length) { -%>
<div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: group.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody>
<div class="table-wrap"><table class="table"><caption class="sr-only">Members of <%= group.name %></caption><thead><tr><th scope="col">Member</th><th scope="col">Type</th><th class="col-actions" scope="col"><span class="sr-only">Actions</span></th></tr></thead><tbody>
<% members.rows.forEach((m) => { -%>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><% if (locals.canWrite !== false) { %><form method="post" action="<%= localeHref(members.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("common.remove") %></button></form><% } %></td></tr>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? "Group" : "User" %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg>Remove</button></form></td></tr>
<% }) -%>
</tbody></table></div>
<% } else { -%>
<p class="cell-muted"><%= t("admin.groups.noMembers") %></p>
<p class="cell-muted">No members yet.</p>
<% } -%>
</section>
<% if (locals.canWrite !== false) { -%>
<section class="form-card" aria-labelledby="add-h">
<h2 class="card-title" id="add-h"><%= t("admin.groups.addMember") %></h2>
<h2 class="card-title" id="add-h">Add a member</h2>
<% if (add.options.length) { -%>
<form class="inline-form" method="post" action="<%= localeHref(add.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("common.add") %></button></form>
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add</button></form>
<% } else { -%>
<p class="cell-muted"><%= t("admin.groups.allMembers") %></p>
<p class="cell-muted">All users and groups are already members.</p>
<% } -%>
</section>
<% } -%>
<% if (locals.permissions) { -%>
<%- include("partials/permission-picker", { csrfToken: csrf, permissions: locals.permissions }) %>
<% } -%>
<% if (locals.canWrite !== false) { -%>
<section class="form-card admin-actions" aria-label="<%= t("admin.groups.actions") %>">
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a>
<section class="form-card admin-actions" aria-label="Group actions">
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete group</a>
</section>
<% } -%>
</div>
@@ -10,16 +10,16 @@
<% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<form class="form-card" method="post" action="<%= form.action %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("partials/field", form.nameField) %>
<div class="field">
<label for="member"><%= t("admin.groups.firstMember") %></label>
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>><%= t("admin.common.chooseMember") %></option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
<span class="field-hint"><%= t("admin.groups.firstMemberHint") %></span>
<label for="member">First member</label>
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a member</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
<span class="field-hint">A group exists once it has a member; add more after creating it.</span>
</div>
<div class="form-actions">
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
@@ -1,49 +0,0 @@
<%#
The permission picker, shared by the user-edit and group-detail pages. A fieldset of checkboxes —
one per permission the installed plugins declare — ticked where this user/group holds it. The whole
set posts back, so what is submitted IS the desired set of *direct* grants (see admin-grants.ts).
Two rows never post, by design: an `inherited` one (the grant comes from a group, so it is changed
there) and every row when `readOnly` (the viewer holds :read but not :write). Neither can be diffed
into an accidental revoke, because grantDiff compares against the direct grants only.
Locals: csrfToken, permissions ({ action, choices, empty, error, field, hint, inheritedNote, legend, pending, readOnly, submit }).
%>
<section class="form-card" aria-labelledby="permissions-h">
<h2 class="card-title" id="permissions-h"><%= permissions.legend %></h2>
<% if (permissions.error) { -%>
<%- include("partials/alert", { text: permissions.error, tone: "neg" }) %>
<% } -%>
<% if (permissions.empty) { -%>
<p class="cell-muted"><%= permissions.empty %></p>
<% } else { -%>
<p class="cell-muted"><%= permissions.hint %></p>
<% if (permissions.readOnly) { -%>
<fieldset class="check-group">
<legend class="sr-only"><%= permissions.legend %></legend>
<% permissions.choices.forEach((c) => { -%>
<label class="check"><input type="checkbox"<%= c.checked ? " checked" : "" %> disabled><span><%= c.description || c.name %></span><span class="cell-muted"><%= c.name %></span></label>
<% }) -%>
</fieldset>
<% } else { -%>
<form method="post" action="<%= localeHref(permissions.action) %>">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<fieldset class="check-group">
<legend class="sr-only"><%= permissions.legend %></legend>
<% permissions.choices.forEach((c) => { -%>
<label class="check"><input type="checkbox" name="<%= permissions.field %>" value="<%= c.name %>"<%= c.checked ? " checked" : "" %><%= c.inherited ? " disabled" : "" %>><span><%= c.description || c.name %></span><span class="cell-muted"><%= c.name %></span></label>
<% }) -%>
</fieldset>
<div class="form-actions">
<button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><%= permissions.submit %></button>
</div>
</form>
<% } -%>
<% if (permissions.inheritedNote) { -%>
<p class="cell-muted"><%= permissions.inheritedNote %></p>
<% } -%>
<% if (permissions.pending) { -%>
<p class="cell-muted"><%= permissions.pending %></p>
<% } -%>
<% } -%>
</section>
@@ -0,0 +1,57 @@
<%#
Admin role detail body, captured into the shell content slot. Config:
role { name }
members { action, rows: { kind:"group"|"user", label, subject }[] } action = revoke endpoint
effective { label }[] users who hold the role (expand)
add { action, options: {label,value}[] } action = assign endpoint
del { action } delete the whole role
csrfToken, error?
%><%
const role = locals.role;
const members = locals.members;
const effective = locals.effective;
const add = locals.add;
const del = locals.del;
const csrf = locals.csrfToken;
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<section class="form-card" aria-labelledby="members-h">
<h2 class="card-title" id="members-h">Assigned to</h2>
<% if (members.rows.length) { -%>
<div class="table-wrap"><table class="table"><caption class="sr-only">Members of <%= role.name %></caption><thead><tr><th scope="col">Member</th><th scope="col">Type</th><th class="col-actions" scope="col"><span class="sr-only">Actions</span></th></tr></thead><tbody>
<% members.rows.forEach((m) => { -%>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? "Group" : "User" %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg>Revoke</button></form></td></tr>
<% }) -%>
</tbody></table></div>
<% } else { -%>
<p class="cell-muted">Not assigned to anyone yet.</p>
<% } -%>
</section>
<section class="form-card" aria-labelledby="effective-h">
<h2 class="card-title" id="effective-h">Effective access</h2>
<p class="field-hint">Everyone who holds this role — directly or through a group (resolved by Keto).</p>
<% if (effective.length) { -%>
<ul class="plain-list">
<% effective.forEach((u) => { -%>
<li><span class="cell-strong"><%= u.label %></span></li>
<% }) -%>
</ul>
<% } else { -%>
<p class="cell-muted">No users hold this role yet.</p>
<% } -%>
</section>
<section class="form-card" aria-labelledby="add-h">
<h2 class="card-title" id="add-h">Assign the role</h2>
<% if (add.options.length) { -%>
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Assign</button></form>
<% } else { -%>
<p class="cell-muted">All users and groups already have this role.</p>
<% } -%>
</section>
<section class="form-card admin-actions" aria-label="Role actions">
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete role</a>
</section>
</div>
@@ -0,0 +1,26 @@
<%#
Admin role create form body, captured into the shell content slot. Config:
form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config,
memberOptions: {label,value}[], selectedMember }
error? string shown when a write was rejected
%><%
const form = locals.form;
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<form class="form-card" method="post" action="<%= form.action %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("partials/field", form.nameField) %>
<div class="field">
<label for="member">Assign to</label>
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a user or group…</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
<span class="field-hint">A role exists once assigned; add more users or groups after creating it.</span>
</div>
<div class="form-actions">
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
</div>
@@ -14,28 +14,23 @@
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<% if (recovery) { -%>
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong><%= t("admin.users.recovery.title") %></strong><span><%= t("admin.users.recovery.body") %> <a href="<%= localeHref("/recovery") %>"><%= t("admin.users.recovery.link") %></a></span><% if (recovery.code) { %><span class="recovery-code"><code><%= recovery.code %></code></span><% } %></div></div>
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong>Recovery code generated</strong><span>Give it to the user — they enter it on the <a href="/recovery">password-reset screen</a> to set a new password (generate a fresh one if it has expired).</span><% if (recovery.code) { %><span class="recovery-code"><code><%= recovery.code %></code></span><% } %></div></div>
<% } -%>
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<form class="form-card" method="post" action="<%= form.action %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<% form.fields.forEach((field) => { -%>
<%- include("partials/field", field) %>
<% }) -%>
<div class="form-actions">
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
<% if (locals.canWrite !== false) { -%>
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
<% } -%>
</div>
</form>
<% if (edit && locals.permissions) { -%>
<%- include("partials/permission-picker", { csrfToken: form.csrfToken, permissions: locals.permissions }) %>
<% } -%>
<% if (edit && locals.canWrite !== false) { -%>
<section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>">
<form method="post" action="<%= localeHref(edit.recoveryAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form>
<form method="post" action="<%= localeHref(edit.stateAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
<a class="btn btn-danger" href="<%= localeHref(edit.deleteAction) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.users.delete") %></a>
<% if (edit) { -%>
<section class="form-card admin-actions" aria-label="Account actions">
<form method="post" action="<%= edit.recoveryAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg>Generate recovery code</button></form>
<form method="post" action="<%= edit.stateAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
<a class="btn btn-danger" href="<%= edit.deleteAction %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete user</a>
</section>
<% } -%>
</div>
@@ -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,
}) %>
@@ -0,0 +1,16 @@
<%#
Role admin detail page: the role-detail body (members · effective access) in the shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/role-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, role: model.role });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -0,0 +1,16 @@
<%#
Role admin create page: the role-form body captured into the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/role-form-body", { error: model.error, form: model.form });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+21
View File
@@ -0,0 +1,21 @@
<%#
Roles admin list: the same building blocks as the Groups screen, around the shell, backed
by live Keto Role subject sets (admin-roles.ts). Filter/sort/page round-trip the URL.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination);
const actions = '<a class="btn btn-primary" href="/admin/roles/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add role</a>';
-%>
<%- include("partials/shell", {
actions,
body: filters + table + pager,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+1 -1
View File
@@ -2,7 +2,7 @@
Users admin create/edit page: the user-form body captured into the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/user-form-body", { canWrite: model.canWrite, edit: model.edit, error: model.error, form: model.form, permissions: model.permissions, recovery: model.recovery });
const body = include("partials/user-form-body", { edit: model.edit, error: model.error, form: model.form, recovery: model.recovery });
-%>
<%- include("partials/shell", {
body,
+1 -2
View File
@@ -6,8 +6,7 @@
const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination);
// Only offer "New user" to a users:write holder — a users:read one would get the 403 page.
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>';
const actions = '<a class="btn btn-primary" href="/admin/users/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add user</a>';
-%>
<%- include("partials/shell", {
actions,
+2 -6
View File
@@ -17,17 +17,13 @@ What it demonstrates:
reusing the core `field` partial.
- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
`scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant.
- **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including
the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and
the views carry the visitor's language onto their links with `localeHref()`.
(README → [Languages](../../../README.md#languages-i18n).)
The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and
`fetch` is injectable, so they unit-test as pure functions (`shifts.test.ts`).
## 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).
@@ -50,6 +46,6 @@ cosmetically) — normalise to your backend's format there if it matters.
## Granting access
A user sees Scheduling once they hold the `scheduling:read` permission in Keto (and `scheduling:write`
A user sees Scheduling once they hold the `scheduling:read` role in Keto (and `scheduling:write`
to create). The one-command bootstrap grants both to the demo admin, so the seeded
`admin@plainpages.local` can use it immediately.
-40
View File
@@ -1,40 +0,0 @@
// This plugin's own catalog, and the baseline its other locales are written against. Keys are
// looked up here first and fall back to the host's, so a plugin owns its words without prefixing
// them, and `shifts.count` shows the plural form (host: README → Translating).
import type { PluralMessage } from "@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.nav.overview": "Overview",
"scheduling.nav.section": "Scheduling",
"scheduling.nav.shifts": "Shifts",
"scheduling.new.title": "New shift",
"scheduling.overview.lead":
"Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.",
"scheduling.overview.signIn": "Sign in to view shifts",
"scheduling.overview.title": "Scheduling",
"scheduling.overview.view": "View shifts",
"scheduling.shifts.count": { one: "{{count}} shift", other: "{{count}} shifts" } as PluralMessage,
"scheduling.shifts.new": "New shift",
"scheduling.shifts.title": "Shifts",
"scheduling.table.assignee": "Assignee",
"scheduling.table.end": "End",
"scheduling.table.shift": "Shift",
"scheduling.table.start": "Start",
"scheduling.upstream.create": "Couldn't save the shift — the scheduling service is unavailable.",
"scheduling.upstream.list": "Couldn't reach the scheduling service — try again shortly.",
"scheduling.validation.assignee": "Assign the shift to someone.",
"scheduling.validation.title": "A shift needs a title.",
};
export type SchedulingMessages = typeof messages;
export default messages;
-34
View File
@@ -1,34 +0,0 @@
import type { SchedulingMessages } from "./en-US.ts";
const messages: SchedulingMessages = {
"scheduling.field.assignee": "Tilldelad",
"scheduling.field.end": "Slut",
"scheduling.field.start": "Start",
"scheduling.field.title": "Passets namn",
"scheduling.filter.label": "Filtrera pass",
"scheduling.filter.searchLabel": "Sök pass",
"scheduling.filter.searchPlaceholder": "Sök på namn eller person…",
"scheduling.form.submit": "Skapa pass",
"scheduling.nav.overview": "Översikt",
"scheduling.nav.section": "Schemaläggning",
"scheduling.nav.shifts": "Pass",
"scheduling.new.title": "Nytt pass",
"scheduling.overview.lead":
"Schemaläggningen samordnar teamets pass. Alla kan läsa den här översikten; själva passlistan kräver behörigheten <code>scheduling:read</code>.",
"scheduling.overview.signIn": "Logga in för att se passen",
"scheduling.overview.title": "Schemaläggning",
"scheduling.overview.view": "Visa pass",
"scheduling.shifts.count": { one: "{{count}} pass", other: "{{count}} pass" },
"scheduling.shifts.new": "Nytt pass",
"scheduling.shifts.title": "Pass",
"scheduling.table.assignee": "Tilldelad",
"scheduling.table.end": "Slut",
"scheduling.table.shift": "Pass",
"scheduling.table.start": "Start",
"scheduling.upstream.create": "Passet kunde inte sparas — schemaläggningstjänsten är otillgänglig.",
"scheduling.upstream.list": "Vi når inte schemaläggningstjänsten — försök igen om en stund.",
"scheduling.validation.assignee": "Passet måste tilldelas någon.",
"scheduling.validation.title": "Passet behöver ett namn.",
};
export default messages;
+17 -30
View File
@@ -2,59 +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, 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.2.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: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ },
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true },
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ },
],
icon: "i-cal",
id: "scheduling",
label: "scheduling.nav.section",
label: "Scheduling",
}],
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
// Tokens this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
permissions: [
{ description: "View shifts", name: READ },
{ description: "Create and edit shifts", name: WRITE },
{ description: "View shifts", token: READ },
{ description: "Create and edit shifts", token: WRITE },
],
// Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public`
// (anyone may reach /scheduling, signed in or not); the rest need a permission.
// (anyone may reach /scheduling, signed in or not); the rest need a role.
routes: [
{ handler: overview(), method: "GET", path: "/", public: true },
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
{ 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",
},
],
});
+32 -33
View File
@@ -2,25 +2,22 @@ import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
import test from "node:test";
// Import only from the @plainpages/plugin-api barrel — the same contract boundary shifts.ts uses (the host may
// Import only from the #plugin-api barrel — the same contract boundary shifts.ts uses (the host may
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts";
import { GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api";
import {
buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
} from "./shifts.ts";
const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
function fakeCtx(opts: { body?: string; roles?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
return {
chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
verifyCsrf: opts.verifyCsrf ?? (() => true),
chrome: CHROME, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
roles: opts.roles ?? [], url, user: null, verifyCsrf: opts.verifyCsrf ?? (() => true),
};
}
@@ -35,25 +32,27 @@ const asView = (r: RouteResult | void) => {
return r as { data: Record<string, unknown>; status?: number; view: string };
};
// ---- the upstream URL as a declared setting ----
// ---- upstream config validation (the onBoot hook) ----
test("the manifest declares its upstream as a URL setting the host validates", async () => {
const manifest = (await import("./plugin.ts")).default;
assert.deepEqual(manifest.settings?.map((s) => s.key), ["upstream"]);
assert.equal(manifest.settings?.[0]?.type, "url"); // so a typo'd URL fails the boot, not every request
assert.equal(manifest.settings?.[0]?.default, "http://shifts-upstream:4000"); // the dev compose's mock
assert.equal(typeof manifest.hooks?.onBoot, "function"); // without it the resolved value never arrives
test("assertHttpUrl accepts http(s) and fails loud on a malformed or non-http upstream URL", () => {
assert.doesNotThrow(() => assertHttpUrl("http://shifts-upstream:4000", "SCHEDULING_UPSTREAM"));
assert.doesNotThrow(() => assertHttpUrl("https://api.example.com/v1", "SCHEDULING_UPSTREAM"));
assert.throws(() => assertHttpUrl("not a url", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*valid URL/); // unparseable
assert.throws(() => assertHttpUrl("shifts-upstream:4000", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // missing // → parsed as a bogus scheme
assert.throws(() => assertHttpUrl("ftp://host/x", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // wrong scheme
});
test("the client re-reads its base URL, so onBoot can bind it after the manifest is built", async () => {
let baseUrl = "http://first:4000";
const seen: string[] = [];
const http = (async (url) => { seen.push(String(url)); return new Response("[]", { status: 200 }); }) as typeof fetch;
const upstream = createUpstream(() => baseUrl, http);
await upstream.list();
baseUrl = "http://second:4000";
await upstream.list();
assert.deepEqual(seen, ["http://first:4000/shifts", "http://second:4000/shifts"]);
test("the manifest's onBoot hook validates SCHEDULING_UPSTREAM (the binding, not just the helper)", async () => {
const prev = process.env["SCHEDULING_UPSTREAM"];
process.env["SCHEDULING_UPSTREAM"] = "nope://bad"; // read at import time below
try {
const manifest = (await import("./plugin.ts")).default;
assert.equal(typeof manifest.hooks?.onBoot, "function");
assert.throws(() => manifest.hooks!.onBoot!(), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud
} finally {
if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"];
else process.env["SCHEDULING_UPSTREAM"] = prev;
}
});
// ---- upstream client (fetch injected) ----
@@ -65,21 +64,21 @@ test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", as
assert.equal((init?.headers as Record<string, string>).accept, "application/json");
return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
}) as typeof fetch;
const shifts = await createUpstream(() => "http://up:4000/", http).list(); // trailing slash trimmed
const shifts = await createUpstream("http://up:4000/", http).list(); // trailing slash trimmed
assert.equal(seen, "http://up:4000/shifts");
assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]);
});
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
const http = (async () => new Response("nope", { status: 503 })) as typeof fetch;
await assert.rejects(createUpstream(() => "http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503);
await assert.rejects(createUpstream("http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503);
});
test("createUpstream.create POSTs the input as JSON", async () => {
let body: unknown, method = "";
const http = (async (_url, init) => { method = init?.method ?? ""; body = JSON.parse(String(init?.body)); return new Response(null, { status: 201 }); }) as typeof fetch;
const input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" };
await createUpstream(() => "http://up:4000", http).create(input);
await createUpstream("http://up:4000", http).create(input);
assert.equal(method, "POST");
assert.deepEqual(body, input);
});
@@ -94,8 +93,8 @@ test("readInput trims; validate requires title + assignee", () => {
// ---- list handler ----
test("listShifts renders the upstream rows; q filters; canWrite reflects the permission", async () => {
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ permissions: ["scheduling:write"] })));
test("listShifts renders the upstream rows; q filters; canWrite reflects the role", async () => {
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ roles: ["scheduling:write"] })));
assert.equal(r.view, "shifts");
const table = r.data["table"] as { rows: { name: string }[] };
assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]);
@@ -113,15 +112,15 @@ test("listShifts degrades to a recoverable error page when the upstream is down
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
});
// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
// ---- public overview handler (a page anyone can reach, gated data stays behind the role) ----
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
const anon = asView(await overview()(fakeCtx())); // user null, no permissions
const anon = asView(await overview()(fakeCtx())); // user null, no roles
assert.equal(anon.view, "overview");
assert.equal(anon.data["chrome"], CHROME);
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] })));
const reader = asView(await overview()(fakeCtx({ roles: ["scheduling:read"] })));
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
});
+48 -56
View File
@@ -5,19 +5,13 @@
// Handlers are factories bound to a ShiftsUpstream, and `fetch` is injectable, so they unit-test as
// pure functions against a mock upstream with no network (README.md → Local dev & test story).
// One import from the host's @plainpages/plugin-api barrel — the stable author surface (see README.md → Building plugins).
import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts";
// The plugin's own English (its catalog, then the host's), for a view model built outside a request:
// its unit tests. At runtime a handler passes ctx.t, which reads this catalog in the visitor's
// locale first, then the host's.
const EN: Translate = englishTranslator(enUS);
// One import from the host's #plugin-api barrel — the stable author surface (see README.md → Building plugins).
import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, tracedFetch } from "#plugin-api";
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
export const SHIFTS_PATH = "/scheduling/shifts";
export const READ = "scheduling:read"; // the permission gating the list + nav
export const WRITE = "scheduling:write"; // the permission gating create
export const READ = "scheduling:read"; // permission token gating the list + nav
export const WRITE = "scheduling:write"; // permission token gating create
export interface Shift {
id: string;
@@ -49,16 +43,26 @@ export interface ShiftsUpstream {
list(): Promise<Shift[]>;
}
// Fail loud at boot (the plugin's onBoot hook) on a malformed/non-http upstream URL — a config
// typo surfaces at startup, not as a degraded page later. Reachability stays a runtime concern.
export function assertHttpUrl(value: string, name: string): void {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error(`${name} is not a valid URL: ${JSON.stringify(value)}`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${name} must be an http(s) URL: ${JSON.stringify(value)}`);
}
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch`
// defaults to the host's tracedFetch, so each upstream call joins the request's trace (a client
// span + a propagated traceparent); it's injectable so handlers unit-test against a mock, no network.
// `baseUrl` is read per call: the plugin's settings arrive on onBoot, after the manifest that binds
// these handlers has already been built.
export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
const base = (): string => baseUrl().replace(/\/+$/, "");
export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
const base = baseUrl.replace(/\/+$/, "");
return {
async create(input) {
const res = await fetchImpl(`${base()}/shifts`, {
const res = await fetchImpl(`${base}/shifts`, {
body: JSON.stringify(input),
headers: { "content-type": "application/json" },
method: "POST",
@@ -66,7 +70,7 @@ export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch =
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
},
async list() {
const res = await fetchImpl(`${base()}/shifts`, { headers: { accept: "application/json" } });
const res = await fetchImpl(`${base}/shifts`, { headers: { accept: "application/json" } });
if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status);
const data: unknown = await res.json();
return Array.isArray(data) ? data.map(toShift) : [];
@@ -83,63 +87,58 @@ function toShift(raw: unknown): Shift {
// ---- view models (pure; the EJS views read these) -----------------------------------
export function buildListModel(opts: { canWrite: boolean; chrome: PageChrome; error?: string; q: string; shifts: Shift[]; t?: Translate }) {
const t = opts.t ?? EN;
export function buildListModel(opts: { canWrite: boolean; chrome: PageChrome; error?: string; q: string; shifts: Shift[] }) {
return {
breadcrumbs: [{ label: t("scheduling.shifts.title") }], // SHIFTS_PATH is the list itself; the form links back to it
breadcrumbs: [{ label: "Shifts" }], // SHIFTS_PATH is the list itself; the form links back to it as "Shifts"
canWrite: opts.canWrite,
chrome: opts.chrome,
// A plural message: one catalog key, the right form per locale and count (Intl.PluralRules).
count: t("scheduling.shifts.count", { count: opts.shifts.length }),
...(opts.error ? { error: opts.error } : {}),
filterBar: {
applyLabel: t("filter.search"),
applyLabel: "Search",
clearHref: SHIFTS_PATH,
label: t("scheduling.filter.label"),
pills: opts.q ? [{ label: t("filter.search"), remove: SHIFTS_PATH, value: opts.q }] : [],
label: "Filter shifts",
pills: opts.q ? [{ label: "Search", remove: SHIFTS_PATH, value: opts.q }] : [],
rows: [[
{ label: t("scheduling.filter.searchLabel"), name: "q", placeholder: t("scheduling.filter.searchPlaceholder"), type: "search", value: opts.q },
{ label: "Search shifts", name: "q", placeholder: "Search title or assignee…", type: "search", value: opts.q },
{ type: "spacer" },
]],
},
newHref: `${SHIFTS_PATH}/new`,
table: {
caption: t("scheduling.shifts.title"),
columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.assignee") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }],
caption: "Shifts",
columns: [{ label: "Shift" }, { label: "Assignee" }, { label: "Start" }, { label: "End" }],
rows: opts.shifts.map((s) => ({
cells: [{ rowHeader: { text: s.title } }, s.assignee, s.start, s.end],
name: s.title,
})),
},
title: t("scheduling.shifts.title"),
title: "Shifts",
};
}
export function buildFormModel(opts: { chrome: PageChrome; errors?: Record<string, string>; formError?: string; t?: Translate; values?: Partial<ShiftInput> }) {
const t = opts.t ?? EN;
export function buildFormModel(opts: { chrome: PageChrome; errors?: Record<string, string>; formError?: string; values?: Partial<ShiftInput> }) {
const v = opts.values ?? {};
const e = opts.errors ?? {};
const field = (cfg: { icon?: string; id: string; label: string; type?: string; value: string }) => ({
...cfg, name: cfg.id, ...(e[cfg.id] ? { error: e[cfg.id] } : {}), ...(cfg.id === "title" || cfg.id === "assignee" ? { required: true } : {}),
});
return {
breadcrumbs: [{ href: SHIFTS_PATH, label: t("scheduling.shifts.title") }, { label: t("scheduling.new.title") }],
breadcrumbs: [{ href: SHIFTS_PATH, label: "Shifts" }, { label: "New shift" }],
chrome: opts.chrome,
...(opts.formError ? { formError: opts.formError } : {}),
form: {
action: SHIFTS_PATH,
cancelHref: SHIFTS_PATH,
csrfToken: opts.chrome.csrfToken,
cancelLabel: t("common.cancel"),
fields: [
field({ icon: "i-cal", id: "title", label: t("scheduling.field.title"), value: v.title ?? "" }),
field({ icon: "i-user", id: "assignee", label: t("scheduling.field.assignee"), value: v.assignee ?? "" }),
field({ id: "start", label: t("scheduling.field.start"), type: "datetime-local", value: v.start ?? "" }),
field({ id: "end", label: t("scheduling.field.end"), type: "datetime-local", value: v.end ?? "" }),
field({ icon: "i-cal", id: "title", label: "Shift title", value: v.title ?? "" }),
field({ icon: "i-user", id: "assignee", label: "Assignee", value: v.assignee ?? "" }),
field({ id: "start", label: "Start", type: "datetime-local", value: v.start ?? "" }),
field({ id: "end", label: "End", type: "datetime-local", value: v.end ?? "" }),
],
submitLabel: t("scheduling.form.submit"),
submitLabel: "Create shift",
},
title: t("scheduling.new.title"),
title: "New shift",
};
}
@@ -156,10 +155,10 @@ export function readInput(form: URLSearchParams): ShiftInput {
// Required-field validation → { field: message } or null. Kept deliberately small; the upstream
// owns the real domain rules (overlap, capacity, …) and rejects with a 4xx the handler surfaces.
export function validate(input: ShiftInput, t: Translate = EN): Record<string, string> | null {
export function validate(input: ShiftInput): Record<string, string> | null {
const errors: Record<string, string> = {};
if (!input.title) errors["title"] = t("scheduling.validation.title");
if (!input.assignee) errors["assignee"] = t("scheduling.validation.assignee");
if (!input.title) errors["title"] = "A shift needs a title.";
if (!input.assignee) errors["assignee"] = "Assign the shift to someone.";
return Object.keys(errors).length ? errors : null;
}
@@ -174,32 +173,25 @@ export function listShifts(upstream: ShiftsUpstream): RouteHandler {
shifts = await upstream.list();
} catch (err) {
ctx.log.warn("scheduling upstream unreachable", { error: String(err) }); // plugin logging via ctx.log
error = ctx.t("scheduling.upstream.list");
error = "Couldn't reach the scheduling service — try again shortly.";
}
const needle = q.toLowerCase();
const rows = needle ? shifts.filter((s) => s.title.toLowerCase().includes(needle) || s.assignee.toLowerCase().includes(needle)) : shifts;
return { data: buildListModel({ canWrite: can(ctx, WRITE), chrome: ctx.chrome, ...(error ? { error } : {}), q, shifts: rows, t: ctx.t }), view: "shifts" };
return { data: buildListModel({ canWrite: can(ctx, WRITE), chrome: ctx.chrome, ...(error ? { error } : {}), q, shifts: rows }), view: "shifts" };
};
}
export function newShiftForm(): RouteHandler {
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" });
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome }), view: "shift-new" });
}
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
// else a prompt to sign in. ctx.user may be null here, so read the permission via can() (zero I/O).
// else a prompt to sign in. ctx.user may be null here, so read the role via can() (zero I/O).
export function overview(): RouteHandler {
return (ctx) => ({
data: {
breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }],
canRead: can(ctx, READ),
chrome: ctx.chrome,
shiftsHref: ctx.localeHref(SHIFTS_PATH), // a plugin carries the visitor's locale onto its own links
signInHref: ctx.localeHref(`/login?return_to=${encodeURIComponent(ctx.localeHref(SHIFTS_PATH))}`),
title: ctx.t("scheduling.overview.title"),
},
data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" },
view: "overview",
});
}
@@ -210,13 +202,13 @@ export function createShift(upstream: ShiftsUpstream): RouteHandler {
// A write is a first-party form, so guard it with the host's double-submit token (ctx.verifyCsrf).
if (!ctx.verifyCsrf(form.get(CSRF_FIELD))) throw new GuardError(403, "invalid CSRF token");
const input = readInput(form);
const errors = validate(input, ctx.t);
if (errors) return { data: buildFormModel({ chrome: ctx.chrome, errors, t: ctx.t, values: input }), status: 400, view: "shift-new" };
const errors = validate(input);
if (errors) return { data: buildFormModel({ chrome: ctx.chrome, errors, values: input }), status: 400, view: "shift-new" };
try {
await upstream.create(input);
} catch (err) {
ctx.log.warn("scheduling shift create failed (upstream)", { error: String(err) });
return { data: buildFormModel({ chrome: ctx.chrome, formError: ctx.t("scheduling.upstream.create"), t: ctx.t, values: input }), status: 502, view: "shift-new" };
return { data: buildFormModel({ chrome: ctx.chrome, formError: "Couldn't save the shift — the scheduling service is unavailable.", values: input }), status: 502, view: "shift-new" };
}
ctx.log.info("scheduling shift created", { assignee: input.assignee, title: input.title });
return { redirect: SHIFTS_PATH }; // POST-redirect-GET
@@ -3,16 +3,16 @@
nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for
everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
Data: chrome, title, breadcrumbs, canRead, shiftsHref, signInHref
Data: chrome, title, breadcrumbs, canRead, shiftsHref
%><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
const cta = canRead
? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>'
: '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>';
? '<a class="btn btn-primary" href="' + shiftsHref + '">View shifts</a>'
: '<a class="btn btn-primary" href="/login?return_to=' + encodeURIComponent(shiftsHref) + '">Sign in to view shifts</a>';
-%>
<%- include("partials/shell", {
actions: "",
body: '<div class="scheduling-page"><p>' + t("scheduling.overview.lead") + '</p>' + cta + '</div>',
body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> role.</p>' + cta + '</div>',
brand: chrome.brand,
breadcrumbs,
csrfToken: chrome.csrfToken,
@@ -1,7 +1,7 @@
<%#
A plugin's own partial (resolved before the core ones). The new-shift form body, reusing the core
`partials/field` + `partials/alert`. Config: form { action, csrfToken, submitLabel, cancelHref,
cancelLabel, fields: field.ejs config[] }, formError?
fields: field.ejs config[] }, formError?
%><%
const form = locals.form;
-%>
@@ -9,13 +9,13 @@
<% if (locals.formError) { -%>
<%- include("partials/alert", { text: locals.formError, tone: "neg" }) %>
<% } -%>
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<form class="form-card" method="post" action="<%= form.action %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<% form.fields.forEach((field) => { -%>
<%- include("partials/field", field) %>
<% }) -%>
<div class="form-actions">
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= form.cancelLabel %></a>
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
+3 -3
View File
@@ -3,19 +3,19 @@
service; this view renders them with the core building blocks inside the native app shell
(ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table,
alert) — see docs/plugin-contract.md. Zero-JS: search round-trips the URL.
Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error?
Data: chrome, title, breadcrumbs, filterBar, table, canWrite, newHref, error?
%><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
const filtersHtml = include("partials/filter-bar", filterBar);
const tableHtml = include("partials/data-table", table);
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
const actions = canWrite
? '<a class="btn btn-primary" href="' + localeHref(newHref) + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("scheduling.shifts.new") + '</a>'
? '<a class="btn btn-primary" href="' + newHref + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>New shift</a>'
: "";
-%>
<%- include("partials/shell", {
actions,
body: '<div class="scheduling-page">' + alertHtml + filtersHtml + '<p class="shift-count">' + count + '</p>' + tableHtml + '</div>',
body: '<div class="scheduling-page">' + alertHtml + filtersHtml + tableHtml + '</div>',
brand: chrome.brand,
breadcrumbs,
csrfToken: chrome.csrfToken,
+1 -1
View File
@@ -1,6 +1,6 @@
// Dev-only mock upstream for the reference plugin (examples/plugins/scheduling) — a stand-in for the
// customer's real backend, ready for when you copy the reference plugin into plugins/. NOT part
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point PLUGIN_SETTING_SCHEDULING_UPSTREAM
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point SCHEDULING_UPSTREAM
// at your real service in production.
//
// GET /shifts → 200 [ { id, title, assignee, start, end }, … ]
View File
+1 -1
View File
@@ -1,4 +1,4 @@
# Ory Keto — authorization (ReBAC), the source of truth for permissions/groups and the rare
# Ory Keto — authorization (ReBAC), the source of truth for roles/groups and the rare
# fine-grained check (README: three tiers of "may I?"). The permission model lives in
# namespaces.keto.ts (OPL); DSN comes from the env (the per-service keto DB). The web
# app never connects directly — it calls the read (4466) / write (4467) APIs, the ports
+9 -11
View File
@@ -4,30 +4,28 @@
// identity ids (== the JWT `sub`).
import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types"
// A person. Ory calls this an "identity" (Kratos owns the record); Plainpages says "user"
// throughout. Subjects are written as `user:<kratos-identity-id>`.
// A human identity. Subjects are written as `user:<kratos-identity-id>`.
class User implements Namespace {}
// A named set of users (and nested groups), resolved transitively. The admin "Groups"
// screen manages membership; checks expand it automatically.
// A subject set: a named collection of users (and nested groups), resolved transitively.
// The admin "Groups" screen manages membership; checks expand it automatically.
class Group implements Namespace {
related: {
members: (User | SubjectSet<Group, "members">)[]
}
}
// A coarse permission — an operation a route or menu item gates on, and the source of truth
// for the JWT `permissions` claim. At login the app reads `Permission:<name>#granted@user:<id>`
// from Keto and projects the result into the token (README: Login → session JWT). A group can
// hold a permission, so grants go to a user or to a whole group.
class Permission implements Namespace {
// A coarse role — the source of truth for the JWT `roles` claim. At login the app reads
// `role:<name>#members@user:<id>` from Keto and projects the result into the token
// (README: Login → session JWT). A group can hold a role, so members can be users or groups.
class Role implements Namespace {
related: {
granted: (User | SubjectSet<Group, "members">)[]
members: (User | SubjectSet<Group, "members">)[]
}
}
// A fine-grained, relationship-checked resource — README's third "may I?" tier, the rare
// live Keto check (e.g. sharing/delegation). Permits nest: owner ⊇ editor ⊇ viewer.
// live Keto check (e.g. sharing/delegation). Permissions nest: owner ⊇ editor ⊇ viewer.
// Grants accept a user directly or any member of a group.
class Resource implements Namespace {
related: {
+2 -2
View File
@@ -41,7 +41,7 @@ selfservice:
ui_url: http://localhost:3000/login
after:
# After authenticating, land on our completion route — it mints the session JWT
# (permissions from Keto → metadata_public projection → tokenize) and sets our cookie.
# (roles from Keto → metadata_public projection → tokenize) and sets our cookie.
default_browser_return_url: http://localhost:3000/auth/complete
registration:
ui_url: http://localhost:3000/registration
@@ -94,7 +94,7 @@ session:
same_site: Lax
# Session→JWT tokenizer: whoami(tokenize_as: plainpages) mints a short-lived,
# locally-verifiable JWT so the hot path never calls Ory. Claims come from the
# committed Jsonnet mapper (sub = identity id, email from traits, permissions from the
# committed Jsonnet mapper (sub = identity id, email from traits, roles from the
# metadata_public projection); signed with tokenizer/jwks.json.
whoami:
tokenizer:
+3 -3
View File
@@ -1,7 +1,7 @@
// Session→JWT claims mapper for the `plainpages` tokenizer. Kratos exposes the
// session as `session`; `sub` is set from the identity id (subject_source: id) and
// can't be overridden here. permissions come from metadata_public — the per-login projection
// of Keto permissions the app refreshes at login (metadata_admin is NOT carried in the session
// can't be overridden here. roles come from metadata_public — the per-login projection
// of Keto roles the app refreshes at login (metadata_admin is NOT carried in the session
// the tokenizer sees; metadata_public is). Absent on a fresh identity ⇒ empty list.
local session = std.extVar('session');
local meta =
@@ -12,6 +12,6 @@ local meta =
{
claims: {
email: session.identity.traits.email,
permissions: if std.objectHas(meta, 'permissions') then meta.permissions else [],
roles: if std.objectHas(meta, 'roles') then meta.roles else [],
},
}
+1 -9
View File
@@ -1,14 +1,6 @@
-- Runs once on first boot (docker-entrypoint-initdb.d), as the POSTGRES_USER.
-- One database per Ory service: each owns its schema and runs its own migrations,
-- so they never collide. A plugin's database does not belong here: bootstrap provisions those on
-- every boot, so one dropped in later is picked up too (README → Plugin storage).
-- so they never collide. The web app never connects here (stateless — see README).
CREATE DATABASE kratos;
CREATE DATABASE keto;
CREATE DATABASE hydra;
-- Postgres grants CONNECT to PUBLIC by default, so every plugin role could otherwise open the auth
-- plane's databases and read pg_catalog; table data stays protected either way. Ory connects as the
-- POSTGRES_USER, which owns these and keeps its access.
REVOKE CONNECT ON DATABASE kratos FROM PUBLIC;
REVOKE CONNECT ON DATABASE keto FROM PUBLIC;
REVOKE CONNECT ON DATABASE hydra FROM PUBLIC;
+83 -388
View File
@@ -1,20 +1,21 @@
{
"name": "plainpages",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plainpages",
"version": "0.1.0",
"dependencies": {
"@larvit/log": "2.3.0",
"ejs": "6.0.1",
"lucide-static": "1.33.0",
"postgres": "3.4.9"
"ejs": "3.1.10",
"lucide-static": "1.18.0"
},
"devDependencies": {
"@types/ejs": "3.1.5",
"@types/node": "24.13.3",
"typescript": "7.0.2"
"@types/node": "24.13.2",
"typescript": "5.9.3"
},
"engines": {
"node": ">=24"
@@ -37,419 +38,113 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"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/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"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/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"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_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/ejs": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
"integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
"license": "Apache-2.0",
"dependencies": {
"jake": "^10.8.5"
},
"bin": {
"ejs": "bin/cli.js"
},
"engines": {
"node": ">=0.12.18"
"node": ">=0.10.0"
}
},
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
"integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.0.1"
}
},
"node_modules/jake": {
"version": "10.9.4",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
"license": "Apache-2.0",
"dependencies": {
"async": "^3.2.6",
"filelist": "^1.0.4",
"picocolors": "^1.1.1"
},
"bin": {
"jake": "bin/cli.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/lucide-static": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.33.0.tgz",
"integrity": "sha512-jNGgvTNcLUfVRX4N9PH9pVVTJzoph/BmYmgU838bYBQodkUJL4nAThkuymFz1x3OUYMhJxPndC7rdg1sxOPYKg==",
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.18.0.tgz",
"integrity": "sha512-0WRXLQnjbte5SXuzom6yfeGlVSFsEsC9rzxn66DZN0pXows3+N34CQHy3BHI1qA3uH7u/SUzx8LQhjeAnxd8JQ==",
"license": "ISC"
},
"node_modules/postgres": {
"version": "3.4.9",
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
"integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==",
"license": "Unlicense",
"engines": {
"node": ">=12"
"node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/porsager"
"engines": {
"node": ">=10"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc"
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"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": ">=14.17"
}
},
"node_modules/undici-types": {
+8 -7
View File
@@ -1,29 +1,30 @@
{
"name": "plainpages",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=24"
},
"imports": {
"#menu-config": "./src/ui/menu-config.ts"
"#menu-config": "./src/ui/menu-config.ts",
"#plugin-api": "./src/plugin-host/plugin-api.ts"
},
"scripts": {
"start": "node src/server.ts",
"dev": "node --watch src/server.ts",
"gen-jwks": "node src/auth/gen-jwks.ts",
"typecheck": "tsc --noEmit",
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"release-tooling/**/*.test.ts\""
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"auto-release/**/*.test.ts\""
},
"dependencies": {
"@larvit/log": "2.3.0",
"ejs": "6.0.1",
"lucide-static": "1.33.0",
"postgres": "3.4.9"
"ejs": "3.1.10",
"lucide-static": "1.18.0"
},
"devDependencies": {
"@types/ejs": "3.1.5",
"@types/node": "24.13.3",
"typescript": "7.0.2"
"@types/node": "24.13.2",
"typescript": "5.9.3"
}
}
-2
View File
@@ -1,2 +0,0 @@
// Re-export rather than the surface itself: a package's `exports` target may not escape its folder.
export * from "../src/plugin-host/plugin-api.ts";
-6
View File
@@ -1,6 +0,0 @@
{
"name": "@plainpages/plugin-api",
"private": true,
"type": "module",
"exports": "./index.ts"
}
+15 -42
View File
@@ -126,7 +126,7 @@ summary { list-style: none; cursor: pointer; }
border-radius: 3px;
}
@media (prefers-reduced-motion: no-preference) {
.sidebar, .scrim, summary, .nav-item, .btn, .chip, .profile, .kebab { transition: .15s ease; }
.sidebar, .scrim, summary, .nav-item, .btn, .chip { transition: .15s ease; }
}
.sr-only {
@@ -282,6 +282,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
outline: 2px solid var(--focus); outline-offset: 1px;
}
/* profile / settings row */
.footer-actions { display: flex; align-items: center; gap: 4px; }
.profile {
display: flex; align-items: center; gap: 9px; flex: 1 1 auto;
@@ -474,44 +475,20 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.check input, .radio input { width: 15px; height: 15px; accent-color: var(--accent);
margin: 0; cursor: pointer; }
.check:hover, .radio:hover { color: var(--text); }
/* A stacked group of .check rows in a <fieldset> — the right element for related checkboxes, but the
UA gives it a groove border, so reset it like .filter-field and .menu-field do. A disabled row is
still readable: it states a fact (a permission held through a group) rather than offering an edit. */
.check-group { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.check-group .check { align-items: baseline; }
.check-group .check input:disabled { cursor: default; }
.check-group .check:has(input:disabled) { opacity: .7; cursor: default; }
.check-group .check .cell-muted { margin-left: auto; padding-left: 12px; font-size: var(--fz-xs); }
/* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover]
panel, wrapped so the pair is one element in any layout. The browser owns open/close, and the top
layer keeps the panel clear of a scrolling ancestor's overflow. `position-anchor: auto` binds it to
the button that opened it; a bare anchor() without it resolves to nothing in every engine. `color`
is set because the UA sheet gives [popover] a CanvasText of its own, which would ignore the theme. */
.menu { display: inline-flex; }
/* popover menu (column settings, kebab, etc.) — pure <details> */
.menu { position: relative; display: inline-flex; }
.menu > summary { display: inline-flex; }
.menu > summary::after { content: none; }
.menu-pop {
position: absolute; inset: auto; margin: 6px 0 0;
position-anchor: auto;
position-try-fallbacks: flip-block, flip-inline;
top: anchor(bottom); right: anchor(right);
position: absolute; top: calc(100% + 6px); right: 0; z-index: 40;
min-width: 210px; padding: 6px;
background: var(--surface); color: var(--text);
border: 1px solid var(--border-2); border-radius: var(--radius);
background: var(--surface); border: 1px solid var(--border-2);
border-radius: var(--radius);
box-shadow: 0 8px 28px rgba(0,0,0,.16);
}
.menu-pop.left { right: auto; left: anchor(left); }
.menu-pop.up { top: auto; bottom: anchor(top); margin: 0 0 6px; }
/* A browser too old for the Popover API leaves the trigger inert, so let each panel flow under its
trigger rather than stranding Sign out and the language picker behind a button that does nothing.
The wrapper turns block so the pair stacks instead of sitting side by side, and min-width goes —
it would otherwise push the sidebar and a 44px action cell far past their width. Cramped but
reachable, and unreachable by any test: a browser with both features cannot render this path.
Both are guarded — an engine with popover but no anchor positioning would otherwise leave the
panel absolutely positioned with every anchor() dropped, detached from its trigger. */
@supports not (selector([popover]:popover-open) and (position-anchor: auto)) {
.menu { display: block; }
.menu-pop, .menu-pop.up { position: static; min-width: 0; margin: 6px 0 0; }
}
.menu-pop.left { right: auto; left: 0; }
.menu-pop.up { top: auto; bottom: calc(100% + 6px); }
.menu-head { font-size: var(--fz-xs); text-transform: uppercase;
letter-spacing: .05em; color: var(--text-faint); font-weight: 600;
padding: 5px 8px; }
@@ -523,7 +500,6 @@ span.nav-self { cursor: default; } /* static / non-clickable */
}
.menu-item-form { display: contents; } /* form wraps the Sign-out button without changing layout */
.menu-item:hover { background: var(--surface-2); }
.menu-item[aria-current] { font-weight: 600; color: var(--text); } /* the language you are in */
.menu-item.danger { color: var(--neg); }
.menu-item .ico { color: var(--text-faint); }
.menu-item.danger .ico { color: var(--neg); }
@@ -634,13 +610,10 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
/* row kebab */
.col-actions { width: 44px; text-align: center; }
.kebab { width: 26px; height: 26px; border-radius: var(--radius);
display: grid; place-items: center; color: var(--text-faint); margin: 0 auto;
background: transparent; border: 0; padding: 0; cursor: pointer; }
.kebab:hover { background: var(--surface-2); color: var(--text); }
/* Held open — a trigger's panel is its next sibling. Its own rule: :has() is non-forgiving, so an
engine that doesn't know :popover-open would drop the hover style along with it. */
.kebab:has(+ .menu-pop:popover-open) { background: var(--surface-2); color: var(--text); }
.kebab summary { width: 26px; height: 26px; border-radius: var(--radius);
display: grid; place-items: center; color: var(--text-faint); margin: 0 auto; }
.kebab summary:hover { background: var(--surface-2); color: var(--text); }
.kebab[open] summary { background: var(--surface-2); color: var(--text); }
/* ---------- 10. PAGINATION --------------------------------- */
.pager {
-58
View File
@@ -1,58 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { checkTagMatchesContract, readHostApiVersion } from "./contract-version.ts";
test("readHostApiVersion pulls the constant out of the real source, and returns null when absent", () => {
const real = readFileSync("src/plugin-host/plugin.ts", "utf8");
assert.match(readHostApiVersion(real) ?? "", /^\d+\.\d+\.\d+$/);
assert.equal(readHostApiVersion('export const SOMETHING_ELSE = "1.0.0";'), null);
});
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
// makes an accidental edit fail here rather than at release time.
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.2.0");
});
test("every author-facing apiVersion sample matches the shipped contract", () => {
// A plugin author copies these; a stale one produces a boot-aborting refuse on first run. The
// examples deliberately write a literal rather than importing the constant (AGENTS.md), so this
// is the only thing keeping the copies honest.
const host = readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")) ?? "";
const [major, minor] = host.split(".");
for (const file of [
"README.md",
"examples/plugins/admin/plugin.ts",
"examples/plugins/scheduling/plugin.ts",
"release-tooling/dockerhub-overview.md.tmpl",
"views/index.ejs",
]) {
const found = [...readFileSync(file, "utf8").matchAll(/apiVersion: "(\d+\.\d+\.\d+)"/g)].map((m) => m[1]);
assert.ok(found.length > 0, `${file} should carry at least one apiVersion sample`);
for (const sample of found) {
const [sMajor, sMinor] = (sample ?? "").split(".");
assert.equal(`${sMajor}.${sMinor}`, `${major}.${minor}`, `${file} samples apiVersion ${sample}, host is ${host}`);
}
}
});
test("checkTagMatchesContract: major.minor must agree, patch may lag", () => {
assert.equal(checkTagMatchesContract("v0.1.0", "0.1.0").ok, true);
assert.equal(checkTagMatchesContract("v0.1.7", "0.1.0").ok, true); // auto-release cut patches
assert.equal(checkTagMatchesContract("0.1.0", "0.1.0").ok, true); // bare tag, no v
assert.equal(checkTagMatchesContract("v0.2.0", "0.1.0").ok, false); // plugin-visible, needs a bump
assert.equal(checkTagMatchesContract("v1.0.0", "0.1.0").ok, false);
});
test("checkTagMatchesContract names what to fix rather than just failing", () => {
const res = checkTagMatchesContract("v0.2.0", "0.1.0");
assert.equal(res.ok, false);
assert.match(res.ok === false ? res.error : "", /HOST_API_VERSION to 0\.2\.0/);
});
test("checkTagMatchesContract rejects junk on either side without throwing", () => {
assert.equal(checkTagMatchesContract("v0.1.0", null).ok, false); // constant not found
assert.equal(checkTagMatchesContract("nope", "0.1.0").ok, false);
assert.equal(checkTagMatchesContract("v0.1.0", "1.0").ok, false);
});
-49
View File
@@ -1,49 +0,0 @@
import { readFileSync } from "node:fs";
export type ContractCheck = { ok: true } | { ok: false; error: string };
const SEMVER = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
export function readHostApiVersion(source: string): string | null {
return /^export const HOST_API_VERSION = "([^"]+)";/m.exec(source)?.[1] ?? null;
}
// Patch is deliberately not compared: checkApiVersion ignores it, and auto-release cuts patch
// releases with no commit to bump the constant in.
export function checkTagMatchesContract(tag: string, hostApiVersion: string | null): ContractCheck {
if (hostApiVersion === null) {
return { error: "HOST_API_VERSION not found", ok: false };
}
const t = SEMVER.exec(tag);
if (!t) return { error: `tag must be vX.Y.Z, got ${JSON.stringify(tag)}`, ok: false };
const h = SEMVER.exec(hostApiVersion);
if (!h) return { error: `HOST_API_VERSION must be X.Y.Z, got ${JSON.stringify(hostApiVersion)}`, ok: false };
if (t[1] === h[1] && t[2] === h[2]) return { ok: true };
return {
error:
`${tag} does not match HOST_API_VERSION ${hostApiVersion} — the contract version IS the release ` +
`version. Set HOST_API_VERSION to ${t[1]}.${t[2]}.0 in src/plugin-host/plugin.ts, merge that, ` +
"then tag.",
ok: false,
};
}
// CLI: node release-tooling/contract-version.ts <tag> <path/to/plugin.ts | -> → exits 1 on
// mismatch. `-` reads the source on stdin, so a caller checking a ref other than its checkout
// (`git show origin/main:… | …`) needs no scratch file in the workspace.
if (process.argv[1]?.endsWith("/contract-version.ts")) {
const [, , tag, pluginPath = "src/plugin-host/plugin.ts"] = process.argv;
let source = "";
try {
source = readFileSync(pluginPath === "-" ? 0 : pluginPath, "utf8");
} catch (err) {
process.stderr.write(`${pluginPath}: ${err instanceof Error ? err.message : String(err)}\n`);
process.exitCode = 1;
}
const result = checkTagMatchesContract(tag ?? "", readHostApiVersion(source));
if (!result.ok) {
process.stderr.write(`${pluginPath}: ${result.error}\n`);
process.exit(1);
}
process.stdout.write(`${tag} matches HOST_API_VERSION\n`);
}
@@ -1,48 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { jwtFrom, leftoverPlaceholders, renderOverview } from "./dockerhub-overview.ts";
const TEMPLATE = "release-tooling/dockerhub-overview.md.tmpl";
const template = () => readFileSync(TEMPLATE, "utf8");
test("renderOverview substitutes every occurrence, not just the first", () => {
const out = renderOverview("pull a:{{VERSION}} then b:{{VERSION}}", "1.2.3");
assert.equal(out, "pull a:1.2.3 then b:1.2.3");
});
test("leftoverPlaceholders catches a typo'd placeholder, deduped, and passes clean text", () => {
assert.deepEqual(leftoverPlaceholders("a {{VERISON}} b {{VERISON}}"), ["{{VERISON}}"]);
assert.deepEqual(leftoverPlaceholders(renderOverview("x {{VERSION}}", "0.1.0")), []);
});
test("the real template renders clean, and the release owns its own image tag", () => {
const rendered = renderOverview(template(), "9.9.9");
assert.deepEqual(leftoverPlaceholders(rendered), []);
assert.match(rendered, /larvit\/plainpages:9\.9\.9/); // the placeholder actually reaches the examples
assert.doesNotMatch(rendered, /larvit\/plainpages:\d+\.\d+\.\d+(?<!9\.9\.9)/);
});
test("the quick start's sidecars are pinned to the same versions this repo runs", () => {
// The page is published automatically, so a drifted pin here ships a topology CI never tested.
const pins = (source: string) =>
new Map([...source.matchAll(/image: ([^:\s]+):(v?\d\S*)/g)].map((m) => [m[1] ?? "", m[2] ?? ""]));
const ours = new Map([
...pins(readFileSync("compose.override.yml", "utf8")),
...pins(readFileSync("compose.yml", "utf8")), // production wins: the template is the prod quick start
]);
const published = pins(template());
assert.ok(published.size > 0, "the template should pin sidecars");
for (const [image, tag] of published) {
assert.equal(tag, ours.get(image), `${TEMPLATE} pins ${image}:${tag}, this repo runs ${ours.get(image)}`);
}
});
test("jwtFrom accepts only a non-empty string token, never throwing on a hostile body", () => {
assert.equal(jwtFrom({ token: "abc" }), "abc");
assert.equal(jwtFrom(null), null); // valid JSON, and the shape a proxy can return
assert.equal(jwtFrom("<html>rate limited</html>"), null);
assert.equal(jwtFrom({}), null);
assert.equal(jwtFrom({ token: "" }), null);
assert.equal(jwtFrom({ token: 42 }), null);
});
-104
View File
@@ -1,104 +0,0 @@
// Publishes the Docker Hub repository overview from dockerhub-overview.md.tmpl, rendering
// `{{VERSION}}` to the release being published.
import { readFileSync } from "node:fs";
import { join } from "node:path";
const HUB = "https://hub.docker.com/v2";
const TIMEOUT_MS = 30_000;
const VERSION = /^\d+\.\d+\.\d+$/;
export function renderOverview(source: string, version: string): string {
return source.replaceAll("{{VERSION}}", version);
}
// A typo'd placeholder would publish literal braces to a public page, so fail the release instead.
export function leftoverPlaceholders(rendered: string): string[] {
return [...new Set(rendered.match(/\{\{[^}]*\}\}/g) ?? [])];
}
export function jwtFrom(body: unknown): string | null {
if (typeof body !== "object" || body === null || !("token" in body)) return null;
return typeof body.token === "string" && body.token !== "" ? body.token : null;
}
type Fetched = { error: string } | { json: unknown; ok: boolean; status: number; text: string };
// fetch and its body readers throw; this is the one edge that converts that into a value.
async function post(url: string, init: RequestInit): Promise<Fetched> {
try {
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
const text = await res.text();
let json: unknown = null;
try {
json = JSON.parse(text);
} catch {
json = null;
}
return { json, ok: res.ok, status: res.status, text };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
async function main(): Promise<number> {
const fail = (message: string): number => {
process.stderr.write(`${message}\n`);
return 1;
};
const [, , version] = process.argv;
const repo = process.env["DOCKERHUB_REPO"];
const user = process.env["DOCKERHUB_USER"];
const token = process.env["DOCKERHUB_TOKEN"];
if (!version || !repo || !user || !token) {
return fail(
"usage: dockerhub-overview.ts <X.Y.Z>; needs DOCKERHUB_REPO, DOCKERHUB_USER and " +
"DOCKERHUB_TOKEN (README -> CI/CD)",
);
}
// The page is public, so never render a version that resolves to no image.
if (!VERSION.test(version)) return fail(`version must be X.Y.Z, got ${JSON.stringify(version)}`);
const templatePath = join(import.meta.dirname, "dockerhub-overview.md.tmpl");
let template = "";
try {
template = readFileSync(templatePath, "utf8");
} catch (err) {
return fail(`${templatePath}: ${err instanceof Error ? err.message : String(err)}`);
}
const body = renderOverview(template, version);
const leftover = leftoverPlaceholders(body);
if (leftover.length > 0) return fail(`${templatePath} has unrendered placeholders: ${leftover.join(", ")}`);
const login = await post(`${HUB}/users/login`, {
body: JSON.stringify({ password: token, username: user }),
headers: { "content-type": "application/json" },
method: "POST",
});
if ("error" in login) return fail(`Docker Hub login unreachable: ${login.error}`);
if (!login.ok) return fail(`Docker Hub login failed: ${login.status} ${login.text}`);
const jwt = jwtFrom(login.json);
if (!jwt) return fail("Docker Hub login returned no token");
const res = await post(`${HUB}/repositories/${repo}/`, {
body: JSON.stringify({ full_description: body }),
headers: { authorization: `Bearer ${jwt}`, "content-type": "application/json" },
method: "PATCH",
});
if ("error" in res) return fail(`Docker Hub unreachable: ${res.error}`);
if (!res.ok) {
return fail(
`Docker Hub overview PATCH failed: ${res.status} ${res.text}` +
(res.status === 403
? "\n403 means DOCKERHUB_TOKEN lacks the delete scope — editing the overview needs " +
"read/write/delete, which pushing images alone does not (README -> CI/CD)."
: ""),
);
}
process.stdout.write(`Docker Hub overview updated for ${repo} at ${version}\n`);
return 0;
}
if (process.argv[1]?.endsWith("/dockerhub-overview.ts")) {
process.exitCode = await main();
}
+3 -42
View File
@@ -1,41 +1,9 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"description": "ignorePaths overrides config:recommended's :ignoreModulesAndTests, which ignores **/examples/** — an example plugin's dependencies get update PRs like any other manifest here",
"ignorePaths": ["**/node_modules/**"],
"automerge": true,
"commitBody": "Release-Bump: {{{updateType}}}",
"packageRules": [
{
"description": "The host's own runtime deps. Release-Bump is opt-in per surface (README → CI/CD): updateType rates the dependency's own jump, not whether it reaches a running Plainpages",
"matchDepTypes": ["dependencies"],
"matchFileNames": ["package.json"],
"matchManagers": ["npm"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "The shipped image's base — e2e-tests/Dockerfile is test-only",
"matchFileNames": ["Dockerfile"],
"matchManagers": ["dockerfile"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "The production topology — compose.override.yml is dev, e2e-tests/compose.*.yml are test",
"matchFileNames": ["compose.yml"],
"matchManagers": ["docker-compose"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "The production sidecars, wherever they are pinned — compose.yml and the published quick start move in one branch, so the trailer must not depend on which upgrade sorts first. mailpit is dev-only and stays out",
"matchDatasources": ["docker"],
"matchPackageNames": ["oryd/hydra", "oryd/keto", "oryd/kratos", "postgres"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "node is pinned to one version across Dockerfile, dev, E2E and CI, so Renovate moves them in a single branch whose commitBody would otherwise depend on upgrade order — the Dockerfile copy ships, so any node bump is a product change",
"matchDatasources": ["docker"],
"matchPackageNames": ["node"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "Ory services share one release train - update kratos, keto and hydra together",
"matchDatasources": ["docker"],
@@ -59,15 +27,8 @@
},
{
"customType": "regex",
"description": "The published quick start ships a compose file, so its sidecars move with the repo's own pins. The version group starts at a digit, which skips the {{VERSION}} placeholder the release renders",
"managerFilePatterns": ["release-tooling/dockerhub-overview.md.tmpl"],
"matchStrings": ["image: (?<depName>[^:\\s]+):(?<currentValue>v?\\d[^\\s]*)"],
"datasourceTemplate": "docker"
},
{
"customType": "regex",
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, renovate auto-release, release)",
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/release.yml", ".gitea/workflows/renovate.yml"],
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, auto-release)",
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/renovate.yml"],
"matchStrings": ["\\snode:(?<currentValue>[0-9][^\\s\"']*)"],
"depNameTemplate": "node",
"datasourceTemplate": "docker"
+25 -107
View File
@@ -1,14 +1,11 @@
// One-command bootstrap: idempotent first-boot seeding. Guards the pure payload
// builders (Kratos create-identity body + Keto permission tuple), the idempotent seedAdmin
// builders (Kratos create-identity body + Keto role tuple), the idempotent seedAdmin
// orchestration (fresh 201 vs existing 409 → reuse id), and the JWKS generate-if-absent
// safety net. Live boot is verified by running the stack; these catch contract drift.
import { test } from "node:test";
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, provisionPluginStorage, seedAdmin, seedPermissions, serverMismatch } from "./bootstrap.ts";
import { createLogger } from "../logger.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import type { ProvisionOptions, ProvisionResult } from "../plugin-host/storage-provisioning.ts";
import { ensureJwks, firstRunBanner, identityPayload, roleTuple, seedAdmin, seedRoles } from "./bootstrap.ts";
const json = (status: number, body?: unknown) =>
new Response(body === undefined ? null : JSON.stringify(body), {
@@ -23,46 +20,27 @@ test("identityPayload is a valid Kratos create-identity body with a password cre
assert.equal(body.credentials.password.config.password, "admin");
});
test("permissionTuple grants a permission to user:<id> in the Permission namespace", () => {
test("roleTuple grants a role to user:<id> in the Role namespace", () => {
const id = randomUUID();
assert.deepEqual(permissionTuple(id, "admin"), {
namespace: "Permission",
assert.deepEqual(roleTuple(id, "admin"), {
namespace: "Role",
object: "admin",
relation: "granted",
relation: "members",
subject_id: `user:${id}`,
});
});
test("seedPermissions unions ADMIN_PERMISSIONS (empty by default) with the discovered plugins' declared permissions", () => {
// Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two names → the demo admin
// holds exactly what the installed plugins gate on, derived from discovery, not hardcoded here.
const names = (env: string | undefined, declared: string[]): string[] => seedPermissions(env, declared).permissions;
assert.deepEqual(names(undefined, ["scheduling:read", "scheduling:write"]), ["scheduling:read", "scheduling:write"]);
// No plugins → nothing to grant. A host-invented base would be a permission that gates nothing.
assert.deepEqual(names(undefined, []), []);
assert.deepEqual(names("ops:read, ops:write ", ["inventory:read"]), ["ops:read", "ops:write", "inventory:read"]); // env trimmed + extended
assert.deepEqual(names("scheduling:read", ["scheduling:read"]), ["scheduling:read"]); // dedup, no double grant
assert.deepEqual(names(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides)
test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared tokens", () => {
// Clean clone: no ADMIN_ROLES, the scheduling plugin declares its two tokens → the demo admin
// gets exactly today's behaviour, but derived from discovery, not hardcoded in the host.
assert.deepEqual(seedRoles(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
assert.deepEqual(seedRoles(undefined, []), ["admin"]); // no plugins → just the base admin role
assert.deepEqual(seedRoles("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended
assert.deepEqual(seedRoles("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant
assert.deepEqual(seedRoles("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides)
});
// Bootstrap gates `web`, so it must never refuse to start over operator env — a leftover
// ADMIN_PERMISSIONS would otherwise brick the whole stack. Drop what it can't use, report it, seed
// the rest.
test("seedPermissions drops an ADMIN_PERMISSIONS name that isn't <resource>:<action>, and never throws", () => {
const legacy = seedPermissions("admin", ["users:read"]);
assert.deepEqual(legacy, { ignored: ["admin"], permissions: ["users:read"] });
const mixed = seedPermissions("admin, ops:read ,Bad Name", ["users:read"]);
assert.deepEqual(mixed, { ignored: ["admin", "Bad Name"], permissions: ["ops:read", "users:read"] });
// Whatever an operator puts there, the boot survives it — that is the property, not the parsing.
for (const value of ["admin", "Bad Name", ":", "::", "a".repeat(200), ",,,", "ADMIN", "1"]) {
assert.doesNotThrow(() => seedPermissions(value, ["users:read"]), value);
assert.deepEqual(seedPermissions(value, ["users:read"]).permissions.includes("users:read"), true, value);
}
});
test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => {
test("seedAdmin on a fresh stack creates the identity and grants every role (one tuple each)", async () => {
const id = randomUUID();
const calls: { method: string; url: string; body?: unknown }[] = [];
const fetchImpl = (async (url, init) => {
@@ -79,20 +57,20 @@ test("seedAdmin on a fresh stack creates the identity and grants every permissio
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
password: "admin",
permissions: ["admin", "scheduling:read"],
roles: ["admin", "scheduling:read"],
});
assert.deepEqual(result, { created: true, id, permissions: ["admin", "scheduling:read"] });
assert.deepEqual(result, { created: true, id, roles: ["admin", "scheduling:read"] });
const puts = calls.filter((c) => c.url.includes("relation-tuples"));
assert.equal(puts.length, 2); // one grant per permission
assert.equal(puts.length, 2); // one grant per role
assert.ok(puts.every((p) => p.method === "PUT"));
assert.deepEqual(puts.map((p) => p.body), [
{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` },
{ namespace: "Permission", object: "scheduling:read", relation: "granted", subject_id: `user:${id}` },
{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` },
{ namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `user:${id}` },
]);
});
test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the permission", async () => {
test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the role", async () => {
const id = randomUUID();
let granted: unknown;
const fetchImpl = (async (url, init) => {
@@ -112,11 +90,11 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
password: "admin",
permissions: ["admin"],
roles: ["admin"],
});
assert.deepEqual(result, { created: false, id, permissions: ["admin"] });
assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` });
assert.deepEqual(result, { created: false, id, roles: ["admin"] });
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` });
});
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
@@ -128,7 +106,7 @@ test("seedAdmin fails loud on an unexpected Kratos error", async () => {
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
password: "admin",
permissions: ["admin"],
roles: ["admin"],
}),
/Kratos/,
);
@@ -154,63 +132,3 @@ test("ensureJwks generates a key only when the file is absent", () => {
assert.equal(ensureJwks(path, { exists: () => true, write }), false);
assert.equal(writes.length, 1); // present → nothing written
});
// --- Plugin storage provisioning -----------------------------------------------------
// The provisioner is injected, so the branch decisions are testable without a Postgres.
const SILENT = createLogger({ level: "none" });
const storagePlugin = (id: string): Plugin => ({ apiVersion: "1.0.0", id, storage: true });
const EMPTY: ProvisionResult = { orphans: [], provisioned: [] };
function recordingProvisioner(result: ProvisionResult = EMPTY) {
const calls: ProvisionOptions[] = [];
return { calls, provision: async (options: ProvisionOptions) => { calls.push(options); return result; } };
}
test("provisioning is skipped entirely when nothing declares storage and none is configured", async () => {
const { calls, provision } = recordingProvisioner();
await provisionPluginStorage({}, [{ apiVersion: "1.0.0", id: "plain" }], SILENT, provision);
assert.deepEqual(calls, []); // no connection attempted, so an unconfigured stack still boots
});
// Uninstalling the last storage plugin is exactly when a left-behind database needs naming.
test("provisioning still runs with nothing to provision, so orphans are reported", async () => {
const { calls, provision } = recordingProvisioner({ orphans: ["plugin_gone"], provisioned: [] });
await provisionPluginStorage({ PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db:5432/ory" }, [], SILENT, provision);
assert.equal(calls.length, 1);
assert.deepEqual(calls[0]?.pluginIds, []);
});
test("a plugin declaring storage without a provisioning DSN fails loud, naming the plugin", async () => {
const { calls, provision } = recordingProvisioner();
await assert.rejects(
provisionPluginStorage({}, [storagePlugin("things")], SILENT, provision),
/PLUGIN_DB_ADMIN_URL.*things/s,
);
assert.deepEqual(calls, []);
});
test("the connection limit and derived secret reach the provisioner", async () => {
const { calls, provision } = recordingProvisioner();
const env = { PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db:5432/ory", PLUGIN_DB_CONNECTION_LIMIT: "25", PLUGIN_DB_SECRET: "real" };
await provisionPluginStorage(env, [storagePlugin("things")], SILENT, provision);
assert.equal(calls[0]?.connectionLimit, 25);
assert.equal(calls[0]?.secret, "real");
assert.deepEqual(calls[0]?.pluginIds, ["things"]);
});
// bootstrap creates the role on one server; web tells the plugin to connect to another. Left
// unsaid it surfaces inside a plugin as "password authentication failed", naming neither. Warned
// rather than refused: web reaching a pooler bootstrap cannot provision through is legitimate.
test("a storage URL mismatch is reported, and provisioning still runs", async () => {
const { calls, provision } = recordingProvisioner();
const env = { PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db-a:5432/ory", PLUGIN_DB_URL: "postgres://db-b:5432" };
await provisionPluginStorage(env, [storagePlugin("things")], SILENT, provision);
assert.equal(calls.length, 1);
});
test("the same server spelled with an implicit port still agrees", () => {
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", "postgres://db"), null); // 5432 is the default
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", undefined), null); // web's own boot error to raise
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", "postgres://db:6543"), "db:5432 vs db:6543");
});
+41 -112
View File
@@ -2,21 +2,15 @@
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
// 3. grant it its permissions in Keto so menu/permission checks resolve out of the box — every
// discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so a dropped-in
// plugin is usable by the demo admin with no host config edit (the host stays plugin-agnostic).
// 3. grant it its roles in Keto so menu/permission checks resolve out of the box — `admin` plus
// every discovered plugin's declared permission tokens, so a dropped-in plugin is usable by
// the demo admin with no host config edit (the host stays plugin-agnostic).
// Then prints a first-run banner; fails loud on any unexpected upstream error.
import { existsSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "../config.ts";
import { discoverPlugins } from "../plugin-host/discovery.ts";
import { declaredPermissions, isValidPermissionName, type Plugin } from "../plugin-host/plugin.ts";
import { provisionStorage } from "../plugin-host/storage-provisioning.ts";
import { storagePluginIds } from "../plugin-host/storage.ts";
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
import { createLogger, runWithLog, tracedFetch, type Log } from "../logger.ts";
type Env = Record<string, string | undefined>;
import { createLogger, runWithLog, tracedFetch } from "../logger.ts";
// --- Pure payload builders (the Kratos/Keto request contracts) -----------------------
@@ -28,26 +22,19 @@ export function identityPayload(email: string, password: string) {
};
}
// Coarse-permission grant: `Permission:<permission>#members@user:<id>`. Subject ids are `user:<kratos-id>`
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT permissions.
export function permissionTuple(userId: string, permission: string) {
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
// Coarse-role grant: `Role:<role>#members@user:<id>`. Subject ids are `user:<kratos-id>`
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT roles.
export function roleTuple(identityId: string, role: string) {
return { namespace: "Role", object: role, relation: "members", subject_id: `user:${identityId}` };
}
// ADMIN_PERMISSIONS (empty by default) unioned with every discovered plugin's declared names, so
// the host names no plugin yet a dropped-in one is seeded out of the box.
//
// ADMIN_PERMISSIONS is the one place an operator names a permission by hand, so it is held to the
// same `<resource>:<action>` rule as a manifest — but *dropped with a warning*, never fatal:
// fail-loud belongs at the manifest boundary where a developer authored the mistake, whereas this
// is operator env and bootstrap gates `web`, so the whole stack must not refuse to start over a
// stale variable. The name it would have written gates nothing anyway.
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredNames: string[]): { ignored: string[]; permissions: string[] } {
// The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`)
// unioned with every discovered plugin's declared permission tokens (a route/nav `permission` is a
// coarse role — granted as a Keto `Role:<token>#members` tuple). So the host names no plugin, yet a
// dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped.
export function seedRoles(adminRolesEnv: string | undefined, declaredTokens: string[]): string[] {
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
const configured = clean((adminPermissionsEnv ?? "").split(","));
const ignored = configured.filter((name) => !isValidPermissionName(name));
const valid = configured.filter((name) => isValidPermissionName(name));
return { ignored, permissions: [...new Set([...valid, ...clean(declaredNames)])] };
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredTokens)])];
}
// --- JWKS safety net -----------------------------------------------------------------
@@ -76,13 +63,13 @@ export interface SeedOptions {
ketoWriteUrl: string;
kratosAdminUrl: string;
password: string;
permissions: string[];
roles: string[];
}
export interface SeedResult {
created: boolean;
id: string;
permissions: string[];
roles: string[];
}
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
@@ -106,17 +93,17 @@ export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
throw new Error(`bootstrap: Kratos create identity failed (${res.status}): ${await res.text()}`);
}
// Grant each permission in Keto. PUT is idempotent — re-running just re-asserts the tuple.
for (const permission of opts.permissions) {
// Grant each role in Keto. PUT is idempotent — re-running just re-asserts the tuple.
for (const role of opts.roles) {
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, {
body: JSON.stringify(permissionTuple(id, permission)),
body: JSON.stringify(roleTuple(id, role)),
headers: { "content-type": "application/json" },
method: "PUT",
});
if (!grant.ok) throw new Error(`bootstrap: Keto grant permission "${permission}" failed (${grant.status}): ${await grant.text()}`);
if (!grant.ok) throw new Error(`bootstrap: Keto grant role "${role}" failed (${grant.status}): ${await grant.text()}`);
}
return { created, id, permissions: opts.permissions };
return { created, id, roles: opts.roles };
}
async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
@@ -135,7 +122,7 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
const rule = "─".repeat(58);
return [
`${rule}`,
`│ Plainpages is ready — sign in at ${opts.appUrl}`,
`│ Plainpages is ready — log in at ${opts.appUrl}`,
`│ email: ${opts.email}`,
`│ password: ${opts.password}`,
`│ ⚠ Demo admin credentials — change them before production.`,
@@ -146,7 +133,7 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
// --- CLI (the bootstrap container entrypoint) ----------------------------------------
async function main() {
const env = { ...process.env }; // snapshot: the storage credentials leave process.env before discovery
const env = process.env;
// Structured like the web app so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME.
const log = createLogger({
format: env["LOG_FORMAT"] === "json" ? "json" : "text",
@@ -155,84 +142,26 @@ async function main() {
// runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls.
await runWithLog(log, async () => {
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
// Discovery imports every plugin module — and its dependencies — into *this* process, which holds
// the credential that may CREATE DATABASE/ROLE. Same move as server.ts, on the stronger secret.
delete process.env["PLUGIN_DB_ADMIN_URL"];
delete process.env["PLUGIN_DB_SECRET"];
const plugins = await discoverPlugins();
await provisionPluginStorage(env, plugins, log);
await seedAdminAndPermissions(env, plugins, log);
// Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared permission tokens, so the
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.token));
const roles = seedRoles(env["ADMIN_ROLES"], declared);
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin";
const result = await seedAdmin({
email,
fetchImpl: tracedFetch,
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
password,
roles,
});
log.info("admin seeded", { created: result.created, id: result.id, roles: result.roles.join(", ") });
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
});
await log.end(); // flush any pending OTLP spans/logs before the one-shot exits
}
// A database and login role for each plugin that asked for one. It happens here because bootstrap
// holds the stack's only provisioning credentials — web derives the same password and connects as
// the plugin's own role.
export async function provisionPluginStorage(env: Env, plugins: Plugin[], log: Log, provision = provisionStorage): Promise<void> {
const ids = storagePluginIds(plugins);
const adminUrl = env["PLUGIN_DB_ADMIN_URL"];
// Still connect with nothing to provision, as long as storage is configured: uninstalling the
// last storage plugin is exactly when an orphaned database needs naming.
if (ids.length === 0 && !adminUrl) return;
if (!adminUrl) throw new Error(`bootstrap: PLUGIN_DB_ADMIN_URL must be set — these plugins declare storage: ${ids.join(", ")}`);
// Provisioned here, connected to from web: a different server means the role is created in one
// place and looked for in another, surfacing inside a plugin as "password authentication failed".
// Warned, not refused — web reaching a pooler that cannot run CREATE DATABASE is a legitimate split.
const mismatch = serverMismatch(adminUrl, env["PLUGIN_DB_URL"]);
if (mismatch) log.warn("PLUGIN_DB_ADMIN_URL and PLUGIN_DB_URL name different servers", { servers: mismatch });
const result = await provision({
adminUrl,
connectionLimit: resolvePluginDbConnectionLimit(env),
pluginIds: ids,
secret: resolvePluginDbSecret(env),
});
if (result.provisioned.length > 0) log.info("plugin storage provisioned", { databases: result.provisioned.join(", ") });
// Never dropped, so an uninstalled plugin's data outlives it — say so, or nobody can find it.
if (result.orphans.length > 0) {
log.warn("plugin databases no installed plugin claims", { databases: result.orphans.join(", ") });
}
}
// Describes the disagreement, or null when they agree (or when web's URL is unset — that is web's
// own boot error to raise, naming the plugin that wanted storage).
export function serverMismatch(adminUrl: string, webUrl: string | undefined): string | null {
if (!webUrl) return null;
const [admin, web] = [safeHostPort(adminUrl), safeHostPort(webUrl)];
if (admin === null || web === null || admin === web) return null; // a malformed URL fails in config.ts
return `${admin} vs ${web}`;
}
function safeHostPort(url: string): string | null {
try {
const parsed = new URL(url);
return `${parsed.hostname}:${parsed.port || "5432"}`;
} catch {
return null;
}
}
// Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
async function seedAdminAndPermissions(env: Env, plugins: Plugin[], log: Log): Promise<void> {
const declared = declaredPermissions(plugins).map((decl) => decl.name);
const { ignored, permissions } = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
if (ignored.length > 0) {
log.warn("ignoring ADMIN_PERMISSIONS entries that are not <resource>:<action>", { ignored: ignored.join(", ") });
}
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin";
const result = await seedAdmin({
email,
fetchImpl: tracedFetch,
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
password,
permissions,
});
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") });
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
}
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();

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